import maplibregl from "maplibre-gl" const trackingMinTimeMs = 5000 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.markers || "[]") if (!Array.isArray(points)) return [] return points.filter(point => Number.isFinite(point?.longitude) && Number.isFinite(point?.latitude) ) } catch (_error) { return [] } } const createAidMap = element => { const state = { element, map: null, markers: [] } state.markLoading = () => { if (state.element.dataset.mapReady !== "true") { state.element.dataset.mapReady = "false" } } state.markReady = () => { state.element.dataset.mapReady = "true" } state.showUnavailable = () => { state.map = null state.element.dataset.mapUnavailable = "true" state.markReady() if (state.element.dataset.demoMap !== "true") { const fallback = document.createElement("p") fallback.className = "grid h-full place-items-center p-6 text-center text-sm" fallback.dataset.mapFallback = "true" fallback.textContent = state.element.dataset.mapUnavailableLabel state.element.replaceChildren(fallback) return } const fallback = document.createElement("div") fallback.className = "home-demo-map-fallback" fallback.dataset.mapFallback = "true" 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" markerPoints(state.element).forEach((point, index) => { const item = document.createElement("div") item.className = "home-demo-map-fallback-point" 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) points.append(item) }) fallback.append(points) state.element.replaceChildren(fallback) } state.renderMarkers = () => { if (!state.map) return state.markers.forEach(marker => marker.remove()) state.markers = [] const points = markerPoints(state.element) points.forEach(point => { const popupContent = document.createElement("div") const title = document.createElement("strong") title.textContent = point.title || "" popupContent.append(title, document.createElement("br")) popupContent.append(document.createTextNode(point.location || "")) const popup = new maplibregl.Popup({offset: 18}).setDOMContent(popupContent) const marker = new maplibregl.Marker({color: point.exact ? "#d6573b" : "#278467"}) .setLngLat([point.longitude, point.latitude]) .setPopup(popup) .addTo(state.map) state.markers.push(marker) }) if (points.length === 1) { state.map.flyTo({center: [points[0].longitude, points[0].latitude], zoom: 12}) } else if (points.length > 1) { const bounds = new maplibregl.LngLatBounds() points.forEach(point => bounds.extend([point.longitude, point.latitude])) state.map.fitBounds(bounds, {padding: 52, maxZoom: 13}) } } state.mount = () => { state.markLoading() const demoMap = state.element.dataset.demoMap === "true" if (!supportsMapCanvas()) { state.showUnavailable() return } try { state.map = new maplibregl.Map({ container: state.element, style: defaultStyle, center: [30.5234, 50.4501], zoom: 5, attributionControl: true, dragRotate: !demoMap, pitchWithRotate: !demoMap, scrollZoom: !demoMap }) } catch (_error) { state.showUnavailable() return } if (demoMap) state.map.touchZoomRotate.disableRotation() state.map.addControl( new maplibregl.NavigationControl({showCompass: !demoMap}), demoMap ? "bottom-right" : "top-right" ) state.map.on("dataloading", state.markLoading) state.map.on("load", state.markReady) state.map.on("idle", state.markReady) state.renderMarkers() } state.destroy = () => { state.map?.off("dataloading", state.markLoading) state.map?.off("load", state.markReady) state.map?.off("idle", state.markReady) state.map?.remove() } 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 = { 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.aidMap.mount() }, updated() { this.aidMap.renderMarkers() }, destroyed() { this.aidMap.destroy() } }, 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 = "Location is unavailable" 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 = "Location added" this.el.classList.add("btn-success") }, () => { this.el.textContent = "Location permission denied" 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.pushEvent("stop-tracking", {}) } window.addEventListener("wnh:native-tracking-error", this.nativeError) 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 supportsMapCanvas() { 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) return false context.getExtension("WEBGL_lose_context")?.loseContext() return true } catch (_error) { return false } }