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" import {resolveNativePushState} from "./native_push_state.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, cooperativeGestures: true, 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] || "" this.applyNativePresentation = () => { if (!this.native || !this.buttonLabel) return this.buttonLabel.dataset.enableLabel = this.buttonLabel.dataset.nativeEnableLabel this.buttonLabel.dataset.enabledLabel = this.buttonLabel.dataset.nativeEnabledLabel } this.applyNativePresentation() 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.activeServerDeviceIds = () => [...this.el.querySelectorAll("[data-disable-device]")] .map(element => element.dataset.disableDevice) .filter(Boolean) this.clearStaleNativeRegistration = () => { window.localStorage.removeItem("wnh.push.server-device-id") this.postNative({action: "disable_push"}) this.setButtonState(false) this.setStatus(this.message("statusAndroidPrompt")) if (this.button) this.button.disabled = false } 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 const state = resolveNativePushState({ configured: true, requested: true, serverDeviceId: detail.server_device_id, activeDeviceIds: this.activeServerDeviceIds() }) if (state.stale) { this.clearStaleNativeRegistration() 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.nativeState = event => { const detail = event.detail || {} const state = resolveNativePushState({ configured: detail.configured, requested: detail.requested, serverDeviceId: detail.server_device_id, activeDeviceIds: this.activeServerDeviceIds() }) if (!state.configured) { this.setButtonState(false) this.setStatus(this.message("statusAndroidUnavailable")) if (this.button) this.button.disabled = true return } if (state.stale) { this.clearStaleNativeRegistration() return } if (state.registered) { window.localStorage.setItem("wnh.push.server-device-id", state.serverDeviceId) this.setButtonState(true) this.setStatus(this.message("statusAndroidEnabled")) } else { window.localStorage.removeItem("wnh.push.server-device-id") this.setButtonState(false) this.setStatus(this.message("statusAndroidPrompt")) } 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.activeServerDeviceIds().includes(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) window.addEventListener("wnh:native-push-state", this.nativeState) 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]") this.applyNativePresentation() 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) window.removeEventListener("wnh:native-push-state", this.nativeState) 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, cooperativeGestures: 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", {}) } this.nativeCancelled = () => { this.pushEvent("location-cancelled", {}) } window.addEventListener("wnh:native-tracking-error", this.nativeError) window.addEventListener("wnh:native-tracking-cancelled", this.nativeCancelled) 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.nativeCancelled) { window.removeEventListener("wnh:native-tracking-cancelled", this.nativeCancelled) } 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} } }