embed pathfinder map and render route instructions

This commit is contained in:
синечка ♡ 2026-09-09 17:42:31 +00:00
parent b0a801a820
commit bd7be0a5ef
16 changed files with 17053 additions and 1398 deletions

1
.gitignore vendored
View file

@ -8,7 +8,6 @@ timetables/*.json
.VSCodeCounter .VSCodeCounter
.timetracker .timetracker
web/analytics/data.json web/analytics/data.json
web/map/data/navigator.json
DEBUG DEBUG
schedule_client.py schedule_client.py
config.py config.py

View file

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

View file

@ -72,7 +72,7 @@ def _remember_request(user_id: int, request_id: str) -> bool:
def _map_url(request: RouteRequest, route: dict | None) -> str: def _map_url(request: RouteRequest, route: dict | None) -> str:
base = f"{config.WEB_BASE_URL.rstrip('/')}/map/" base = f"{config.WEB_BASE_URL.rstrip('/')}/map/"
params = { params = {
"v": "14", "v": "15",
"mode": "navigator", "mode": "navigator",
"start": request.start.room, "start": request.start.room,
"end": request.end.room, "end": request.end.room,

View file

@ -13,8 +13,8 @@ WEB_BASE_URL: str = 'https://zatups.example.com'
# Base URL for the schedule (should be runned separately) # Base URL for the schedule (should be runned separately)
SCHEDULE_BASE_URL: str = 'http://10.9.8.3:18822' SCHEDULE_BASE_URL: str = 'http://10.9.8.3:18822'
# The navigator reads its local graph from web/map/data/navigator.json. # The navigator graph is embedded in web/map/data/navigator.json and is shared
# That generated file is ignored by Git and must be supplied at deployment. # by the bot and the WebView.
# Telegram user IDs # Telegram user IDs
ADMINS: list[int] = [5016590523, 5001115363] ADMINS: list[int] = [5016590523, 5001115363]

View file

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

View file

@ -225,7 +225,7 @@ def _segment(floor: CompiledFloor, path: list[int]) -> dict[str, Any]:
def calculate_route(request: RouteRequest) -> dict[str, Any] | None: def calculate_route(request: RouteRequest) -> dict[str, Any] | None:
"""Calculate a route from the ignored local pathfinder manifest.""" """Calculate a route from the pathfinder manifest shared with the WebView."""
floors = _floors() floors = _floors()
start_floor = _resolve_floor(floors, request.start) start_floor = _resolve_floor(floors, request.start)
@ -254,8 +254,16 @@ def calculate_route(request: RouteRequest) -> dict[str, Any] | None:
if not start_path or not second_floor: if not start_path or not second_floor:
return None return None
second_start = _nearest_node(second_floor, start_floor.positions[start_path[-1]]) second_start = _nearest_node(second_floor, start_floor.positions[start_path[-1]])
if second_start is None:
return None
if end_floor.name == "2":
second_path = _bfs(second_floor, second_start, request.end.room)
if not second_path:
return None
return {"v": 1, "segments": [_segment(start_floor, start_path), _segment(second_floor, second_path)]}
second_end = _nearest_node(second_floor, end_floor.positions[end_path[-1]]) second_end = _nearest_node(second_floor, end_floor.positions[end_path[-1]])
if second_start is None or second_end is None: if second_end is None:
return None return None
second_path = _bfs(second_floor, second_start, second_end) second_path = _bfs(second_floor, second_start, second_end)
if not second_path: if not second_path:

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

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 696 KiB

After

Width:  |  Height:  |  Size: 710 KiB

File diff suppressed because it is too large Load diff

Before

Width:  |  Height:  |  Size: 723 KiB

After

Width:  |  Height:  |  Size: 751 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 697 KiB

After

Width:  |  Height:  |  Size: 708 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 668 KiB

After

Width:  |  Height:  |  Size: 678 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 620 KiB

After

Width:  |  Height:  |  Size: 628 KiB

View file

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

Before

Width:  |  Height:  |  Size: 536 KiB

After

Width:  |  Height:  |  Size: 538 KiB

View file

@ -6,8 +6,8 @@
<meta name="theme-color" content="#ffffff"> <meta name="theme-color" content="#ffffff">
<title>Карта</title> <title>Карта</title>
<script src="../common/telegram.js"></script> <script src="../common/telegram.js"></script>
<script src="../common/eruda.min.js?v14"></script> <script src="../common/eruda.min.js?v15"></script>
<link rel="stylesheet" href="./styles/main.css?v14"> <link rel="stylesheet" href="./styles/main.css?v15">
</head> </head>
<body> <body>
<section id="block" aria-live="polite"> <section id="block" aria-live="polite">
@ -76,12 +76,11 @@
</div> </div>
<strong id="route-title" class="route-title">Маршрут</strong> <strong id="route-title" class="route-title">Маршрут</strong>
<div id="route-steps" class="route-steps"></div> <div id="route-steps" class="route-steps"></div>
<button id="route-open-navigator" class="secondary-button" type="button">Закрыть маршрут</button>
</section> </section>
</section> </section>
</main> </main>
<script src="./js/map.js?v14"></script> <script src="./js/map.js?v15"></script>
<script>window.Telegram.WebApp.expand(); window.ZatupsMap.init();</script> <script>window.Telegram.WebApp.expand(); window.ZatupsMap.init();</script>
</body> </body>
</html> </html>

View file

@ -4,7 +4,7 @@
* both inside and outside Telegram. * both inside and outside Telegram.
*/ */
const FLOOR_COUNT = 6 const FLOOR_COUNT = 6
const MAP_VERSION = '1' const MAP_VERSION = '15'
const $ = (selector) => document.querySelector(selector) const $ = (selector) => document.querySelector(selector)
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
@ -187,12 +187,12 @@ async function loadNavigatorManifest() {
const value = await response.json() const value = await response.json()
if (value && typeof value === 'object') state.navigatorManifest = value if (value && typeof value === 'object') state.navigatorManifest = value
} catch (_) { } catch (_) {
// The compiled graph is optional while the map UI is developed. // The map itself remains usable if the embedded navigator data is unavailable.
} }
} }
async function loadFloor(index) { async function loadFloor(index) {
const response = await fetch(`./images/${index + 1}.svg`) const response = await fetch(`./images/${index + 1}.svg?v=${MAP_VERSION}`)
if (!response.ok) throw new Error(`floor ${index + 1}: ${response.status}`) if (!response.ok) throw new Error(`floor ${index + 1}: ${response.status}`)
const text = await response.text() const text = await response.text()
fdata.floors[index] = text fdata.floors[index] = text
@ -477,7 +477,7 @@ function initEruda() {
if (window.eruda) return start() if (window.eruda) return start()
window.__zatupsErudaLoading = true window.__zatupsErudaLoading = true
const script = document.createElement('script') const script = document.createElement('script')
script.src = '../common/eruda.min.js?v14' script.src = `../common/eruda.min.js?v${MAP_VERSION}`
script.onload = () => { window.__zatupsErudaLoading = false; start() } script.onload = () => { window.__zatupsErudaLoading = false; start() }
script.onerror = () => { window.__zatupsErudaLoading = false } script.onerror = () => { window.__zatupsErudaLoading = false }
document.head.append(script) document.head.append(script)
@ -567,7 +567,7 @@ function encodeText(value) {
function createRouteStep(title, description) { function createRouteStep(title, description) {
const element = document.createElement('div') const element = document.createElement('div')
element.className = 'route-step' element.className = 'route-step route-step-message'
const heading = document.createElement('strong') const heading = document.createElement('strong')
heading.textContent = title heading.textContent = title
const text = document.createElement('span') const text = document.createElement('span')
@ -576,6 +576,107 @@ function createRouteStep(title, description) {
return element return element
} }
function createRouteIcon(kind, {descending = false} = {}) {
const wrapper = document.createElement('span')
wrapper.className = `route-step-icon route-step-icon-${kind}${descending ? ' descending' : ''}`
wrapper.setAttribute('aria-hidden', 'true')
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
svg.setAttribute('viewBox', '0 0 24 24')
svg.setAttribute('focusable', 'false')
const paths = {
pin: ['M12 22s7-6.2 7-13a7 7 0 1 0-14 0c0 6.8 7 13 7 13Z', 'M12 6.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5Z'],
turn: ['M5 4v8a5 5 0 0 0 5 5h8', 'm14 13 4 4-4 4'],
stairs: ['M3 20h5v-4h4v-4h4V8h5V4'],
}
;(paths[kind] || []).forEach((data) => {
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
path.setAttribute('d', data)
svg.append(path)
})
wrapper.append(svg)
return wrapper
}
function scrollToCurrentRoute() {
const map = $('#wr')
const svg = currentSvg()
const route = svg?.querySelector('#Линия')
if (!map || !svg || !route) return
try {
const bbox = route.getBBox()
const viewBox = svg.viewBox?.baseVal
const svgHeight = viewBox?.height || svg.getBoundingClientRect().height
const scale = svgHeight ? svg.clientHeight / svgHeight : 1
const center = (bbox.x + bbox.width / 2) * scale
const contentWidth = Math.max((viewBox?.width || svg.clientWidth) * scale, svg.clientWidth)
map.scrollTo({
left: Math.max(0, Math.min(contentWidth - map.clientWidth, center - map.clientWidth / 2)),
behavior: 'smooth',
})
} catch (_) {
// Older WebViews can fail to calculate an injected SVG group's bbox.
}
}
function showRouteFloor(floor) {
setFloor(floor, {keepHighlight: true})
requestAnimationFrame(scrollToCurrentRoute)
}
function createRouteInstruction(label, icon, {floor = null, descending = false} = {}) {
const element = document.createElement('div')
element.className = 'route-step'
const row = document.createElement('div')
row.className = 'route-step-row'
const text = document.createElement('p')
text.className = 'route-step-label'
text.textContent = label
row.append(text, createRouteIcon(icon, {descending}))
element.append(row)
if (floor) {
const button = document.createElement('button')
button.type = 'button'
button.className = 'route-floor-button'
button.textContent = 'Показать этаж'
button.addEventListener('click', () => showRouteFloor(floor))
element.append(button)
}
return element
}
function renderRouteInstructions(steps, segments) {
const routeSegments = segments
.map((segment) => ({segment, floor: segmentFloor(segment)}))
.filter(({floor}) => Boolean(floor))
if (!routeSegments.length) {
steps.append(createRouteStep('Маршрут пуст', 'Не удалось найти точки между аудиториями.'))
return
}
const startFloor = routeSegments[0].floor
steps.append(createRouteInstruction(`Вы сейчас на ${startFloor} этаже`, 'pin', {floor: startFloor}))
if (routeSegments.length === 1) {
steps.append(createRouteInstruction('Пройдите до аудитории', 'pin'))
return
}
steps.append(createRouteInstruction('Пройдите до лестницы', 'turn'))
for (let index = 1; index < routeSegments.length; index += 1) {
const previousFloor = routeSegments[index - 1].floor
const floor = routeSegments[index].floor
if (floor !== previousFloor) {
const descending = floor < previousFloor
steps.append(createRouteInstruction(
`${descending ? 'Спуститесь' : 'Поднимитесь'} на ${floor} этаж`,
'stairs',
{floor, descending},
))
}
const last = index === routeSegments.length - 1
steps.append(createRouteInstruction(last ? 'Пройдите до аудитории' : 'Пройдите до лестницы', last ? 'pin' : 'turn'))
}
}
function renderRouteSummary(summary, start, end) { function renderRouteSummary(summary, start, end) {
summary.replaceChildren() summary.replaceChildren()
if (!start || !end) return if (!start || !end) return
@ -602,27 +703,6 @@ function enterRouteView() {
} }
} }
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() { function toggleRouteCollapsed() {
const card = $('#route-card') const card = $('#route-card')
const button = $('#route-hide') const button = $('#route-hide')
@ -653,15 +733,7 @@ function renderRouteCard() {
} else { } else {
title.textContent = 'Маршрут построен' title.textContent = 'Маршрут построен'
const segments = Array.isArray(state.route.segments) ? state.route.segments : [] const segments = Array.isArray(state.route.segments) ? state.route.segments : []
segments.forEach((segment, index) => { renderRouteInstructions(steps, segments)
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() enterRouteView()
} }
@ -717,7 +789,6 @@ function bindEvents() {
$('#route-end')?.addEventListener('input', () => updateNavigatorHints('end')) $('#route-end')?.addEventListener('input', () => updateNavigatorHints('end'))
$('#route-submit')?.addEventListener('click', submitRoute) $('#route-submit')?.addEventListener('click', submitRoute)
$('#route-hide')?.addEventListener('click', toggleRouteCollapsed) $('#route-hide')?.addEventListener('click', toggleRouteCollapsed)
$('#route-open-navigator')?.addEventListener('click', closeRouteView)
// Mobile WebViews resize their visual viewport when the keyboard opens. // Mobile WebViews resize their visual viewport when the keyboard opens.
// Reapply the captured size, but never measure the shrunken viewport. // Reapply the captured size, but never measure the shrunken viewport.
document.addEventListener('focusin', (event) => { document.addEventListener('focusin', (event) => {

View file

@ -352,7 +352,7 @@ input {
padding: 0 0 .08rem 1.7rem; padding: 0 0 .08rem 1.7rem;
} }
.primary-button, .secondary-button { .primary-button {
min-height: 2.3rem; min-height: 2.3rem;
padding: .34rem 1.1rem; padding: .34rem 1.1rem;
border-radius: .78rem; border-radius: .78rem;
@ -372,11 +372,6 @@ input {
cursor: not-allowed; cursor: not-allowed;
} }
.secondary-button {
color: #fff;
background: var(--panel-light);
}
.feedback { .feedback {
min-height: 0; min-height: 0;
margin: .3rem 0 0; margin: .3rem 0 0;
@ -448,22 +443,72 @@ input {
.route-steps { .route-steps {
display: grid; display: grid;
gap: .42rem; gap: .72rem;
margin-bottom: .65rem; margin-top: .72rem;
} }
.route-step { .route-step {
padding: .48rem .58rem; padding: .12rem 0;
background: var(--panel-muted);
border-radius: .68rem;
} }
.route-step strong { .route-step-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: .9rem;
min-height: 1.8rem;
}
.route-step-label {
margin: 0;
color: var(--panel-text);
font-size: 1.08rem;
line-height: 1.25;
}
.route-step-icon {
flex: 0 0 1.55rem;
width: 1.55rem;
height: 1.55rem;
color: var(--panel-text);
}
.route-step-icon svg {
display: block;
width: 100%;
height: 100%;
fill: none;
stroke: currentColor;
stroke-width: 2.25;
stroke-linecap: round;
stroke-linejoin: round;
}
.route-step-icon-stairs.descending {
transform: rotate(180deg);
}
.route-floor-button {
margin-top: .42rem;
padding: .38rem .72rem;
color: var(--panel-text);
background: var(--panel-light);
border-radius: .72rem;
font-size: 1rem;
}
.route-step-message {
padding: .55rem .62rem;
background: var(--panel-muted);
border-radius: .72rem;
}
.route-step-message strong {
display: block; display: block;
margin-bottom: .12rem; margin-bottom: .12rem;
} }
.route-step span { .route-step-message > span {
color: var(--panel-hint); color: var(--panel-hint);
font-size: .9rem; font-size: .9rem;
} }
@ -492,8 +537,7 @@ input {
} }
#app.route-view #route-card.route-collapsed #route-title, #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-steps {
#app.route-view #route-card.route-collapsed #route-open-navigator {
display: none; display: none;
} }