838 lines
31 KiB
JavaScript
838 lines
31 KiB
JavaScript
/*
|
||
* 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}
|