calculate navigator routes locally

This commit is contained in:
синечка ♡ 2026-09-09 17:27:47 +00:00
parent f69e6dc76f
commit b0a801a820
5 changed files with 195 additions and 23 deletions

View file

@ -25,8 +25,8 @@ uv run main.py
`route.request` через `Telegram.WebApp.sendData`. Обработчик находится в
`bot/navigator.py`, а протокол — в `models/navigator.py`.
Скомпилированный граф пока не подключён: `calculate_route` намеренно
возвращает `None`, поэтому бот отвечает «Маршрут не построен» и открывает
карту с выбранными аудиториями. После появления данных нужно заменить только
этот адаптер; UI уже умеет отрисовывать сегменты с индексами `#Точки` или
координатами.
Скомпилированный граф хранится локально в игнорируемом файле
`web/map/data/navigator.json`. `calculate_route` строит путь по нему без
сетевых запросов; внешний API навигатора используется только как эталон при
проверке. UI получает номера этажей и индексы точек и рисует маршрут по
координатам из того же manifest.

View file

@ -101,7 +101,7 @@ def _send_route_result(message: Message, request: RouteRequest, route: dict | No
else:
text = (
f"<b>Маршрут не построен</b>\n\n{start} - {end}\n"
"Для этой версии карты ещё не подключены скомпилированные точки."
"Не удалось найти путь между выбранными аудиториями."
)
return send(message, text, reply_markup=_result_markup(_map_url(request, route)))

View file

@ -13,8 +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.
# The navigator reads its local graph from web/map/data/navigator.json.
# That generated file is ignored by Git and must be supplied at deployment.
# Telegram user IDs
ADMINS: list[int] = [5016590523, 5001115363]

View file

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

View file

@ -649,7 +649,7 @@ function renderRouteCard() {
if (failed) {
title.textContent = 'Маршрут не построен'
steps.append(createRouteStep('Попробуй ещё раз', 'Для этой версии карты скомпилированные точки маршрута ещё не подключены.'))
steps.append(createRouteStep('Попробуй ещё раз', 'Не удалось найти путь между выбранными аудиториями.'))
} else {
title.textContent = 'Маршрут построен'
const segments = Array.isArray(state.route.segments) ? state.route.segments : []