Compare commits

...

5 commits

Author SHA1 Message Date
синечка ♡
ef308a58de hide map action for failed routes 2026-09-09 17:57:48 +00:00
синечка ♡
bd7be0a5ef embed pathfinder map and render route instructions 2026-09-09 17:42:31 +00:00
синечка ♡
b0a801a820 calculate navigator routes locally 2026-09-09 17:27:47 +00:00
синечка ♡
f69e6dc76f store local navigator pathfinder manifest 2026-09-09 17:20:01 +00:00
синечка ♡
1e185e1607 redesign map and mock navigator flow 2026-09-09 17:08:45 +00:00
18 changed files with 18758 additions and 1721 deletions

View file

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

View file

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

132
bot/navigator.py Normal file
View file

@ -0,0 +1,132 @@
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": "15",
"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"
"Не удалось найти путь между выбранными аудиториями."
)
markup = _result_markup(_map_url(request, route)) if route else None
return send(message, text, reply_markup=markup)
@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) # Base URL for the schedule (should be runned separately)
SCHEDULE_BASE_URL: str = 'http://10.9.8.3:18822' SCHEDULE_BASE_URL: str = 'http://10.9.8.3:18822'
# The navigator graph is embedded in web/map/data/navigator.json and is shared
# by the bot and the WebView.
# Telegram user IDs # Telegram user IDs
ADMINS: list[int] = [5016590523, 5001115363] ADMINS: list[int] = [5016590523, 5001115363]

View file

@ -4,7 +4,10 @@ if __name__ == '__main__':
sys.path.append(os.path.dirname(os.path.abspath(__file__))) sys.path.append(os.path.dirname(os.path.abspath(__file__)))
import models.logger 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 = [ 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 if sys.platform == 'win32' else os.system, args=() if sys.platform == 'win32' else (f'{CMD} -c "import bot; bot.run()"',)),
# Process(target=bot.run), # Process(target=bot.run),
@ -23,4 +26,3 @@ if __name__ == '__main__':
except Exception as e: except Exception as e:
models.logger.error('Main', f'{e}') models.logger.error('Main', f'{e}')

View file

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

294
models/navigator.py Normal file
View file

@ -0,0 +1,294 @@
"""Validated WebApp protocol and local pathfinder implementation."""
from __future__ import annotations
import base64
import binascii
import json
import math
import re
from collections import deque
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
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
@dataclass(frozen=True)
class CompiledFloor:
name: str
floor_id: str
neighbors: tuple[tuple[str, ...], ...]
graph: tuple[tuple[int, ...], ...]
positions: tuple[tuple[float, float], ...]
_MANIFEST_PATH = Path(__file__).resolve().parents[1] / "web" / "map" / "data" / "navigator.json"
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 _decode_pathfinder(value: Any) -> dict[str, Any] | None:
for _ in range(2):
if not isinstance(value, str):
break
try:
value = json.loads(value)
except json.JSONDecodeError:
return None
return value if isinstance(value, dict) else None
@lru_cache(maxsize=4)
def _load_floors(mtime_ns: int) -> dict[str, CompiledFloor]:
del mtime_ns # cache key; contents are read again whenever the file changes
manifest = json.loads(_MANIFEST_PATH.read_text(encoding="utf-8"))
raw_floors = manifest.get("floors") if isinstance(manifest, dict) else None
if not isinstance(raw_floors, dict):
return {}
floors: dict[str, CompiledFloor] = {}
for key, raw_floor in raw_floors.items():
if not isinstance(raw_floor, dict):
continue
name = str(raw_floor.get("floor_name", key)).strip()
pathfinder = _decode_pathfinder(raw_floor.get("pathfinder_data", raw_floor))
if not pathfinder:
continue
raw_neighbors = pathfinder.get("neighbors")
raw_graph = pathfinder.get("graph")
raw_positions = raw_floor.get("positions", pathfinder.get("positions"))
if not all(isinstance(value, list) for value in (raw_neighbors, raw_graph, raw_positions)):
continue
if not (len(raw_neighbors) == len(raw_graph) == len(raw_positions)):
continue
try:
neighbors = tuple(tuple(str(item) for item in items) for items in raw_neighbors)
graph = tuple(tuple(int(item) for item in items) for items in raw_graph)
positions = tuple((float(item[0]), float(item[1])) for item in raw_positions)
except (TypeError, ValueError, IndexError):
continue
node_count = len(graph)
if any(node < 0 or node >= node_count for edges in graph for node in edges):
continue
floors[name] = CompiledFloor(
name=name,
floor_id=str(raw_floor.get("id", name)),
neighbors=neighbors,
graph=graph,
positions=positions,
)
return floors
def _floors() -> dict[str, CompiledFloor]:
try:
return _load_floors(_MANIFEST_PATH.stat().st_mtime_ns)
except (OSError, ValueError, TypeError, json.JSONDecodeError):
return {}
def _contains_room(floor: CompiledFloor, room: str) -> bool:
return any(room in names for names in floor.neighbors)
def _resolve_floor(floors: dict[str, CompiledFloor], endpoint: RouteEndpoint) -> CompiledFloor | None:
if endpoint.floor and endpoint.floor in floors:
floor = floors[endpoint.floor]
return floor if _contains_room(floor, endpoint.room) else None
matches = [floor for floor in floors.values() if _contains_room(floor, endpoint.room)]
return matches[0] if len(matches) == 1 else None
def _bfs(floor: CompiledFloor, start: str | int, end: str | int) -> list[int] | None:
if isinstance(start, int):
start_index = start if 0 <= start < len(floor.graph) else None
else:
start_index = next((index for index, names in enumerate(floor.neighbors) if start in names), None)
if start_index is None:
return None
def reached(index: int) -> bool:
return index == end if isinstance(end, int) else end in floor.neighbors[index]
if reached(start_index):
return [start_index]
queue = deque([start_index])
visited = {start_index}
parents: dict[int, int] = {}
while queue:
node = queue.popleft()
for neighbor in floor.graph[node]:
if neighbor in visited:
continue
parents[neighbor] = node
if reached(neighbor):
path = [neighbor]
while path[-1] != start_index:
path.append(parents[path[-1]])
return path[::-1]
visited.add(neighbor)
queue.append(neighbor)
return None
def _xy_search(floor: CompiledFloor, x: float, y: float, *, safe: bool = True) -> int | None:
closest_index: int | None = None
closest_distance = math.inf
for index, (point_x, point_y) in enumerate(floor.positions):
if safe and "лест" not in ",".join(floor.neighbors[index]).casefold():
continue
distance = math.hypot(point_x - x, point_y - y)
if distance < closest_distance:
closest_distance = distance
closest_index = index
return closest_index if closest_index is not None and closest_distance < 2000 else None
def _nearest_node(floor: CompiledFloor, position: tuple[float, float]) -> int | None:
safe_index = _xy_search(floor, *position, safe=True)
return safe_index if safe_index is not None else _xy_search(floor, *position, safe=False)
def _segment(floor: CompiledFloor, path: list[int]) -> dict[str, Any]:
# The WebView only needs the display floor number. Keeping the UUID out of
# the payload also makes route URLs shorter.
return {"array": path, "floor_id": floor.name, "floor_name": floor.name}
def calculate_route(request: RouteRequest) -> dict[str, Any] | None:
"""Calculate a route from the pathfinder manifest shared with the WebView."""
floors = _floors()
start_floor = _resolve_floor(floors, request.start)
end_floor = _resolve_floor(floors, request.end)
if not start_floor or not end_floor:
return None
if start_floor.name == end_floor.name:
path = _bfs(start_floor, request.start.room, request.end.room)
return {"v": 1, "segments": [_segment(start_floor, path)]} if path else None
end_path = _bfs(end_floor, request.end.room, "Лестница")
if not end_path:
return None
if start_floor.name == "2":
destination = _nearest_node(start_floor, end_floor.positions[end_path[-1]])
start_path = _bfs(start_floor, request.start.room, destination) if destination is not None else None
if not start_path:
return None
segments = [_segment(start_floor, start_path), _segment(end_floor, end_path)]
return {"v": 1, "segments": segments}
start_path = _bfs(start_floor, request.start.room, "Лестница")
second_floor = floors.get("2")
if not start_path or not second_floor:
return None
second_start = _nearest_node(second_floor, start_floor.positions[start_path[-1]])
if second_start is None:
return None
if end_floor.name == "2":
second_path = _bfs(second_floor, second_start, request.end.room)
if not second_path:
return None
return {"v": 1, "segments": [_segment(start_floor, start_path), _segment(second_floor, second_path)]}
second_end = _nearest_node(second_floor, end_floor.positions[end_path[-1]])
if second_end is None:
return None
second_path = _bfs(second_floor, second_start, second_end)
if not second_path:
return None
segments = [
_segment(start_floor, start_path),
_segment(second_floor, second_path),
_segment(end_floor, end_path),
]
return {"v": 1, "segments": segments}
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

15149
web/map/data/navigator.json Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 696 KiB

After

Width:  |  Height:  |  Size: 710 KiB

File diff suppressed because it is too large Load diff

Before

Width:  |  Height:  |  Size: 723 KiB

After

Width:  |  Height:  |  Size: 751 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 697 KiB

After

Width:  |  Height:  |  Size: 708 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 668 KiB

After

Width:  |  Height:  |  Size: 678 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 620 KiB

After

Width:  |  Height:  |  Size: 628 KiB

View file

@ -35,6 +35,7 @@
<g id="Коридоры" transform="matrix(1,0,0,1,280,0)"> <g id="Коридоры" transform="matrix(1,0,0,1,280,0)">
<path d="M3061.77,2336.07l-73.434,0l0.019,-169.434l73.415,0l0,8.709l320.728,0l0,-8.709l470.548,0l0,6.612l123.711,0l0,-114.104l38.594,0l0,115.621l109.572,0.024l-0.019,-114.826l49.546,-0.819l0,150.113l-1112.68,0l0,126.813Z" style="fill:#d4d4d4;"/> <path d="M3061.77,2336.07l-73.434,0l0.019,-169.434l73.415,0l0,8.709l320.728,0l0,-8.709l470.548,0l0,6.612l123.711,0l0,-114.104l38.594,0l0,115.621l109.572,0.024l-0.019,-114.826l49.546,-0.819l0,150.113l-1112.68,0l0,126.813Z" style="fill:#d4d4d4;"/>
</g> </g>
<g id="Точки"><circle cx="3898" cy="2188" r="2" fill="transparent" /> <circle cx="3945" cy="2188" r="2" fill="transparent" /> <circle cx="3979" cy="2188" r="2" fill="transparent" /> <circle cx="4029" cy="2185" r="2" fill="transparent" /> <circle cx="4067" cy="2188" r="2" fill="transparent" /> <circle cx="4105" cy="2188" r="2" fill="transparent" /> <circle cx="4142" cy="2188" r="2" fill="transparent" /> <circle cx="4183" cy="2188" r="2" fill="transparent" /> <circle cx="4221" cy="2191" r="2" fill="transparent" /> <circle cx="4268" cy="2191" r="2" fill="transparent" /> <circle cx="4268" cy="2144" r="2" fill="transparent" /> <circle cx="4268" cy="2097" r="2" fill="transparent" /> <circle cx="4318" cy="2197" r="2" fill="transparent" /> <circle cx="4365" cy="2197" r="2" fill="transparent" /> <circle cx="4397" cy="2191" r="2" fill="transparent" /> <circle cx="4434" cy="2188" r="2" fill="transparent" /> <circle cx="3854" cy="2178" r="2" fill="transparent" /> <circle cx="3804" cy="2188" r="2" fill="transparent" /> <circle cx="3747" cy="2185" r="2" fill="transparent" /> <circle cx="3700" cy="2185" r="2" fill="transparent" /> <circle cx="3647" cy="2185" r="2" fill="transparent" /> <circle cx="3593" cy="2191" r="2" fill="transparent" /> <circle cx="3546" cy="2191" r="2" fill="transparent" /> <circle cx="3502" cy="2191" r="2" fill="transparent" /> <circle cx="3455" cy="2188" r="2" fill="transparent" /> <circle cx="3418" cy="2182" r="2" fill="transparent" /> <circle cx="3377" cy="2185" r="2" fill="transparent" /> <circle cx="3342" cy="2185" r="2" fill="transparent" /> <circle cx="3302" cy="2185" r="2" fill="transparent" /> <circle cx="3308" cy="2213" r="2" fill="transparent" /></g>
<g id="Аудитории" transform="matrix(1,0,0,1,280,0)"> <g id="Аудитории" transform="matrix(1,0,0,1,280,0)">
<g id="_1-609" serif:id="1-609"> <g id="_1-609" serif:id="1-609">
<rect id="_1-6091" serif:id="1-609" x="3380.5" y="2061.24" width="158.619" height="105.394" style="fill:none;stroke:#000;stroke-width:6.9px;"/> <rect id="_1-6091" serif:id="1-609" x="3380.5" y="2061.24" width="158.619" height="105.394" style="fill:none;stroke:#000;stroke-width:6.9px;"/>

Before

Width:  |  Height:  |  Size: 536 KiB

After

Width:  |  Height:  |  Size: 538 KiB

View file

@ -1,54 +1,86 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="ru">
<head> <head>
<meta charset="UTF-8"> <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> <title>Карта</title>
<script src="../common/telegram.js"></script> <script src="../common/telegram.js"></script>
<script src="../common/eruda.min.js?v13"></script> <script src="../common/eruda.min.js?v15"></script>
<link rel="stylesheet" href="./styles/main.css?v13"> <link rel="stylesheet" href="./styles/main.css?v15">
</head> </head>
<body> <body>
<section id="block"> <section id="block" aria-live="polite">
<div id="loader"></div> <div id="loader"></div>
<h3>Загрузка карты</h3> <h3>Загрузка карты</h3>
<p>0/6 загружено...</p> <p>0/6 загружено...</p>
<div id="mp"></div>
</section> </section>
<section id="map">
<main id="app" aria-label="Карта университета">
<section id="map" aria-label="Карта здания">
<div id="controls"> <div id="controls">
<div class="current" onclick="floorsopen()"> <button id="floor-current" class="floor-current" type="button" aria-expanded="false">
<span>2 этаж</span> <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> <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>
</div> </button>
<div class="select hidden"> <div id="floor-select" class="floor-select hidden" aria-label="Выбор этажа">
<span onclick="fswitch(1)">1 этаж</span> <button type="button" data-floor="1">1 этаж</button>
<span onclick="fswitch(2)" class="active">2 этаж</span> <button type="button" data-floor="2" class="active">2 этаж</button>
<span onclick="fswitch(3)">3 этаж</span> <button type="button" data-floor="3">3 этаж</button>
<span onclick="fswitch(4)">4 этаж</span> <button type="button" data-floor="4">4 этаж</button>
<span onclick="fswitch(5)">5 этаж</span> <button type="button" data-floor="5">5 этаж</button>
<span onclick="fswitch(6)">6 этаж</span> <button type="button" data-floor="6">6 этаж</button>
</div> </div>
</div> </div>
<div id="wr" aria-live="polite"></div>
<div id="wr"></div> <div id="map-message" class="map-message hidden"></div>
</section> </section>
<footer > <section id="bottom-panel" aria-label="Поиск и навигация">
<!-- <div class="dragger"></div> --> <nav id="mode-switch" aria-label="Режим карты">
<div class="hints"> <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>
<div class="i"> <div id="search-hints" class="hints" aria-live="polite"></div>
<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> </section>
<input type="text" placeholder="Введи нужную аудиторию...">
<!-- <button onclick="footerfind()">Найти</button> --> <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> </div>
</footer> <div id="route-start-hints" class="hints" aria-live="polite"></div>
<script src="./js/footer.js?v13"></script> <div class="field-row">
<script src="./js/map.js?v13"></script> <svg aria-hidden="true" viewBox="0 -960 960 960"><path d="M480-80 160-400l56-56 224 224v-528h80v528l224-224 56 56L480-80Z"/></svg>
<script>mapload()</script> <input id="route-end" type="text" autocomplete="off" inputmode="search" placeholder="Аудитория которая тебе нужна..." aria-label="Конечная аудитория">
<script> </div>
window.Telegram.WebApp.expand() <div id="route-end-hints" class="hints" aria-live="polite"></div>
</script> </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>
</section>
</section>
</main>
<script src="./js/map.js?v15"></script>
<script>window.Telegram.WebApp.expand(); window.ZatupsMap.init();</script>
</body> </body>
</html> </html>

View file

@ -1,136 +1,838 @@
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 = '15'
const $ = (selector) => document.querySelector(selector)
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const fdata = window.fdata = {
floor: 2, floor: 2,
floors: [null,null,null,null,null,null], floors: Array(FLOOR_COUNT).fill(null),
floors_rooms: [[], [], [], [], [], []], floors_rooms: Array.from({length: FLOOR_COUNT}, () => []),
room_floors: Object.create(null),
inited: false, inited: false,
check: async () => { results: (query, limit = 10) => roomResults(query, limit),
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) => { const state = {
query = query.toLowerCase() initialized: false,
var found = [] mode: 'search',
var foundrooms = [] highlightedRoom: '',
fdata.floors_rooms.forEach((rooms, floor) => { route: null,
rooms.forEach( routeStatus: null,
room => routeStart: null,
room && room.toLowerCase().includes(query) && !foundrooms.includes(room) routeEnd: null,
? function() {found.push({r: room, f: floor+1}); foundrooms.push(room)} () navigatorManifest: null,
: 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 = () => { function clearHints(...containers) {
document.querySelector('#controls > .select').classList.toggle('hidden') containers.forEach((container) => container?.replaceChildren())
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()
} }
const removeblock = () => { function removeBlock() {
document.querySelector('#block').remove() $('#block')?.remove()
} }
const highlight = (room) => {
document.querySelectorAll('.highlight').forEach(e => e.classList.remove('highlight')) function applyMapHeightLock() {
var lastfound = null const map = $('#map')
document.querySelectorAll('#Аудитории > g').forEach(e => { const wrapper = $('#wr')
if (e.getAttribute('serif:id') == room) { if (!map || !wrapper || !state.mapHeight) return
e.querySelector('*').classList.add('highlight') const height = `${state.mapHeight}px`
lastfound = e 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) => { return names
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
} async function loadNavigatorManifest() {
const val = values_to_scroll[building] try {
if (!val) {return scrollauto(lastfound)} const response = await fetch('./data/navigator.json', {cache: 'no-store'})
if (!response.ok) return
const sc = (map.scrollWidth - map.clientWidth) / val const value = await response.json()
map.scroll({left: sc, behavior: 'smooth'}) if (value && typeof value === 'object') state.navigatorManifest = value
} catch (_) {
} else if (room.includes('Туале')) { // The map itself remains usable if the embedded navigator data is unavailable.
const sc = (map.scrollWidth - map.clientWidth) / 1.1
map.scroll({left: sc, behavior: 'smooth'})
} else {
return scrollauto(lastfound)
} }
} }
const mapload = async () => { async function loadFloor(index) {
const response = await fetch(`./images/${index + 1}.svg?v=${MAP_VERSION}`)
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++) { function floorValue(value) {
await fetch(`./images/${i+1}.svg`).then( const number = Number.parseInt(String(value), 10)
async e => { return Number.isInteger(number) && number >= 1 && number <= FLOOR_COUNT ? number : null
const mp = document.createElement('div'); }
const svg = await e.text()
fdata.floors[i] = svg function setFloor(floor, {keepHighlight = false} = {}) {
mp.innerHTML = svg const next = floorValue(floor)
mp.querySelectorAll('#Аудитории > g').forEach(e => { if (!next || !fdata.floors[next - 1]) return
// console.log(e.getAttributeNS('serif:id')) fdata.floor = next
fdata.floors_rooms[i].push(e.getAttribute('serif:id')) 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) renderFloor()
fdata.check() if (!keepHighlight) state.highlightedRoom = ''
} haptic('light')
)
} }
// [0,1,2,3,4,5].map(async (_, i) => function toggleFloors() {
// await fetch(`./images/${i+1}.svg`).then(async e => {fdata.floors[i] = await e.text(); fdata.check()})) 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)]
if (Array.isArray(value)) return value
if (Array.isArray(value?.points)) return value.points
if (Array.isArray(value?.positions)) return value.positions
if (Array.isArray(value?.pathfinder_data?.positions)) return value.pathfinder_data.positions
return []
}
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] const svg = fdata.floors[fdata.floor - 1]
if (!map || !svg) return
map.innerHTML = svg map.innerHTML = svg
if (!fdata.inited) { if (!fdata.inited) {
const sc = (map.scrollWidth - map.clientWidth) / 1.1 // The source drawings are wide; starting near the centre gives a
map.scrollLeft = sc; // useful view while still allowing horizontal scrolling.
console.log(sc) map.scrollLeft = Math.max(0, (map.scrollWidth - map.clientWidth) * .9)
fdata.inited = true 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?v${MAP_VERSION}`
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 route-step-message'
const heading = document.createElement('strong')
heading.textContent = title
const text = document.createElement('span')
text.textContent = description
element.append(heading, text)
return element
}
function createRouteIcon(kind, {descending = false} = {}) {
const wrapper = document.createElement('span')
wrapper.className = `route-step-icon route-step-icon-${kind}${descending ? ' descending' : ''}`
wrapper.setAttribute('aria-hidden', 'true')
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
svg.setAttribute('viewBox', '0 0 24 24')
svg.setAttribute('focusable', 'false')
const paths = {
pin: ['M12 22s7-6.2 7-13a7 7 0 1 0-14 0c0 6.8 7 13 7 13Z', 'M12 6.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5Z'],
turn: ['M5 4v8a5 5 0 0 0 5 5h8', 'm14 13 4 4-4 4'],
stairs: ['M3 20h5v-4h4v-4h4V8h5V4'],
}
;(paths[kind] || []).forEach((data) => {
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
path.setAttribute('d', data)
svg.append(path)
})
wrapper.append(svg)
return wrapper
}
function scrollToCurrentRoute() {
const map = $('#wr')
const svg = currentSvg()
const route = svg?.querySelector('#Линия')
if (!map || !svg || !route) return
try {
const bbox = route.getBBox()
const viewBox = svg.viewBox?.baseVal
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((viewBox?.width || svg.clientWidth) * scale, svg.clientWidth)
map.scrollTo({
left: Math.max(0, Math.min(contentWidth - map.clientWidth, center - map.clientWidth / 2)),
behavior: 'smooth',
})
} catch (_) {
// Older WebViews can fail to calculate an injected SVG group's bbox.
}
}
function showRouteFloor(floor) {
setFloor(floor, {keepHighlight: true})
requestAnimationFrame(scrollToCurrentRoute)
}
function createRouteInstruction(label, icon, {floor = null, descending = false} = {}) {
const element = document.createElement('div')
element.className = 'route-step'
const row = document.createElement('div')
row.className = 'route-step-row'
const text = document.createElement('p')
text.className = 'route-step-label'
text.textContent = label
row.append(text, createRouteIcon(icon, {descending}))
element.append(row)
if (floor) {
const button = document.createElement('button')
button.type = 'button'
button.className = 'route-floor-button'
button.textContent = 'Показать этаж'
button.addEventListener('click', () => showRouteFloor(floor))
element.append(button)
}
return element
}
function renderRouteInstructions(steps, segments) {
const routeSegments = segments
.map((segment) => ({segment, floor: segmentFloor(segment)}))
.filter(({floor}) => Boolean(floor))
if (!routeSegments.length) {
steps.append(createRouteStep('Маршрут пуст', 'Не удалось найти точки между аудиториями.'))
return
}
const startFloor = routeSegments[0].floor
steps.append(createRouteInstruction(`Вы сейчас на ${startFloor} этаже`, 'pin', {floor: startFloor}))
if (routeSegments.length === 1) {
steps.append(createRouteInstruction('Пройдите до аудитории', 'pin'))
return
}
steps.append(createRouteInstruction('Пройдите до лестницы', 'turn'))
for (let index = 1; index < routeSegments.length; index += 1) {
const previousFloor = routeSegments[index - 1].floor
const floor = routeSegments[index].floor
if (floor !== previousFloor) {
const descending = floor < previousFloor
steps.append(createRouteInstruction(
`${descending ? 'Спуститесь' : 'Поднимитесь'} на ${floor} этаж`,
'stairs',
{floor, descending},
))
}
const last = index === routeSegments.length - 1
steps.append(createRouteInstruction(last ? 'Пройдите до аудитории' : 'Пройдите до лестницы', last ? 'pin' : 'turn'))
}
}
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 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 : []
renderRouteInstructions(steps, segments)
}
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)
// 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,585 @@
:root { :root {
/* Telegram injects these variables inside its WebApp. Keep a light theme
for a normal browser, where that injection is absent. */
color-scheme: light; color-scheme: light;
--tg-theme-bg-color: #e5e5ea; --page: #ffffff;
--tg-theme-text-color: #000000; --panel: #444444;
--tg-theme-hint-color: #707579; --panel-muted: #5e5e5e;
--tg-theme-secondary-bg-color: #f1f1f1; --panel-light: #707070;
--tg-theme-destructive-text-color: #e53935; --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; */ box-sizing: border-box;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
touch-action: pan-x pan-y;
user-select: none; user-select: none;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
}
html {
font-size: 16px;
} }
body { body {
overflow: hidden;
margin: 0; margin: 0;
background: var(--tg-theme-bg-color); color: var(--panel-text);
color: var(--tg-theme-text-color); background: var(--page);
height: 100vh; font-size: 1rem;
/* height: 100dvh; */ }
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 { #map {
width: calc(100vw - 1rem); position: absolute;
margin: 0.5rem; top: 0;
background: #fff; right: 0;
/* box-shadow: rgba(255,255,255,.1) 0 0 .5rem .1rem; */ left: 0;
border-radius: 1rem; bottom: auto;
height: calc(100% - 7.5rem); height: calc(100% - 12.2rem);
overflow: auto; overflow: hidden;
scrollbar-width: none; background: var(--white);
padding: 0; border-bottom: 4px solid #f6e7a2;
display: flex;
scroll-behavior: smooth;
} }
#map > .controls {
display: none; #map.map-locked {
height: var(--map-locked-height);
} }
#wr { #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%; width: 100%;
font-size: 1.2rem; height: 100%;
color: var(--tg-theme-text-color); overflow: auto;
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;
scrollbar-width: none; scrollbar-width: none;
} overscroll-behavior: contain;
.hints.a { scroll-behavior: smooth;
padding: .6rem 1rem .3rem; background: var(--white);
}
.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;
} }
#wr::-webkit-scrollbar {
#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 {
display: none; display: none;
} }
#controls > .select > span { #wr > svg {
cursor: pointer; display: block;
width: auto;
min-width: 100%;
height: 100%;
background: var(--white);
} }
.highlight, .highlight > *, .highlight > * > * { #controls {
fill: var(--tg-theme-destructive-text-color) !important; position: absolute;
/* animation: highlight 2s linear infinite; */ z-index: 5;
top: .65rem;
left: .65rem;
display: flex;
flex-direction: column;
align-items: stretch;
gap: .35rem;
} }
@keyframes highlight { .floor-current, .floor-select {
0% { color: var(--panel-text);
fill: var(--tg-theme-destructive-text-color) !important; 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 {
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;
}
.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: .72rem;
margin-top: .72rem;
}
.route-step {
padding: .12rem 0;
}
.route-step-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: .9rem;
min-height: 1.8rem;
}
.route-step-label {
margin: 0;
color: var(--panel-text);
font-size: 1.08rem;
line-height: 1.25;
}
.route-step-icon {
flex: 0 0 1.55rem;
width: 1.55rem;
height: 1.55rem;
color: var(--panel-text);
}
.route-step-icon svg {
display: block;
width: 100%;
height: 100%;
fill: none;
stroke: currentColor;
stroke-width: 2.25;
stroke-linecap: round;
stroke-linejoin: round;
}
.route-step-icon-stairs.descending {
transform: rotate(180deg);
}
.route-floor-button {
margin-top: .42rem;
padding: .38rem .72rem;
color: var(--panel-text);
background: var(--panel-light);
border-radius: .72rem;
font-size: 1rem;
}
.route-step-message {
padding: .55rem .62rem;
background: var(--panel-muted);
border-radius: .72rem;
}
.route-step-message strong {
display: block;
margin-bottom: .12rem;
}
.route-step-message > 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 {
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%);
} }
} }