redesign map and mock navigator flow

This commit is contained in:
синечка ♡ 2026-09-09 16:36:36 +00:00
parent bb50c3e199
commit 1e185e1607
10 changed files with 1594 additions and 390 deletions

View file

@ -17,3 +17,16 @@ uv run main.py
## Решение проблем
- Если не работает рассылка с json то надо создать папку `./temp` руками
- Оно вообще не запустится без бекенда расписания, при желании его можно зареверсить из этого исходного кода, либо я когда-нибудь сделаю версию без парсеров
## Карта и навигатор
Карта использует статичную светлую тему и не зависит от Telegram theme
переменных. Вкладка «Навигатор» отправляет в бот проверенный JSON-запрос
`route.request` через `Telegram.WebApp.sendData`. Обработчик находится в
`bot/navigator.py`, а протокол — в `models/navigator.py`.
Скомпилированный граф пока не подключён: `calculate_route` намеренно
возвращает `None`, поэтому бот отвечает «Маршрут не построен» и открывает
карту с выбранными аудиториями. После появления данных нужно заменить только
этот адаптер; UI уже умеет отрисовывать сегменты с индексами `#Точки` или
координатами.

View file

@ -16,6 +16,7 @@ from .main.register_user import *
# #? Another code
from .analytics import *
from .navigator import *
from .iternal import *
from .admin_schedule import *
from .inline import *

131
bot/navigator.py Normal file
View file

@ -0,0 +1,131 @@
from __future__ import annotations
import html
import re
import time
from pathlib import Path
from urllib.parse import urlencode
import config
from telebot.types import InlineKeyboardButton, InlineKeyboardMarkup, Message, WebAppInfo
from models import logger
from models.bot import bot, send
from models.navigator import (
RouteRequest,
calculate_route,
encode_route,
parse_route_request,
)
_SEEN_REQUESTS: dict[tuple[int, str], float] = {}
_SEEN_TTL = 300.0
_ROOMS_BY_FLOOR: dict[str, set[str]] | None = None
def _map_rooms() -> dict[str, set[str]]:
"""Read the room IDs from the same static SVGs used by the WebApp."""
global _ROOMS_BY_FLOOR
if _ROOMS_BY_FLOOR is not None:
return _ROOMS_BY_FLOOR
rooms: dict[str, set[str]] = {}
pattern = re.compile(r'serif:id="([^"]+)"')
root = Path(__file__).resolve().parents[1] / "web" / "map" / "images"
for floor in range(1, 7):
path = root / f"{floor}.svg"
try:
rooms[str(floor)] = set(pattern.findall(path.read_text(encoding="utf-8")))
except OSError:
rooms[str(floor)] = set()
_ROOMS_BY_FLOOR = rooms
return rooms
def _validate_map_rooms(request: RouteRequest) -> bool:
rooms = _map_rooms()
# A missing bundle should not make the bot unusable during deployment;
# the actual navigator adapter will perform its own manifest validation.
if not any(rooms.values()):
return True
for endpoint in (request.start, request.end):
if endpoint.floor and endpoint.room not in rooms.get(endpoint.floor, set()):
return False
if not endpoint.floor and not any(endpoint.room in values for values in rooms.values()):
return False
return True
def _remember_request(user_id: int, request_id: str) -> bool:
now = time.monotonic()
expired = [key for key, timestamp in _SEEN_REQUESTS.items() if now - timestamp > _SEEN_TTL]
for key in expired:
_SEEN_REQUESTS.pop(key, None)
key = (user_id, request_id)
if key in _SEEN_REQUESTS:
return False
_SEEN_REQUESTS[key] = now
return True
def _map_url(request: RouteRequest, route: dict | None) -> str:
base = f"{config.WEB_BASE_URL.rstrip('/')}/map/"
params = {
"v": "14",
"mode": "navigator",
"start": request.start.room,
"end": request.end.room,
"status": "built" if route else "failed",
}
if request.start.floor:
params["sf"] = request.start.floor
if request.end.floor:
params["ef"] = request.end.floor
if route:
params["route"] = encode_route(route)
return f"{base}?{urlencode(params)}"
def _result_markup(url: str) -> InlineKeyboardMarkup:
markup = InlineKeyboardMarkup()
markup.add(InlineKeyboardButton("Открыть карту 🗺️", web_app=WebAppInfo(url)))
return markup
def _send_route_result(message: Message, request: RouteRequest, route: dict | None):
start = html.escape(request.start.room)
end = html.escape(request.end.room)
if route:
text = f"<b>Маршрут построен</b>\n\n{start} - {end}"
else:
text = (
f"<b>Маршрут не построен</b>\n\n{start} - {end}\n"
"Для этой версии карты ещё не подключены скомпилированные точки."
)
return send(message, text, reply_markup=_result_markup(_map_url(request, route)))
@bot.message_handler(content_types=["web_app_data"])
def route_request_from_webapp(message: Message):
"""Receive ``Telegram.WebApp.sendData`` from the navigator tab."""
data = getattr(getattr(message, "web_app_data", None), "data", "")
try:
request = parse_route_request(data)
except (TypeError, ValueError, UnicodeError) as error:
logger.error("Navigator", f"[{message.from_user.id}] invalid request: {error}")
return send(message, "Не удалось распознать запрос маршрута. Открой карту заново.")
if not _remember_request(message.from_user.id, request.request_id):
return
if not _validate_map_rooms(request):
return send(message, "Одной из аудиторий нет на карте. Выбери аудитории из подсказок.")
try:
route = calculate_route(request)
except Exception as error: # keep a future adapter from breaking polling
logger.error("Navigator", f"[{message.from_user.id}] route calculation failed: {error}")
route = None
return _send_route_result(message, request, route)

View file

@ -13,5 +13,8 @@ WEB_BASE_URL: str = 'https://zatups.example.com'
# Base URL for the schedule (should be runned separately)
SCHEDULE_BASE_URL: str = 'http://10.9.8.3:18822'
# The map navigator is mocked until the compiled points/graph are supplied.
# Route requests are still validated and delivered back to the bot.
# Telegram user IDs
ADMINS: list[int] = [5016590523, 5001115363]

View file

@ -4,7 +4,10 @@ if __name__ == '__main__':
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
import models.logger
CMD = 'python3.13.exe' if sys.platform == 'win32' else 'uv run python'
# Reuse the interpreter that launched this process. Calling ``uv`` from
# a child shell breaks when uv was installed outside PATH (and also makes
# ``uv run main.py`` recurse through a second environment).
CMD = 'python3.13.exe' if sys.platform == 'win32' else f'"{sys.executable}"'
PROCESSES = [
Process(target=bot.run if sys.platform == 'win32' else os.system, args=() if sys.platform == 'win32' else (f'{CMD} -c "import bot; bot.run()"',)),
# Process(target=bot.run),
@ -23,4 +26,3 @@ if __name__ == '__main__':
except Exception as e:
models.logger.error('Main', f'{e}')

View file

@ -101,7 +101,7 @@ class Markup_v2:
markup.add(
KeyboardButton(
text=Strings.map,
web_app=WebAppInfo(f'{config.WEB_BASE_URL}/map/?v13')
web_app=WebAppInfo(f'{config.WEB_BASE_URL.rstrip("/")}/map/?v=14&mode=search')
),
Strings.schedule,
row_width=2
@ -138,7 +138,7 @@ class Markup_v1:
markup.add(
KeyboardButton(
text=Strings.map,
web_app=WebAppInfo(f'{config.WEB_BASE_URL}/map/?v13')
web_app=WebAppInfo(f'{config.WEB_BASE_URL.rstrip("/")}/map/?v=14&mode=search')
),
Strings.search,
row_width=2

114
models/navigator.py Normal file
View file

@ -0,0 +1,114 @@
"""Validated protocol used by the map WebApp and the bot.
The current map bundle intentionally does not contain the compiled graph yet.
Keeping parsing and the route adapter here means the real navigator can be
connected later without trusting nodes or edges supplied by the WebApp.
"""
from __future__ import annotations
import base64
import binascii
import json
import re
from dataclasses import dataclass
from typing import Any
MAX_ROUTE_DATA = 4096
MAX_REQUEST_ID = 80
MAX_ENDPOINT_TEXT = 80
_REQUEST_ID = re.compile(r"^[A-Za-z0-9_.:-]{1,80}$")
class NavigatorRequestError(ValueError):
"""The WebApp sent an unsupported or unsafe route request."""
@dataclass(frozen=True)
class RouteEndpoint:
room: str
floor: str | None = None
@dataclass(frozen=True)
class RouteRequest:
version: int
request_id: str
start: RouteEndpoint
end: RouteEndpoint
def _text(value: Any, field: str, limit: int = MAX_ENDPOINT_TEXT) -> str:
if not isinstance(value, str):
raise NavigatorRequestError(f"{field} должен быть строкой")
value = value.strip()
if not value or len(value) > limit or any(char in value for char in "<>\r\n"):
raise NavigatorRequestError(f"Некорректное поле {field}")
return value
def _endpoint(value: Any, field: str) -> RouteEndpoint:
if not isinstance(value, dict):
raise NavigatorRequestError(f"{field} должен быть объектом")
room = _text(value.get("room"), f"{field}.room")
floor_value = value.get("floor")
floor = None if floor_value is None else _text(floor_value, f"{field}.floor", 16)
return RouteEndpoint(room=room, floor=floor)
def parse_route_request(raw: str | bytes) -> RouteRequest:
if isinstance(raw, bytes):
if len(raw) > MAX_ROUTE_DATA:
raise NavigatorRequestError("Слишком большой запрос маршрута")
raw = raw.decode("utf-8", errors="strict")
elif not isinstance(raw, str) or len(raw.encode("utf-8")) > MAX_ROUTE_DATA:
raise NavigatorRequestError("Слишком большой запрос маршрута")
try:
payload = json.loads(raw)
except (UnicodeError, json.JSONDecodeError) as error:
raise NavigatorRequestError("Запрос маршрута не является JSON") from error
if not isinstance(payload, dict) or payload.get("v") != 1 or payload.get("type") != "route.request":
raise NavigatorRequestError("Неподдерживаемый формат запроса маршрута")
request_id = _text(payload.get("request_id"), "request_id", MAX_REQUEST_ID)
if not _REQUEST_ID.fullmatch(request_id):
raise NavigatorRequestError("Некорректный request_id")
start = _endpoint(payload.get("start"), "start")
end = _endpoint(payload.get("end"), "end")
if start.room.casefold() == end.room.casefold() and start.floor == end.floor:
raise NavigatorRequestError("Начальная и конечная аудитории совпадают")
return RouteRequest(version=1, request_id=request_id, start=start, end=end)
def calculate_route(request: RouteRequest) -> dict[str, Any] | None:
"""Return a trusted route or ``None`` until compiled graph data is added.
This is an explicit mock rather than a proximity-based fallback. A real
implementation should load the server-side compiled ``neighbors`` /
``graph`` data, validate both room IDs against its manifest, and return a
payload with ``segments`` containing node indexes or coordinates.
"""
del request
return None
def encode_route(route: dict[str, Any]) -> str:
"""Encode a route for a URL without putting credentials in the URL."""
data = json.dumps(route, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
def decode_route(value: str) -> dict[str, Any] | None:
"""Decode a route in tests/tools; the WebApp performs the same operation."""
try:
padded = value + "=" * ((4 - len(value) % 4) % 4)
result = json.loads(base64.urlsafe_b64decode(padded).decode("utf-8"))
except (ValueError, TypeError, UnicodeError, binascii.Error, json.JSONDecodeError):
return None
return result if isinstance(result, dict) else None

View file

@ -1,54 +1,87 @@
<!DOCTYPE html>
<html lang="en">
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#ffffff">
<title>Карта</title>
<script src="../common/telegram.js"></script>
<script src="../common/eruda.min.js?v13"></script>
<link rel="stylesheet" href="./styles/main.css?v13">
<script src="../common/eruda.min.js?v14"></script>
<link rel="stylesheet" href="./styles/main.css?v14">
</head>
<body>
<section id="block">
<section id="block" aria-live="polite">
<div id="loader"></div>
<h3>Загрузка карты</h3>
<p>0/6 загружено...</p>
<div id="mp"></div>
</section>
<section id="map">
<main id="app" aria-label="Карта университета">
<section id="map" aria-label="Карта здания">
<div id="controls">
<div class="current" onclick="floorsopen()">
<button id="floor-current" class="floor-current" type="button" aria-expanded="false">
<span>2 этаж</span>
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e3e3e3"><path d="M480-344 240-584l56-56 184 184 184-184 56 56-240 240Z"/></svg>
</div>
<div class="select hidden">
<span onclick="fswitch(1)">1 этаж</span>
<span onclick="fswitch(2)" class="active">2 этаж</span>
<span onclick="fswitch(3)">3 этаж</span>
<span onclick="fswitch(4)">4 этаж</span>
<span onclick="fswitch(5)">5 этаж</span>
<span onclick="fswitch(6)">6 этаж</span>
<svg aria-hidden="true" viewBox="0 -960 960 960"><path d="M480-344 240-584l56-56 184 184 184-184 56 56-240 240Z"/></svg>
</button>
<div id="floor-select" class="floor-select hidden" aria-label="Выбор этажа">
<button type="button" data-floor="1">1 этаж</button>
<button type="button" data-floor="2" class="active">2 этаж</button>
<button type="button" data-floor="3">3 этаж</button>
<button type="button" data-floor="4">4 этаж</button>
<button type="button" data-floor="5">5 этаж</button>
<button type="button" data-floor="6">6 этаж</button>
</div>
</div>
<div id="wr"></div>
<div id="wr" aria-live="polite"></div>
<div id="map-message" class="map-message hidden"></div>
</section>
<footer >
<!-- <div class="dragger"></div> -->
<div class="hints">
<section id="bottom-panel" aria-label="Поиск и навигация">
<nav id="mode-switch" aria-label="Режим карты">
<button id="search-mode" type="button" class="active" aria-selected="true">Поиск</button>
<button id="navigator-mode" type="button" aria-selected="false">Навигатор</button>
</nav>
<section id="search-panel" class="panel-card" aria-labelledby="search-mode">
<p id="search-title" class="panel-title">Напиши название аудитории ниже</p>
<div class="field-row">
<svg aria-hidden="true" viewBox="0 -960 960 960"><path d="M480-480q33 0 56.5-23.5T560-560q0-33-23.5-56.5T480-640q-33 0-56.5 23.5T400-560q0 33 23.5 56.5T480-480Zm0 294q122-112 181-203.5T720-552q0-109-69.5-178.5T480-800q-101 0-170.5 69.5T240-552q0 71 59 162.5T480-186Zm0 106Q319-217 239.5-334.5T160-552q0-150 96.5-239T480-880q127 0 223.5 89T800-552q0 100-79.5 217.5T480-80Zm0-480Z"/></svg>
<input id="room-search" type="text" autocomplete="off" inputmode="search" placeholder="Писать сюда..." aria-label="Поиск аудитории">
</div>
<div class="i">
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e3e3e3"><path d="M480-480q33 0 56.5-23.5T560-560q0-33-23.5-56.5T480-640q-33 0-56.5 23.5T400-560q0 33 23.5 56.5T480-480Zm0 294q122-112 181-203.5T720-552q0-109-69.5-178.5T480-800q-101 0-170.5 69.5T240-552q0 71 59 162.5T480-186Zm0 106Q319-217 239.5-334.5T160-552q0-150 96.5-239T480-880q127 0 223.5 89T800-552q0 100-79.5 217.5T480-80Zm0-480Z"/></svg>
<input type="text" placeholder="Введи нужную аудиторию...">
<!-- <button onclick="footerfind()">Найти</button> -->
<div id="search-hints" class="hints" aria-live="polite"></div>
</section>
<section id="navigator-panel" class="panel-card hidden" aria-labelledby="navigator-mode">
<p class="panel-title">Построй маршрут до аудитории</p>
<div class="navigator-fields">
<div class="field-row">
<svg aria-hidden="true" viewBox="0 -960 960 960"><path d="m120-120 80-80h560l80 80H120Zm80-160v-160h560v160H200Zm0-240v-160h560v160H200Zm0-240v-80h560v80H200Z"/></svg>
<input id="route-start" type="text" autocomplete="off" inputmode="search" placeholder="Аудитория возле тебя..." aria-label="Начальная аудитория">
</div>
</footer>
<script src="./js/footer.js?v13"></script>
<script src="./js/map.js?v13"></script>
<script>mapload()</script>
<script>
window.Telegram.WebApp.expand()
</script>
<div id="route-start-hints" class="hints" aria-live="polite"></div>
<div class="field-row">
<svg aria-hidden="true" viewBox="0 -960 960 960"><path d="M480-80 160-400l56-56 224 224v-528h80v528l224-224 56 56L480-80Z"/></svg>
<input id="route-end" type="text" autocomplete="off" inputmode="search" placeholder="Аудитория которая тебе нужна..." aria-label="Конечная аудитория">
</div>
<div id="route-end-hints" class="hints" aria-live="polite"></div>
</div>
<button id="route-submit" class="primary-button" type="button">Найти</button>
<p id="route-feedback" class="feedback" aria-live="polite"></p>
</section>
<section id="route-card" class="route-card hidden" aria-live="polite">
<div class="route-card-head">
<p id="route-summary" class="route-summary" aria-label="Маршрут"></p>
<button id="route-hide" type="button" aria-expanded="true">Скрыть</button>
</div>
<strong id="route-title" class="route-title">Маршрут</strong>
<div id="route-steps" class="route-steps"></div>
<button id="route-open-navigator" class="secondary-button" type="button">Закрыть маршрут</button>
</section>
</section>
</main>
<script src="./js/map.js?v14"></script>
<script>window.Telegram.WebApp.expand(); window.ZatupsMap.init();</script>
</body>
</html>

View file

@ -1,136 +1,763 @@
const fdata = {
/*
* The map is deliberately self-contained. Telegram's theme variables are
* not used: the floor plans are white and the controls use one fixed palette
* both inside and outside Telegram.
*/
const FLOOR_COUNT = 6
const MAP_VERSION = '1'
const $ = (selector) => document.querySelector(selector)
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const fdata = window.fdata = {
floor: 2,
floors: [null,null,null,null,null,null],
floors_rooms: [[], [], [], [], [], []],
floors: Array(FLOOR_COUNT).fill(null),
floors_rooms: Array.from({length: FLOOR_COUNT}, () => []),
room_floors: Object.create(null),
inited: false,
check: async () => {
var count = 0
fdata.floors.forEach(e => e == null ? count += 1 : null)
document.querySelector('#block > p').innerHTML = `${6-count}/6 загружено...`
if (count == 0) {
await sleep(100)
mapdisplay()
removeblock()
results: (query, limit = 10) => roomResults(query, limit),
}
},
results: (query) => {
query = query.toLowerCase()
var found = []
var foundrooms = []
fdata.floors_rooms.forEach((rooms, floor) => {
rooms.forEach(
room =>
room && room.toLowerCase().includes(query) && !foundrooms.includes(room)
? function() {found.push({r: room, f: floor+1}); foundrooms.push(room)} ()
: null
)
const state = {
initialized: false,
mode: 'search',
highlightedRoom: '',
route: null,
routeStatus: null,
routeStart: null,
routeEnd: null,
navigatorManifest: null,
mapHeight: null,
routeView: false,
routeCollapsed: false,
}
function haptic(type = 'light') {
try {
window.Telegram?.WebApp?.HapticFeedback?.impactOccurred(type)
} catch (_) {
// Haptic feedback is optional outside Telegram.
}
}
function normalize(value) {
return String(value || '').trim().toLocaleLowerCase('ru-RU')
}
function compact(value) {
return normalize(value).replace(/[^\p{L}\p{N}]/gu, '')
}
function roomResults(query, limit = 10) {
const needle = compact(query)
if (!needle) return []
const all = []
const seen = new Set()
fdata.floors_rooms.forEach((rooms, index) => rooms.forEach((name) => {
const key = normalize(name)
if (!key || seen.has(key)) return
seen.add(key)
all.push({name, floor: index + 1})
}))
const exact = all.filter((room) => compact(room.name) === needle)
if (exact.length) return exact.slice(0, limit)
const contains = all.filter((room) => compact(room.name).includes(needle))
if (contains.length) return contains.slice(0, limit)
// Keep the fallback useful for a mistyped room without pulling in a large
// fuzzy-search library.
const distance = (name) => {
const value = compact(name)
let previous = Array.from({length: needle.length + 1}, (_, i) => i)
for (let i = 0; i < value.length; i += 1) {
const current = [i + 1]
for (let j = 0; j < needle.length; j += 1) {
current.push(Math.min(
current[j] + 1,
previous[j + 1] + 1,
previous[j] + (value[i] === needle[j] ? 0 : 1),
))
}
previous = current
}
return previous[needle.length]
}
return all
.map((room) => ({room, score: distance(room.name)}))
.filter(({room, score}) => score <= Math.max(2, Math.ceil(compact(room.name).length * .45)))
.sort((a, b) => a.score - b.score || a.room.name.localeCompare(b.room.name, 'ru'))
.slice(0, limit)
.map(({room}) => room)
}
function exactRoom(value) {
const key = compact(value)
if (!key) return null
return roomResults(value, 100).find((room) => compact(room.name) === key) || null
}
function showHints(container, results, onSelect) {
container.replaceChildren()
if (!results.length) {
const empty = document.createElement('span')
empty.className = 'hint-empty'
empty.textContent = 'Аудитория не найдена'
container.append(empty)
return
}
results.forEach((result) => {
const button = document.createElement('button')
button.type = 'button'
button.textContent = result.name
button.addEventListener('click', () => onSelect(result))
container.append(button)
})
return found
}
}
const floorsopen = () => {
document.querySelector('#controls > .select').classList.toggle('hidden')
Telegram.WebApp.HapticFeedback.impactOccurred('light')
}
const fswitch = floor => {
if (floor == fdata.floor) return
fdata.floor = floor
if (!document.querySelector('#controls > .select').classList.contains('hidden')) document.querySelector('#controls > .select').classList.add('hidden')
//
document.querySelector('#controls > .select > .active').classList.remove('active')
document.querySelector('#controls > .current > span').innerHTML = `${floor} этаж`
document.querySelectorAll('#controls > .select > span')[floor-1].classList.add('active')
input.placeholder = 'Введи нужную аудиторию...'
Telegram.WebApp.HapticFeedback.impactOccurred('light')
mapdisplay()
function clearHints(...containers) {
containers.forEach((container) => container?.replaceChildren())
}
const removeblock = () => {
document.querySelector('#block').remove()
function removeBlock() {
$('#block')?.remove()
}
const highlight = (room) => {
document.querySelectorAll('.highlight').forEach(e => e.classList.remove('highlight'))
var lastfound = null
document.querySelectorAll('#Аудитории > g').forEach(e => {
if (e.getAttribute('serif:id') == room) {
e.querySelector('*').classList.add('highlight')
lastfound = e
function applyMapHeightLock() {
const map = $('#map')
const wrapper = $('#wr')
if (!map || !wrapper || !state.mapHeight) return
const height = `${state.mapHeight}px`
map.style.height = height
wrapper.style.height = height
}
function lockMapHeight() {
const map = $('#map')
const wrapper = $('#wr')
if (!map || !wrapper || state.mapHeight) return
const measured = Math.round(map.getBoundingClientRect().height)
const panel = $('#bottom-panel')
const fallback = Math.round(window.innerHeight - (panel?.getBoundingClientRect().height || 0))
state.mapHeight = Math.max(1, measured || fallback)
document.documentElement.style.setProperty('--map-locked-height', `${state.mapHeight}px`)
map.classList.add('map-locked')
applyMapHeightLock()
}
function setMapMessage(text = '') {
const element = $('#map-message')
if (!element) return
element.textContent = text
element.classList.toggle('hidden', !text)
}
function updateLoading() {
const loaded = fdata.floors.filter(Boolean).length
const progress = $('#block > p')
if (progress) progress.textContent = `${loaded}/${FLOOR_COUNT} загружено...`
}
function roomIdFromElement(element) {
return element.getAttribute('serif:id') || element.getAttribute('id') || ''
}
function parseRooms(svgText, floor) {
const doc = new DOMParser().parseFromString(svgText, 'image/svg+xml')
const root = doc.querySelector('#Аудитории')
if (!root) return []
const names = []
const seen = new Set()
Array.from(root.children).filter((element) => element.tagName?.toLowerCase() === 'g').forEach((element) => {
const name = roomIdFromElement(element).trim()
if (!name || seen.has(normalize(name))) return
seen.add(normalize(name))
names.push(name)
if (!fdata.room_floors[name]) fdata.room_floors[name] = floor
})
const scrollauto = (room) => {
const bbox = room.getBBox()
const svg = document.querySelector('#map > #wr > svg')
const roomCenterX = (bbox.x%svg.viewBox.baseVal.width) + bbox.width / 2
const scale = svg.clientHeight / svg.viewBox.baseVal.height
const centerX = roomCenterX * scale
const sc = centerX - map.clientWidth / 2
map.scroll({left: sc, behavior: 'smooth'})
console.log(sc, bbox,roomCenterX, centerX, scale, map.clientWidth)
return names
}
const map = document.querySelector('#map > #wr')
if (room.includes('-')) {
const building = room.split('-')[0]
const values_to_scroll = {
1: 1.1,
2: 1.1,
3: 1.1,
4: 1.1,
5: 1.1,
6: 1.1,
7: null,
9: 10000,
8: 3.9,
10: 1.9
}
const val = values_to_scroll[building]
if (!val) {return scrollauto(lastfound)}
const sc = (map.scrollWidth - map.clientWidth) / val
map.scroll({left: sc, behavior: 'smooth'})
} else if (room.includes('Туале')) {
const sc = (map.scrollWidth - map.clientWidth) / 1.1
map.scroll({left: sc, behavior: 'smooth'})
} else {
return scrollauto(lastfound)
async function loadNavigatorManifest() {
try {
const response = await fetch('./data/navigator.json', {cache: 'no-store'})
if (!response.ok) return
const value = await response.json()
if (value && typeof value === 'object') state.navigatorManifest = value
} catch (_) {
// The compiled graph is optional while the map UI is developed.
}
}
const mapload = async () => {
async function loadFloor(index) {
const response = await fetch(`./images/${index + 1}.svg`)
if (!response.ok) throw new Error(`floor ${index + 1}: ${response.status}`)
const text = await response.text()
fdata.floors[index] = text
fdata.floors_rooms[index] = parseRooms(text, index + 1)
updateLoading()
}
for (let i = 0; i < 6; i++) {
await fetch(`./images/${i+1}.svg`).then(
async e => {
const mp = document.createElement('div');
const svg = await e.text()
fdata.floors[i] = svg
mp.innerHTML = svg
mp.querySelectorAll('#Аудитории > g').forEach(e => {
// console.log(e.getAttributeNS('serif:id'))
fdata.floors_rooms[i].push(e.getAttribute('serif:id'))
function floorValue(value) {
const number = Number.parseInt(String(value), 10)
return Number.isInteger(number) && number >= 1 && number <= FLOOR_COUNT ? number : null
}
function setFloor(floor, {keepHighlight = false} = {}) {
const next = floorValue(floor)
if (!next || !fdata.floors[next - 1]) return
fdata.floor = next
const current = $('#floor-current')
if (current) {
current.querySelector('span').textContent = `${next} этаж`
current.setAttribute('aria-expanded', 'false')
}
$('#floor-select')?.classList.add('hidden')
$('#floor-select')?.querySelectorAll('button').forEach((button) => {
button.classList.toggle('active', Number(button.dataset.floor) === next)
})
await sleep(100)
fdata.check()
}
)
renderFloor()
if (!keepHighlight) state.highlightedRoom = ''
haptic('light')
}
// [0,1,2,3,4,5].map(async (_, i) =>
// await fetch(`./images/${i+1}.svg`).then(async e => {fdata.floors[i] = await e.text(); fdata.check()}))
function toggleFloors() {
const select = $('#floor-select')
const current = $('#floor-current')
if (!select || !current) return
const opened = select.classList.toggle('hidden') === false
current.setAttribute('aria-expanded', String(opened))
haptic('light')
}
const mapdisplay = async () => {
const map = document.querySelector('#map > #wr')
function currentSvg() {
return $('#wr > svg')
}
function scrollToRoom(element) {
const map = $('#wr')
const svg = currentSvg()
if (!svg || !map || !element) return
const maxScroll = Math.max(0, map.scrollWidth - map.clientWidth)
const room = roomIdFromElement(element)
const building = room.includes('-') ? room.split('-')[0] : ''
// The source drawings place buildings at different horizontal offsets.
// These are the calibrated offsets used by the previous map version.
const buildingScrollFactors = {
'1': 1.1,
'2': 1.1,
'3': 1.1,
'4': 1.1,
'5': 1.1,
'6': 1.1,
'7': null,
'8': 3.9,
'9': 10000,
'10': 1.9,
}
if (Object.prototype.hasOwnProperty.call(buildingScrollFactors, building) && buildingScrollFactors[building]) {
map.scrollTo({left: maxScroll / buildingScrollFactors[building], behavior: 'smooth'})
return
}
if (room.includes('Туале')) {
map.scrollTo({left: maxScroll / 1.1, behavior: 'smooth'})
return
}
try {
const bbox = element.getBBox()
const viewBox = svg.viewBox?.baseVal
const svgWidth = viewBox?.width || svg.getBoundingClientRect().width
const svgHeight = viewBox?.height || svg.getBoundingClientRect().height
const scale = svgHeight ? svg.clientHeight / svgHeight : 1
const center = (bbox.x + bbox.width / 2) * scale
const contentWidth = Math.max(svgWidth * scale, svg.clientWidth)
map.scrollTo({left: Math.max(0, Math.min(contentWidth - map.clientWidth, center - map.clientWidth / 2)), behavior: 'smooth'})
} catch (_) {
// Some SVG elements do not expose a bounding box in older WebViews.
}
}
function roomElement(room) {
const svg = currentSvg()
if (!svg) return null
return Array.from(svg.querySelectorAll('#Аудитории > g')).find((element) => roomIdFromElement(element) === room) || null
}
function highlight(room) {
currentSvg()?.querySelectorAll('.highlight').forEach((element) => element.classList.remove('highlight'))
const element = roomElement(room)
if (!element) return false
element.classList.add('highlight')
element.querySelectorAll('*').forEach((child) => child.classList.add('highlight'))
state.highlightedRoom = room
scrollToRoom(element)
return true
}
function nodePoint(node) {
if (Array.isArray(node) && node.length >= 2) return {x: Number(node[0]), y: Number(node[1])}
if (!node || typeof node !== 'object') return null
const x = Number(node.x ?? node.cx ?? node[0])
const y = Number(node.y ?? node.cy ?? node[1])
return Number.isFinite(x) && Number.isFinite(y) ? {x, y} : null
}
function manifestPoints(floorId) {
const manifest = state.navigatorManifest
if (!manifest) return []
const floors = manifest.floors || manifest
const value = floors?.[String(floorId)] || floors?.[Number(floorId)]
return Array.isArray(value) ? value : (Array.isArray(value?.points) ? value.points : [])
}
function segmentFloor(segment) {
const direct = floorValue(segment?.floor_name) || floorValue(segment?.floor_id)
if (direct) return direct
const floors = state.navigatorManifest?.floors || state.navigatorManifest
if (!floors || typeof floors !== 'object') return null
for (const [number, value] of Object.entries(floors)) {
if (value?.id != null && String(value.id) === String(segment?.floor_id)) return floorValue(number)
}
return null
}
function routePoints(segment) {
if (Array.isArray(segment?.points)) return segment.points.map(nodePoint).filter(Boolean)
const explicit = manifestPoints(segment?.floor_id)
const fallback = explicit.length ? explicit : manifestPoints(segmentFloor(segment))
const nodes = Array.isArray(segment?.node_ids) ? segment.node_ids : segment?.array
if (Array.isArray(nodes) && fallback.length) return nodes.map((node) => nodePoint(fallback[node] ?? node)).filter(Boolean)
const svg = currentSvg()
const pointsGroup = svg?.querySelector('#Точки')
if (!pointsGroup || !Array.isArray(nodes)) return []
const pointNodes = Array.from(pointsGroup.children)
return nodes.map((index) => {
const point = pointNodes[Number(index)]
if (!point) return null
return nodePoint({x: point.getAttribute('cx'), y: point.getAttribute('cy')})
}).filter(Boolean)
}
function drawRoute(segment) {
const svg = currentSvg()
if (!svg) return false
svg.querySelector('#Линия')?.remove()
const points = routePoints(segment)
if (points.length < 2) return false
const group = document.createElementNS('http://www.w3.org/2000/svg', 'g')
group.id = 'Линия'
const viewBoxWidth = svg.viewBox?.baseVal?.width || 1000
const strokeWidth = Math.max(4, viewBoxWidth * .006)
for (let index = 1; index < points.length; index += 1) {
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line')
line.setAttribute('x1', String(points[index - 1].x))
line.setAttribute('y1', String(points[index - 1].y))
line.setAttribute('x2', String(points[index].x))
line.setAttribute('y2', String(points[index].y))
line.setAttribute('stroke', 'var(--line)')
line.setAttribute('stroke-width', String(strokeWidth))
line.setAttribute('stroke-linecap', 'round')
line.setAttribute('stroke-linejoin', 'round')
line.setAttribute('vector-effect', 'non-scaling-stroke')
group.append(line)
}
const markerSize = Math.max(strokeWidth * 2.5, 12)
;[points[0], points[points.length - 1]].forEach((point) => {
const marker = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
marker.setAttribute('x', String(point.x - markerSize / 2))
marker.setAttribute('y', String(point.y - markerSize / 2))
marker.setAttribute('width', String(markerSize))
marker.setAttribute('height', String(markerSize))
marker.setAttribute('fill', 'var(--line)')
marker.setAttribute('stroke', '#ffffff')
marker.setAttribute('stroke-width', String(Math.max(1, strokeWidth * .25)))
marker.setAttribute('vector-effect', 'non-scaling-stroke')
group.append(marker)
})
svg.append(group)
return true
}
function findSegment(floor) {
const segments = state.route?.segments
if (!Array.isArray(segments)) return null
return segments.find((segment) => segmentFloor(segment) === Number(floor)) || null
}
function drawCurrentRoute() {
if (!state.route) return
const segment = findSegment(fdata.floor)
if (!segment) {
currentSvg()?.querySelector('#Линия')?.remove()
return
}
if (!drawRoute(segment)) setMapMessage('В этой версии карты нет скомпилированных точек маршрута.')
else setMapMessage('')
}
function renderFloor() {
const map = $('#wr')
const svg = fdata.floors[fdata.floor - 1]
if (!map || !svg) return
map.innerHTML = svg
if (!fdata.inited) {
const sc = (map.scrollWidth - map.clientWidth) / 1.1
map.scrollLeft = sc;
console.log(sc)
// The source drawings are wide; starting near the centre gives a
// useful view while still allowing horizontal scrolling.
map.scrollLeft = Math.max(0, (map.scrollWidth - map.clientWidth) * .9)
fdata.inited = true
}
if (state.highlightedRoom && fdata.room_floors[state.highlightedRoom] === fdata.floor) highlight(state.highlightedRoom)
drawCurrentRoute()
}
function updateSearchHints() {
const input = $('#room-search')
const hints = $('#search-hints')
const title = $('#search-title')
if (!input || !hints) return
const query = input.value.trim()
if (normalize(query) === 'eruda') {
initEruda()
clearHints(hints)
return
}
if (!query) {
if (title) title.textContent = 'Напиши название аудитории ниже'
return clearHints(hints)
}
const results = roomResults(query)
if (title) title.textContent = results.some((result) => compact(result.name) === compact(query))
? 'Аудитория найдена'
: (results.length ? 'Похожие аудитории' : 'Аудитория не найдена')
showHints(hints, results, (result) => {
input.value = result.name
clearHints(hints)
setFloor(result.floor)
highlight(result.name)
haptic('success')
})
}
function updateNavigatorHints(which) {
const input = which === 'start' ? $('#route-start') : $('#route-end')
const hints = which === 'start' ? $('#route-start-hints') : $('#route-end-hints')
if (!input || !hints) return
const query = input.value.trim()
if (!query) return clearHints(hints)
showHints(hints, roomResults(query), (result) => {
input.value = result.name
clearHints(hints)
if (which === 'start') state.routeStart = result
else state.routeEnd = result
setFloor(result.floor, {keepHighlight: true})
highlight(result.name)
haptic('success')
})
}
function initEruda() {
if (window.__zatupsErudaInitialized || window.__zatupsErudaLoading) return
const start = () => {
if (!window.eruda) return
try {
window.eruda.init()
window.__zatupsErudaInitialized = true
} catch (_) {
// Dev-only console; never interrupt map use.
}
}
if (window.eruda) return start()
window.__zatupsErudaLoading = true
const script = document.createElement('script')
script.src = '../common/eruda.min.js?v14'
script.onload = () => { window.__zatupsErudaLoading = false; start() }
script.onerror = () => { window.__zatupsErudaLoading = false }
document.head.append(script)
}
function switchMode(mode) {
if (state.routeView) return
state.mode = mode === 'navigator' ? 'navigator' : 'search'
const searchActive = state.mode === 'search'
$('#search-mode')?.classList.toggle('active', searchActive)
$('#navigator-mode')?.classList.toggle('active', !searchActive)
$('#search-mode')?.setAttribute('aria-selected', String(searchActive))
$('#navigator-mode')?.setAttribute('aria-selected', String(!searchActive))
$('#search-panel')?.classList.toggle('hidden', !searchActive)
$('#navigator-panel')?.classList.toggle('hidden', searchActive || Boolean(state.routeStatus))
if (searchActive && !state.routeStatus) $('#route-card')?.classList.add('hidden')
if (!searchActive && !state.routeStatus) $('#route-card')?.classList.add('hidden')
haptic('light')
}
function randomRequestId() {
try {
if (crypto.randomUUID) return crypto.randomUUID()
} catch (_) {}
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
}
function roomForRequest(input, remembered) {
const exact = exactRoom(input.value)
if (exact) return exact
return remembered && compact(remembered.name) === compact(input.value) ? remembered : null
}
function submitRoute() {
const startInput = $('#route-start')
const endInput = $('#route-end')
const feedback = $('#route-feedback')
if (!startInput || !endInput || !feedback) return
const start = roomForRequest(startInput, state.routeStart)
const end = roomForRequest(endInput, state.routeEnd)
state.routeStart = start
state.routeEnd = end
if (!start || !end) {
feedback.textContent = 'Выбери две существующие аудитории из подсказок.'
haptic('error')
return
}
if (compact(start.name) === compact(end.name)) {
feedback.textContent = 'Начальная и конечная аудитории должны отличаться.'
haptic('error')
return
}
const payload = {
v: 1,
type: 'route.request',
request_id: randomRequestId(),
start: {floor: String(start.floor), room: start.name},
end: {floor: String(end.floor), room: end.name},
}
window.__lastRouteRequest = payload
try {
const webApp = window.Telegram?.WebApp
if (typeof webApp?.sendData !== 'function') throw new Error('Telegram WebApp is unavailable')
webApp.sendData(JSON.stringify(payload))
feedback.textContent = 'Запрос отправлен в бот. Вернись в чат за результатом.'
haptic('success')
} catch (_) {
feedback.textContent = 'Открой карту из кнопки бота, чтобы отправить маршрут.'
haptic('error')
}
}
function decodeRoute(value) {
try {
const normalized = value.replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((value.length + 3) % 4)
const bytes = Uint8Array.from(atob(normalized), (char) => char.charCodeAt(0))
return JSON.parse(new TextDecoder().decode(bytes))
} catch (_) {
return null
}
}
function encodeText(value) {
return String(value || '').trim()
}
function createRouteStep(title, description) {
const element = document.createElement('div')
element.className = 'route-step'
const heading = document.createElement('strong')
heading.textContent = title
const text = document.createElement('span')
text.textContent = description
element.append(heading, text)
return element
}
function renderRouteSummary(summary, start, end) {
summary.replaceChildren()
if (!start || !end) return
const startLabel = document.createElement('span')
startLabel.textContent = start
const arrow = document.createElement('span')
arrow.className = 'route-arrow'
arrow.setAttribute('aria-hidden', 'true')
const endLabel = document.createElement('span')
endLabel.textContent = end
summary.append(startLabel, arrow, endLabel)
}
function enterRouteView() {
state.routeView = true
state.routeCollapsed = false
state.mode = 'navigator'
$('#app')?.classList.add('route-view')
$('#route-card')?.classList.remove('hidden', 'route-collapsed')
const hide = $('#route-hide')
if (hide) {
hide.textContent = 'Скрыть'
hide.setAttribute('aria-expanded', 'true')
}
}
function closeRouteView() {
state.routeView = false
state.routeCollapsed = false
state.routeStatus = null
state.route = null
state.routeStart = null
state.routeEnd = null
state.highlightedRoom = ''
$('#app')?.classList.remove('route-view')
$('#route-card')?.classList.add('hidden')
$('#route-card')?.classList.remove('route-collapsed', 'route-failed')
$('#route-start')?.setAttribute('value', '')
$('#route-end')?.setAttribute('value', '')
if ($('#route-start')) $('#route-start').value = ''
if ($('#route-end')) $('#route-end').value = ''
currentSvg()?.querySelectorAll('.highlight').forEach((element) => element.classList.remove('highlight'))
currentSvg()?.querySelector('#Линия')?.remove()
applyMapHeightLock()
switchMode('search')
}
function toggleRouteCollapsed() {
const card = $('#route-card')
const button = $('#route-hide')
if (!card || !button || !state.routeView) return
state.routeCollapsed = !state.routeCollapsed
card.classList.toggle('route-collapsed', state.routeCollapsed)
button.textContent = state.routeCollapsed ? 'Показать' : 'Скрыть'
button.setAttribute('aria-expanded', String(!state.routeCollapsed))
haptic('light')
}
function renderRouteCard() {
const card = $('#route-card')
const title = $('#route-title')
const summary = $('#route-summary')
const steps = $('#route-steps')
if (!card || !title || !summary || !steps) return
steps.replaceChildren()
const start = encodeText(state.routeStart?.name || new URLSearchParams(location.search).get('start'))
const end = encodeText(state.routeEnd?.name || new URLSearchParams(location.search).get('end'))
renderRouteSummary(summary, start, end)
const failed = state.routeStatus === 'failed' || !state.route
card.classList.toggle('route-failed', failed)
if (failed) {
title.textContent = 'Маршрут не построен'
steps.append(createRouteStep('Попробуй ещё раз', 'Для этой версии карты скомпилированные точки маршрута ещё не подключены.'))
} else {
title.textContent = 'Маршрут построен'
const segments = Array.isArray(state.route.segments) ? state.route.segments : []
segments.forEach((segment, index) => {
const floor = segmentFloor(segment) || segment.floor_id || '?'
const nodes = Array.isArray(segment.node_ids || segment.array) ? (segment.node_ids || segment.array).length : 0
steps.append(createRouteStep(
index === 0 ? `Начните на ${floor} этаже` : `Перейдите на ${floor} этаж`,
nodes ? `Точек маршрута: ${nodes}` : 'Покажите этот этаж на карте',
))
})
if (!segments.length) steps.append(createRouteStep('Маршрут пуст', 'Не удалось найти точки между аудиториями.'))
}
enterRouteView()
}
function initRouteFromQuery() {
const params = new URLSearchParams(location.search)
const start = params.get('start') || ''
const end = params.get('end') || ''
const startRoom = exactRoom(start)
const endRoom = exactRoom(end)
if (startRoom) {
state.routeStart = startRoom
$('#route-start').value = startRoom.name
} else if (start) $('#route-start').value = start
if (endRoom) {
state.routeEnd = endRoom
$('#route-end').value = endRoom.name
} else if (end) $('#route-end').value = end
const encoded = params.get('route')
state.route = encoded ? decodeRoute(encoded) : null
state.routeStatus = params.get('status') || (state.route ? 'built' : null)
if (start || end || state.route || state.routeStatus) switchMode('navigator')
if (state.route || state.routeStatus === 'failed') renderRouteCard()
const floor = floorValue(params.get('floor') || params.get('sf') || startRoom?.floor)
if (floor) fdata.floor = floor
}
function hydrateRouteEndpoints() {
const params = new URLSearchParams(location.search)
const start = state.routeStart || exactRoom(params.get('start') || '')
const end = state.routeEnd || exactRoom(params.get('end') || '')
if (start && !state.routeStart) {
state.routeStart = start
$('#route-start').value = start.name
}
if (end && !state.routeEnd) {
state.routeEnd = end
$('#route-end').value = end.name
}
}
function bindEvents() {
$('#floor-current')?.addEventListener('click', toggleFloors)
$('#floor-select')?.querySelectorAll('button[data-floor]').forEach((button) => {
button.addEventListener('click', () => setFloor(button.dataset.floor))
})
$('#search-mode')?.addEventListener('click', () => switchMode('search'))
$('#navigator-mode')?.addEventListener('click', () => switchMode('navigator'))
$('#room-search')?.addEventListener('input', updateSearchHints)
$('#route-start')?.addEventListener('input', () => updateNavigatorHints('start'))
$('#route-end')?.addEventListener('input', () => updateNavigatorHints('end'))
$('#route-submit')?.addEventListener('click', submitRoute)
$('#route-hide')?.addEventListener('click', toggleRouteCollapsed)
$('#route-open-navigator')?.addEventListener('click', closeRouteView)
// Mobile WebViews resize their visual viewport when the keyboard opens.
// Reapply the captured size, but never measure the shrunken viewport.
document.addEventListener('focusin', (event) => {
if (event.target instanceof HTMLInputElement) applyMapHeightLock()
})
}
async function mapload() {
const results = await Promise.allSettled(Array.from({length: FLOOR_COUNT}, (_, index) => loadFloor(index)))
const failed = results.filter((result) => result.status === 'rejected').length
if (failed === FLOOR_COUNT) {
setMapMessage('Не удалось загрузить карту')
const progress = $('#block > p')
if (progress) progress.textContent = 'Проверь соединение и попробуй снова'
return
}
removeBlock()
hydrateRouteEndpoints()
renderFloor()
if (state.routeStart?.floor) setFloor(state.routeStart.floor, {keepHighlight: true})
if (state.routeStart?.name) highlight(state.routeStart.name)
drawCurrentRoute()
}
async function init() {
if (state.initialized) return
state.initialized = true
bindEvents()
initRouteFromQuery()
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))
lockMapHeight()
await Promise.all([loadNavigatorManifest(), mapload()])
}
// Keep the small global API used by older deep links and by the developer
// console while exposing the new state in one namespace.
window.floorsopen = toggleFloors
window.fswitch = setFloor
window.highlight = highlight
window.mapload = mapload
window.find = (room, floor) => {
const input = $('#room-search')
if (input) input.value = room
setFloor(floor)
highlight(room)
}
window.ZatupsMap = {init, state, fdata, setFloor, highlight, submitRoute}

View file

@ -1,261 +1,541 @@
:root {
/* Telegram injects these variables inside its WebApp. Keep a light theme
for a normal browser, where that injection is absent. */
color-scheme: light;
--tg-theme-bg-color: #e5e5ea;
--tg-theme-text-color: #000000;
--tg-theme-hint-color: #707579;
--tg-theme-secondary-bg-color: #f1f1f1;
--tg-theme-destructive-text-color: #e53935;
--page: #ffffff;
--panel: #444444;
--panel-muted: #5e5e5e;
--panel-light: #707070;
--panel-text: #f6f6f6;
--panel-hint: #d4d4d4;
--white: #ffffff;
--accent: #b01820;
--line: #b01820;
}
@property --found-fill {
syntax: "<color>";
inherits: true;
initial-value: #b01820;
}
* {
/* border: solid 1px #f00; */
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
touch-action: pan-x pan-y;
box-sizing: border-box;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
}
html {
font-size: 16px;
}
body {
overflow: hidden;
margin: 0;
background: var(--tg-theme-bg-color);
color: var(--tg-theme-text-color);
height: 100vh;
/* height: 100dvh; */
color: var(--panel-text);
background: var(--page);
font-size: 1rem;
}
button, input {
font: inherit;
}
button {
border: 0;
cursor: pointer;
}
input {
user-select: text;
-webkit-user-select: text;
}
.hidden {
display: none !important;
}
#app {
height: 100%;
min-height: 100%;
}
#map {
width: calc(100vw - 1rem);
margin: 0.5rem;
background: #fff;
/* box-shadow: rgba(255,255,255,.1) 0 0 .5rem .1rem; */
border-radius: 1rem;
height: calc(100% - 7.5rem);
overflow: auto;
scrollbar-width: none;
padding: 0;
display: flex;
scroll-behavior: smooth;
position: absolute;
top: 0;
right: 0;
left: 0;
bottom: auto;
height: calc(100% - 12.2rem);
overflow: hidden;
background: var(--white);
border-bottom: 4px solid #f6e7a2;
}
#map > .controls {
display: none;
#map.map-locked {
height: var(--map-locked-height);
}
#wr {
height: 100%;
overflow-y: hidden;
scrollbar-width: none;
}
#wr > svg {
width: auto;
/* transform: translateX(27%); */
/* height: calc(100% - 5rem); */
/* scale: 1.1; */
}
footer {
width: calc(100vw);
background: var(--tg-theme-bg-color);
position: fixed;
bottom: 0;
display: flex;
/* border: solid 2px var(--tg-theme-bg-color); */
/* border-top: solid 2px var(--tg-theme-hint-color); */
box-shadow: var(--tg-theme-secondary-bg-color) 0 0rem .9rem .2rem;
border-radius: 1.5rem 1.5rem 0 0;
padding: .5rem 0 2.5rem;
flex-direction: column;
/* gap: .5rem; */
}
.dragger {
height: .3rem;
background: var(--tg-theme-hint-color);
opacity: .2;
margin: 0 auto;
border-radius: 100vw;
width: 3rem;
}
footer > .i {
display: flex;
align-items: center;
background: var(--tg-theme-secondary-bg-color);
border-radius: 5rem;
padding: 0 .5rem;
margin: 0 1rem;
gap: .5rem;
margin-top: .5rem;
/* border: solid 1px var(--tg-theme-hint-color); */
padding-right: 0;
}
footer > .i > svg {
fill: var(--tg-theme-text-color) !important;
}
footer > .i > input {
background: transparent;
outline: none;
border: 0;
padding: .5rem 0;
width: 100%;
font-size: 1.2rem;
color: var(--tg-theme-text-color);
font-family: Arial, Helvetica, sans-serif;
}
.i > input::placeholder {
color: var(--tg-theme-hint-color);
}
.i > button {
padding: .62rem .8rem;
/* font-weight: bold; */
/* height: 100%; */
border-radius: 100vw;
border: 0;
color: var(--tg-theme-bg-color);
background: var(--tg-theme-text-color);
}
.hints {
padding: 0 1rem;
display: flex;
flex-wrap: nowrap;
gap: .5rem;
overflow-x: auto;
height: 100%;
overflow: auto;
scrollbar-width: none;
}
.hints.a {
padding: .6rem 1rem .3rem;
}
.hints > span {
background: var(--tg-theme-secondary-bg-color);
padding: .3rem .7rem;
border-radius: 100vw;
border: solid 1px var(--tg-theme-hint-color);
/* color: var(--tg-theme-hint-color); */
text-wrap-mode: nowrap;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
overscroll-behavior: contain;
scroll-behavior: smooth;
background: var(--white);
}
#controls {
margin: .5rem;
position: fixed;
display: flex;
flex-direction: column;
gap: .3rem;
}
#controls > .current {
background: var(--tg-theme-bg-color);
padding: .2rem .4rem .2rem;
padding-left: .8rem;
display: flex;
align-items: center;
gap: .4rem;
border-radius: 10rem;
font-size: 1.2rem;
cursor: pointer;
width: fit-content;
}
#controls > .current > svg {
transform: translateY(.12rem);
}
#controls > .select {
display: flex;
flex-direction: column;
padding: .5rem;
gap: .4rem;
background: var(--tg-theme-bg-color);
border-radius: 1.4rem;
}
#controls > .select > span {
background: var(--tg-theme-secondary-bg-color);
padding: .2rem .05rem;
text-align: center;
border-radius: .5rem;
font-size: 1.3rem;
}
#controls > .select > span:nth-child(1) {
border-radius: 1rem 1rem .5rem .5rem;
}
#controls > .select > span:nth-child(6) {
border-radius: .5rem .5rem 1rem 1rem ;
}
#controls > .select > span.active {
/* color: var(--tg-theme-bg-color); */
background: var(--tg-theme-hint-color);
}
#mp {
height: 10px;
width: 10px;
opacity: 0;
}
#block {
position: fixed;
top: 0;
left: 0;
z-index: 100;
height: 100vh;
/* height: 100dvh; */
width: 100vw;
display: flex;
flex-direction: column;
background: var(--tg-theme-bg-color);
align-items: center;
justify-content: center;
}
#block > h3 {
font-weight: normal;
margin-bottom: 0;
}#block > p {
color: var(--tg-theme-hint-color);
}
#loader {
height: 2rem;
width: 2rem;
border: solid 3px var(--tg-theme-text-color);
border-top: solid 3px transparent;
border-radius: 100vw;
animation: anim .7s linear infinite;
}
@keyframes anim {
to {
transform: rotate(360deg);
}
}
#controls > .select.hidden {
#wr::-webkit-scrollbar {
display: none;
}
#controls > .select > span {
cursor: pointer;
#wr > svg {
display: block;
width: auto;
min-width: 100%;
height: 100%;
background: var(--white);
}
.highlight, .highlight > *, .highlight > * > * {
fill: var(--tg-theme-destructive-text-color) !important;
/* animation: highlight 2s linear infinite; */
#controls {
position: absolute;
z-index: 5;
top: .65rem;
left: .65rem;
display: flex;
flex-direction: column;
align-items: stretch;
gap: .35rem;
}
@keyframes highlight {
0% {
fill: var(--tg-theme-destructive-text-color) !important;
.floor-current, .floor-select {
color: var(--panel-text);
background: rgba(68, 68, 68, .94);
border-radius: .75rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, .16);
}
50% {
fill: var(--tg-theme-bg-color) !important;
.floor-current {
display: flex;
align-items: center;
gap: .35rem;
padding: .35rem .55rem .35rem .75rem;
font-size: 1.05rem;
white-space: nowrap;
}
100% {
fill: var(--tg-theme-destructive-text-color) !important;
.floor-current svg {
width: 1.15rem;
height: 1.15rem;
fill: currentColor;
transition: transform .16s ease;
}
.floor-current[aria-expanded="true"] svg {
transform: rotate(180deg);
}
.floor-select {
display: flex;
flex-direction: column;
gap: .18rem;
padding: .32rem;
}
.floor-select button {
min-width: 4.8rem;
padding: .28rem .45rem;
color: var(--panel-text);
background: transparent;
border-radius: .48rem;
font-size: .98rem;
}
.floor-select button.active,
.floor-select button:active {
background: var(--panel-light);
}
.highlight > *,
.highlight > * > * {
fill: var(--found-fill) !important;
stroke: #171717 !important;
}
.highlight {
--found-fill: var(--accent);
animation: found-fill 2s ease-in-out infinite;
}
#Линия {
--found-fill: var(--line);
animation: found-fill 2s ease-in-out infinite;
transform-box: fill-box;
transform-origin: center;
}
#Линия line {
stroke: var(--found-fill) !important;
}
#Линия rect {
fill: var(--found-fill) !important;
}
@keyframes found-fill {
0%, 100% { --found-fill: var(--accent); }
50% { --found-fill: #7f1118; }
}
#map-message {
position: absolute;
z-index: 4;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
max-width: calc(100% - 2rem);
padding: .75rem 1rem;
color: #fff;
background: rgba(68, 68, 68, .94);
border-radius: .85rem;
text-align: center;
}
#bottom-panel {
position: fixed;
z-index: 10;
right: 0;
bottom: 0;
left: 0;
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: .38rem;
min-height: 12.2rem;
padding: .42rem .62rem calc(.65rem + env(safe-area-inset-bottom));
background: transparent;
}
/* A route is a focused, read-only map view. The map itself remains usable,
* while floor controls, tabs, and both input panels stay out of the way. */
#app.route-view #controls,
#app.route-view #mode-switch,
#app.route-view #search-panel,
#app.route-view #navigator-panel {
display: none !important;
}
#app.route-view #bottom-panel {
min-height: 0;
gap: 0;
}
#mode-switch {
display: flex;
align-self: flex-start;
overflow: hidden;
border-radius: 1.2rem;
background: var(--panel);
}
#mode-switch button {
min-width: 5.4rem;
padding: .28rem .8rem;
color: #eee;
background: var(--panel);
font-size: 1rem;
}
#mode-switch button.active {
color: #fff;
background: #8d8d8d;
}
#mode-switch,
.panel-card,
.route-card {
box-shadow: 0 .7rem 2.2rem rgba(0, 0, 0, .16), 0 .12rem .45rem rgba(0, 0, 0, .12);
}
.panel-card, .route-card {
position: relative;
width: 100%;
padding: .62rem .68rem .68rem;
color: var(--panel-text);
background: var(--panel);
border-radius: 1rem;
}
.panel-title {
margin: 0 0 .48rem;
font-size: 1.02rem;
line-height: 1.2;
}
.field-row {
display: flex;
align-items: center;
gap: .45rem;
min-height: 2.35rem;
padding: .1rem .58rem;
color: var(--panel-hint);
background: var(--panel-muted);
border-radius: .72rem;
}
.field-row svg {
flex: 0 0 1.25rem;
width: 1.25rem;
height: 1.25rem;
fill: currentColor;
}
.field-row input {
min-width: 0;
width: 100%;
padding: .36rem 0;
color: #fff;
background: transparent;
border: 0;
outline: none;
font-size: 1rem;
}
.field-row input::placeholder {
color: #c6c6c6;
opacity: .8;
}
.hints {
display: flex;
flex-wrap: wrap;
gap: .35rem;
max-height: 4.5rem;
padding-top: .35rem;
overflow: auto;
scrollbar-width: none;
}
.hints:empty {
display: none;
}
.hints::-webkit-scrollbar {
display: none;
}
.hints button, .hint-empty {
padding: .28rem .68rem;
color: #333;
background: #f8f8f8;
border-radius: 1rem;
font-size: .91rem;
white-space: nowrap;
}
.hint-empty {
color: var(--panel-hint);
background: var(--panel-light);
}
.navigator-fields {
display: grid;
gap: .28rem;
}
.navigator-fields .hints {
padding: 0 0 .08rem 1.7rem;
}
.primary-button, .secondary-button {
min-height: 2.3rem;
padding: .34rem 1.1rem;
border-radius: .78rem;
font-size: 1rem;
}
.primary-button {
display: block;
margin: .5rem 0 0 auto;
color: #333;
background: #fff;
}
.primary-button:disabled {
color: #aaa;
background: #777;
cursor: not-allowed;
}
.secondary-button {
color: #fff;
background: var(--panel-light);
}
.feedback {
min-height: 0;
margin: .3rem 0 0;
color: #ffd1d1;
font-size: .85rem;
}
.route-card {
max-height: min(62vh, 26rem);
overflow: auto;
}
.route-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: .7rem;
}
.route-title {
display: block;
margin: .42rem 0 .58rem;
font-size: 1.05rem;
}
.route-card:not(.route-failed) .route-title {
display: none;
}
.route-card-head button {
flex: 0 0 auto;
padding: .3rem .8rem;
color: #fff;
background: var(--panel-light);
border-radius: .7rem;
}
#route-summary {
min-width: 0;
margin: 0;
color: var(--panel-text);
}
.route-summary {
display: flex;
align-items: center;
gap: .62rem;
line-height: 1.2;
}
.route-summary > span:not(.route-arrow) {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Keep the route direction independent from the active font. */
.route-arrow {
flex: 0 0 .56rem;
width: .56rem;
height: .56rem;
margin-right: .3rem;
color: var(--panel-hint);
border-top: .12rem solid currentColor;
border-right: .12rem solid currentColor;
transform: rotate(45deg);
}
.route-steps {
display: grid;
gap: .42rem;
margin-bottom: .65rem;
}
.route-step {
padding: .48rem .58rem;
background: var(--panel-muted);
border-radius: .68rem;
}
.route-step strong {
display: block;
margin-bottom: .12rem;
}
.route-step span {
color: var(--panel-hint);
font-size: .9rem;
}
#app.route-view #route-card.route-collapsed {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: .65rem;
max-height: none;
overflow: visible;
}
#app.route-view #route-card.route-collapsed .route-card-head {
display: contents;
}
#app.route-view #route-card.route-collapsed #route-summary {
grid-column: 1;
grid-row: 1;
}
#app.route-view #route-card.route-collapsed #route-hide {
grid-column: 2;
grid-row: 1;
}
#app.route-view #route-card.route-collapsed #route-title,
#app.route-view #route-card.route-collapsed #route-steps,
#app.route-view #route-card.route-collapsed #route-open-navigator {
display: none;
}
#block {
position: fixed;
z-index: 100;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #333;
background: var(--page);
}
#block h3 {
margin: 1rem 0 .1rem;
font-weight: 500;
}
#block p {
margin: 0;
color: #555;
}
#loader {
width: 2rem;
height: 2rem;
border: 3px solid #444;
border-top-color: transparent;
border-radius: 50%;
animation: spin .7s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (min-width: 600px) {
#bottom-panel {
right: 50%;
width: min(34rem, 100%);
transform: translateX(50%);
}
}