who_need_help/assets/js/hooks.js

2707 lines
89 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 {
clusterCountLabel,
clusterExpansionTarget,
clusterSizeClass,
pointFeatureCollection
} from "./map_cluster_features.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,
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 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,
viewportFrame: null,
resizeFrame: null,
resizeObserver: null,
markers: [],
markerElements: new Map(),
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`,
clusterCircleId: `${element.id || "aid-map"}-cluster-circle`,
serverClusterCircleId: `${element.id || "aid-map"}-server-cluster-circle`,
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.markReady = () => {
state.element.dataset.mapReady = "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
state.markerElements.forEach((element, requestId) => {
element.classList.toggle("is-selected", requestId === id)
})
if (state.map?.getLayer(state.areaSelectedId)) {
state.map.setFilter(state.areaSelectedId, ["==", ["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 (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()
if (current.width >= 160 && current.height >= 160) {
state.map.resize()
}
})
}
state.applySavedViewport = () => {
if (!state.active || !state.map) return
const viewport = savedViewport(state.element)
const serialized = viewport ? JSON.stringify(viewport) : ""
if (!viewport || state.appliedViewport === serialized) return
state.appliedViewport = serialized
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.expandNativeCluster = feature => {
const source = state.map?.getSource(state.clusterSourceId)
const clusterId = Number(feature?.properties?.cluster_id)
if (!source || !Number.isFinite(clusterId)) return
Promise.resolve(source.getClusterExpansionZoom(clusterId)).then(expansionZoom => {
if (!state.active || !state.map) return
const currentZoom = state.map.getZoom()
const targetZoom = clusterExpansionTarget(currentZoom, Number(expansionZoom))
if (currentZoom >= 21.75 && targetZoom <= 22) {
const count = Number(
feature.properties?.total_count || feature.properties?.point_count
) || 1
const label = (state.element.dataset.clusterLabel || "%{count} items")
.replace("%{count}", new Intl.NumberFormat(uiLocale()).format(count))
new maplibregl.Popup({offset: 18})
.setLngLat(feature.geometry.coordinates)
.setDOMContent(state.popupContent({
title: label,
location: state.element.dataset.clusterMaxZoomLabel || ""
}))
.addTo(state.map)
return
}
state.clusterExpansionPending = true
state.map.easeTo({
center: feature.geometry.coordinates,
zoom: targetZoom,
duration: 300
})
})
}
state.expandServerCluster = feature => {
if (!feature || !state.map) return
const count = Number(feature.properties?.weight) || 1
const requestedZoom = Number(feature.properties?.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(feature.geometry.coordinates)
.setDOMContent(state.popupContent({
title: label,
location: state.element.dataset.clusterMaxZoomLabel || ""
}))
.addTo(state.map)
return
}
state.clusterExpansionPending = true
state.map.easeTo({
center: feature.geometry.coordinates,
zoom: expansionZoom,
duration: 300
})
}
state.syncClusterMarkers = () => {
state.markers.forEach(marker => marker.remove())
state.markers = []
if (
!state.map ||
state.element.dataset.discoveryMap !== "true" ||
!state.map.getLayer(state.clusterCircleId) ||
!state.map.getLayer(state.serverClusterCircleId)
) return
const features = state.map.queryRenderedFeatures({
layers: [state.clusterCircleId, state.serverClusterCircleId]
})
const seen = new Set()
features.forEach(feature => {
const nativeCluster =
feature.properties?.cluster === true || feature.properties?.cluster === "true"
const key = nativeCluster
? `native:${feature.properties.cluster_id}`
: `server:${feature.properties?.server_cluster_id || feature.properties?.id}`
if (seen.has(key)) return
seen.add(key)
const count = Number(
nativeCluster
? feature.properties?.total_count || feature.properties?.point_count
: feature.properties?.weight
) || 1
const urgentCount = Number(
nativeCluster
? feature.properties?.urgent_count
: feature.properties?.urgent_weight
) || 0
const button = document.createElement("button")
button.type = "button"
button.className = `request-map-cluster ${clusterSizeClass(count)}`
button.dataset.clusterCount = String(count)
button.classList.toggle("is-urgent", urgentCount > 0)
button.textContent = clusterCountLabel(count, uiLocale())
const formattedCount = new Intl.NumberFormat(uiLocale()).format(count)
button.setAttribute(
"aria-label",
(state.element.dataset.clusterLabel || "%{count} items")
.replace("%{count}", formattedCount)
)
button.title = button.getAttribute("aria-label")
button.addEventListener("click", event => {
event.stopPropagation()
if (nativeCluster) {
state.expandNativeCluster(feature)
} else {
state.expandServerCluster(feature)
}
})
state.markers.push(
new maplibregl.Marker({element: button})
.setLngLat(feature.geometry.coordinates)
.addTo(state.map)
)
})
}
state.syncVisibleAreas = () => {
if (!state.map) return
const areaSource = state.map.getSource(state.areaSourceId)
if (!areaSource) return
let visiblePoints = state.points
if (state.element.dataset.discoveryMap === "true") {
if (!state.map.getSource(state.clusterSourceId)?.loaded()) return
const visibleIds = new Set(
state.map
.querySourceFeatures(state.clusterSourceId, {
filter: [
"all",
["!", ["has", "point_count"]],
["!=", ["get", "item_type"], "cluster"]
]
})
.map(feature => feature.properties?.id)
.filter(Boolean)
)
visiblePoints = state.points.filter(point => visibleIds.has(point.id))
}
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
state.markers.forEach(marker => marker.remove())
state.markers = []
state.markerElements.clear()
const points = markerPoints(state.element)
state.points = points
const pointFeatures = pointFeatureCollection(points)
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
const existingAreaSource = state.map.getSource(state.areaSourceId)
const existingPointSource = state.map.getSource(state.clusterSourceId)
if (
(!existingAreaSource || !existingPointSource) &&
!state.map.isStyleLoaded()
) {
state.pendingMarkerRender = true
return
}
state.pendingMarkerRender = false
{
const areaSource = existingAreaSource
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,
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": 2,
"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}
})
state.map.on("mouseenter", state.areaFillId, () => {
state.map.getCanvas().style.cursor = "pointer"
})
state.map.on("mouseleave", state.areaFillId, () => {
state.map.getCanvas().style.cursor = ""
})
state.map.on("click", state.areaFillId, event => {
const properties = event.features?.[0]?.properties
if (!properties?.id) return
state.selectRequest(properties.id)
new maplibregl.Popup({offset: 12})
.setLngLat(event.lngLat)
.setDOMContent(state.popupContent(properties))
.addTo(state.map)
})
}
const pointSource = existingPointSource
if (pointSource) {
pointSource.setData(pointFeatures)
} else {
const sourceOptions = {
type: "geojson",
data: pointFeatures,
generateId: false
}
if (state.element.dataset.discoveryMap === "true") {
Object.assign(sourceOptions, {
maxzoom: 22,
cluster: true,
clusterRadius: 64,
clusterMaxZoom: 21,
clusterMinPoints: 2,
clusterProperties: {
total_count: ["+", ["get", "weight"]],
urgent_count: ["+", ["get", "urgent_weight"]]
}
})
}
state.map.addSource(state.clusterSourceId, sourceOptions)
state.map.addLayer({
id: state.clusterCircleId,
type: "circle",
source: state.clusterSourceId,
filter: ["has", "point_count"],
paint: {
"circle-color": [
"case",
[">", ["coalesce", ["get", "urgent_count"], 0], 0],
"#c2412d",
"#087f5b"
],
"circle-radius": [
"step",
["coalesce", ["get", "total_count"], ["get", "point_count"]],
22,
100,
25,
1_000,
28,
10_000,
31,
100_000,
34
],
"circle-stroke-color": "#ffffff",
"circle-stroke-width": 3,
"circle-opacity": 0.96
}
})
state.map.addLayer({
id: state.serverClusterCircleId,
type: "circle",
source: state.clusterSourceId,
filter: [
"all",
["!", ["has", "point_count"]],
["==", ["get", "item_type"], "cluster"]
],
paint: {
"circle-color": [
"case",
[">", ["coalesce", ["get", "urgent_weight"], 0], 0],
"#c2412d",
"#087f5b"
],
"circle-radius": [
"step",
["get", "weight"],
22,
100,
25,
1_000,
28,
10_000,
31,
100_000,
34
],
"circle-stroke-color": "#ffffff",
"circle-stroke-width": 3,
"circle-opacity": 0.96
}
})
state.map.addLayer({
id: state.pointCircleId,
type: "circle",
source: state.clusterSourceId,
filter: [
"all",
["!", ["has", "point_count"]],
["!=", ["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.clusterCircleId,
state.serverClusterCircleId,
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.clusterCircleId, event => {
const feature = event.features?.[0]
if (feature) state.expandNativeCluster(feature)
})
state.map.on("click", state.serverClusterCircleId, event => {
const feature = event.features?.[0]
if (feature) state.expandServerCluster(feature)
})
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)
})
}
}
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
}
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()
if (state.statusBadge) {
state.statusBadge.textContent = state.statusBadge.dataset.interactiveLabel
}
state.applySavedViewport()
state.renderMarkers()
state.viewportFrame = window.requestAnimationFrame(() => {
state.viewportFrame = null
if (!state.active || !state.map) return
state.initializedViewport = true
if (state.element.dataset.discoveryMap === "true" && !savedViewport(state.element)) {
const viewport = mapViewport(state.map)
if (viewport) state.onInitializeViewport?.(viewport)
}
})
}
state.onMoveEnd = () => {
if (!state.initializedViewport || state.element.dataset.discoveryMap !== "true") return
const viewport = mapViewport(state.map)
if (!viewport) return
if (state.clusterExpansionPending) {
state.clusterExpansionPending = false
state.onClusterExpand?.(viewport)
} else {
state.onViewportChange?.(viewport)
}
}
state.onIdle = () => {
state.markReady()
if (state.pendingMarkerRender) {
state.renderMarkers()
if (state.pendingMarkerRender) return
}
state.syncVisibleAreas()
state.syncClusterMarkers()
}
state.onRequestHighlight = event => state.highlightRequest(event.detail?.id)
state.map.on("dataloading", state.markLoading)
state.map.on("load", state.onLoad)
state.map.on("idle", state.onIdle)
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
if (state.viewportFrame !== null) window.cancelAnimationFrame(state.viewportFrame)
if (state.resizeFrame !== null) window.cancelAnimationFrame(state.resizeFrame)
state.resizeObserver?.disconnect()
state.map?.off("dataloading", state.markLoading)
state.map?.off("load", state.onLoad)
state.map?.off("idle", state.onIdle)
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() {
this.aidMap.applySavedViewport()
this.aidMap.renderMarkers()
this.aidMap.resize()
},
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}
}
}