bot/web/map/js/map.js
2026-09-09 17:08:45 +00:00

763 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* 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: Array(FLOOR_COUNT).fill(null),
floors_rooms: Array.from({length: FLOOR_COUNT}, () => []),
room_floors: Object.create(null),
inited: false,
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.
}
}
function normalize(value) {
return String(value || '').trim().toLocaleLowerCase('ru-RU')
}
function compact(value) {
return normalize(value).replace(/[^\p{L}\p{N}]/gu, '')
}
function roomResults(query, limit = 10) {
const needle = compact(query)
if (!needle) return []
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]
}
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)
}
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) {
// 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}