From 1e185e1607ccf59ede2ed43a62484698ae936f6c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D1=81=D0=B8=D0=BD=D0=B5=D1=87=D0=BA=D0=B0=20=E2=99=A1?=
<63897347+ktnk-dev@users.noreply.github.com>
Date: Wed, 9 Sep 2026 16:36:36 +0000
Subject: [PATCH] redesign map and mock navigator flow
---
README.md | 15 +-
bot/__init__.py | 1 +
bot/navigator.py | 131 ++++++
config.example.py | 3 +
main.py | 6 +-
models/bot.py | 4 +-
models/navigator.py | 114 ++++++
web/map/index.html | 113 ++++--
web/map/js/map.js | 859 ++++++++++++++++++++++++++++++++++------
web/map/styles/main.css | 738 +++++++++++++++++++++++-----------
10 files changed, 1594 insertions(+), 390 deletions(-)
create mode 100644 bot/navigator.py
create mode 100644 models/navigator.py
diff --git a/README.md b/README.md
index b1cffbf..378f849 100644
--- a/README.md
+++ b/README.md
@@ -16,4 +16,17 @@ uv run main.py
## Решение проблем
- Если не работает рассылка с json то надо создать папку `./temp` руками
-- Оно вообще не запустится без бекенда расписания, при желании его можно зареверсить из этого исходного кода, либо я когда-нибудь сделаю версию без парсеров
\ No newline at end of file
+- Оно вообще не запустится без бекенда расписания, при желании его можно зареверсить из этого исходного кода, либо я когда-нибудь сделаю версию без парсеров
+
+## Карта и навигатор
+
+Карта использует статичную светлую тему и не зависит от Telegram theme
+переменных. Вкладка «Навигатор» отправляет в бот проверенный JSON-запрос
+`route.request` через `Telegram.WebApp.sendData`. Обработчик находится в
+`bot/navigator.py`, а протокол — в `models/navigator.py`.
+
+Скомпилированный граф пока не подключён: `calculate_route` намеренно
+возвращает `None`, поэтому бот отвечает «Маршрут не построен» и открывает
+карту с выбранными аудиториями. После появления данных нужно заменить только
+этот адаптер; UI уже умеет отрисовывать сегменты с индексами `#Точки` или
+координатами.
diff --git a/bot/__init__.py b/bot/__init__.py
index 8e9ad8f..3d379bb 100644
--- a/bot/__init__.py
+++ b/bot/__init__.py
@@ -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 *
diff --git a/bot/navigator.py b/bot/navigator.py
new file mode 100644
index 0000000..8da5fe1
--- /dev/null
+++ b/bot/navigator.py
@@ -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"Маршрут построен\n\n{start} - {end}"
+ else:
+ text = (
+ f"Маршрут не построен\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)
diff --git a/config.example.py b/config.example.py
index d6c8f97..966f2be 100644
--- a/config.example.py
+++ b/config.example.py
@@ -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]
diff --git a/main.py b/main.py
index bed1a27..4799374 100644
--- a/main.py
+++ b/main.py
@@ -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}')
-
diff --git a/models/bot.py b/models/bot.py
index 6449df9..d2630b5 100644
--- a/models/bot.py
+++ b/models/bot.py
@@ -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
diff --git a/models/navigator.py b/models/navigator.py
new file mode 100644
index 0000000..0daf492
--- /dev/null
+++ b/models/navigator.py
@@ -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
diff --git a/web/map/index.html b/web/map/index.html
index cfd9fa0..b436693 100644
--- a/web/map/index.html
+++ b/web/map/index.html
@@ -1,54 +1,87 @@
-
+
-
+
+
Карта
-
-
+
+
-
+
Загрузка карты
0/6 загружено...
-
-
-
-
-
-
- 1 этаж
- 2 этаж
- 3 этаж
- 4 этаж
- 5 этаж
- 6 этаж
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Напиши название аудитории ниже
+
+
+
+
+
+ Построй маршрут до аудитории
+
+
+
+
+
+
+
+ Маршрут
+
+
+
+
+
+
+
+
diff --git a/web/map/js/map.js b/web/map/js/map.js
index 6bf0885..5a903ab 100644
--- a/web/map/js/map.js
+++ b/web/map/js/map.js
@@ -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) => {
- 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
- )
- })
- return found
+ results: (query, limit = 10) => roomResults(query, limit),
+}
+
+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.
}
}
-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 normalize(value) {
+ return String(value || '').trim().toLocaleLowerCase('ru-RU')
}
-const removeblock = () => {
- document.querySelector('#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
- }
- })
- 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)
-
- }
- 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)
- }
+function compact(value) {
+ return normalize(value).replace(/[^\p{L}\p{N}]/gu, '')
}
-const mapload = async () => {
+function roomResults(query, limit = 10) {
+ const needle = compact(query)
+ if (!needle) return []
- 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'))
- })
- await sleep(100)
- fdata.check()
+ 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]
}
-
- // [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()}))
+ 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)
}
-const mapdisplay = async () => {
- const map = document.querySelector('#map > #wr')
- const svg = fdata.floors[fdata.floor-1]
+
+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)
+ })
+}
+
+function clearHints(...containers) {
+ containers.forEach((container) => container?.replaceChildren())
+}
+
+function removeBlock() {
+ $('#block')?.remove()
+}
+
+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
+ })
+ return names
+}
+
+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.
+ }
+}
+
+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()
+}
+
+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)
+ })
+ renderFloor()
+ if (!keepHighlight) state.highlightedRoom = ''
+ haptic('light')
+}
+
+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')
+}
+
+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}
diff --git a/web/map/styles/main.css b/web/map/styles/main.css
index 8a78e6a..5fe372a 100644
--- a/web/map/styles/main.css
+++ b/web/map/styles/main.css
@@ -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: "";
+ 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);
+ height: 100%;
+ overflow: auto;
+ scrollbar-width: none;
+ overscroll-behavior: contain;
+ scroll-behavior: smooth;
+ background: var(--white);
}
-
-.hints {
- padding: 0 1rem;
- display: flex;
- flex-wrap: nowrap;
- gap: .5rem;
- overflow-x: 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;
-}
-
-
-#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;
- }
- 50% {
- fill: var(--tg-theme-bg-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);
+}
- }
- 100% {
- fill: var(--tg-theme-destructive-text-color) !important;
+.floor-current {
+ display: flex;
+ align-items: center;
+ gap: .35rem;
+ padding: .35rem .55rem .35rem .75rem;
+ font-size: 1.05rem;
+ white-space: nowrap;
+}
+
+.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%);
}
}