redesign map and mock navigator flow
This commit is contained in:
parent
bb50c3e199
commit
1e185e1607
10 changed files with 1594 additions and 390 deletions
13
README.md
13
README.md
|
|
@ -17,3 +17,16 @@ uv run main.py
|
|||
## Решение проблем
|
||||
- Если не работает рассылка с json то надо создать папку `./temp` руками
|
||||
- Оно вообще не запустится без бекенда расписания, при желании его можно зареверсить из этого исходного кода, либо я когда-нибудь сделаю версию без парсеров
|
||||
|
||||
## Карта и навигатор
|
||||
|
||||
Карта использует статичную светлую тему и не зависит от Telegram theme
|
||||
переменных. Вкладка «Навигатор» отправляет в бот проверенный JSON-запрос
|
||||
`route.request` через `Telegram.WebApp.sendData`. Обработчик находится в
|
||||
`bot/navigator.py`, а протокол — в `models/navigator.py`.
|
||||
|
||||
Скомпилированный граф пока не подключён: `calculate_route` намеренно
|
||||
возвращает `None`, поэтому бот отвечает «Маршрут не построен» и открывает
|
||||
карту с выбранными аудиториями. После появления данных нужно заменить только
|
||||
этот адаптер; UI уже умеет отрисовывать сегменты с индексами `#Точки` или
|
||||
координатами.
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ from .main.register_user import *
|
|||
|
||||
# #? Another code
|
||||
from .analytics import *
|
||||
from .navigator import *
|
||||
from .iternal import *
|
||||
from .admin_schedule import *
|
||||
from .inline import *
|
||||
|
|
|
|||
131
bot/navigator.py
Normal file
131
bot/navigator.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import config
|
||||
from telebot.types import InlineKeyboardButton, InlineKeyboardMarkup, Message, WebAppInfo
|
||||
|
||||
from models import logger
|
||||
from models.bot import bot, send
|
||||
from models.navigator import (
|
||||
RouteRequest,
|
||||
calculate_route,
|
||||
encode_route,
|
||||
parse_route_request,
|
||||
)
|
||||
|
||||
|
||||
_SEEN_REQUESTS: dict[tuple[int, str], float] = {}
|
||||
_SEEN_TTL = 300.0
|
||||
_ROOMS_BY_FLOOR: dict[str, set[str]] | None = None
|
||||
|
||||
|
||||
def _map_rooms() -> dict[str, set[str]]:
|
||||
"""Read the room IDs from the same static SVGs used by the WebApp."""
|
||||
|
||||
global _ROOMS_BY_FLOOR
|
||||
if _ROOMS_BY_FLOOR is not None:
|
||||
return _ROOMS_BY_FLOOR
|
||||
rooms: dict[str, set[str]] = {}
|
||||
pattern = re.compile(r'serif:id="([^"]+)"')
|
||||
root = Path(__file__).resolve().parents[1] / "web" / "map" / "images"
|
||||
for floor in range(1, 7):
|
||||
path = root / f"{floor}.svg"
|
||||
try:
|
||||
rooms[str(floor)] = set(pattern.findall(path.read_text(encoding="utf-8")))
|
||||
except OSError:
|
||||
rooms[str(floor)] = set()
|
||||
_ROOMS_BY_FLOOR = rooms
|
||||
return rooms
|
||||
|
||||
|
||||
def _validate_map_rooms(request: RouteRequest) -> bool:
|
||||
rooms = _map_rooms()
|
||||
# A missing bundle should not make the bot unusable during deployment;
|
||||
# the actual navigator adapter will perform its own manifest validation.
|
||||
if not any(rooms.values()):
|
||||
return True
|
||||
for endpoint in (request.start, request.end):
|
||||
if endpoint.floor and endpoint.room not in rooms.get(endpoint.floor, set()):
|
||||
return False
|
||||
if not endpoint.floor and not any(endpoint.room in values for values in rooms.values()):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _remember_request(user_id: int, request_id: str) -> bool:
|
||||
now = time.monotonic()
|
||||
expired = [key for key, timestamp in _SEEN_REQUESTS.items() if now - timestamp > _SEEN_TTL]
|
||||
for key in expired:
|
||||
_SEEN_REQUESTS.pop(key, None)
|
||||
key = (user_id, request_id)
|
||||
if key in _SEEN_REQUESTS:
|
||||
return False
|
||||
_SEEN_REQUESTS[key] = now
|
||||
return True
|
||||
|
||||
|
||||
def _map_url(request: RouteRequest, route: dict | None) -> str:
|
||||
base = f"{config.WEB_BASE_URL.rstrip('/')}/map/"
|
||||
params = {
|
||||
"v": "14",
|
||||
"mode": "navigator",
|
||||
"start": request.start.room,
|
||||
"end": request.end.room,
|
||||
"status": "built" if route else "failed",
|
||||
}
|
||||
if request.start.floor:
|
||||
params["sf"] = request.start.floor
|
||||
if request.end.floor:
|
||||
params["ef"] = request.end.floor
|
||||
if route:
|
||||
params["route"] = encode_route(route)
|
||||
return f"{base}?{urlencode(params)}"
|
||||
|
||||
|
||||
def _result_markup(url: str) -> InlineKeyboardMarkup:
|
||||
markup = InlineKeyboardMarkup()
|
||||
markup.add(InlineKeyboardButton("Открыть карту 🗺️", web_app=WebAppInfo(url)))
|
||||
return markup
|
||||
|
||||
|
||||
def _send_route_result(message: Message, request: RouteRequest, route: dict | None):
|
||||
start = html.escape(request.start.room)
|
||||
end = html.escape(request.end.room)
|
||||
if route:
|
||||
text = f"<b>Маршрут построен</b>\n\n{start} - {end}"
|
||||
else:
|
||||
text = (
|
||||
f"<b>Маршрут не построен</b>\n\n{start} - {end}\n"
|
||||
"Для этой версии карты ещё не подключены скомпилированные точки."
|
||||
)
|
||||
return send(message, text, reply_markup=_result_markup(_map_url(request, route)))
|
||||
|
||||
|
||||
@bot.message_handler(content_types=["web_app_data"])
|
||||
def route_request_from_webapp(message: Message):
|
||||
"""Receive ``Telegram.WebApp.sendData`` from the navigator tab."""
|
||||
|
||||
data = getattr(getattr(message, "web_app_data", None), "data", "")
|
||||
try:
|
||||
request = parse_route_request(data)
|
||||
except (TypeError, ValueError, UnicodeError) as error:
|
||||
logger.error("Navigator", f"[{message.from_user.id}] invalid request: {error}")
|
||||
return send(message, "Не удалось распознать запрос маршрута. Открой карту заново.")
|
||||
|
||||
if not _remember_request(message.from_user.id, request.request_id):
|
||||
return
|
||||
|
||||
if not _validate_map_rooms(request):
|
||||
return send(message, "Одной из аудиторий нет на карте. Выбери аудитории из подсказок.")
|
||||
|
||||
try:
|
||||
route = calculate_route(request)
|
||||
except Exception as error: # keep a future adapter from breaking polling
|
||||
logger.error("Navigator", f"[{message.from_user.id}] route calculation failed: {error}")
|
||||
route = None
|
||||
return _send_route_result(message, request, route)
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
6
main.py
6
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}')
|
||||
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ class Markup_v2:
|
|||
markup.add(
|
||||
KeyboardButton(
|
||||
text=Strings.map,
|
||||
web_app=WebAppInfo(f'{config.WEB_BASE_URL}/map/?v13')
|
||||
web_app=WebAppInfo(f'{config.WEB_BASE_URL.rstrip("/")}/map/?v=14&mode=search')
|
||||
),
|
||||
Strings.schedule,
|
||||
row_width=2
|
||||
|
|
@ -138,7 +138,7 @@ class Markup_v1:
|
|||
markup.add(
|
||||
KeyboardButton(
|
||||
text=Strings.map,
|
||||
web_app=WebAppInfo(f'{config.WEB_BASE_URL}/map/?v13')
|
||||
web_app=WebAppInfo(f'{config.WEB_BASE_URL.rstrip("/")}/map/?v=14&mode=search')
|
||||
),
|
||||
Strings.search,
|
||||
row_width=2
|
||||
|
|
|
|||
114
models/navigator.py
Normal file
114
models/navigator.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
"""Validated protocol used by the map WebApp and the bot.
|
||||
|
||||
The current map bundle intentionally does not contain the compiled graph yet.
|
||||
Keeping parsing and the route adapter here means the real navigator can be
|
||||
connected later without trusting nodes or edges supplied by the WebApp.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
MAX_ROUTE_DATA = 4096
|
||||
MAX_REQUEST_ID = 80
|
||||
MAX_ENDPOINT_TEXT = 80
|
||||
_REQUEST_ID = re.compile(r"^[A-Za-z0-9_.:-]{1,80}$")
|
||||
|
||||
|
||||
class NavigatorRequestError(ValueError):
|
||||
"""The WebApp sent an unsupported or unsafe route request."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RouteEndpoint:
|
||||
room: str
|
||||
floor: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RouteRequest:
|
||||
version: int
|
||||
request_id: str
|
||||
start: RouteEndpoint
|
||||
end: RouteEndpoint
|
||||
|
||||
|
||||
def _text(value: Any, field: str, limit: int = MAX_ENDPOINT_TEXT) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise NavigatorRequestError(f"{field} должен быть строкой")
|
||||
value = value.strip()
|
||||
if not value or len(value) > limit or any(char in value for char in "<>\r\n"):
|
||||
raise NavigatorRequestError(f"Некорректное поле {field}")
|
||||
return value
|
||||
|
||||
|
||||
def _endpoint(value: Any, field: str) -> RouteEndpoint:
|
||||
if not isinstance(value, dict):
|
||||
raise NavigatorRequestError(f"{field} должен быть объектом")
|
||||
room = _text(value.get("room"), f"{field}.room")
|
||||
floor_value = value.get("floor")
|
||||
floor = None if floor_value is None else _text(floor_value, f"{field}.floor", 16)
|
||||
return RouteEndpoint(room=room, floor=floor)
|
||||
|
||||
|
||||
def parse_route_request(raw: str | bytes) -> RouteRequest:
|
||||
if isinstance(raw, bytes):
|
||||
if len(raw) > MAX_ROUTE_DATA:
|
||||
raise NavigatorRequestError("Слишком большой запрос маршрута")
|
||||
raw = raw.decode("utf-8", errors="strict")
|
||||
elif not isinstance(raw, str) or len(raw.encode("utf-8")) > MAX_ROUTE_DATA:
|
||||
raise NavigatorRequestError("Слишком большой запрос маршрута")
|
||||
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except (UnicodeError, json.JSONDecodeError) as error:
|
||||
raise NavigatorRequestError("Запрос маршрута не является JSON") from error
|
||||
|
||||
if not isinstance(payload, dict) or payload.get("v") != 1 or payload.get("type") != "route.request":
|
||||
raise NavigatorRequestError("Неподдерживаемый формат запроса маршрута")
|
||||
|
||||
request_id = _text(payload.get("request_id"), "request_id", MAX_REQUEST_ID)
|
||||
if not _REQUEST_ID.fullmatch(request_id):
|
||||
raise NavigatorRequestError("Некорректный request_id")
|
||||
start = _endpoint(payload.get("start"), "start")
|
||||
end = _endpoint(payload.get("end"), "end")
|
||||
if start.room.casefold() == end.room.casefold() and start.floor == end.floor:
|
||||
raise NavigatorRequestError("Начальная и конечная аудитории совпадают")
|
||||
return RouteRequest(version=1, request_id=request_id, start=start, end=end)
|
||||
|
||||
|
||||
def calculate_route(request: RouteRequest) -> dict[str, Any] | None:
|
||||
"""Return a trusted route or ``None`` until compiled graph data is added.
|
||||
|
||||
This is an explicit mock rather than a proximity-based fallback. A real
|
||||
implementation should load the server-side compiled ``neighbors`` /
|
||||
``graph`` data, validate both room IDs against its manifest, and return a
|
||||
payload with ``segments`` containing node indexes or coordinates.
|
||||
"""
|
||||
|
||||
del request
|
||||
return None
|
||||
|
||||
|
||||
def encode_route(route: dict[str, Any]) -> str:
|
||||
"""Encode a route for a URL without putting credentials in the URL."""
|
||||
|
||||
data = json.dumps(route, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def decode_route(value: str) -> dict[str, Any] | None:
|
||||
"""Decode a route in tests/tools; the WebApp performs the same operation."""
|
||||
|
||||
try:
|
||||
padded = value + "=" * ((4 - len(value) % 4) % 4)
|
||||
result = json.loads(base64.urlsafe_b64decode(padded).decode("utf-8"))
|
||||
except (ValueError, TypeError, UnicodeError, binascii.Error, json.JSONDecodeError):
|
||||
return None
|
||||
return result if isinstance(result, dict) else None
|
||||
|
|
@ -1,54 +1,87 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
<title>Карта</title>
|
||||
<script src="../common/telegram.js"></script>
|
||||
<script src="../common/eruda.min.js?v13"></script>
|
||||
<link rel="stylesheet" href="./styles/main.css?v13">
|
||||
<script src="../common/eruda.min.js?v14"></script>
|
||||
<link rel="stylesheet" href="./styles/main.css?v14">
|
||||
</head>
|
||||
<body>
|
||||
<section id="block">
|
||||
<section id="block" aria-live="polite">
|
||||
<div id="loader"></div>
|
||||
<h3>Загрузка карты</h3>
|
||||
<p>0/6 загружено...</p>
|
||||
<div id="mp"></div>
|
||||
</section>
|
||||
<section id="map">
|
||||
<div id="controls">
|
||||
<div class="current" onclick="floorsopen()">
|
||||
<span>2 этаж</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e3e3e3"><path d="M480-344 240-584l56-56 184 184 184-184 56 56-240 240Z"/></svg>
|
||||
</div>
|
||||
<div class="select hidden">
|
||||
<span onclick="fswitch(1)">1 этаж</span>
|
||||
<span onclick="fswitch(2)" class="active">2 этаж</span>
|
||||
<span onclick="fswitch(3)">3 этаж</span>
|
||||
<span onclick="fswitch(4)">4 этаж</span>
|
||||
<span onclick="fswitch(5)">5 этаж</span>
|
||||
<span onclick="fswitch(6)">6 этаж</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="wr"></div>
|
||||
</section>
|
||||
|
||||
<footer >
|
||||
<!-- <div class="dragger"></div> -->
|
||||
<div class="hints">
|
||||
</div>
|
||||
<div class="i">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e3e3e3"><path d="M480-480q33 0 56.5-23.5T560-560q0-33-23.5-56.5T480-640q-33 0-56.5 23.5T400-560q0 33 23.5 56.5T480-480Zm0 294q122-112 181-203.5T720-552q0-109-69.5-178.5T480-800q-101 0-170.5 69.5T240-552q0 71 59 162.5T480-186Zm0 106Q319-217 239.5-334.5T160-552q0-150 96.5-239T480-880q127 0 223.5 89T800-552q0 100-79.5 217.5T480-80Zm0-480Z"/></svg>
|
||||
<input type="text" placeholder="Введи нужную аудиторию...">
|
||||
<!-- <button onclick="footerfind()">Найти</button> -->
|
||||
</div>
|
||||
</footer>
|
||||
<script src="./js/footer.js?v13"></script>
|
||||
<script src="./js/map.js?v13"></script>
|
||||
<script>mapload()</script>
|
||||
<script>
|
||||
window.Telegram.WebApp.expand()
|
||||
</script>
|
||||
<main id="app" aria-label="Карта университета">
|
||||
<section id="map" aria-label="Карта здания">
|
||||
<div id="controls">
|
||||
<button id="floor-current" class="floor-current" type="button" aria-expanded="false">
|
||||
<span>2 этаж</span>
|
||||
<svg aria-hidden="true" viewBox="0 -960 960 960"><path d="M480-344 240-584l56-56 184 184 184-184 56 56-240 240Z"/></svg>
|
||||
</button>
|
||||
<div id="floor-select" class="floor-select hidden" aria-label="Выбор этажа">
|
||||
<button type="button" data-floor="1">1 этаж</button>
|
||||
<button type="button" data-floor="2" class="active">2 этаж</button>
|
||||
<button type="button" data-floor="3">3 этаж</button>
|
||||
<button type="button" data-floor="4">4 этаж</button>
|
||||
<button type="button" data-floor="5">5 этаж</button>
|
||||
<button type="button" data-floor="6">6 этаж</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="wr" aria-live="polite"></div>
|
||||
<div id="map-message" class="map-message hidden"></div>
|
||||
</section>
|
||||
|
||||
<section id="bottom-panel" aria-label="Поиск и навигация">
|
||||
<nav id="mode-switch" aria-label="Режим карты">
|
||||
<button id="search-mode" type="button" class="active" aria-selected="true">Поиск</button>
|
||||
<button id="navigator-mode" type="button" aria-selected="false">Навигатор</button>
|
||||
</nav>
|
||||
|
||||
<section id="search-panel" class="panel-card" aria-labelledby="search-mode">
|
||||
<p id="search-title" class="panel-title">Напиши название аудитории ниже</p>
|
||||
<div class="field-row">
|
||||
<svg aria-hidden="true" viewBox="0 -960 960 960"><path d="M480-480q33 0 56.5-23.5T560-560q0-33-23.5-56.5T480-640q-33 0-56.5 23.5T400-560q0 33 23.5 56.5T480-480Zm0 294q122-112 181-203.5T720-552q0-109-69.5-178.5T480-800q-101 0-170.5 69.5T240-552q0 71 59 162.5T480-186Zm0 106Q319-217 239.5-334.5T160-552q0-150 96.5-239T480-880q127 0 223.5 89T800-552q0 100-79.5 217.5T480-80Zm0-480Z"/></svg>
|
||||
<input id="room-search" type="text" autocomplete="off" inputmode="search" placeholder="Писать сюда..." aria-label="Поиск аудитории">
|
||||
</div>
|
||||
<div id="search-hints" class="hints" aria-live="polite"></div>
|
||||
</section>
|
||||
|
||||
<section id="navigator-panel" class="panel-card hidden" aria-labelledby="navigator-mode">
|
||||
<p class="panel-title">Построй маршрут до аудитории</p>
|
||||
<div class="navigator-fields">
|
||||
<div class="field-row">
|
||||
<svg aria-hidden="true" viewBox="0 -960 960 960"><path d="m120-120 80-80h560l80 80H120Zm80-160v-160h560v160H200Zm0-240v-160h560v160H200Zm0-240v-80h560v80H200Z"/></svg>
|
||||
<input id="route-start" type="text" autocomplete="off" inputmode="search" placeholder="Аудитория возле тебя..." aria-label="Начальная аудитория">
|
||||
</div>
|
||||
<div id="route-start-hints" class="hints" aria-live="polite"></div>
|
||||
<div class="field-row">
|
||||
<svg aria-hidden="true" viewBox="0 -960 960 960"><path d="M480-80 160-400l56-56 224 224v-528h80v528l224-224 56 56L480-80Z"/></svg>
|
||||
<input id="route-end" type="text" autocomplete="off" inputmode="search" placeholder="Аудитория которая тебе нужна..." aria-label="Конечная аудитория">
|
||||
</div>
|
||||
<div id="route-end-hints" class="hints" aria-live="polite"></div>
|
||||
</div>
|
||||
<button id="route-submit" class="primary-button" type="button">Найти</button>
|
||||
<p id="route-feedback" class="feedback" aria-live="polite"></p>
|
||||
</section>
|
||||
|
||||
<section id="route-card" class="route-card hidden" aria-live="polite">
|
||||
<div class="route-card-head">
|
||||
<p id="route-summary" class="route-summary" aria-label="Маршрут"></p>
|
||||
<button id="route-hide" type="button" aria-expanded="true">Скрыть</button>
|
||||
</div>
|
||||
<strong id="route-title" class="route-title">Маршрут</strong>
|
||||
<div id="route-steps" class="route-steps"></div>
|
||||
<button id="route-open-navigator" class="secondary-button" type="button">Закрыть маршрут</button>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="./js/map.js?v14"></script>
|
||||
<script>window.Telegram.WebApp.expand(); window.ZatupsMap.init();</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -1,261 +1,541 @@
|
|||
:root {
|
||||
/* Telegram injects these variables inside its WebApp. Keep a light theme
|
||||
for a normal browser, where that injection is absent. */
|
||||
color-scheme: light;
|
||||
--tg-theme-bg-color: #e5e5ea;
|
||||
--tg-theme-text-color: #000000;
|
||||
--tg-theme-hint-color: #707579;
|
||||
--tg-theme-secondary-bg-color: #f1f1f1;
|
||||
--tg-theme-destructive-text-color: #e53935;
|
||||
--page: #ffffff;
|
||||
--panel: #444444;
|
||||
--panel-muted: #5e5e5e;
|
||||
--panel-light: #707070;
|
||||
--panel-text: #f6f6f6;
|
||||
--panel-hint: #d4d4d4;
|
||||
--white: #ffffff;
|
||||
--accent: #b01820;
|
||||
--line: #b01820;
|
||||
}
|
||||
|
||||
@property --found-fill {
|
||||
syntax: "<color>";
|
||||
inherits: true;
|
||||
initial-value: #b01820;
|
||||
}
|
||||
|
||||
* {
|
||||
/* border: solid 1px #f00; */
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
touch-action: pan-x pan-y;
|
||||
box-sizing: border-box;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
body {
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
background: var(--tg-theme-bg-color);
|
||||
color: var(--tg-theme-text-color);
|
||||
height: 100vh;
|
||||
/* height: 100dvh; */
|
||||
color: var(--panel-text);
|
||||
background: var(--page);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
button, input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input {
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
#map {
|
||||
width: calc(100vw - 1rem);
|
||||
margin: 0.5rem;
|
||||
background: #fff;
|
||||
/* box-shadow: rgba(255,255,255,.1) 0 0 .5rem .1rem; */
|
||||
border-radius: 1rem;
|
||||
height: calc(100% - 7.5rem);
|
||||
overflow: auto;
|
||||
scrollbar-width: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
scroll-behavior: smooth;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
bottom: auto;
|
||||
height: calc(100% - 12.2rem);
|
||||
overflow: hidden;
|
||||
background: var(--white);
|
||||
border-bottom: 4px solid #f6e7a2;
|
||||
}
|
||||
#map > .controls {
|
||||
display: none;
|
||||
|
||||
#map.map-locked {
|
||||
height: var(--map-locked-height);
|
||||
}
|
||||
|
||||
#wr {
|
||||
height: 100%;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
#wr > svg {
|
||||
width: auto;
|
||||
/* transform: translateX(27%); */
|
||||
/* height: calc(100% - 5rem); */
|
||||
/* scale: 1.1; */
|
||||
}
|
||||
footer {
|
||||
width: calc(100vw);
|
||||
background: var(--tg-theme-bg-color);
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
/* border: solid 2px var(--tg-theme-bg-color); */
|
||||
/* border-top: solid 2px var(--tg-theme-hint-color); */
|
||||
box-shadow: var(--tg-theme-secondary-bg-color) 0 0rem .9rem .2rem;
|
||||
|
||||
border-radius: 1.5rem 1.5rem 0 0;
|
||||
padding: .5rem 0 2.5rem;
|
||||
flex-direction: column;
|
||||
/* gap: .5rem; */
|
||||
}
|
||||
.dragger {
|
||||
height: .3rem;
|
||||
background: var(--tg-theme-hint-color);
|
||||
opacity: .2;
|
||||
margin: 0 auto;
|
||||
border-radius: 100vw;
|
||||
width: 3rem;
|
||||
}
|
||||
footer > .i {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--tg-theme-secondary-bg-color);
|
||||
border-radius: 5rem;
|
||||
padding: 0 .5rem;
|
||||
margin: 0 1rem;
|
||||
gap: .5rem;
|
||||
margin-top: .5rem;
|
||||
/* border: solid 1px var(--tg-theme-hint-color); */
|
||||
|
||||
padding-right: 0;
|
||||
}
|
||||
footer > .i > svg {
|
||||
fill: var(--tg-theme-text-color) !important;
|
||||
}
|
||||
footer > .i > input {
|
||||
background: transparent;
|
||||
outline: none;
|
||||
border: 0;
|
||||
padding: .5rem 0;
|
||||
width: 100%;
|
||||
font-size: 1.2rem;
|
||||
color: var(--tg-theme-text-color);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
.i > input::placeholder {
|
||||
color: var(--tg-theme-hint-color);
|
||||
}
|
||||
.i > button {
|
||||
padding: .62rem .8rem;
|
||||
/* font-weight: bold; */
|
||||
/* height: 100%; */
|
||||
border-radius: 100vw;
|
||||
border: 0;
|
||||
color: var(--tg-theme-bg-color);
|
||||
background: var(--tg-theme-text-color);
|
||||
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%);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue