diff --git a/README.md b/README.md index b1cffbf..378f849 100644 --- a/README.md +++ b/README.md @@ -16,4 +16,17 @@ uv run main.py ## Решение проблем - Если не работает рассылка с json то надо создать папку `./temp` руками -- Оно вообще не запустится без бекенда расписания, при желании его можно зареверсить из этого исходного кода, либо я когда-нибудь сделаю версию без парсеров \ No newline at end of file +- Оно вообще не запустится без бекенда расписания, при желании его можно зареверсить из этого исходного кода, либо я когда-нибудь сделаю версию без парсеров + +## Карта и навигатор + +Карта использует статичную светлую тему и не зависит от Telegram theme +переменных. Вкладка «Навигатор» отправляет в бот проверенный JSON-запрос +`route.request` через `Telegram.WebApp.sendData`. Обработчик находится в +`bot/navigator.py`, а протокол — в `models/navigator.py`. + +Скомпилированный граф пока не подключён: `calculate_route` намеренно +возвращает `None`, поэтому бот отвечает «Маршрут не построен» и открывает +карту с выбранными аудиториями. После появления данных нужно заменить только +этот адаптер; UI уже умеет отрисовывать сегменты с индексами `#Точки` или +координатами. diff --git a/bot/__init__.py b/bot/__init__.py index 8e9ad8f..3d379bb 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -16,6 +16,7 @@ from .main.register_user import * # #? Another code from .analytics import * +from .navigator import * from .iternal import * from .admin_schedule import * from .inline import * diff --git a/bot/navigator.py b/bot/navigator.py new file mode 100644 index 0000000..8da5fe1 --- /dev/null +++ b/bot/navigator.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +import html +import re +import time +from pathlib import Path +from urllib.parse import urlencode + +import config +from telebot.types import InlineKeyboardButton, InlineKeyboardMarkup, Message, WebAppInfo + +from models import logger +from models.bot import bot, send +from models.navigator import ( + RouteRequest, + calculate_route, + encode_route, + parse_route_request, +) + + +_SEEN_REQUESTS: dict[tuple[int, str], float] = {} +_SEEN_TTL = 300.0 +_ROOMS_BY_FLOOR: dict[str, set[str]] | None = None + + +def _map_rooms() -> dict[str, set[str]]: + """Read the room IDs from the same static SVGs used by the WebApp.""" + + global _ROOMS_BY_FLOOR + if _ROOMS_BY_FLOOR is not None: + return _ROOMS_BY_FLOOR + rooms: dict[str, set[str]] = {} + pattern = re.compile(r'serif:id="([^"]+)"') + root = Path(__file__).resolve().parents[1] / "web" / "map" / "images" + for floor in range(1, 7): + path = root / f"{floor}.svg" + try: + rooms[str(floor)] = set(pattern.findall(path.read_text(encoding="utf-8"))) + except OSError: + rooms[str(floor)] = set() + _ROOMS_BY_FLOOR = rooms + return rooms + + +def _validate_map_rooms(request: RouteRequest) -> bool: + rooms = _map_rooms() + # A missing bundle should not make the bot unusable during deployment; + # the actual navigator adapter will perform its own manifest validation. + if not any(rooms.values()): + return True + for endpoint in (request.start, request.end): + if endpoint.floor and endpoint.room not in rooms.get(endpoint.floor, set()): + return False + if not endpoint.floor and not any(endpoint.room in values for values in rooms.values()): + return False + return True + + +def _remember_request(user_id: int, request_id: str) -> bool: + now = time.monotonic() + expired = [key for key, timestamp in _SEEN_REQUESTS.items() if now - timestamp > _SEEN_TTL] + for key in expired: + _SEEN_REQUESTS.pop(key, None) + key = (user_id, request_id) + if key in _SEEN_REQUESTS: + return False + _SEEN_REQUESTS[key] = now + return True + + +def _map_url(request: RouteRequest, route: dict | None) -> str: + base = f"{config.WEB_BASE_URL.rstrip('/')}/map/" + params = { + "v": "14", + "mode": "navigator", + "start": request.start.room, + "end": request.end.room, + "status": "built" if route else "failed", + } + if request.start.floor: + params["sf"] = request.start.floor + if request.end.floor: + params["ef"] = request.end.floor + if route: + params["route"] = encode_route(route) + return f"{base}?{urlencode(params)}" + + +def _result_markup(url: str) -> InlineKeyboardMarkup: + markup = InlineKeyboardMarkup() + markup.add(InlineKeyboardButton("Открыть карту 🗺️", web_app=WebAppInfo(url))) + return markup + + +def _send_route_result(message: Message, request: RouteRequest, route: dict | None): + start = html.escape(request.start.room) + end = html.escape(request.end.room) + if route: + text = f"Маршрут построен\n\n{start} - {end}" + else: + text = ( + f"Маршрут не построен\n\n{start} - {end}\n" + "Для этой версии карты ещё не подключены скомпилированные точки." + ) + return send(message, text, reply_markup=_result_markup(_map_url(request, route))) + + +@bot.message_handler(content_types=["web_app_data"]) +def route_request_from_webapp(message: Message): + """Receive ``Telegram.WebApp.sendData`` from the navigator tab.""" + + data = getattr(getattr(message, "web_app_data", None), "data", "") + try: + request = parse_route_request(data) + except (TypeError, ValueError, UnicodeError) as error: + logger.error("Navigator", f"[{message.from_user.id}] invalid request: {error}") + return send(message, "Не удалось распознать запрос маршрута. Открой карту заново.") + + if not _remember_request(message.from_user.id, request.request_id): + return + + if not _validate_map_rooms(request): + return send(message, "Одной из аудиторий нет на карте. Выбери аудитории из подсказок.") + + try: + route = calculate_route(request) + except Exception as error: # keep a future adapter from breaking polling + logger.error("Navigator", f"[{message.from_user.id}] route calculation failed: {error}") + route = None + return _send_route_result(message, request, route) diff --git a/config.example.py b/config.example.py index d6c8f97..966f2be 100644 --- a/config.example.py +++ b/config.example.py @@ -13,5 +13,8 @@ WEB_BASE_URL: str = 'https://zatups.example.com' # Base URL for the schedule (should be runned separately) SCHEDULE_BASE_URL: str = 'http://10.9.8.3:18822' +# The map navigator is mocked until the compiled points/graph are supplied. +# Route requests are still validated and delivered back to the bot. + # Telegram user IDs ADMINS: list[int] = [5016590523, 5001115363] diff --git a/main.py b/main.py index bed1a27..4799374 100644 --- a/main.py +++ b/main.py @@ -4,7 +4,10 @@ if __name__ == '__main__': sys.path.append(os.path.dirname(os.path.abspath(__file__))) import models.logger - CMD = 'python3.13.exe' if sys.platform == 'win32' else 'uv run python' + # Reuse the interpreter that launched this process. Calling ``uv`` from + # a child shell breaks when uv was installed outside PATH (and also makes + # ``uv run main.py`` recurse through a second environment). + CMD = 'python3.13.exe' if sys.platform == 'win32' else f'"{sys.executable}"' PROCESSES = [ Process(target=bot.run if sys.platform == 'win32' else os.system, args=() if sys.platform == 'win32' else (f'{CMD} -c "import bot; bot.run()"',)), # Process(target=bot.run), @@ -23,4 +26,3 @@ if __name__ == '__main__': except Exception as e: models.logger.error('Main', f'{e}') - diff --git a/models/bot.py b/models/bot.py index 6449df9..d2630b5 100644 --- a/models/bot.py +++ b/models/bot.py @@ -101,7 +101,7 @@ class Markup_v2: markup.add( KeyboardButton( text=Strings.map, - web_app=WebAppInfo(f'{config.WEB_BASE_URL}/map/?v13') + web_app=WebAppInfo(f'{config.WEB_BASE_URL.rstrip("/")}/map/?v=14&mode=search') ), Strings.schedule, row_width=2 @@ -138,7 +138,7 @@ class Markup_v1: markup.add( KeyboardButton( text=Strings.map, - web_app=WebAppInfo(f'{config.WEB_BASE_URL}/map/?v13') + web_app=WebAppInfo(f'{config.WEB_BASE_URL.rstrip("/")}/map/?v=14&mode=search') ), Strings.search, row_width=2 diff --git a/models/navigator.py b/models/navigator.py new file mode 100644 index 0000000..0daf492 --- /dev/null +++ b/models/navigator.py @@ -0,0 +1,114 @@ +"""Validated protocol used by the map WebApp and the bot. + +The current map bundle intentionally does not contain the compiled graph yet. +Keeping parsing and the route adapter here means the real navigator can be +connected later without trusting nodes or edges supplied by the WebApp. +""" + +from __future__ import annotations + +import base64 +import binascii +import json +import re +from dataclasses import dataclass +from typing import Any + + +MAX_ROUTE_DATA = 4096 +MAX_REQUEST_ID = 80 +MAX_ENDPOINT_TEXT = 80 +_REQUEST_ID = re.compile(r"^[A-Za-z0-9_.:-]{1,80}$") + + +class NavigatorRequestError(ValueError): + """The WebApp sent an unsupported or unsafe route request.""" + + +@dataclass(frozen=True) +class RouteEndpoint: + room: str + floor: str | None = None + + +@dataclass(frozen=True) +class RouteRequest: + version: int + request_id: str + start: RouteEndpoint + end: RouteEndpoint + + +def _text(value: Any, field: str, limit: int = MAX_ENDPOINT_TEXT) -> str: + if not isinstance(value, str): + raise NavigatorRequestError(f"{field} должен быть строкой") + value = value.strip() + if not value or len(value) > limit or any(char in value for char in "<>\r\n"): + raise NavigatorRequestError(f"Некорректное поле {field}") + return value + + +def _endpoint(value: Any, field: str) -> RouteEndpoint: + if not isinstance(value, dict): + raise NavigatorRequestError(f"{field} должен быть объектом") + room = _text(value.get("room"), f"{field}.room") + floor_value = value.get("floor") + floor = None if floor_value is None else _text(floor_value, f"{field}.floor", 16) + return RouteEndpoint(room=room, floor=floor) + + +def parse_route_request(raw: str | bytes) -> RouteRequest: + if isinstance(raw, bytes): + if len(raw) > MAX_ROUTE_DATA: + raise NavigatorRequestError("Слишком большой запрос маршрута") + raw = raw.decode("utf-8", errors="strict") + elif not isinstance(raw, str) or len(raw.encode("utf-8")) > MAX_ROUTE_DATA: + raise NavigatorRequestError("Слишком большой запрос маршрута") + + try: + payload = json.loads(raw) + except (UnicodeError, json.JSONDecodeError) as error: + raise NavigatorRequestError("Запрос маршрута не является JSON") from error + + if not isinstance(payload, dict) or payload.get("v") != 1 or payload.get("type") != "route.request": + raise NavigatorRequestError("Неподдерживаемый формат запроса маршрута") + + request_id = _text(payload.get("request_id"), "request_id", MAX_REQUEST_ID) + if not _REQUEST_ID.fullmatch(request_id): + raise NavigatorRequestError("Некорректный request_id") + start = _endpoint(payload.get("start"), "start") + end = _endpoint(payload.get("end"), "end") + if start.room.casefold() == end.room.casefold() and start.floor == end.floor: + raise NavigatorRequestError("Начальная и конечная аудитории совпадают") + return RouteRequest(version=1, request_id=request_id, start=start, end=end) + + +def calculate_route(request: RouteRequest) -> dict[str, Any] | None: + """Return a trusted route or ``None`` until compiled graph data is added. + + This is an explicit mock rather than a proximity-based fallback. A real + implementation should load the server-side compiled ``neighbors`` / + ``graph`` data, validate both room IDs against its manifest, and return a + payload with ``segments`` containing node indexes or coordinates. + """ + + del request + return None + + +def encode_route(route: dict[str, Any]) -> str: + """Encode a route for a URL without putting credentials in the URL.""" + + data = json.dumps(route, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=") + + +def decode_route(value: str) -> dict[str, Any] | None: + """Decode a route in tests/tools; the WebApp performs the same operation.""" + + try: + padded = value + "=" * ((4 - len(value) % 4) % 4) + result = json.loads(base64.urlsafe_b64decode(padded).decode("utf-8")) + except (ValueError, TypeError, UnicodeError, binascii.Error, json.JSONDecodeError): + return None + return result if isinstance(result, dict) else None diff --git a/web/map/index.html b/web/map/index.html index cfd9fa0..b436693 100644 --- a/web/map/index.html +++ b/web/map/index.html @@ -1,54 +1,87 @@ - + - + + Карта - - + + -
+

Загрузка карты

0/6 загружено...

-
-
-
-
-
- 2 этаж - -
- -
- -
- - - - - +
+
+
+ + +
+
+ +
+ +
+ + +
+

Напиши название аудитории ниже

+
+ + +
+
+
+ + + + +
+
+ + + diff --git a/web/map/js/map.js b/web/map/js/map.js index 6bf0885..5a903ab 100644 --- a/web/map/js/map.js +++ b/web/map/js/map.js @@ -1,136 +1,763 @@ -const fdata = { +/* + * The map is deliberately self-contained. Telegram's theme variables are + * not used: the floor plans are white and the controls use one fixed palette + * both inside and outside Telegram. + */ +const FLOOR_COUNT = 6 +const MAP_VERSION = '1' + +const $ = (selector) => document.querySelector(selector) +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +const fdata = window.fdata = { floor: 2, - floors: [null,null,null,null,null,null], - floors_rooms: [[], [], [], [], [], []], + floors: Array(FLOOR_COUNT).fill(null), + floors_rooms: Array.from({length: FLOOR_COUNT}, () => []), + room_floors: Object.create(null), inited: false, - check: async () => { - var count = 0 - fdata.floors.forEach(e => e == null ? count += 1 : null) - document.querySelector('#block > p').innerHTML = `${6-count}/6 загружено...` - if (count == 0) { - await sleep(100) - mapdisplay() - removeblock() - } - }, - results: (query) => { - query = query.toLowerCase() - var found = [] - var foundrooms = [] - fdata.floors_rooms.forEach((rooms, floor) => { - rooms.forEach( - room => - room && room.toLowerCase().includes(query) && !foundrooms.includes(room) - ? function() {found.push({r: room, f: floor+1}); foundrooms.push(room)} () - : null - ) - }) - return found + results: (query, limit = 10) => roomResults(query, limit), +} + +const state = { + initialized: false, + mode: 'search', + highlightedRoom: '', + route: null, + routeStatus: null, + routeStart: null, + routeEnd: null, + navigatorManifest: null, + mapHeight: null, + routeView: false, + routeCollapsed: false, +} + +function haptic(type = 'light') { + try { + window.Telegram?.WebApp?.HapticFeedback?.impactOccurred(type) + } catch (_) { + // Haptic feedback is optional outside Telegram. } } -const floorsopen = () => { - document.querySelector('#controls > .select').classList.toggle('hidden') - Telegram.WebApp.HapticFeedback.impactOccurred('light') -} -const fswitch = floor => { - if (floor == fdata.floor) return - fdata.floor = floor - if (!document.querySelector('#controls > .select').classList.contains('hidden')) document.querySelector('#controls > .select').classList.add('hidden') - // - document.querySelector('#controls > .select > .active').classList.remove('active') - document.querySelector('#controls > .current > span').innerHTML = `${floor} этаж` - document.querySelectorAll('#controls > .select > span')[floor-1].classList.add('active') - input.placeholder = 'Введи нужную аудиторию...' - Telegram.WebApp.HapticFeedback.impactOccurred('light') - mapdisplay() +function normalize(value) { + return String(value || '').trim().toLocaleLowerCase('ru-RU') } -const removeblock = () => { - document.querySelector('#block').remove() -} -const highlight = (room) => { - document.querySelectorAll('.highlight').forEach(e => e.classList.remove('highlight')) - var lastfound = null - document.querySelectorAll('#Аудитории > g').forEach(e => { - if (e.getAttribute('serif:id') == room) { - e.querySelector('*').classList.add('highlight') - lastfound = e - } - }) - const scrollauto = (room) => { - const bbox = room.getBBox() - const svg = document.querySelector('#map > #wr > svg') - const roomCenterX = (bbox.x%svg.viewBox.baseVal.width) + bbox.width / 2 - const scale = svg.clientHeight / svg.viewBox.baseVal.height - const centerX = roomCenterX * scale - const sc = centerX - map.clientWidth / 2 - map.scroll({left: sc, behavior: 'smooth'}) - console.log(sc, bbox,roomCenterX, centerX, scale, map.clientWidth) - - } - const map = document.querySelector('#map > #wr') - if (room.includes('-')) { - const building = room.split('-')[0] - const values_to_scroll = { - 1: 1.1, - 2: 1.1, - 3: 1.1, - 4: 1.1, - 5: 1.1, - 6: 1.1, - 7: null, - 9: 10000, - 8: 3.9, - 10: 1.9 - - } - const val = values_to_scroll[building] - if (!val) {return scrollauto(lastfound)} - - const sc = (map.scrollWidth - map.clientWidth) / val - map.scroll({left: sc, behavior: 'smooth'}) - - } else if (room.includes('Туале')) { - const sc = (map.scrollWidth - map.clientWidth) / 1.1 - map.scroll({left: sc, behavior: 'smooth'}) - } else { - return scrollauto(lastfound) - } +function compact(value) { + return normalize(value).replace(/[^\p{L}\p{N}]/gu, '') } -const mapload = async () => { +function roomResults(query, limit = 10) { + const needle = compact(query) + if (!needle) return [] - for (let i = 0; i < 6; i++) { - await fetch(`./images/${i+1}.svg`).then( - async e => { - const mp = document.createElement('div'); - const svg = await e.text() - fdata.floors[i] = svg - mp.innerHTML = svg - mp.querySelectorAll('#Аудитории > g').forEach(e => { - // console.log(e.getAttributeNS('serif:id')) - fdata.floors_rooms[i].push(e.getAttribute('serif:id')) - }) - await sleep(100) - fdata.check() + const all = [] + const seen = new Set() + fdata.floors_rooms.forEach((rooms, index) => rooms.forEach((name) => { + const key = normalize(name) + if (!key || seen.has(key)) return + seen.add(key) + all.push({name, floor: index + 1}) + })) + + const exact = all.filter((room) => compact(room.name) === needle) + if (exact.length) return exact.slice(0, limit) + + const contains = all.filter((room) => compact(room.name).includes(needle)) + if (contains.length) return contains.slice(0, limit) + + // Keep the fallback useful for a mistyped room without pulling in a large + // fuzzy-search library. + const distance = (name) => { + const value = compact(name) + let previous = Array.from({length: needle.length + 1}, (_, i) => i) + for (let i = 0; i < value.length; i += 1) { + const current = [i + 1] + for (let j = 0; j < needle.length; j += 1) { + current.push(Math.min( + current[j] + 1, + previous[j + 1] + 1, + previous[j] + (value[i] === needle[j] ? 0 : 1), + )) } - ) + previous = current + } + return previous[needle.length] } - - // [0,1,2,3,4,5].map(async (_, i) => - // await fetch(`./images/${i+1}.svg`).then(async e => {fdata.floors[i] = await e.text(); fdata.check()})) + return all + .map((room) => ({room, score: distance(room.name)})) + .filter(({room, score}) => score <= Math.max(2, Math.ceil(compact(room.name).length * .45))) + .sort((a, b) => a.score - b.score || a.room.name.localeCompare(b.room.name, 'ru')) + .slice(0, limit) + .map(({room}) => room) } -const mapdisplay = async () => { - const map = document.querySelector('#map > #wr') - const svg = fdata.floors[fdata.floor-1] + +function exactRoom(value) { + const key = compact(value) + if (!key) return null + return roomResults(value, 100).find((room) => compact(room.name) === key) || null +} + +function showHints(container, results, onSelect) { + container.replaceChildren() + if (!results.length) { + const empty = document.createElement('span') + empty.className = 'hint-empty' + empty.textContent = 'Аудитория не найдена' + container.append(empty) + return + } + results.forEach((result) => { + const button = document.createElement('button') + button.type = 'button' + button.textContent = result.name + button.addEventListener('click', () => onSelect(result)) + container.append(button) + }) +} + +function clearHints(...containers) { + containers.forEach((container) => container?.replaceChildren()) +} + +function removeBlock() { + $('#block')?.remove() +} + +function applyMapHeightLock() { + const map = $('#map') + const wrapper = $('#wr') + if (!map || !wrapper || !state.mapHeight) return + const height = `${state.mapHeight}px` + map.style.height = height + wrapper.style.height = height +} + +function lockMapHeight() { + const map = $('#map') + const wrapper = $('#wr') + if (!map || !wrapper || state.mapHeight) return + const measured = Math.round(map.getBoundingClientRect().height) + const panel = $('#bottom-panel') + const fallback = Math.round(window.innerHeight - (panel?.getBoundingClientRect().height || 0)) + state.mapHeight = Math.max(1, measured || fallback) + document.documentElement.style.setProperty('--map-locked-height', `${state.mapHeight}px`) + map.classList.add('map-locked') + applyMapHeightLock() +} + +function setMapMessage(text = '') { + const element = $('#map-message') + if (!element) return + element.textContent = text + element.classList.toggle('hidden', !text) +} + +function updateLoading() { + const loaded = fdata.floors.filter(Boolean).length + const progress = $('#block > p') + if (progress) progress.textContent = `${loaded}/${FLOOR_COUNT} загружено...` +} + +function roomIdFromElement(element) { + return element.getAttribute('serif:id') || element.getAttribute('id') || '' +} + +function parseRooms(svgText, floor) { + const doc = new DOMParser().parseFromString(svgText, 'image/svg+xml') + const root = doc.querySelector('#Аудитории') + if (!root) return [] + const names = [] + const seen = new Set() + Array.from(root.children).filter((element) => element.tagName?.toLowerCase() === 'g').forEach((element) => { + const name = roomIdFromElement(element).trim() + if (!name || seen.has(normalize(name))) return + seen.add(normalize(name)) + names.push(name) + if (!fdata.room_floors[name]) fdata.room_floors[name] = floor + }) + return names +} + +async function loadNavigatorManifest() { + try { + const response = await fetch('./data/navigator.json', {cache: 'no-store'}) + if (!response.ok) return + const value = await response.json() + if (value && typeof value === 'object') state.navigatorManifest = value + } catch (_) { + // The compiled graph is optional while the map UI is developed. + } +} + +async function loadFloor(index) { + const response = await fetch(`./images/${index + 1}.svg`) + if (!response.ok) throw new Error(`floor ${index + 1}: ${response.status}`) + const text = await response.text() + fdata.floors[index] = text + fdata.floors_rooms[index] = parseRooms(text, index + 1) + updateLoading() +} + +function floorValue(value) { + const number = Number.parseInt(String(value), 10) + return Number.isInteger(number) && number >= 1 && number <= FLOOR_COUNT ? number : null +} + +function setFloor(floor, {keepHighlight = false} = {}) { + const next = floorValue(floor) + if (!next || !fdata.floors[next - 1]) return + fdata.floor = next + const current = $('#floor-current') + if (current) { + current.querySelector('span').textContent = `${next} этаж` + current.setAttribute('aria-expanded', 'false') + } + $('#floor-select')?.classList.add('hidden') + $('#floor-select')?.querySelectorAll('button').forEach((button) => { + button.classList.toggle('active', Number(button.dataset.floor) === next) + }) + renderFloor() + if (!keepHighlight) state.highlightedRoom = '' + haptic('light') +} + +function toggleFloors() { + const select = $('#floor-select') + const current = $('#floor-current') + if (!select || !current) return + const opened = select.classList.toggle('hidden') === false + current.setAttribute('aria-expanded', String(opened)) + haptic('light') +} + +function currentSvg() { + return $('#wr > svg') +} + +function scrollToRoom(element) { + const map = $('#wr') + const svg = currentSvg() + if (!svg || !map || !element) return + const maxScroll = Math.max(0, map.scrollWidth - map.clientWidth) + const room = roomIdFromElement(element) + const building = room.includes('-') ? room.split('-')[0] : '' + // The source drawings place buildings at different horizontal offsets. + // These are the calibrated offsets used by the previous map version. + const buildingScrollFactors = { + '1': 1.1, + '2': 1.1, + '3': 1.1, + '4': 1.1, + '5': 1.1, + '6': 1.1, + '7': null, + '8': 3.9, + '9': 10000, + '10': 1.9, + } + if (Object.prototype.hasOwnProperty.call(buildingScrollFactors, building) && buildingScrollFactors[building]) { + map.scrollTo({left: maxScroll / buildingScrollFactors[building], behavior: 'smooth'}) + return + } + if (room.includes('Туале')) { + map.scrollTo({left: maxScroll / 1.1, behavior: 'smooth'}) + return + } + try { + const bbox = element.getBBox() + const viewBox = svg.viewBox?.baseVal + const svgWidth = viewBox?.width || svg.getBoundingClientRect().width + const svgHeight = viewBox?.height || svg.getBoundingClientRect().height + const scale = svgHeight ? svg.clientHeight / svgHeight : 1 + const center = (bbox.x + bbox.width / 2) * scale + const contentWidth = Math.max(svgWidth * scale, svg.clientWidth) + map.scrollTo({left: Math.max(0, Math.min(contentWidth - map.clientWidth, center - map.clientWidth / 2)), behavior: 'smooth'}) + } catch (_) { + // Some SVG elements do not expose a bounding box in older WebViews. + } +} + +function roomElement(room) { + const svg = currentSvg() + if (!svg) return null + return Array.from(svg.querySelectorAll('#Аудитории > g')).find((element) => roomIdFromElement(element) === room) || null +} + +function highlight(room) { + currentSvg()?.querySelectorAll('.highlight').forEach((element) => element.classList.remove('highlight')) + const element = roomElement(room) + if (!element) return false + element.classList.add('highlight') + element.querySelectorAll('*').forEach((child) => child.classList.add('highlight')) + state.highlightedRoom = room + scrollToRoom(element) + return true +} + +function nodePoint(node) { + if (Array.isArray(node) && node.length >= 2) return {x: Number(node[0]), y: Number(node[1])} + if (!node || typeof node !== 'object') return null + const x = Number(node.x ?? node.cx ?? node[0]) + const y = Number(node.y ?? node.cy ?? node[1]) + return Number.isFinite(x) && Number.isFinite(y) ? {x, y} : null +} + +function manifestPoints(floorId) { + const manifest = state.navigatorManifest + if (!manifest) return [] + const floors = manifest.floors || manifest + const value = floors?.[String(floorId)] || floors?.[Number(floorId)] + return Array.isArray(value) ? value : (Array.isArray(value?.points) ? value.points : []) +} + +function segmentFloor(segment) { + const direct = floorValue(segment?.floor_name) || floorValue(segment?.floor_id) + if (direct) return direct + const floors = state.navigatorManifest?.floors || state.navigatorManifest + if (!floors || typeof floors !== 'object') return null + for (const [number, value] of Object.entries(floors)) { + if (value?.id != null && String(value.id) === String(segment?.floor_id)) return floorValue(number) + } + return null +} + +function routePoints(segment) { + if (Array.isArray(segment?.points)) return segment.points.map(nodePoint).filter(Boolean) + const explicit = manifestPoints(segment?.floor_id) + const fallback = explicit.length ? explicit : manifestPoints(segmentFloor(segment)) + const nodes = Array.isArray(segment?.node_ids) ? segment.node_ids : segment?.array + if (Array.isArray(nodes) && fallback.length) return nodes.map((node) => nodePoint(fallback[node] ?? node)).filter(Boolean) + + const svg = currentSvg() + const pointsGroup = svg?.querySelector('#Точки') + if (!pointsGroup || !Array.isArray(nodes)) return [] + const pointNodes = Array.from(pointsGroup.children) + return nodes.map((index) => { + const point = pointNodes[Number(index)] + if (!point) return null + return nodePoint({x: point.getAttribute('cx'), y: point.getAttribute('cy')}) + }).filter(Boolean) +} + +function drawRoute(segment) { + const svg = currentSvg() + if (!svg) return false + svg.querySelector('#Линия')?.remove() + const points = routePoints(segment) + if (points.length < 2) return false + + const group = document.createElementNS('http://www.w3.org/2000/svg', 'g') + group.id = 'Линия' + const viewBoxWidth = svg.viewBox?.baseVal?.width || 1000 + const strokeWidth = Math.max(4, viewBoxWidth * .006) + for (let index = 1; index < points.length; index += 1) { + const line = document.createElementNS('http://www.w3.org/2000/svg', 'line') + line.setAttribute('x1', String(points[index - 1].x)) + line.setAttribute('y1', String(points[index - 1].y)) + line.setAttribute('x2', String(points[index].x)) + line.setAttribute('y2', String(points[index].y)) + line.setAttribute('stroke', 'var(--line)') + line.setAttribute('stroke-width', String(strokeWidth)) + line.setAttribute('stroke-linecap', 'round') + line.setAttribute('stroke-linejoin', 'round') + line.setAttribute('vector-effect', 'non-scaling-stroke') + group.append(line) + } + const markerSize = Math.max(strokeWidth * 2.5, 12) + ;[points[0], points[points.length - 1]].forEach((point) => { + const marker = document.createElementNS('http://www.w3.org/2000/svg', 'rect') + marker.setAttribute('x', String(point.x - markerSize / 2)) + marker.setAttribute('y', String(point.y - markerSize / 2)) + marker.setAttribute('width', String(markerSize)) + marker.setAttribute('height', String(markerSize)) + marker.setAttribute('fill', 'var(--line)') + marker.setAttribute('stroke', '#ffffff') + marker.setAttribute('stroke-width', String(Math.max(1, strokeWidth * .25))) + marker.setAttribute('vector-effect', 'non-scaling-stroke') + group.append(marker) + }) + svg.append(group) + return true +} + +function findSegment(floor) { + const segments = state.route?.segments + if (!Array.isArray(segments)) return null + return segments.find((segment) => segmentFloor(segment) === Number(floor)) || null +} + +function drawCurrentRoute() { + if (!state.route) return + const segment = findSegment(fdata.floor) + if (!segment) { + currentSvg()?.querySelector('#Линия')?.remove() + return + } + if (!drawRoute(segment)) setMapMessage('В этой версии карты нет скомпилированных точек маршрута.') + else setMapMessage('') +} + +function renderFloor() { + const map = $('#wr') + const svg = fdata.floors[fdata.floor - 1] + if (!map || !svg) return map.innerHTML = svg if (!fdata.inited) { - const sc = (map.scrollWidth - map.clientWidth) / 1.1 - map.scrollLeft = sc; - console.log(sc) + // The source drawings are wide; starting near the centre gives a + // useful view while still allowing horizontal scrolling. + map.scrollLeft = Math.max(0, (map.scrollWidth - map.clientWidth) * .9) fdata.inited = true } + if (state.highlightedRoom && fdata.room_floors[state.highlightedRoom] === fdata.floor) highlight(state.highlightedRoom) + drawCurrentRoute() +} + +function updateSearchHints() { + const input = $('#room-search') + const hints = $('#search-hints') + const title = $('#search-title') + if (!input || !hints) return + const query = input.value.trim() + if (normalize(query) === 'eruda') { + initEruda() + clearHints(hints) + return + } + if (!query) { + if (title) title.textContent = 'Напиши название аудитории ниже' + return clearHints(hints) + } + const results = roomResults(query) + if (title) title.textContent = results.some((result) => compact(result.name) === compact(query)) + ? 'Аудитория найдена' + : (results.length ? 'Похожие аудитории' : 'Аудитория не найдена') + showHints(hints, results, (result) => { + input.value = result.name + clearHints(hints) + setFloor(result.floor) + highlight(result.name) + haptic('success') + }) +} + +function updateNavigatorHints(which) { + const input = which === 'start' ? $('#route-start') : $('#route-end') + const hints = which === 'start' ? $('#route-start-hints') : $('#route-end-hints') + if (!input || !hints) return + const query = input.value.trim() + if (!query) return clearHints(hints) + showHints(hints, roomResults(query), (result) => { + input.value = result.name + clearHints(hints) + if (which === 'start') state.routeStart = result + else state.routeEnd = result + setFloor(result.floor, {keepHighlight: true}) + highlight(result.name) + haptic('success') + }) +} + +function initEruda() { + if (window.__zatupsErudaInitialized || window.__zatupsErudaLoading) return + const start = () => { + if (!window.eruda) return + try { + window.eruda.init() + window.__zatupsErudaInitialized = true + } catch (_) { + // Dev-only console; never interrupt map use. + } + } + if (window.eruda) return start() + window.__zatupsErudaLoading = true + const script = document.createElement('script') + script.src = '../common/eruda.min.js?v14' + script.onload = () => { window.__zatupsErudaLoading = false; start() } + script.onerror = () => { window.__zatupsErudaLoading = false } + document.head.append(script) +} + +function switchMode(mode) { + if (state.routeView) return + state.mode = mode === 'navigator' ? 'navigator' : 'search' + const searchActive = state.mode === 'search' + $('#search-mode')?.classList.toggle('active', searchActive) + $('#navigator-mode')?.classList.toggle('active', !searchActive) + $('#search-mode')?.setAttribute('aria-selected', String(searchActive)) + $('#navigator-mode')?.setAttribute('aria-selected', String(!searchActive)) + $('#search-panel')?.classList.toggle('hidden', !searchActive) + $('#navigator-panel')?.classList.toggle('hidden', searchActive || Boolean(state.routeStatus)) + if (searchActive && !state.routeStatus) $('#route-card')?.classList.add('hidden') + if (!searchActive && !state.routeStatus) $('#route-card')?.classList.add('hidden') + haptic('light') +} + +function randomRequestId() { + try { + if (crypto.randomUUID) return crypto.randomUUID() + } catch (_) {} + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` +} + +function roomForRequest(input, remembered) { + const exact = exactRoom(input.value) + if (exact) return exact + return remembered && compact(remembered.name) === compact(input.value) ? remembered : null +} + +function submitRoute() { + const startInput = $('#route-start') + const endInput = $('#route-end') + const feedback = $('#route-feedback') + if (!startInput || !endInput || !feedback) return + const start = roomForRequest(startInput, state.routeStart) + const end = roomForRequest(endInput, state.routeEnd) + state.routeStart = start + state.routeEnd = end + if (!start || !end) { + feedback.textContent = 'Выбери две существующие аудитории из подсказок.' + haptic('error') + return + } + if (compact(start.name) === compact(end.name)) { + feedback.textContent = 'Начальная и конечная аудитории должны отличаться.' + haptic('error') + return + } + + const payload = { + v: 1, + type: 'route.request', + request_id: randomRequestId(), + start: {floor: String(start.floor), room: start.name}, + end: {floor: String(end.floor), room: end.name}, + } + window.__lastRouteRequest = payload + try { + const webApp = window.Telegram?.WebApp + if (typeof webApp?.sendData !== 'function') throw new Error('Telegram WebApp is unavailable') + webApp.sendData(JSON.stringify(payload)) + feedback.textContent = 'Запрос отправлен в бот. Вернись в чат за результатом.' + haptic('success') + } catch (_) { + feedback.textContent = 'Открой карту из кнопки бота, чтобы отправить маршрут.' + haptic('error') + } } +function decodeRoute(value) { + try { + const normalized = value.replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((value.length + 3) % 4) + const bytes = Uint8Array.from(atob(normalized), (char) => char.charCodeAt(0)) + return JSON.parse(new TextDecoder().decode(bytes)) + } catch (_) { + return null + } +} +function encodeText(value) { + return String(value || '').trim() +} + +function createRouteStep(title, description) { + const element = document.createElement('div') + element.className = 'route-step' + const heading = document.createElement('strong') + heading.textContent = title + const text = document.createElement('span') + text.textContent = description + element.append(heading, text) + return element +} + +function renderRouteSummary(summary, start, end) { + summary.replaceChildren() + if (!start || !end) return + const startLabel = document.createElement('span') + startLabel.textContent = start + const arrow = document.createElement('span') + arrow.className = 'route-arrow' + arrow.setAttribute('aria-hidden', 'true') + const endLabel = document.createElement('span') + endLabel.textContent = end + summary.append(startLabel, arrow, endLabel) +} + +function enterRouteView() { + state.routeView = true + state.routeCollapsed = false + state.mode = 'navigator' + $('#app')?.classList.add('route-view') + $('#route-card')?.classList.remove('hidden', 'route-collapsed') + const hide = $('#route-hide') + if (hide) { + hide.textContent = 'Скрыть' + hide.setAttribute('aria-expanded', 'true') + } +} + +function closeRouteView() { + state.routeView = false + state.routeCollapsed = false + state.routeStatus = null + state.route = null + state.routeStart = null + state.routeEnd = null + state.highlightedRoom = '' + $('#app')?.classList.remove('route-view') + $('#route-card')?.classList.add('hidden') + $('#route-card')?.classList.remove('route-collapsed', 'route-failed') + $('#route-start')?.setAttribute('value', '') + $('#route-end')?.setAttribute('value', '') + if ($('#route-start')) $('#route-start').value = '' + if ($('#route-end')) $('#route-end').value = '' + currentSvg()?.querySelectorAll('.highlight').forEach((element) => element.classList.remove('highlight')) + currentSvg()?.querySelector('#Линия')?.remove() + applyMapHeightLock() + switchMode('search') +} + +function toggleRouteCollapsed() { + const card = $('#route-card') + const button = $('#route-hide') + if (!card || !button || !state.routeView) return + state.routeCollapsed = !state.routeCollapsed + card.classList.toggle('route-collapsed', state.routeCollapsed) + button.textContent = state.routeCollapsed ? 'Показать' : 'Скрыть' + button.setAttribute('aria-expanded', String(!state.routeCollapsed)) + haptic('light') +} + +function renderRouteCard() { + const card = $('#route-card') + const title = $('#route-title') + const summary = $('#route-summary') + const steps = $('#route-steps') + if (!card || !title || !summary || !steps) return + steps.replaceChildren() + const start = encodeText(state.routeStart?.name || new URLSearchParams(location.search).get('start')) + const end = encodeText(state.routeEnd?.name || new URLSearchParams(location.search).get('end')) + renderRouteSummary(summary, start, end) + const failed = state.routeStatus === 'failed' || !state.route + card.classList.toggle('route-failed', failed) + + if (failed) { + title.textContent = 'Маршрут не построен' + steps.append(createRouteStep('Попробуй ещё раз', 'Для этой версии карты скомпилированные точки маршрута ещё не подключены.')) + } else { + title.textContent = 'Маршрут построен' + const segments = Array.isArray(state.route.segments) ? state.route.segments : [] + segments.forEach((segment, index) => { + const floor = segmentFloor(segment) || segment.floor_id || '?' + const nodes = Array.isArray(segment.node_ids || segment.array) ? (segment.node_ids || segment.array).length : 0 + steps.append(createRouteStep( + index === 0 ? `Начните на ${floor} этаже` : `Перейдите на ${floor} этаж`, + nodes ? `Точек маршрута: ${nodes}` : 'Покажите этот этаж на карте', + )) + }) + if (!segments.length) steps.append(createRouteStep('Маршрут пуст', 'Не удалось найти точки между аудиториями.')) + } + enterRouteView() +} + +function initRouteFromQuery() { + const params = new URLSearchParams(location.search) + const start = params.get('start') || '' + const end = params.get('end') || '' + const startRoom = exactRoom(start) + const endRoom = exactRoom(end) + if (startRoom) { + state.routeStart = startRoom + $('#route-start').value = startRoom.name + } else if (start) $('#route-start').value = start + if (endRoom) { + state.routeEnd = endRoom + $('#route-end').value = endRoom.name + } else if (end) $('#route-end').value = end + + const encoded = params.get('route') + state.route = encoded ? decodeRoute(encoded) : null + state.routeStatus = params.get('status') || (state.route ? 'built' : null) + if (start || end || state.route || state.routeStatus) switchMode('navigator') + if (state.route || state.routeStatus === 'failed') renderRouteCard() + + const floor = floorValue(params.get('floor') || params.get('sf') || startRoom?.floor) + if (floor) fdata.floor = floor +} + +function hydrateRouteEndpoints() { + const params = new URLSearchParams(location.search) + const start = state.routeStart || exactRoom(params.get('start') || '') + const end = state.routeEnd || exactRoom(params.get('end') || '') + if (start && !state.routeStart) { + state.routeStart = start + $('#route-start').value = start.name + } + if (end && !state.routeEnd) { + state.routeEnd = end + $('#route-end').value = end.name + } +} + +function bindEvents() { + $('#floor-current')?.addEventListener('click', toggleFloors) + $('#floor-select')?.querySelectorAll('button[data-floor]').forEach((button) => { + button.addEventListener('click', () => setFloor(button.dataset.floor)) + }) + $('#search-mode')?.addEventListener('click', () => switchMode('search')) + $('#navigator-mode')?.addEventListener('click', () => switchMode('navigator')) + $('#room-search')?.addEventListener('input', updateSearchHints) + $('#route-start')?.addEventListener('input', () => updateNavigatorHints('start')) + $('#route-end')?.addEventListener('input', () => updateNavigatorHints('end')) + $('#route-submit')?.addEventListener('click', submitRoute) + $('#route-hide')?.addEventListener('click', toggleRouteCollapsed) + $('#route-open-navigator')?.addEventListener('click', closeRouteView) + // Mobile WebViews resize their visual viewport when the keyboard opens. + // Reapply the captured size, but never measure the shrunken viewport. + document.addEventListener('focusin', (event) => { + if (event.target instanceof HTMLInputElement) applyMapHeightLock() + }) +} + +async function mapload() { + const results = await Promise.allSettled(Array.from({length: FLOOR_COUNT}, (_, index) => loadFloor(index))) + const failed = results.filter((result) => result.status === 'rejected').length + if (failed === FLOOR_COUNT) { + setMapMessage('Не удалось загрузить карту') + const progress = $('#block > p') + if (progress) progress.textContent = 'Проверь соединение и попробуй снова' + return + } + removeBlock() + hydrateRouteEndpoints() + renderFloor() + if (state.routeStart?.floor) setFloor(state.routeStart.floor, {keepHighlight: true}) + if (state.routeStart?.name) highlight(state.routeStart.name) + drawCurrentRoute() +} + +async function init() { + if (state.initialized) return + state.initialized = true + bindEvents() + initRouteFromQuery() + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))) + lockMapHeight() + await Promise.all([loadNavigatorManifest(), mapload()]) +} + +// Keep the small global API used by older deep links and by the developer +// console while exposing the new state in one namespace. +window.floorsopen = toggleFloors +window.fswitch = setFloor +window.highlight = highlight +window.mapload = mapload +window.find = (room, floor) => { + const input = $('#room-search') + if (input) input.value = room + setFloor(floor) + highlight(room) +} +window.ZatupsMap = {init, state, fdata, setFloor, highlight, submitRoute} diff --git a/web/map/styles/main.css b/web/map/styles/main.css index 8a78e6a..5fe372a 100644 --- a/web/map/styles/main.css +++ b/web/map/styles/main.css @@ -1,261 +1,541 @@ :root { - /* Telegram injects these variables inside its WebApp. Keep a light theme - for a normal browser, where that injection is absent. */ color-scheme: light; - --tg-theme-bg-color: #e5e5ea; - --tg-theme-text-color: #000000; - --tg-theme-hint-color: #707579; - --tg-theme-secondary-bg-color: #f1f1f1; - --tg-theme-destructive-text-color: #e53935; + --page: #ffffff; + --panel: #444444; + --panel-muted: #5e5e5e; + --panel-light: #707070; + --panel-text: #f6f6f6; + --panel-hint: #d4d4d4; + --white: #ffffff; + --accent: #b01820; + --line: #b01820; +} + +@property --found-fill { + syntax: ""; + inherits: true; + initial-value: #b01820; } * { - /* border: solid 1px #f00; */ - font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; - touch-action: pan-x pan-y; + box-sizing: border-box; + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; user-select: none; -webkit-tap-highlight-color: transparent; +} +html, body { + width: 100%; + height: 100%; + overflow: hidden; +} + +html { + font-size: 16px; } body { - overflow: hidden; margin: 0; - background: var(--tg-theme-bg-color); - color: var(--tg-theme-text-color); - height: 100vh; - /* height: 100dvh; */ - + color: var(--panel-text); + background: var(--page); + font-size: 1rem; +} + +button, input { + font: inherit; +} + +button { + border: 0; + cursor: pointer; +} + +input { + user-select: text; + -webkit-user-select: text; +} + +.hidden { + display: none !important; +} + +#app { + height: 100%; + min-height: 100%; } #map { - width: calc(100vw - 1rem); - margin: 0.5rem; - background: #fff; - /* box-shadow: rgba(255,255,255,.1) 0 0 .5rem .1rem; */ - border-radius: 1rem; - height: calc(100% - 7.5rem); - overflow: auto; - scrollbar-width: none; - padding: 0; - display: flex; - scroll-behavior: smooth; + position: absolute; + top: 0; + right: 0; + left: 0; + bottom: auto; + height: calc(100% - 12.2rem); + overflow: hidden; + background: var(--white); + border-bottom: 4px solid #f6e7a2; } -#map > .controls { - display: none; + +#map.map-locked { + height: var(--map-locked-height); } #wr { - height: 100%; - overflow-y: hidden; - scrollbar-width: none; -} -#wr > svg { - width: auto; - /* transform: translateX(27%); */ - /* height: calc(100% - 5rem); */ - /* scale: 1.1; */ -} -footer { - width: calc(100vw); - background: var(--tg-theme-bg-color); - position: fixed; - bottom: 0; - display: flex; - /* border: solid 2px var(--tg-theme-bg-color); */ - /* border-top: solid 2px var(--tg-theme-hint-color); */ - box-shadow: var(--tg-theme-secondary-bg-color) 0 0rem .9rem .2rem; - - border-radius: 1.5rem 1.5rem 0 0; - padding: .5rem 0 2.5rem; - flex-direction: column; - /* gap: .5rem; */ -} -.dragger { - height: .3rem; - background: var(--tg-theme-hint-color); - opacity: .2; - margin: 0 auto; - border-radius: 100vw; - width: 3rem; -} -footer > .i { - display: flex; - align-items: center; - background: var(--tg-theme-secondary-bg-color); - border-radius: 5rem; - padding: 0 .5rem; - margin: 0 1rem; - gap: .5rem; - margin-top: .5rem; - /* border: solid 1px var(--tg-theme-hint-color); */ - - padding-right: 0; -} -footer > .i > svg { - fill: var(--tg-theme-text-color) !important; -} -footer > .i > input { - background: transparent; - outline: none; - border: 0; - padding: .5rem 0; width: 100%; - font-size: 1.2rem; - color: var(--tg-theme-text-color); - font-family: Arial, Helvetica, sans-serif; -} -.i > input::placeholder { - color: var(--tg-theme-hint-color); -} -.i > button { - padding: .62rem .8rem; - /* font-weight: bold; */ - /* height: 100%; */ - border-radius: 100vw; - border: 0; - color: var(--tg-theme-bg-color); - background: var(--tg-theme-text-color); + height: 100%; + overflow: auto; + scrollbar-width: none; + overscroll-behavior: contain; + scroll-behavior: smooth; + background: var(--white); } - -.hints { - padding: 0 1rem; - display: flex; - flex-wrap: nowrap; - gap: .5rem; - overflow-x: auto; - scrollbar-width:none; -} -.hints.a { - padding: .6rem 1rem .3rem; -} -.hints > span { - background: var(--tg-theme-secondary-bg-color); - padding: .3rem .7rem; - border-radius: 100vw; - border: solid 1px var(--tg-theme-hint-color); - /* color: var(--tg-theme-hint-color); */ - text-wrap-mode: nowrap; - font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; -} - - -#controls { - margin: .5rem; - position: fixed; - display: flex; - flex-direction: column; - gap: .3rem; -} - -#controls > .current { - background: var(--tg-theme-bg-color); - padding: .2rem .4rem .2rem; - padding-left: .8rem; - display: flex; - align-items: center; - gap: .4rem; - border-radius: 10rem; - font-size: 1.2rem; - cursor: pointer; - width: fit-content; -} -#controls > .current > svg { - transform: translateY(.12rem); -} - - - -#controls > .select { - display: flex; - flex-direction: column; - padding: .5rem; - gap: .4rem; - background: var(--tg-theme-bg-color); - border-radius: 1.4rem; -} -#controls > .select > span { - background: var(--tg-theme-secondary-bg-color); - padding: .2rem .05rem; - text-align: center; - border-radius: .5rem; - font-size: 1.3rem; -} -#controls > .select > span:nth-child(1) { - border-radius: 1rem 1rem .5rem .5rem; -} -#controls > .select > span:nth-child(6) { - border-radius: .5rem .5rem 1rem 1rem ; -} -#controls > .select > span.active { - /* color: var(--tg-theme-bg-color); */ - background: var(--tg-theme-hint-color); -} - -#mp { - height: 10px; - width: 10px; - opacity: 0; -} -#block { - position: fixed; - top: 0; - left: 0; - z-index: 100; - height: 100vh; - /* height: 100dvh; */ - width: 100vw; - display: flex; - flex-direction: column; - background: var(--tg-theme-bg-color); - align-items: center; - justify-content: center; -} -#block > h3 { - font-weight: normal; - margin-bottom: 0; -}#block > p { - color: var(--tg-theme-hint-color); -} - -#loader { - height: 2rem; - width: 2rem; - border: solid 3px var(--tg-theme-text-color); - border-top: solid 3px transparent; - border-radius: 100vw; - animation: anim .7s linear infinite; -} - -@keyframes anim { - to { - transform: rotate(360deg); - } -} - -#controls > .select.hidden { +#wr::-webkit-scrollbar { display: none; } -#controls > .select > span { - cursor: pointer; +#wr > svg { + display: block; + width: auto; + min-width: 100%; + height: 100%; + background: var(--white); } -.highlight, .highlight > *, .highlight > * > * { - fill: var(--tg-theme-destructive-text-color) !important; - /* animation: highlight 2s linear infinite; */ +#controls { + position: absolute; + z-index: 5; + top: .65rem; + left: .65rem; + display: flex; + flex-direction: column; + align-items: stretch; + gap: .35rem; } -@keyframes highlight { - 0% { - fill: var(--tg-theme-destructive-text-color) !important; - } - 50% { - fill: var(--tg-theme-bg-color) !important; +.floor-current, .floor-select { + color: var(--panel-text); + background: rgba(68, 68, 68, .94); + border-radius: .75rem; + box-shadow: 0 2px 8px rgba(0, 0, 0, .16); +} - } - 100% { - fill: var(--tg-theme-destructive-text-color) !important; +.floor-current { + display: flex; + align-items: center; + gap: .35rem; + padding: .35rem .55rem .35rem .75rem; + font-size: 1.05rem; + white-space: nowrap; +} + +.floor-current svg { + width: 1.15rem; + height: 1.15rem; + fill: currentColor; + transition: transform .16s ease; +} + +.floor-current[aria-expanded="true"] svg { + transform: rotate(180deg); +} + +.floor-select { + display: flex; + flex-direction: column; + gap: .18rem; + padding: .32rem; +} + +.floor-select button { + min-width: 4.8rem; + padding: .28rem .45rem; + color: var(--panel-text); + background: transparent; + border-radius: .48rem; + font-size: .98rem; +} + +.floor-select button.active, +.floor-select button:active { + background: var(--panel-light); +} + +.highlight > *, +.highlight > * > * { + fill: var(--found-fill) !important; + stroke: #171717 !important; +} + +.highlight { + --found-fill: var(--accent); + animation: found-fill 2s ease-in-out infinite; +} + +#Линия { + --found-fill: var(--line); + animation: found-fill 2s ease-in-out infinite; + transform-box: fill-box; + transform-origin: center; +} + +#Линия line { + stroke: var(--found-fill) !important; +} + +#Линия rect { + fill: var(--found-fill) !important; +} + +@keyframes found-fill { + 0%, 100% { --found-fill: var(--accent); } + 50% { --found-fill: #7f1118; } +} + +#map-message { + position: absolute; + z-index: 4; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + max-width: calc(100% - 2rem); + padding: .75rem 1rem; + color: #fff; + background: rgba(68, 68, 68, .94); + border-radius: .85rem; + text-align: center; +} + +#bottom-panel { + position: fixed; + z-index: 10; + right: 0; + bottom: 0; + left: 0; + display: flex; + flex-direction: column; + justify-content: flex-end; + gap: .38rem; + min-height: 12.2rem; + padding: .42rem .62rem calc(.65rem + env(safe-area-inset-bottom)); + background: transparent; +} + +/* A route is a focused, read-only map view. The map itself remains usable, + * while floor controls, tabs, and both input panels stay out of the way. */ +#app.route-view #controls, +#app.route-view #mode-switch, +#app.route-view #search-panel, +#app.route-view #navigator-panel { + display: none !important; +} + +#app.route-view #bottom-panel { + min-height: 0; + gap: 0; +} + +#mode-switch { + display: flex; + align-self: flex-start; + overflow: hidden; + border-radius: 1.2rem; + background: var(--panel); +} + +#mode-switch button { + min-width: 5.4rem; + padding: .28rem .8rem; + color: #eee; + background: var(--panel); + font-size: 1rem; +} + +#mode-switch button.active { + color: #fff; + background: #8d8d8d; +} + +#mode-switch, +.panel-card, +.route-card { + box-shadow: 0 .7rem 2.2rem rgba(0, 0, 0, .16), 0 .12rem .45rem rgba(0, 0, 0, .12); +} + +.panel-card, .route-card { + position: relative; + width: 100%; + padding: .62rem .68rem .68rem; + color: var(--panel-text); + background: var(--panel); + border-radius: 1rem; +} + +.panel-title { + margin: 0 0 .48rem; + font-size: 1.02rem; + line-height: 1.2; +} + +.field-row { + display: flex; + align-items: center; + gap: .45rem; + min-height: 2.35rem; + padding: .1rem .58rem; + color: var(--panel-hint); + background: var(--panel-muted); + border-radius: .72rem; +} + +.field-row svg { + flex: 0 0 1.25rem; + width: 1.25rem; + height: 1.25rem; + fill: currentColor; +} + +.field-row input { + min-width: 0; + width: 100%; + padding: .36rem 0; + color: #fff; + background: transparent; + border: 0; + outline: none; + font-size: 1rem; +} + +.field-row input::placeholder { + color: #c6c6c6; + opacity: .8; +} + +.hints { + display: flex; + flex-wrap: wrap; + gap: .35rem; + max-height: 4.5rem; + padding-top: .35rem; + overflow: auto; + scrollbar-width: none; +} + +.hints:empty { + display: none; +} + +.hints::-webkit-scrollbar { + display: none; +} + +.hints button, .hint-empty { + padding: .28rem .68rem; + color: #333; + background: #f8f8f8; + border-radius: 1rem; + font-size: .91rem; + white-space: nowrap; +} + +.hint-empty { + color: var(--panel-hint); + background: var(--panel-light); +} + +.navigator-fields { + display: grid; + gap: .28rem; +} + +.navigator-fields .hints { + padding: 0 0 .08rem 1.7rem; +} + +.primary-button, .secondary-button { + min-height: 2.3rem; + padding: .34rem 1.1rem; + border-radius: .78rem; + font-size: 1rem; +} + +.primary-button { + display: block; + margin: .5rem 0 0 auto; + color: #333; + background: #fff; +} + +.primary-button:disabled { + color: #aaa; + background: #777; + cursor: not-allowed; +} + +.secondary-button { + color: #fff; + background: var(--panel-light); +} + +.feedback { + min-height: 0; + margin: .3rem 0 0; + color: #ffd1d1; + font-size: .85rem; +} + +.route-card { + max-height: min(62vh, 26rem); + overflow: auto; +} + +.route-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: .7rem; +} + +.route-title { + display: block; + margin: .42rem 0 .58rem; + font-size: 1.05rem; +} + +.route-card:not(.route-failed) .route-title { + display: none; +} + +.route-card-head button { + flex: 0 0 auto; + padding: .3rem .8rem; + color: #fff; + background: var(--panel-light); + border-radius: .7rem; +} + +#route-summary { + min-width: 0; + margin: 0; + color: var(--panel-text); +} + +.route-summary { + display: flex; + align-items: center; + gap: .62rem; + line-height: 1.2; +} + +.route-summary > span:not(.route-arrow) { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Keep the route direction independent from the active font. */ +.route-arrow { + flex: 0 0 .56rem; + width: .56rem; + height: .56rem; + margin-right: .3rem; + color: var(--panel-hint); + border-top: .12rem solid currentColor; + border-right: .12rem solid currentColor; + transform: rotate(45deg); +} + +.route-steps { + display: grid; + gap: .42rem; + margin-bottom: .65rem; +} + +.route-step { + padding: .48rem .58rem; + background: var(--panel-muted); + border-radius: .68rem; +} + +.route-step strong { + display: block; + margin-bottom: .12rem; +} + +.route-step span { + color: var(--panel-hint); + font-size: .9rem; +} + +#app.route-view #route-card.route-collapsed { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: .65rem; + max-height: none; + overflow: visible; +} + +#app.route-view #route-card.route-collapsed .route-card-head { + display: contents; +} + +#app.route-view #route-card.route-collapsed #route-summary { + grid-column: 1; + grid-row: 1; +} + +#app.route-view #route-card.route-collapsed #route-hide { + grid-column: 2; + grid-row: 1; +} + +#app.route-view #route-card.route-collapsed #route-title, +#app.route-view #route-card.route-collapsed #route-steps, +#app.route-view #route-card.route-collapsed #route-open-navigator { + display: none; +} + +#block { + position: fixed; + z-index: 100; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: #333; + background: var(--page); +} + +#block h3 { + margin: 1rem 0 .1rem; + font-weight: 500; +} + +#block p { + margin: 0; + color: #555; +} + +#loader { + width: 2rem; + height: 2rem; + border: 3px solid #444; + border-top-color: transparent; + border-radius: 50%; + animation: spin .7s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +@media (min-width: 600px) { + #bottom-panel { + right: 50%; + width: min(34rem, 100%); + transform: translateX(50%); } }