/* * 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 = '15' 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 map itself remains usable if the embedded navigator data is unavailable. } } async function loadFloor(index) { const response = await fetch(`./images/${index + 1}.svg?v=${MAP_VERSION}`) 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)] if (Array.isArray(value)) return value if (Array.isArray(value?.points)) return value.points if (Array.isArray(value?.positions)) return value.positions if (Array.isArray(value?.pathfinder_data?.positions)) return value.pathfinder_data.positions return [] } 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?v${MAP_VERSION}` 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 route-step-message' const heading = document.createElement('strong') heading.textContent = title const text = document.createElement('span') text.textContent = description element.append(heading, text) 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) { 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 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 : [] renderRouteInstructions(steps, segments) } 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) // 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}