embed pathfinder map and render route instructions
1
.gitignore
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
10
README.md
|
|
@ -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 этажей содержат соответствующую группу `#Точки`.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
Before Width: | Height: | Size: 696 KiB After Width: | Height: | Size: 710 KiB |
3038
web/map/images/2.svg
|
Before Width: | Height: | Size: 723 KiB After Width: | Height: | Size: 751 KiB |
|
Before Width: | Height: | Size: 697 KiB After Width: | Height: | Size: 708 KiB |
|
Before Width: | Height: | Size: 668 KiB After Width: | Height: | Size: 678 KiB |
|
Before Width: | Height: | Size: 620 KiB After Width: | Height: | Size: 628 KiB |
|
|
@ -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 |
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||