who_need_help/assets/js/hooks.js

2625 lines
87 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.

import maplibregl from "maplibre-gl"
import {
clusterExpansionTarget,
clusterExpansionCenter,
clusterIconDescriptor,
pointFeatureCollection,
pointSourceOptions,
serverClusterDescriptor
} from "./map_cluster_features.mjs"
import {
mapSizeChanged,
shouldApplyInitialViewport,
viewportMatchesBounds
} from "./map_viewport_sync.mjs"
const trackingMinTimeMs = 5000
const uiLocale = () => document.documentElement.lang || undefined
const uiText = key => document.documentElement.dataset[key] || ""
const mapLocaleByLanguage = {
ru: {
"AttributionControl.ToggleAttribution": "Показать сведения об источниках",
"AttributionControl.MapFeedback": "Сообщить о карте",
"FullscreenControl.Enter": "На весь экран",
"FullscreenControl.Exit": "Выйти из полноэкранного режима",
"GeolocateControl.FindMyLocation": "Найти моё местоположение",
"GeolocateControl.LocationNotAvailable": "Местоположение недоступно",
"LogoControl.Title": "Логотип MapLibre",
"Map.Title": "Карта",
"Marker.Title": "Маркер на карте",
"NavigationControl.ResetBearing":
"Перетащите, чтобы повернуть карту; нажмите, чтобы вернуть север вверх",
"NavigationControl.ZoomIn": "Увеличить масштаб",
"NavigationControl.ZoomOut": "Уменьшить масштаб",
"Popup.Close": "Закрыть",
"ScaleControl.Feet": "футы",
"ScaleControl.Meters": "метры",
"ScaleControl.Kilometers": "километры",
"ScaleControl.Miles": "мили",
"ScaleControl.NauticalMiles": "морские мили",
"GlobeControl.Enable": "Показать глобус",
"GlobeControl.Disable": "Показать плоскую карту",
"TerrainControl.Enable": "Показать рельеф",
"TerrainControl.Disable": "Скрыть рельеф",
"CooperativeGesturesHandler.WindowsHelpText":
"Удерживайте Ctrl при прокрутке, чтобы изменить масштаб карты",
"CooperativeGesturesHandler.MacHelpText":
"Удерживайте ⌘ при прокрутке, чтобы изменить масштаб карты",
"CooperativeGesturesHandler.MobileHelpText":
"Используйте два пальца, чтобы перемещать карту"
},
uk: {
"AttributionControl.ToggleAttribution": "Показати відомості про джерела",
"AttributionControl.MapFeedback": "Повідомити про карту",
"FullscreenControl.Enter": "На весь екран",
"FullscreenControl.Exit": "Вийти з повноекранного режиму",
"GeolocateControl.FindMyLocation": "Знайти моє місцезнаходження",
"GeolocateControl.LocationNotAvailable": "Місцезнаходження недоступне",
"LogoControl.Title": "Логотип MapLibre",
"Map.Title": "Карта",
"Marker.Title": "Маркер на карті",
"NavigationControl.ResetBearing":
"Перетягніть, щоб повернути карту; натисніть, щоб повернути північ угору",
"NavigationControl.ZoomIn": "Збільшити масштаб",
"NavigationControl.ZoomOut": "Зменшити масштаб",
"Popup.Close": "Закрити",
"ScaleControl.Feet": "фути",
"ScaleControl.Meters": "метри",
"ScaleControl.Kilometers": "кілометри",
"ScaleControl.Miles": "милі",
"ScaleControl.NauticalMiles": "морські милі",
"GlobeControl.Enable": "Показати глобус",
"GlobeControl.Disable": "Показати пласку карту",
"TerrainControl.Enable": "Показати рельєф",
"TerrainControl.Disable": "Сховати рельєф",
"CooperativeGesturesHandler.WindowsHelpText":
"Утримуйте Ctrl під час прокручування, щоб змінити масштаб карти",
"CooperativeGesturesHandler.MacHelpText":
"Утримуйте ⌘ під час прокручування, щоб змінити масштаб карти",
"CooperativeGesturesHandler.MobileHelpText":
"Використовуйте два пальці, щоб переміщувати карту"
}
}
const mapUiLocale = () => {
const language = (uiLocale() || "en").toLowerCase().split("-")[0]
return mapLocaleByLanguage[language] || {}
}
const mapRegionLabel = () => uiText("mapLabel") || mapUiLocale()["Map.Title"] || ""
const defaultStyle = {
version: 8,
sources: {
osm: {
type: "raster",
tiles: [document.documentElement.dataset.mapTileUrl],
tileSize: 256,
maxzoom: 19,
attribution: "© OpenStreetMap contributors"
}
},
layers: [{id: "osm", type: "raster", source: "osm"}]
}
const markerPoints = element => {
try {
const points = JSON.parse(element.dataset.items || element.dataset.markers || "[]")
if (!Array.isArray(points)) return []
return points.filter(point =>
Number.isFinite(point?.longitude) && Number.isFinite(point?.latitude)
)
} catch (_error) {
return []
}
}
const clusterIconImage = descriptor => {
const pixelRatio = 2
const shadowPadding = 7
const logicalSize = descriptor.diameter + shadowPadding * 2
const canvas = document.createElement("canvas")
canvas.width = logicalSize * pixelRatio
canvas.height = logicalSize * pixelRatio
const context = canvas.getContext("2d")
context.scale(pixelRatio, pixelRatio)
const center = logicalSize / 2
const radius = descriptor.diameter / 2 - 2
context.save()
context.shadowColor = "rgba(0, 0, 0, 0.24)"
context.shadowBlur = 6
context.shadowOffsetY = 3
context.beginPath()
context.arc(center, center, radius, 0, Math.PI * 2)
context.fillStyle = descriptor.urgent ? "#b63b21" : "#16725b"
context.fill()
context.restore()
context.beginPath()
context.arc(center, center, radius, 0, Math.PI * 2)
context.strokeStyle = "#ffffff"
context.lineWidth = 3
context.stroke()
let fontSize = descriptor.label.length >= 4 ? 11 : 12
context.font = `800 ${fontSize}px system-ui, sans-serif`
while (context.measureText(descriptor.label).width > descriptor.diameter - 12 && fontSize > 8) {
fontSize -= 1
context.font = `800 ${fontSize}px system-ui, sans-serif`
}
context.fillStyle = "#ffffff"
context.textAlign = "center"
context.textBaseline = "middle"
context.fillText(descriptor.label, center, center + 0.5)
return {
image: context.getImageData(0, 0, canvas.width, canvas.height),
pixelRatio
}
}
const emptyFeatureCollection = () => ({type: "FeatureCollection", features: []})
const areaPolygon = point => {
const {longitude, latitude, radius_meters: radiusMeters} = point
const earthRadiusMeters = 6371008.8
const angularDistance = radiusMeters / earthRadiusMeters
const latitudeRadians = latitude * Math.PI / 180
const longitudeRadians = longitude * Math.PI / 180
const coordinates = []
for (let step = 0; step <= 64; step += 1) {
const bearing = step / 64 * Math.PI * 2
const pointLatitude = Math.asin(
Math.sin(latitudeRadians) * Math.cos(angularDistance) +
Math.cos(latitudeRadians) * Math.sin(angularDistance) * Math.cos(bearing)
)
const pointLongitude = longitudeRadians + Math.atan2(
Math.sin(bearing) * Math.sin(angularDistance) * Math.cos(latitudeRadians),
Math.cos(angularDistance) - Math.sin(latitudeRadians) * Math.sin(pointLatitude)
)
coordinates.push([pointLongitude * 180 / Math.PI, pointLatitude * 180 / Math.PI])
}
return {
type: "Feature",
id: point.id,
properties: {
id: point.id,
title: point.title || "",
location: point.location || "",
type: point.type || "request"
},
geometry: {type: "Polygon", coordinates: [coordinates]}
}
}
const areaFeatureCollection = points => ({
type: "FeatureCollection",
features: points
.filter(point => !point.exact && Number.isFinite(point.radius_meters))
.map(areaPolygon)
})
const mapViewport = map => {
const bounds = map.getBounds()
const container = map.getContainer()
if (container.clientWidth < 160 || container.clientHeight < 160) return null
return {
west: Math.max(-180, Math.min(180, bounds.getWest())),
south: Math.max(-85.05112878, Math.min(85.05112878, bounds.getSouth())),
east: Math.max(-180, Math.min(180, bounds.getEast())),
north: Math.max(-85.05112878, Math.min(85.05112878, bounds.getNorth())),
zoom: map.getZoom(),
width: Math.round(container.clientWidth),
height: Math.round(container.clientHeight)
}
}
const savedViewport = element => {
try {
const viewport = JSON.parse(element.dataset.viewport || "")
if (!viewport || typeof viewport !== "object") return null
const west = Number(viewport.west)
const south = Number(viewport.south)
const east = Number(viewport.east)
const north = Number(viewport.north)
if (![west, south, east, north].every(Number.isFinite)) return null
return {west, south, east, north}
} catch (_error) {
return null
}
}
const extendBoundsWithFeature = (bounds, feature) => {
feature.geometry.coordinates[0].forEach(coordinate => bounds.extend(coordinate))
}
const privacySafeAreaCenter = (latitude, longitude, radiusMeters) => {
const latitudeStep = radiusMeters * Math.SQRT2 / 111320
const longitudeScale = Math.max(Math.cos(latitude * Math.PI / 180), 0.2)
const longitudeStep = latitudeStep / longitudeScale
return {
latitude: Math.round(latitude / latitudeStep) * latitudeStep,
longitude: Math.round(longitude / longitudeStep) * longitudeStep
}
}
const mapRetryButton = (element, retry) => {
const button = document.createElement("button")
button.type = "button"
button.className = "btn btn-sm btn-outline map-retry-button"
button.dataset.mapRetry = "true"
button.textContent = element.dataset.mapRetryLabel || uiText("mapRetryLabel")
button.addEventListener("click", retry, {once: true})
return button
}
const mapRecenterControl = (element, recenter) => {
let container = null
let button = null
const handleClick = event => {
event.preventDefault()
recenter()
}
return {
onAdd() {
const label = element.dataset.mapRecenterLabel || uiText("mapRecenterLabel")
const iconNamespace = "http://www.w3.org/2000/svg"
container = document.createElement("div")
container.className = "maplibregl-ctrl maplibregl-ctrl-group"
button = document.createElement("button")
button.type = "button"
button.className = "map-recenter-control"
button.dataset.mapRecenter = "true"
button.title = label
button.setAttribute("aria-label", label)
const icon = document.createElementNS(iconNamespace, "svg")
icon.classList.add("map-recenter-icon")
icon.setAttribute("viewBox", "0 0 24 24")
icon.setAttribute("aria-hidden", "true")
const circle = document.createElementNS(iconNamespace, "circle")
circle.setAttribute("cx", "12")
circle.setAttribute("cy", "12")
circle.setAttribute("r", "4.5")
const crosshair = document.createElementNS(iconNamespace, "path")
crosshair.setAttribute("d", "M12 2v4m0 12v4M2 12h4m12 0h4")
icon.append(circle, crosshair)
button.append(icon)
button.addEventListener("click", handleClick)
container.append(button)
return container
},
onRemove() {
button?.removeEventListener("click", handleClick)
container?.remove()
button = null
container = null
}
}
}
const reportMapUnavailable = (element, reason, error = null) => {
element.dataset.mapUnavailable = "true"
element.dataset.mapUnavailableReason = reason
if (error) {
console.warn(`[Who Need Help] Map unavailable (${reason})`, error)
} else {
console.warn(`[Who Need Help] Map unavailable (${reason})`)
}
}
const createAidMap = element => {
const state = {
element,
map: null,
active: true,
resizeFrame: null,
resizeObserver: null,
renderedSize: null,
clusterImageIds: new Set(),
clusterImageRemovalTimers: new Map(),
markerDataSignature: null,
points: [],
selectedItemId: null,
clusterExpansionPending: false,
pendingMarkerRender: false,
initializedViewport: false,
locationBounds: null,
recenterControl: null,
areaSourceId: `${element.id || "aid-map"}-areas`,
areaFillId: `${element.id || "aid-map"}-area-fill`,
areaLineId: `${element.id || "aid-map"}-area-line`,
areaSelectedId: `${element.id || "aid-map"}-area-selected`,
clusterSourceId: `${element.id || "aid-map"}-clusters`,
clusterIconId: `${element.id || "aid-map"}-cluster-icon`,
pointCircleId: `${element.id || "aid-map"}-point-circle`,
pointSelectedId: `${element.id || "aid-map"}-point-selected`,
statusBadge: element.parentElement?.querySelector("[data-map-status-badge]") || null
}
state.markLoading = () => {
if (state.element.dataset.mapReady !== "true") {
state.element.dataset.mapReady = "false"
}
state.element.dataset.mapIdle = "false"
}
state.markReady = () => {
state.element.dataset.mapReady = "true"
}
state.markIdle = () => {
state.element.dataset.mapReady = "true"
state.element.dataset.mapIdle = "true"
}
state.retry = () => {
if (!state.active) return
state.element.dataset.mapReady = "false"
delete state.element.dataset.mapUnavailable
delete state.element.dataset.mapUnavailableReason
state.element.replaceChildren()
state.mount()
}
state.showUnavailable = (reason, error = null) => {
state.map = null
reportMapUnavailable(state.element, reason, error)
state.markReady()
if (state.statusBadge) {
state.statusBadge.textContent =
state.element.dataset.demoMap === "true"
? state.statusBadge.dataset.fallbackLabel
: state.statusBadge.dataset.staticLabel
}
if (state.element.dataset.demoMap !== "true") {
const fallback = document.createElement("div")
fallback.className = "map-unavailable-panel"
fallback.dataset.mapFallback = "true"
fallback.setAttribute("role", "region")
fallback.setAttribute("aria-label", mapRegionLabel())
const message = document.createElement("p")
message.textContent = state.element.dataset.mapUnavailableLabel
fallback.append(message, mapRetryButton(state.element, state.retry))
state.element.replaceChildren(fallback)
return
}
const fallback = document.createElement("div")
fallback.className = "home-demo-map-fallback"
fallback.dataset.mapFallback = "true"
fallback.setAttribute("role", "region")
fallback.setAttribute("aria-label", mapRegionLabel())
const note = document.createElement("p")
note.className = "home-demo-map-fallback-note"
note.textContent = state.element.dataset.mapUnavailableLabel
fallback.append(note)
const points = document.createElement("div")
points.className = "home-demo-map-fallback-points"
const selection = document.createElement("p")
selection.className = "home-demo-map-fallback-selection"
selection.dataset.mapFallbackSelection = "true"
selection.setAttribute("aria-live", "polite")
selection.textContent = state.element.dataset.mapFallbackInteractionLabel
markerPoints(state.element).forEach((point, index) => {
const item = document.createElement("button")
item.type = "button"
item.className = "home-demo-map-fallback-point"
item.dataset.mapFallbackPoint = "true"
item.setAttribute("aria-pressed", "false")
const marker = document.createElement("span")
marker.className = "home-demo-map-fallback-marker"
marker.setAttribute("aria-hidden", "true")
marker.textContent = String(index + 1)
const copy = document.createElement("span")
copy.className = "home-demo-map-fallback-copy"
const title = document.createElement("strong")
title.textContent = point.title || ""
const location = document.createElement("small")
location.textContent = point.location || ""
copy.append(title, location)
item.append(marker, copy)
item.addEventListener("click", () => {
points.querySelectorAll("[data-map-fallback-point]").forEach(candidate => {
const selected = candidate === item
candidate.classList.toggle("is-selected", selected)
candidate.setAttribute("aria-pressed", String(selected))
})
selection.textContent =
`${state.element.dataset.mapFallbackSelectedLabel} ${point.title || ""}`.trim()
})
points.append(item)
})
fallback.append(points, selection, mapRetryButton(state.element, state.retry))
state.element.replaceChildren(fallback)
}
state.popupContent = point => {
const content = document.createElement("div")
content.className = "request-map-popup"
const title = document.createElement("strong")
title.textContent = point.title || ""
content.append(title)
const location = document.createElement("small")
const capturedAt = point.captured_at ? new Date(point.captured_at) : null
const capturedAtLabel =
capturedAt && !Number.isNaN(capturedAt.getTime())
? new Intl.DateTimeFormat(uiLocale(), {
dateStyle: "short",
timeStyle: "medium"
}).format(capturedAt)
: ""
location.textContent =
capturedAtLabel
? `${point.location_prefix || ""} ${capturedAtLabel}`.trim()
: point.location || ""
content.append(location)
if (point.id) {
const link = document.createElement("a")
const pathPrefix = state.element.dataset.itemPathPrefix || "/requests"
link.href = `${pathPrefix}/${encodeURIComponent(point.id)}`
link.textContent =
state.element.dataset.itemLabel ||
state.element.dataset.requestLabel ||
uiText("openRequestLabel")
content.append(link)
}
return content
}
state.highlightRequest = id => {
state.selectedItemId = id || null
if (state.map?.getLayer(state.areaSelectedId)) {
state.map.setFilter(state.areaSelectedId, ["==", ["get", "id"], id || ""])
}
if (state.map?.getLayer(state.areaFillId)) {
state.map.setFilter(state.areaFillId, ["==", ["get", "id"], id || ""])
}
if (state.map?.getLayer(state.pointSelectedId)) {
state.map.setFilter(state.pointSelectedId, ["==", ["get", "id"], id || ""])
}
}
state.selectRequest = id => {
state.highlightRequest(id)
window.dispatchEvent(new CustomEvent("wnh:request-selected", {detail: {id}}))
}
state.resize = () => {
if (!state.active || !state.map) return
const {width, height} = state.element.getBoundingClientRect()
if (width < 160 || height < 160) return
if (!mapSizeChanged(state.renderedSize, {width, height})) return
if (state.resizeFrame !== null) window.cancelAnimationFrame(state.resizeFrame)
state.resizeFrame = window.requestAnimationFrame(() => {
state.resizeFrame = null
if (!state.active || !state.map) return
const current = state.element.getBoundingClientRect()
const currentSize = {width: current.width, height: current.height}
if (
current.width >= 160 &&
current.height >= 160 &&
mapSizeChanged(state.renderedSize, currentSize)
) {
state.renderedSize = currentSize
state.map.resize()
}
})
}
state.applyInitialViewport = () => {
if (
!state.active ||
!state.map ||
!shouldApplyInitialViewport(state.initializedViewport)
) return
const viewport = savedViewport(state.element)
const serialized = viewport ? JSON.stringify(viewport) : ""
if (!viewport || state.appliedViewport === serialized) return
state.appliedViewport = serialized
if (viewportMatchesBounds(viewport, state.map.getBounds())) return
state.map.fitBounds(
[[viewport.west, viewport.south], [viewport.east, viewport.north]],
{padding: 0, duration: 0}
)
}
state.fitMarkers = (duration = 0) => {
if (!state.active || !state.map || !state.locationBounds) return false
state.map.fitBounds(state.locationBounds, {
padding: 52,
maxZoom: 13,
duration
})
return true
}
state.expandServerCluster = point => {
if (!point || !state.map) return
const count = Number(point.count) || 1
const requestedZoom = Number(point.expansion_zoom)
const currentZoom = state.map.getZoom()
const expansionZoom = clusterExpansionTarget(
currentZoom,
Number.isFinite(requestedZoom) ? requestedZoom : currentZoom + 2
)
if (currentZoom >= 21.75 && expansionZoom <= 22) {
const label = (state.element.dataset.clusterLabel || "%{count} items")
.replace("%{count}", String(count))
new maplibregl.Popup({offset: 18})
.setLngLat([point.longitude, point.latitude])
.setDOMContent(state.popupContent({
title: label,
location: state.element.dataset.clusterMaxZoomLabel || ""
}))
.addTo(state.map)
return
}
state.clusterExpansionPending = true
state.map.easeTo({
center: clusterExpansionCenter(point),
zoom: expansionZoom,
duration: 300
})
}
state.syncClusterImages = points => {
if (!state.map || state.element.dataset.discoveryMap !== "true") return
const descriptors = new Map()
points.forEach(point => {
if (!serverClusterDescriptor(point)) return
const descriptor = clusterIconDescriptor(point, uiLocale())
descriptors.set(descriptor.id, descriptor)
})
descriptors.forEach((descriptor, imageId) => {
const removalTimer = state.clusterImageRemovalTimers.get(imageId)
if (removalTimer !== undefined) {
window.clearTimeout(removalTimer)
state.clusterImageRemovalTimers.delete(imageId)
}
if (state.map.hasImage(imageId)) return
const {image, pixelRatio} = clusterIconImage(descriptor)
state.map.addImage(imageId, image, {pixelRatio})
})
state.clusterImageIds.forEach(imageId => {
if (descriptors.has(imageId) || state.clusterImageRemovalTimers.has(imageId)) return
const timer = window.setTimeout(() => {
state.clusterImageRemovalTimers.delete(imageId)
if (!state.active || state.clusterImageIds.has(imageId)) return
if (state.map?.hasImage(imageId)) state.map.removeImage(imageId)
}, 100)
state.clusterImageRemovalTimers.set(imageId, timer)
})
state.clusterImageIds = new Set(descriptors.keys())
}
state.syncVisibleAreas = () => {
if (!state.map) return
const areaSource = state.map.getSource(state.areaSourceId)
if (!areaSource) return
const visiblePoints =
state.element.dataset.discoveryMap === "true"
? state.points.filter(point => point.type !== "cluster")
: state.points
const signature = visiblePoints
.filter(point => !point.exact && Number.isFinite(point.radius_meters))
.map(point => point.id)
.sort()
.join(":")
if (signature === state.visibleAreaSignature) return
state.visibleAreaSignature = signature
areaSource.setData(areaFeatureCollection(visiblePoints))
}
state.renderMarkers = () => {
if (!state.map) return
const markerDataSignature =
state.element.dataset.items || state.element.dataset.markers || "[]"
const existingAreaSource = state.map.getSource(state.areaSourceId)
const existingPointSource = state.map.getSource(state.clusterSourceId)
if (
state.markerDataSignature === markerDataSignature &&
existingAreaSource &&
existingPointSource
) {
state.pendingMarkerRender = false
return
}
const points = markerPoints(state.element)
state.points = points
const pointFeatures = pointFeatureCollection(points, uiLocale())
const areaFeatures =
state.element.dataset.discoveryMap === "true"
? emptyFeatureCollection()
: areaFeatureCollection(points)
const bounds = new maplibregl.LngLatBounds()
points.forEach(point => {
const clusterBounds = point.bounds
if (
clusterBounds &&
[clusterBounds.west, clusterBounds.south, clusterBounds.east, clusterBounds.north]
.every(value => Number.isFinite(Number(value)))
) {
bounds.extend([Number(clusterBounds.west), Number(clusterBounds.south)])
bounds.extend([Number(clusterBounds.east), Number(clusterBounds.north)])
} else {
bounds.extend([point.longitude, point.latitude])
}
})
state.locationBounds = points.length > 0 ? bounds : null
if (
(!existingAreaSource || !existingPointSource) &&
!state.map.isStyleLoaded()
) {
state.pendingMarkerRender = true
return
}
state.pendingMarkerRender = false
{
const areaSource = existingAreaSource
state.syncClusterImages(points)
if (areaSource) {
areaSource.setData(areaFeatures)
} else {
state.map.addSource(state.areaSourceId, {type: "geojson", data: areaFeatures})
state.map.addLayer({
id: state.areaFillId,
type: "fill",
source: state.areaSourceId,
minzoom: state.element.dataset.discoveryMap === "true" ? 12 : 0,
filter: ["==", ["get", "id"], state.selectedItemId || ""],
paint: {"fill-color": "#278467", "fill-opacity": 0.18}
})
state.map.addLayer({
id: state.areaLineId,
type: "line",
source: state.areaSourceId,
minzoom: state.element.dataset.discoveryMap === "true" ? 12 : 0,
paint: {
"line-color": "#16725b",
"line-width": 1.5,
"line-opacity": 0.55,
"line-dasharray": [2, 2]
}
})
state.map.addLayer({
id: state.areaSelectedId,
type: "line",
source: state.areaSourceId,
minzoom: state.element.dataset.discoveryMap === "true" ? 12 : 0,
filter: ["==", ["get", "id"], ""],
paint: {"line-color": "#d6573b", "line-width": 5}
})
}
const pointSource = existingPointSource
if (pointSource) {
pointSource.setData(pointFeatures)
} else {
state.map.addSource(state.clusterSourceId, pointSourceOptions(pointFeatures))
state.map.addLayer({
id: state.clusterIconId,
type: "symbol",
source: state.clusterSourceId,
filter: ["==", ["get", "item_type"], "cluster"],
layout: {
"icon-image": ["get", "cluster_icon"],
"icon-anchor": "center",
"icon-allow-overlap": true,
"icon-ignore-placement": true
}
})
state.map.addLayer({
id: state.pointCircleId,
type: "circle",
source: state.clusterSourceId,
filter: [
"all",
["!=", ["get", "item_type"], "cluster"]
],
paint: {
"circle-color": [
"case",
["==", ["get", "item_type"], "live"],
"#2563eb",
["==", ["get", "exact"], true],
"#d6573b",
"#278467"
],
"circle-radius": 9,
"circle-stroke-color": "#ffffff",
"circle-stroke-width": 3
}
})
state.map.addLayer({
id: state.pointSelectedId,
type: "circle",
source: state.clusterSourceId,
filter: ["==", ["get", "id"], state.selectedItemId || ""],
paint: {
"circle-color": "rgba(0,0,0,0)",
"circle-radius": 15,
"circle-stroke-color": "#111827",
"circle-stroke-width": 3
}
})
const interactiveLayers = [state.clusterIconId, state.pointCircleId]
interactiveLayers.forEach(layerId => {
state.map.on("mouseenter", layerId, () => {
state.map.getCanvas().style.cursor = "pointer"
})
state.map.on("mouseleave", layerId, () => {
state.map.getCanvas().style.cursor = ""
})
})
state.map.on("click", state.clusterIconId, event => {
const clusterId = event.features?.[0]?.properties?.server_cluster_id
const point = state.points.find(candidate => candidate.cluster_id === clusterId)
if (point) state.expandServerCluster(point)
})
state.map.on("click", state.pointCircleId, event => {
const feature = event.features?.[0]
const properties = feature?.properties
if (!feature || !properties) return
if (properties.id) state.selectRequest(properties.id)
new maplibregl.Popup({offset: 18})
.setLngLat(feature.geometry.coordinates)
.setDOMContent(state.popupContent(properties))
.addTo(state.map)
})
}
}
state.markerDataSignature = markerDataSignature
if (state.element.dataset.autoFit !== "false") state.fitMarkers()
}
state.mount = () => {
state.markLoading()
const demoMap = state.element.dataset.demoMap === "true"
const support = mapCanvasSupport()
if (!support.supported) {
state.showUnavailable(support.reason, support.error)
return
}
try {
state.map = new maplibregl.Map({
container: state.element,
style: defaultStyle,
center: [30.5234, 50.4501],
zoom: 5,
locale: mapUiLocale(),
attributionControl: true,
renderWorldCopies: false,
trackResize: false,
dragRotate: !demoMap,
pitchWithRotate: !demoMap,
scrollZoom: !demoMap
})
} catch (error) {
state.showUnavailable("initialization-failed", error)
return
}
// Restore the URL camera before MapLibre's first complete style load. Waiting
// for `load` briefly exposes the default camera and produces a visible jump
// when slow or unavailable tiles delay that event.
state.applyInitialViewport()
state.initializedViewport = true
if (state.element.dataset.discoveryMap === "true" && !savedViewport(state.element)) {
const viewport = mapViewport(state.map)
if (viewport) state.onInitializeViewport?.(viewport)
}
if (demoMap) state.map.touchZoomRotate.disableRotation()
state.map.addControl(
new maplibregl.NavigationControl({showCompass: !demoMap}),
demoMap ? "bottom-right" : "top-right"
)
if (state.element.dataset.mapRecenterLabel) {
state.recenterControl = mapRecenterControl(state.element, () => state.fitMarkers(450))
state.map.addControl(state.recenterControl, "top-right")
}
if (state.element.dataset.discoveryMap === "true" && navigator.geolocation) {
state.map.addControl(
new maplibregl.GeolocateControl({
positionOptions: {enableHighAccuracy: false},
trackUserLocation: false,
showUserLocation: false,
fitBoundsOptions: {maxZoom: 13}
}),
"top-right"
)
}
state.onLoad = () => {
if (!state.active || !state.map) return
delete state.element.dataset.mapUnavailable
delete state.element.dataset.mapUnavailableReason
state.markReady()
const {width, height} = state.element.getBoundingClientRect()
state.renderedSize = {width, height}
if (state.statusBadge) {
state.statusBadge.textContent = state.statusBadge.dataset.interactiveLabel
}
state.renderMarkers()
}
state.onMoveEnd = () => {
if (!state.initializedViewport || state.element.dataset.discoveryMap !== "true") return
const viewport = mapViewport(state.map)
if (!viewport) return
const clusterExpansion = state.clusterExpansionPending
state.clusterExpansionPending = false
// `moveend` already means that the camera transition has finished. Do
// not wait for MapLibre's `idle` event here: `idle` also waits for every
// requested map tile and fade animation, so a slow tile server can delay
// discovery refreshes for seconds even though the user stopped moving
// the map earlier.
if (clusterExpansion) {
state.onClusterExpand?.(viewport)
} else {
state.onViewportChange?.(viewport)
}
}
state.onIdle = () => {
state.markIdle()
if (state.pendingMarkerRender) {
state.renderMarkers()
if (state.pendingMarkerRender) return
}
state.syncVisibleAreas()
}
state.onRequestHighlight = event => state.highlightRequest(event.detail?.id)
state.onDataLoading = () => {
if (state.active) state.element.dataset.mapIdle = "false"
}
state.map.on("load", state.onLoad)
state.map.on("idle", state.onIdle)
state.map.on("dataloading", state.onDataLoading)
state.map.on("moveend", state.onMoveEnd)
window.addEventListener("wnh:request-highlight", state.onRequestHighlight)
if ("ResizeObserver" in window) {
state.resizeObserver = new ResizeObserver(state.resize)
state.resizeObserver.observe(state.element)
}
state.renderMarkers()
}
state.destroy = () => {
state.active = false
state.clusterImageRemovalTimers.forEach(timer => window.clearTimeout(timer))
state.clusterImageRemovalTimers.clear()
if (state.resizeFrame !== null) window.cancelAnimationFrame(state.resizeFrame)
state.resizeObserver?.disconnect()
state.map?.off("load", state.onLoad)
state.map?.off("idle", state.onIdle)
state.map?.off("dataloading", state.onDataLoading)
state.map?.off("moveend", state.onMoveEnd)
window.removeEventListener("wnh:request-highlight", state.onRequestHighlight)
state.map?.remove()
state.map = null
state.locationBounds = null
state.recenterControl = null
}
return state
}
export const mountStaticAidMaps = root => {
root.querySelectorAll("[data-static-aid-map]").forEach(element => {
if (element.dataset.staticMapMounted === "true") return
const mount = () => {
element.dataset.staticMapMounted = "true"
element._aidMap = createAidMap(element)
element._aidMap.mount()
}
if (!("IntersectionObserver" in window)) {
mount()
return
}
const observer = new IntersectionObserver(entries => {
if (!entries.some(entry => entry.isIntersecting)) return
observer.disconnect()
mount()
}, {rootMargin: "240px"})
observer.observe(element)
})
}
export const Hooks = {
SearchableCategoryPicker: {
mounted() {
this.refresh = () => {
this.el.classList.add("is-enhanced")
this.source = this.el.querySelector("[data-category-picker-source]")
this.trigger = this.el.querySelector("[data-category-picker-trigger]")
this.label = this.el.querySelector("[data-category-picker-label]")
this.panel = this.el.querySelector("[data-category-picker-panel]")
this.search = this.el.querySelector("[data-category-picker-search]")
this.options = [...this.el.querySelectorAll("[data-category-picker-option]")]
this.groups = [...this.el.querySelectorAll("[data-category-picker-group]")]
this.empty = this.el.querySelector("[data-category-picker-empty]")
if (this.source) {
this.source.hidden = true
this.source.tabIndex = -1
this.source.setAttribute("aria-hidden", "true")
}
this.syncSelection()
}
this.isOpen = () => this.panel && !this.panel.hidden
this.open = () => {
if (!this.panel || !this.trigger || !this.search) return
this.panel.hidden = false
this.trigger.setAttribute("aria-expanded", "true")
this.search.setAttribute("aria-expanded", "true")
this.search.focus()
this.search.select()
}
this.close = ({restoreFocus = false} = {}) => {
if (!this.panel || !this.trigger || !this.search) return
this.panel.hidden = true
this.trigger.setAttribute("aria-expanded", "false")
this.search.setAttribute("aria-expanded", "false")
this.search.value = ""
this.filter("")
if (restoreFocus) this.trigger.focus()
}
this.filter = rawQuery => {
const query = rawQuery.trim().toLocaleLowerCase()
let visibleCount = 0
this.groups.forEach(group => {
let groupVisible = false
const groupMatches = (group.dataset.searchText || "").includes(query)
group.querySelectorAll("[data-category-picker-option]").forEach(option => {
const visible = query === "" ||
groupMatches ||
(option.dataset.searchText || "").includes(query)
option.hidden = !visible
groupVisible ||= visible
if (visible) visibleCount += 1
})
group.hidden = !groupVisible
})
if (this.empty) this.empty.hidden = visibleCount !== 0
}
this.syncSelection = () => {
if (!this.source || !this.trigger || !this.label) return
const selected = this.options.find(option => option.dataset.value === this.source.value)
const prompt = this.source.options[0]?.textContent?.trim() || ""
this.label.textContent = selected?.dataset.label || prompt
this.label.classList.toggle("category-picker-placeholder", !selected)
this.options.forEach(option => {
const active = option === selected
option.setAttribute("aria-selected", String(active))
option.querySelector(".category-picker-check")?.classList.toggle("invisible", !active)
})
}
this.select = option => {
if (!this.source) return
const value = option.dataset.value || ""
const changed = this.source.value !== value
this.source.value = value
this.syncSelection()
this.close({restoreFocus: true})
if (changed) {
this.source.dispatchEvent(new Event("input", {bubbles: true}))
}
}
this.onClick = event => {
const trigger = event.target.closest("[data-category-picker-trigger]")
if (trigger && this.el.contains(trigger)) {
event.preventDefault()
this.isOpen() ? this.close() : this.open()
return
}
const option = event.target.closest("[data-category-picker-option]")
if (option && this.el.contains(option)) {
event.preventDefault()
this.select(option)
}
}
this.onInput = event => {
if (!event.target.matches("[data-category-picker-search]")) return
event.stopPropagation()
this.filter(event.target.value)
}
this.onKeydown = event => {
if (
event.target.matches("[data-category-picker-trigger]") &&
["ArrowDown", "Enter", " "].includes(event.key)
) {
event.preventDefault()
this.open()
return
}
if (!event.target.matches("[data-category-picker-search]")) return
if (event.key === "Escape") {
event.preventDefault()
this.close({restoreFocus: true})
return
}
if (event.key === "ArrowDown") {
event.preventDefault()
this.options.find(option => !option.hidden && !option.closest("section")?.hidden)?.focus()
}
}
this.onDocumentPointerDown = event => {
if (this.isOpen() && !this.el.contains(event.target)) this.close()
}
this.el.addEventListener("click", this.onClick)
this.el.addEventListener("input", this.onInput)
this.el.addEventListener("keydown", this.onKeydown)
document.addEventListener("pointerdown", this.onDocumentPointerDown)
this.refresh()
},
updated() {
this.refresh()
},
destroyed() {
if (this.source) {
this.source.hidden = false
this.source.removeAttribute("aria-hidden")
this.source.removeAttribute("tabindex")
}
this.el.removeEventListener("click", this.onClick)
this.el.removeEventListener("input", this.onInput)
this.el.removeEventListener("keydown", this.onKeydown)
document.removeEventListener("pointerdown", this.onDocumentPointerDown)
}
},
LanguageSwitcher: {
mounted() {
this.summary = this.el.querySelector(":scope > summary")
this.search = this.el.querySelector("[data-language-search]")
this.options = [...this.el.querySelectorAll("[data-language-option]")]
this.empty = this.el.querySelector("[data-language-empty]")
this.optionsList = this.el.querySelector(".language-switcher-options")
this.filter = rawQuery => {
const query = rawQuery.trim().toLocaleLowerCase()
let visibleCount = 0
this.options.forEach(option => {
const visible = query === "" || (option.dataset.searchText || "").includes(query)
option.hidden = !visible
if (visible) visibleCount += 1
})
if (this.empty) this.empty.hidden = visibleCount !== 0
if (this.optionsList) this.optionsList.hidden = visibleCount === 0
}
this.close = ({restoreFocus = false} = {}) => {
if (!this.el.open) return
this.el.open = false
if (restoreFocus) this.summary?.focus()
}
this.syncExpanded = () => {
const expanded = this.el.open
this.summary?.setAttribute("aria-expanded", String(expanded))
if (expanded) {
requestAnimationFrame(() => {
this.search?.focus()
this.search?.select()
})
} else if (this.search) {
this.search.value = ""
this.filter("")
}
}
this.onInput = event => {
if (event.target !== this.search) return
this.filter(event.target.value)
}
this.onKeydown = event => {
if (event.key === "Escape" && this.el.open) {
event.preventDefault()
event.stopPropagation()
this.close({restoreFocus: true})
return
}
if (event.target === this.search && event.key === "ArrowDown") {
const firstVisibleLink = this.options
.find(option => !option.hidden)
?.querySelector("a")
if (firstVisibleLink) {
event.preventDefault()
firstVisibleLink.focus()
}
}
}
this.onPointerDown = event => {
if (this.el.open && !this.el.contains(event.target)) this.close()
}
this.el.addEventListener("toggle", this.syncExpanded)
this.el.addEventListener("input", this.onInput)
this.el.addEventListener("keydown", this.onKeydown)
document.addEventListener("pointerdown", this.onPointerDown)
this.syncExpanded()
},
destroyed() {
this.el.removeEventListener("toggle", this.syncExpanded)
this.el.removeEventListener("input", this.onInput)
this.el.removeEventListener("keydown", this.onKeydown)
document.removeEventListener("pointerdown", this.onPointerDown)
}
},
AccessibleDisclosure: {
mounted() {
this.summary = this.el.querySelector(":scope > summary")
this.syncExpanded = () => {
this.summary?.setAttribute("aria-expanded", String(this.el.open))
}
this.onKeydown = event => {
if (event.key !== "Escape" || !this.el.open) return
event.preventDefault()
event.stopPropagation()
this.el.open = false
this.syncExpanded()
this.summary?.focus()
}
this.onPointerDown = event => {
if (this.el.open && !this.el.contains(event.target)) {
this.el.open = false
this.syncExpanded()
}
}
this.el.addEventListener("toggle", this.syncExpanded)
this.el.addEventListener("keydown", this.onKeydown)
document.addEventListener("pointerdown", this.onPointerDown)
this.syncExpanded()
},
destroyed() {
this.el.removeEventListener("toggle", this.syncExpanded)
this.el.removeEventListener("keydown", this.onKeydown)
document.removeEventListener("pointerdown", this.onPointerDown)
}
},
NotificationTimeZone: {
mounted() {
const timeZoneInput = this.el.querySelector("[data-time-zone]")
const offsetInput = this.el.querySelector("[data-utc-offset]")
if (timeZoneInput) {
timeZoneInput.value = Intl.DateTimeFormat().resolvedOptions().timeZone || "Etc/UTC"
}
if (offsetInput) offsetInput.value = String(-new Date().getTimezoneOffset())
}
},
PushNotifications: {
mounted() {
this.button = this.el.querySelector("[data-enable-push]")
this.buttonLabel = this.el.querySelector("[data-enable-push-label]")
this.status = this.el.querySelector("[data-push-status]")
this.native = typeof window.WhoNeedHelpAndroid?.postMessage === "function"
this.statusMessage = null
this.buttonEnabled = null
this.message = key => this.el.dataset[key] || ""
if (this.native && this.button) this.button.disabled = false
this.setStatus = message => {
this.statusMessage = message
if (this.status) this.status.textContent = message
}
this.setButtonState = enabled => {
this.buttonEnabled = enabled
if (!this.buttonLabel) return
this.buttonLabel.textContent = enabled
? this.buttonLabel.dataset.enabledLabel
: this.buttonLabel.dataset.enableLabel
}
this.decodeApplicationServerKey = value => {
const padding = "=".repeat((4 - value.length % 4) % 4)
const base64 = (value + padding).replace(/-/g, "+").replace(/_/g, "/")
const raw = window.atob(base64)
return Uint8Array.from([...raw].map(character => character.charCodeAt(0)))
}
this.subscriptionUsesPublicKey = (subscription, publicKey) => {
const configuredKey = this.decodeApplicationServerKey(publicKey)
const subscribedKey = subscription?.options?.applicationServerKey
if (!subscribedKey) return false
const subscribedBytes = new Uint8Array(subscribedKey)
return subscribedBytes.length === configuredKey.length &&
subscribedBytes.every((byte, index) => byte === configuredKey[index])
}
this.subscriptionForPublicKey = async (registration, publicKey) => {
let subscription = await registration.pushManager.getSubscription()
if (subscription && !this.subscriptionUsesPublicKey(subscription, publicKey)) {
const unsubscribed = await subscription.unsubscribe()
if (!unsubscribed) throw new Error("stale_push_subscription_could_not_be_removed")
window.localStorage.removeItem("wnh.push.server-device-id")
subscription = null
}
return subscription || registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: this.decodeApplicationServerKey(publicKey)
})
}
this.installationId = () => {
const key = "wnh.push.installation-id"
const existing = window.localStorage.getItem(key)
if (existing) return existing
const generated = typeof window.crypto?.randomUUID === "function"
? window.crypto.randomUUID()
: `web-${Date.now()}-${Array.from(window.crypto.getRandomValues(new Uint32Array(4)))
.map(value => value.toString(16).padStart(8, "0"))
.join("")}`
window.localStorage.setItem(key, generated)
return generated
}
this.postNative = payload => {
if (!this.native) return
window.WhoNeedHelpAndroid.postMessage(JSON.stringify(payload))
}
this.registerDevice = async payload => {
const csrfToken = document.querySelector("meta[name='csrf-token']")?.content || ""
const response = await window.fetch("/mobile/push-devices", {
method: "POST",
credentials: "same-origin",
headers: {
"accept": "application/json",
"content-type": "application/json",
"x-csrf-token": csrfToken
},
body: JSON.stringify(payload)
})
if (!response.ok) throw new Error(`device_registration_${response.status}`)
const device = await response.json()
window.localStorage.setItem("wnh.push.server-device-id", String(device.id))
if (payload.platform === "android") {
this.postNative({action: "push_registered", device_id: device.id})
}
this.pushEvent("refresh-push-devices", {}, () => {})
return device
}
this.nativeToken = async event => {
const detail = event.detail || {}
if (!detail.token || !detail.installation_id) return
try {
await this.registerDevice({
platform: "android",
provider: "fcm",
token: detail.token,
installation_id: detail.installation_id,
device_label: String(
detail.device_label || this.el.dataset.deviceAndroidLabel || "Android · FCM"
).slice(0, 120),
user_agent: navigator.userAgent.slice(0, 500)
})
this.setButtonState(true)
this.setStatus(this.message("statusAndroidEnabled"))
} catch (error) {
console.warn("Android push registration failed", error)
this.setButtonState(false)
this.setStatus(this.message("statusAndroidSaveFailed"))
} finally {
if (this.button) this.button.disabled = false
}
}
this.nativeError = event => {
const reason = event.detail?.reason
this.setButtonState(false)
this.setStatus(
reason === "permission_denied"
? this.message("statusPermissionDenied")
: this.message("statusAndroidUnavailable")
)
if (this.button) this.button.disabled = false
}
this.disableCurrentDevice = async event => {
const button = event.target.closest("[data-disable-device]")
if (!button) return
const currentId = window.localStorage.getItem("wnh.push.server-device-id")
if (!currentId || currentId !== button.dataset.disableDevice) return
window.localStorage.removeItem("wnh.push.server-device-id")
if (this.native) {
this.postNative({action: "disable_push"})
this.setButtonState(false)
this.setStatus(this.message("statusDisabled"))
return
}
try {
const registration = await navigator.serviceWorker?.ready
const subscription = await registration?.pushManager?.getSubscription()
await subscription?.unsubscribe()
} catch (error) {
console.warn("Browser push unsubscribe failed", error)
} finally {
this.setButtonState(false)
this.setStatus(this.message("statusDisabled"))
}
}
this.enable = async () => {
const publicKey = String(this.el.dataset.vapidPublicKey || "")
if (this.native) {
this.button.disabled = true
this.setStatus(this.message("statusAndroidWaiting"))
this.postNative({action: "enable_push"})
return
}
if (!publicKey || !("serviceWorker" in navigator) || !("PushManager" in window)) {
this.setStatus(this.message("statusBrowserUnavailable"))
return
}
this.button.disabled = true
try {
const permission = await Notification.requestPermission()
if (permission !== "granted") {
this.setStatus(this.message("statusPermissionDenied"))
return
}
const registration = await navigator.serviceWorker.ready
const subscription = await this.subscriptionForPublicKey(registration, publicKey)
const json = subscription.toJSON()
await this.registerDevice({
platform: "web",
provider: "web_push",
token: json.endpoint,
installation_id: this.installationId(),
p256dh: json.keys?.p256dh,
auth_secret: json.keys?.auth,
device_label:
`${navigator.platform || this.el.dataset.deviceBrowserLabel || ""} · Web Push`
.slice(0, 120),
user_agent: navigator.userAgent.slice(0, 500)
})
this.setButtonState(true)
this.setStatus(this.message("statusBrowserEnabled"))
} catch (error) {
console.warn("Push notification registration failed", error)
this.setStatus(this.message("statusBrowserEnableFailed"))
} finally {
this.button.disabled = false
}
}
this.activeServerDevice = () => {
const deviceId = window.localStorage.getItem("wnh.push.server-device-id")
return Boolean(
deviceId &&
[...this.el.querySelectorAll("[data-disable-device]")]
.some(element => element.dataset.disableDevice === deviceId)
)
}
this.restoreNativeState = () => {
if (!this.native || !this.activeServerDevice()) return
this.setButtonState(true)
this.setStatus(this.message("statusAndroidEnabled"))
}
this.restoreWebState = async () => {
if (this.native) return
const publicKey = String(this.el.dataset.vapidPublicKey || "")
if (!publicKey || !("serviceWorker" in navigator) || !("PushManager" in window)) {
this.setStatus(this.message("statusBrowserUnavailable"))
return
}
if (Notification.permission === "denied") {
this.setStatus(this.message("statusPermissionDenied"))
return
}
try {
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.getSubscription()
const currentKey = subscription &&
this.subscriptionUsesPublicKey(subscription, publicKey)
const deviceId = window.localStorage.getItem("wnh.push.server-device-id")
const activeServerDevice = deviceId &&
[...this.el.querySelectorAll("[data-disable-device]")]
.some(element => element.dataset.disableDevice === deviceId)
if (subscription && currentKey && activeServerDevice) {
this.setButtonState(true)
this.setStatus(this.message("statusBrowserEnabled"))
} else if (subscription && !currentKey) {
this.setStatus(
this.message("statusKeysChanged")
)
} else if (subscription || activeServerDevice) {
this.setStatus(this.message("statusReconnectRequired"))
}
} catch (error) {
console.warn("Push notification state could not be restored", error)
}
}
window.addEventListener("wnh:native-push-token", this.nativeToken)
window.addEventListener("wnh:native-push-error", this.nativeError)
this.el.addEventListener("click", this.disableCurrentDevice)
this.button?.addEventListener("click", this.enable)
if (this.native) {
this.restoreNativeState()
this.postNative({action: "push_token_request"})
} else {
this.restoreWebState()
}
},
updated() {
const previousButton = this.button
const nextButton = this.el.querySelector("[data-enable-push]")
if (previousButton && previousButton !== nextButton) {
previousButton.removeEventListener("click", this.enable)
}
this.button = nextButton
this.buttonLabel = this.el.querySelector("[data-enable-push-label]")
this.status = this.el.querySelector("[data-push-status]")
if (this.native && this.button) this.button.disabled = false
this.button?.removeEventListener("click", this.enable)
this.button?.addEventListener("click", this.enable)
if (this.buttonEnabled !== null) this.setButtonState(this.buttonEnabled)
if (this.statusMessage !== null) this.setStatus(this.statusMessage)
if (this.native) {
this.restoreNativeState()
this.postNative({action: "push_token_request"})
} else {
this.restoreWebState()
}
},
destroyed() {
window.removeEventListener("wnh:native-push-token", this.nativeToken)
window.removeEventListener("wnh:native-push-error", this.nativeError)
this.el.removeEventListener("click", this.disableCurrentDevice)
this.button?.removeEventListener("click", this.enable)
}
},
DateTimePicker: {
mounted() {
this.hiddenInput = this.el.querySelector("[data-datetime-value]")
this.unusedInput = this.el.querySelector("[data-datetime-unused]")
this.dateInput = this.el.querySelector("[data-datetime-date]")
this.timeInput = this.el.querySelector("[data-datetime-time]")
this.timezoneLabel = this.el.querySelector("[data-datetime-timezone]")
this.quickButtons = [...this.el.querySelectorAll("[data-datetime-offset]")]
const pad = value => String(value).padStart(2, "0")
const localParts = date => ({
date: `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`,
time: `${pad(date.getHours())}:${pad(date.getMinutes())}`
})
const utcValue = date =>
`${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}` +
`T${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}`
this.parseUtc = value => {
const match = String(value || "").match(
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::\d{2})?/
)
if (!match) return null
const [, year, month, day, hour, minute] = match
const date = new Date(Date.UTC(year, Number(month) - 1, day, hour, minute))
return Number.isNaN(date.getTime()) ? null : date
}
this.applyLocalDate = date => {
const parts = localParts(date)
this.dateInput.value = parts.date
this.timeInput.value = parts.time
this.updateMinimum()
}
this.updateMinimum = () => {
const minimum = this.parseUtc(this.el.dataset.minValue)
if (!minimum) return
const parts = localParts(minimum)
this.dateInput.min = parts.date
this.timeInput.min = this.dateInput.value === parts.date ? parts.time : ""
}
this.syncHidden = () => {
this.updateMinimum()
let value = ""
if (this.dateInput.value && this.timeInput.value) {
const [year, month, day] = this.dateInput.value.split("-").map(Number)
const [hour, minute] = this.timeInput.value.split(":").map(Number)
const date = new Date(year, month - 1, day, hour, minute)
if (!Number.isNaN(date.getTime())) value = utcValue(date)
}
if (this.hiddenInput.value === value) return
this.hiddenInput.value = value
this.hiddenInput.dispatchEvent(new Event("input", {bubbles: true}))
}
this.syncUserInput = () => {
if (this.unusedInput) this.unusedInput.disabled = true
this.syncHidden()
}
this.setQuickTime = event => {
const minutes = Number(event.currentTarget.dataset.datetimeOffset)
if (!Number.isFinite(minutes)) return
const date = new Date(Date.now() + minutes * 60_000)
date.setSeconds(0, 0)
this.applyLocalDate(date)
this.syncUserInput()
}
this.dateInput.addEventListener("input", this.syncUserInput)
this.timeInput.addEventListener("input", this.syncUserInput)
this.quickButtons.forEach(button => button.addEventListener("click", this.setQuickTime))
const initial = this.parseUtc(this.hiddenInput.value)
if (initial) this.applyLocalDate(initial)
this.updateMinimum()
if (this.timezoneLabel) {
this.timezoneLabel.textContent =
Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"
}
},
destroyed() {
this.dateInput?.removeEventListener("input", this.syncUserInput)
this.timeInput?.removeEventListener("input", this.syncUserInput)
this.quickButtons?.forEach(button => button.removeEventListener("click", this.setQuickTime))
}
},
LocalTime: {
mounted() {
this.format = () => {
const value = this.el.dateTime || this.el.dataset.datetime
const date = new Date(value)
if (Number.isNaN(date.getTime())) return
const dateStyle = this.el.dataset.dateStyle || "medium"
const timeStyle = this.el.dataset.timeStyle || "short"
const options = {}
if (dateStyle !== "none") options.dateStyle = dateStyle
if (timeStyle !== "none") options.timeStyle = timeStyle
this.el.textContent = new Intl.DateTimeFormat(uiLocale(), options).format(date)
this.el.title = date.toISOString()
}
this.format()
},
updated() {
this.format()
}
},
ChatMessages: {
mounted() {
this.wasNearBottom = true
this.el.scrollTop = this.el.scrollHeight
this.isIntersecting = false
this.markingRead = false
this.recordPosition = () => {
this.wasNearBottom =
this.el.scrollHeight - this.el.scrollTop - this.el.clientHeight < 80
}
this.markRead = () => {
if (
this.el.dataset.readReceipts !== "true" ||
this.el.dataset.hasUnread !== "true" ||
document.visibilityState !== "visible" ||
!this.isIntersecting ||
this.markingRead
) return
this.markingRead = true
this.pushEvent("mark-messages-read", {}, () => {
this.markingRead = false
})
}
this.visibilityChanged = () => this.markRead()
this.el.addEventListener("scroll", this.recordPosition, {passive: true})
document.addEventListener("visibilitychange", this.visibilityChanged)
if (this.el.dataset.readReceipts === "true" && "IntersectionObserver" in window) {
this.visibilityObserver = new IntersectionObserver(entries => {
this.isIntersecting = entries.some(entry => entry.isIntersecting)
this.markRead()
}, {threshold: 0.1})
this.visibilityObserver.observe(this.el)
} else {
this.isIntersecting = true
this.markRead()
}
},
beforeUpdate() {
this.recordPosition()
const firstMessage = this.el.querySelector("[data-chat-message]")
this.anchorId = firstMessage?.id
this.anchorOffset = firstMessage?.offsetTop
},
updated() {
if (this.wasNearBottom) {
this.el.scrollTop = this.el.scrollHeight
} else if (this.anchorId) {
const anchor = document.getElementById(this.anchorId)
if (anchor && Number.isFinite(this.anchorOffset)) {
this.el.scrollTop += anchor.offsetTop - this.anchorOffset
}
}
this.markRead()
},
destroyed() {
this.el.removeEventListener("scroll", this.recordPosition)
document.removeEventListener("visibilitychange", this.visibilityChanged)
this.visibilityObserver?.disconnect()
}
},
ChatComposer: {
mounted() {
this.resize = () => {
this.el.style.height = "auto"
this.el.style.height = `${Math.min(this.el.scrollHeight, 160)}px`
}
this.submitOnEnter = event => {
if (event.key !== "Enter" || event.shiftKey || event.isComposing) return
event.preventDefault()
this.el.form?.requestSubmit()
}
this.reset = () => window.requestAnimationFrame(this.resize)
this.el.addEventListener("input", this.resize)
this.el.addEventListener("keydown", this.submitOnEnter)
this.el.form?.addEventListener("reset", this.reset)
this.resize()
},
destroyed() {
this.el.removeEventListener("input", this.resize)
this.el.removeEventListener("keydown", this.submitOnEnter)
this.el.form?.removeEventListener("reset", this.reset)
}
},
CopyToClipboard: {
mounted() {
this.resetLabel = () => {
this.el.textContent = this.el.dataset.defaultLabel
}
this.copy = async () => {
const value = this.el.dataset.copyValue || ""
const fallbackCopy = () => {
const input = document.createElement("textarea")
input.value = value
input.setAttribute("readonly", "")
input.style.position = "fixed"
input.style.opacity = "0"
document.body.append(input)
input.select()
const copied = document.execCommand("copy")
input.remove()
if (!copied) throw new Error("Clipboard copy failed")
}
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value)
} else {
fallbackCopy()
}
this.el.textContent = this.el.dataset.copiedLabel
} catch (_error) {
try {
fallbackCopy()
this.el.textContent = this.el.dataset.copiedLabel
} catch (_fallbackError) {
this.el.textContent = this.el.dataset.errorLabel
}
}
window.clearTimeout(this.labelTimer)
this.labelTimer = window.setTimeout(this.resetLabel, 1800)
}
this.el.addEventListener("click", this.copy)
},
destroyed() {
this.el.removeEventListener("click", this.copy)
window.clearTimeout(this.labelTimer)
}
},
HandoverCode: {
mounted() {
this.normalize = () => {
this.el.value = this.el.value.replace(/[^0-9]/g, "").slice(0, 6)
}
this.el.addEventListener("input", this.normalize)
},
destroyed() {
this.el.removeEventListener("input", this.normalize)
}
},
AidMap: {
mounted() {
this.aidMap = createAidMap(this.el)
this.el._aidMap = this.aidMap
this.aidMap.onInitializeViewport = viewport =>
this.pushEvent("initialize-map-area", viewport)
this.aidMap.onViewportChange = viewport =>
this.pushEvent("map-viewport-change", viewport)
this.aidMap.onClusterExpand = viewport =>
this.pushEvent("expand-map-cluster", viewport)
this.aidMap.mount()
},
updated() {
// A server viewport is only the bounds used to query marker data. Once
// the map is mounted, the user's camera is authoritative and must never
// be moved back to bounds from a delayed LiveView response.
this.aidMap.renderMarkers()
},
destroyed() {
this.aidMap.destroy()
delete this.el._aidMap
}
},
RequestDiscovery: {
mounted() {
this.storageKey = this.el.dataset.storageKey || "wnh:request-discovery-view"
this.mapTarget = this.el.dataset.mapTarget || "#request-map"
this.allowedViews = ["list", "split", "map"]
this.resizeMap = () => {
document.querySelector(this.mapTarget)?._aidMap?.resize()
}
this.persistView = () => {
const view = this.el.dataset.viewMode
if (!this.allowedViews.includes(view)) return
try {
window.localStorage.setItem(this.storageKey, view)
} catch (_error) {
// The URL still keeps the selected mode when storage is unavailable.
}
}
this.onPointerOver = event => {
const card = event.target.closest("[data-discovery-card]")
if (!card || !this.el.contains(card)) return
window.dispatchEvent(
new CustomEvent("wnh:request-highlight", {detail: {id: card.dataset.requestId}})
)
}
this.onPointerOut = event => {
const card = event.target.closest("[data-discovery-card]")
if (!card || card.contains(event.relatedTarget)) return
window.dispatchEvent(new CustomEvent("wnh:request-highlight", {detail: {id: null}}))
}
this.onRequestSelected = event => {
this.el.querySelectorAll("[data-discovery-card]").forEach(card => {
card.classList.toggle("is-selected", card.dataset.requestId === event.detail?.id)
})
const selected = this.el.querySelector(
`[data-discovery-card][data-request-id="${CSS.escape(String(event.detail?.id || ""))}"]`
)
if (selected && this.el.dataset.viewMode !== "map") {
selected.scrollIntoView({block: "nearest", behavior: "smooth"})
}
}
this.el.addEventListener("pointerover", this.onPointerOver)
this.el.addEventListener("pointerout", this.onPointerOut)
window.addEventListener("wnh:request-selected", this.onRequestSelected)
let savedView = null
try {
savedView = window.localStorage.getItem(this.storageKey)
} catch (_error) {
savedView = null
}
if (
this.el.dataset.viewExplicit !== "true" &&
this.allowedViews.includes(savedView) &&
savedView !== this.el.dataset.viewMode
) {
this.pushEvent("restore-view-mode", {view: savedView})
} else {
this.persistView()
}
this.resizeMap()
},
updated() {
this.persistView()
this.resizeMap()
},
destroyed() {
this.el.removeEventListener("pointerover", this.onPointerOver)
this.el.removeEventListener("pointerout", this.onPointerOut)
window.removeEventListener("wnh:request-selected", this.onRequestSelected)
}
},
RequestLocationPicker: {
mounted() {
this.map = null
this.marker = null
this.markerMode = null
this.mapLoaded = false
this.mapUnavailable = false
this.preserveHiddenCoordinates = this.el.dataset.preserveHiddenCoordinates === "true"
this.areaSourceId = `${this.el.id}-selection-area`
this.areaFillId = `${this.el.id}-selection-fill`
this.areaLineId = `${this.el.id}-selection-line`
this.cacheElements = () => {
this.mapElement = this.el.querySelector(this.el.dataset.mapTarget)
this.latitudeInput = this.el.querySelector(this.el.dataset.latitudeTarget)
this.longitudeInput = this.el.querySelector(this.el.dataset.longitudeTarget)
this.statusElement = this.el.querySelector(this.el.dataset.statusTarget)
}
this.retryMap = () => {
if (!this.mapElement) return
this.mapUnavailable = false
delete this.el.dataset.mapUnavailable
this.mapElement.dataset.mapReady = "false"
delete this.mapElement.dataset.mapUnavailable
delete this.mapElement.dataset.mapUnavailableReason
this.mapElement.replaceChildren()
this.mountMap()
}
this.renderMapUnavailable = (reason = "webgl-context-unavailable", error = null) => {
if (!this.mapElement) return
this.mapUnavailableReason = reason
this.mapUnavailableError = error
this.el.dataset.mapUnavailable = "true"
reportMapUnavailable(this.mapElement, reason, error)
this.mapElement.dataset.mapReady = "true"
const fallback = document.createElement("div")
fallback.className = "map-unavailable-panel"
fallback.dataset.mapFallback = "true"
fallback.setAttribute("role", "region")
fallback.setAttribute("aria-label", mapRegionLabel())
const message = document.createElement("p")
message.textContent = this.mapElement.dataset.mapUnavailableLabel
fallback.append(message, mapRetryButton(this.mapElement, this.retryMap))
this.mapElement.replaceChildren(fallback)
}
this.mode = () =>
this.el.querySelector("[data-location-mode-input]:checked")?.value ||
this.el.dataset.locationMode ||
"approximate_public"
this.radius = () => {
const allowed = String(this.el.dataset.allowedRadii || "500,1000,2000")
.split(",")
.map(Number)
.filter(Number.isFinite)
const value = Number(
this.el.querySelector("[data-location-radius-input]:checked")?.value || 1000
)
return allowed.includes(value) ? value : allowed[0] || 1000
}
this.coordinates = () => {
const latitudeValue = String(this.latitudeInput?.value || "").trim()
const longitudeValue = String(this.longitudeInput?.value || "").trim()
if (latitudeValue === "" || longitudeValue === "") return null
const latitude = Number(latitudeValue)
const longitude = Number(longitudeValue)
if (
!Number.isFinite(latitude) ||
!Number.isFinite(longitude) ||
latitude < -90 ||
latitude > 90 ||
longitude < -180 ||
longitude > 180
) return null
return {latitude, longitude}
}
this.setStatus = message => {
if (this.statusElement) this.statusElement.textContent = message
}
this.setSelectionStatus = () => {
if (!this.coordinates()) {
this.setStatus(this.el.dataset.mapReadyLabel)
return
}
const mode = this.mode()
const message = mode === "hidden" && this.preserveHiddenCoordinates
? this.el.dataset.privateSelectedLabel
: mode === "approximate_public"
? this.el.dataset.areaSelectedLabel
: this.el.dataset.pointSelectedLabel
this.setStatus(message)
}
this.focusSelection = () => {
const coordinates = this.coordinates()
if (!coordinates || !this.map) return
this.map.flyTo({
center: [coordinates.longitude, coordinates.latitude],
zoom: this.mode() === "approximate_public" ? 13 : 15
})
}
this.notifyCoordinatesChanged = () => {
this.longitudeInput?.dispatchEvent(new Event("input", {bubbles: true}))
}
this.setCoordinates = ({latitude, longitude}, {notify = true, center = false} = {}) => {
if (!this.latitudeInput || !this.longitudeInput) return
this.latitudeInput.value = latitude.toFixed(6)
this.longitudeInput.value = longitude.toFixed(6)
this.renderSelection()
if (center && this.map) {
this.map.flyTo({
center: [longitude, latitude],
zoom: this.mode() === "approximate_public" ? 13 : 15
})
}
if (notify) this.notifyCoordinatesChanged()
}
this.clearCoordinates = ({notify = false} = {}) => {
if (this.latitudeInput) this.latitudeInput.value = ""
if (this.longitudeInput) this.longitudeInput.value = ""
this.renderSelection()
if (notify) this.notifyCoordinatesChanged()
}
this.ensureAreaLayers = () => {
if (!this.mapLoaded || this.map.getSource(this.areaSourceId)) return
this.map.addSource(this.areaSourceId, {type: "geojson", data: emptyFeatureCollection()})
this.map.addLayer({
id: this.areaFillId,
type: "fill",
source: this.areaSourceId,
paint: {"fill-color": "#278467", "fill-opacity": 0.2}
})
this.map.addLayer({
id: this.areaLineId,
type: "line",
source: this.areaSourceId,
paint: {
"line-color": "#16725b",
"line-width": 2,
"line-dasharray": [2, 2]
}
})
}
this.setAreaData = data => {
if (!this.mapLoaded) return
this.ensureAreaLayers()
this.map.getSource(this.areaSourceId)?.setData(data)
}
this.removeMarker = () => {
this.marker?.remove()
this.marker = null
this.markerMode = null
}
this.ensureSelectionMarker = (coordinates, mode) => {
if (this.marker && this.markerMode !== mode) this.removeMarker()
if (!this.marker) {
this.markerMode = mode
this.marker = new maplibregl.Marker({
color: mode === "approximate_public"
? "#16725b"
: mode === "exact_private"
? "#6152a3"
: "#d6573b",
draggable: true
})
.setLngLat([coordinates.longitude, coordinates.latitude])
.addTo(this.map)
const markerElement = this.marker.getElement()
markerElement.setAttribute(
"aria-label",
mode === "approximate_public"
? this.el.dataset.areaMarkerLabel
: mode === "exact_private"
? this.el.dataset.privateMarkerLabel
: this.el.dataset.pointMarkerLabel
)
markerElement.setAttribute(
"title",
mode === "approximate_public"
? this.el.dataset.areaMarkerLabel
: mode === "exact_private"
? this.el.dataset.privateMarkerLabel
: this.el.dataset.pointMarkerLabel
)
this.marker.on("drag", () => {
const point = this.marker.getLngLat()
if (this.latitudeInput) this.latitudeInput.value = point.lat.toFixed(6)
if (this.longitudeInput) this.longitudeInput.value = point.lng.toFixed(6)
if (this.markerMode === "approximate_public") {
this.setAreaData(areaFeatureCollection([{
latitude: point.lat,
longitude: point.lng,
exact: false,
radius_meters: this.radius()
}]))
}
})
this.marker.on("dragend", () => {
const point = this.marker.getLngLat()
this.setCoordinates({latitude: point.lat, longitude: point.lng})
this.setSelectionStatus()
})
} else {
this.marker.setLngLat([coordinates.longitude, coordinates.latitude])
}
}
this.renderSelection = () => {
const mode = this.mode()
const coordinates = this.coordinates()
this.el.dataset.locationMode = mode
if (!this.mapLoaded) return
if (mode === "hidden" && !this.preserveHiddenCoordinates) {
this.removeMarker()
this.setAreaData(emptyFeatureCollection())
return
}
if (!coordinates) {
this.removeMarker()
this.setAreaData(emptyFeatureCollection())
return
}
if (mode === "approximate_public") {
this.ensureSelectionMarker(coordinates, mode)
this.setAreaData(areaFeatureCollection([{
...coordinates,
exact: false,
radius_meters: this.radius()
}]))
return
}
this.setAreaData(emptyFeatureCollection())
this.ensureSelectionMarker(
coordinates,
mode === "hidden" ? "exact_private" : mode
)
}
this.syncMode = () => {
const mode = this.mode()
this.el.dataset.locationMode = mode
if (mode === "hidden" && !this.preserveHiddenCoordinates) {
this.clearCoordinates()
this.setStatus(this.el.dataset.hiddenLabel)
} else {
this.renderSelection()
this.setSelectionStatus()
window.requestAnimationFrame(() => this.map?.resize())
}
}
this.onChange = event => {
if (event.target.matches("[data-location-mode-input]")) {
this.syncMode()
return
}
if (event.target.matches("[data-location-radius-input]")) {
this.renderSelection()
this.setSelectionStatus()
return
}
if (event.target.matches("[data-coordinate-input]")) {
this.renderSelection()
if (this.coordinates()) {
this.focusSelection()
this.setSelectionStatus()
}
}
}
this.onClick = event => {
const button = event.target.closest("[data-use-current-location]")
if (!button) return
if (!navigator.geolocation) {
this.setStatus(this.el.dataset.locationUnavailableLabel)
return
}
button.disabled = true
navigator.geolocation.getCurrentPosition(
position => {
const raw = {
latitude: position.coords.latitude,
longitude: position.coords.longitude
}
const selected = this.mode() === "approximate_public" &&
this.el.dataset.privateCenter !== "true"
? privacySafeAreaCenter(raw.latitude, raw.longitude, this.radius())
: raw
this.setCoordinates(selected, {center: true})
this.setSelectionStatus()
button.disabled = false
},
() => {
this.setStatus(this.el.dataset.locationDeniedLabel)
button.disabled = false
},
{enableHighAccuracy: true, timeout: 10000}
)
}
this.cacheElements()
this.el.addEventListener("change", this.onChange)
this.el.addEventListener("click", this.onClick)
this.mountMap = () => {
if (!this.mapElement) return
this.mapElement.dataset.mapIdle = "false"
const support = mapCanvasSupport()
if (!support.supported) {
this.mapUnavailable = true
this.renderMapUnavailable(support.reason, support.error)
this.syncMode()
return
}
const initialCoordinates = this.coordinates()
try {
this.map = new maplibregl.Map({
container: this.mapElement,
style: defaultStyle,
center: initialCoordinates
? [initialCoordinates.longitude, initialCoordinates.latitude]
: [30.5234, 50.4501],
zoom: initialCoordinates ? 13 : 5,
locale: mapUiLocale(),
attributionControl: true,
dragRotate: false,
pitchWithRotate: false
})
} catch (error) {
this.map = null
this.mapUnavailable = true
this.renderMapUnavailable("initialization-failed", error)
this.syncMode()
return
}
this.map.touchZoomRotate.disableRotation()
this.map.addControl(new maplibregl.NavigationControl({showCompass: false}), "top-left")
this.map.on("dataloading", () => {
if (this.mapElement) this.mapElement.dataset.mapIdle = "false"
})
this.map.on("idle", () => {
if (this.mapElement) this.mapElement.dataset.mapIdle = "true"
})
this.map.on("load", () => {
this.mapLoaded = true
this.mapUnavailable = false
delete this.el.dataset.mapUnavailable
this.mapElement.dataset.mapReady = "true"
delete this.mapElement.dataset.mapUnavailable
delete this.mapElement.dataset.mapUnavailableReason
this.ensureAreaLayers()
this.renderSelection()
})
this.map.on("click", event => {
if (this.mode() === "hidden" && !this.preserveHiddenCoordinates) return
this.setCoordinates(
{latitude: event.lngLat.lat, longitude: event.lngLat.lng},
{center: true}
)
this.setSelectionStatus()
})
this.syncMode()
}
this.mountMap()
},
updated() {
this.cacheElements()
if (this.mapUnavailable) {
this.el.dataset.mapUnavailable = "true"
if (!this.mapElement?.querySelector("[data-map-fallback=true]")) {
this.renderMapUnavailable(this.mapUnavailableReason, this.mapUnavailableError)
}
}
this.syncMode()
},
destroyed() {
this.el.removeEventListener("change", this.onChange)
this.el.removeEventListener("click", this.onClick)
this.removeMarker()
this.map?.remove()
}
},
LocationPicker: {
mounted() {
this.el.addEventListener("click", () => {
const latitudeInput = document.querySelector(this.el.dataset.latitudeTarget)
const longitudeInput = document.querySelector(this.el.dataset.longitudeTarget)
if (!latitudeInput || !longitudeInput || !navigator.geolocation) {
this.el.textContent = this.el.dataset.unavailableLabel
this.el.classList.add("btn-error")
return
}
navigator.geolocation.getCurrentPosition(
position => {
latitudeInput.value = position.coords.latitude
longitudeInput.value = position.coords.longitude
latitudeInput.dispatchEvent(new Event("input", {bubbles: true}))
longitudeInput.dispatchEvent(new Event("input", {bubbles: true}))
this.el.textContent = this.el.dataset.addedLabel
this.el.classList.add("btn-success")
},
() => {
this.el.textContent = this.el.dataset.permissionDeniedLabel
this.el.classList.add("btn-error")
},
{enableHighAccuracy: true, timeout: 10000}
)
})
}
},
LiveTracking: {
mounted() {
this.destroying = false
this.lastLocationSentAt = 0
this.pendingLocation = null
this.locationTimer = undefined
this.sendLocation = payload => {
this.lastLocationSentAt = Date.now()
this.pendingLocation = null
this.pushEvent("location-update", payload)
}
this.queueLocation = payload => {
const elapsed = Date.now() - this.lastLocationSentAt
if (this.lastLocationSentAt === 0 || elapsed >= trackingMinTimeMs) {
if (this.locationTimer !== undefined) {
window.clearTimeout(this.locationTimer)
this.locationTimer = undefined
}
this.sendLocation(payload)
return
}
this.pendingLocation = payload
if (this.locationTimer === undefined) {
this.locationTimer = window.setTimeout(() => {
this.locationTimer = undefined
if (!this.destroying && this.pendingLocation) {
this.sendLocation(this.pendingLocation)
}
}, trackingMinTimeMs - elapsed)
}
}
if (typeof window.WhoNeedHelpAndroid?.postMessage === "function") {
this.nativeError = () => {
this.pushEvent("location-error", {})
}
window.addEventListener("wnh:native-tracking-error", this.nativeError)
return
}
if (!navigator.geolocation) {
this.pushEvent("location-error", {})
return
}
this.watchId = navigator.geolocation.watchPosition(
position => this.queueLocation({
latitude: position.coords.latitude,
longitude: position.coords.longitude,
accuracy_meters: position.coords.accuracy
}),
() => {
if (!this.destroying) this.pushEvent("location-error", {})
},
{enableHighAccuracy: true, maximumAge: 5000, timeout: 15000}
)
},
destroyed() {
this.destroying = true
this.pendingLocation = null
if (this.locationTimer !== undefined) {
window.clearTimeout(this.locationTimer)
this.locationTimer = undefined
}
if (this.nativeError) {
window.removeEventListener("wnh:native-tracking-error", this.nativeError)
}
if (this.watchId !== undefined) navigator.geolocation.clearWatch(this.watchId)
}
}
}
function mapCanvasSupport() {
if (!window.WebGLRenderingContext) {
return {supported: false, reason: "webgl-api-unavailable"}
}
const canvas = document.createElement("canvas")
const attributes = {
alpha: true,
depth: true,
stencil: true,
premultipliedAlpha: true
}
try {
const context =
canvas.getContext("webgl2", attributes) ||
canvas.getContext("webgl", attributes)
if (!context || typeof context.getParameter !== "function") {
return {supported: false, reason: "webgl-context-unavailable"}
}
context.getExtension("WEBGL_lose_context")?.loseContext()
return {supported: true}
} catch (error) {
return {supported: false, reason: "webgl-context-disabled", error}
}
}