who_need_help/assets/js/hooks.js

1906 lines
62 KiB
JavaScript

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.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 clusterFeatureCollection = points => ({
type: "FeatureCollection",
features: points
.filter(point => point.type === "cluster")
.map(point => ({
type: "Feature",
id: point.id,
properties: {
id: point.id,
count: point.count,
urgent_count: point.urgent_count || 0
},
geometry: {type: "Point", coordinates: [point.longitude, point.latitude]}
}))
})
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 || "Try map again"
button.addEventListener("click", retry, {once: true})
return button
}
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(),
initializedViewport: false,
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`,
clusterCountId: `${element.id || "aid-map"}-cluster-count`,
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"
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"
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")
location.textContent = point.location || ""
content.append(location)
if (point.id) {
const link = document.createElement("a")
link.href = `/requests/${encodeURIComponent(point.id)}`
link.textContent = state.element.dataset.requestLabel || "Open request"
content.append(link)
}
return content
}
state.highlightRequest = id => {
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 || ""])
}
}
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.renderMarkers = () => {
if (!state.map) return
state.markers.forEach(marker => marker.remove())
state.markers = []
state.markerElements.clear()
const points = markerPoints(state.element)
const exactPoints = points.filter(
point => point.type !== "cluster" && (point.exact || !Number.isFinite(point.radius_meters))
)
const areaFeatures = areaFeatureCollection(points)
const clusterFeatures = clusterFeatureCollection(points)
if (state.map.isStyleLoaded()) {
const areaSource = state.map.getSource(state.areaSourceId)
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,
paint: {"fill-color": "#278467", "fill-opacity": 0.18}
})
state.map.addLayer({
id: state.areaLineId,
type: "line",
source: state.areaSourceId,
paint: {
"line-color": "#16725b",
"line-width": 2,
"line-dasharray": [2, 2]
}
})
state.map.addLayer({
id: state.areaSelectedId,
type: "line",
source: state.areaSourceId,
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)
})
}
}
exactPoints.forEach(point => {
const popup = new maplibregl.Popup({offset: 18}).setDOMContent(state.popupContent(point))
const markerColor = point.type === "live" ? "#2563eb" : point.exact ? "#d6573b" : "#278467"
const marker = new maplibregl.Marker({color: markerColor})
.setLngLat([point.longitude, point.latitude])
.setPopup(popup)
.addTo(state.map)
const markerElement = marker.getElement()
markerElement.dataset.markerType = point.type || "request"
markerElement.setAttribute("aria-label", point.title || "Map marker")
if (point.id) {
markerElement.dataset.requestId = point.id
markerElement.addEventListener("click", () => state.selectRequest(point.id))
state.markerElements.set(point.id, markerElement)
}
state.markers.push(marker)
})
points.filter(point => point.type === "cluster").forEach(point => {
const element = document.createElement("button")
element.type = "button"
element.className = "request-map-cluster"
element.classList.toggle("is-urgent", Number(point.urgent_count) > 0)
element.textContent = String(point.count)
element.setAttribute("aria-label", `${point.count} requests`)
element.addEventListener("click", event => {
event.stopPropagation()
state.map.easeTo({
center: [point.longitude, point.latitude],
zoom: Math.min(state.map.getZoom() + 2, 20)
})
})
const marker = new maplibregl.Marker({element})
.setLngLat([point.longitude, point.latitude])
.addTo(state.map)
state.markers.push(marker)
})
if (points.length > 0 && state.element.dataset.autoFit !== "false") {
const bounds = new maplibregl.LngLatBounds()
exactPoints.forEach(point => bounds.extend([point.longitude, point.latitude]))
areaFeatures.features.forEach(feature => extendBoundsWithFeature(bounds, feature))
clusterFeatures.features.forEach(feature => bounds.extend(feature.geometry.coordinates))
state.map.fitBounds(bounds, {padding: 52, maxZoom: 13, duration: 0})
}
}
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,
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.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) state.onViewportChange?.(viewport)
}
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.markReady)
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.markReady)
state.map?.off("moveend", state.onMoveEnd)
window.removeEventListener("wnh:request-highlight", state.onRequestHighlight)
state.map?.remove()
state.map = 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 = {
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
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 || "Android · FCM").slice(0, 120),
user_agent: navigator.userAgent.slice(0, 500)
})
this.setButtonState(true)
this.setStatus("Push notifications are enabled on this Android device.")
} catch (error) {
console.warn("Android push registration failed", error)
this.setButtonState(false)
this.setStatus("The Android push token could not be saved. Please try again.")
} finally {
if (this.button) this.button.disabled = false
}
}
this.nativeError = event => {
const reason = event.detail?.reason
this.setButtonState(false)
this.setStatus(
reason === "permission_denied"
? "Notification permission was not granted."
: "Android push notifications are not available yet."
)
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("Push notifications are disabled on this device.")
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("Push notifications are disabled on this device.")
}
}
this.enable = async () => {
const publicKey = String(this.el.dataset.vapidPublicKey || "")
if (this.native) {
this.button.disabled = true
this.setStatus("Waiting for Android notification permission and token…")
this.postNative({action: "enable_push"})
return
}
if (!publicKey || !("serviceWorker" in navigator) || !("PushManager" in window)) {
this.setStatus("Push notifications are unavailable in this browser.")
return
}
this.button.disabled = true
try {
const permission = await Notification.requestPermission()
if (permission !== "granted") {
this.setStatus("Notification permission was not granted.")
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 || "Browser"} · Web Push`.slice(0, 120),
user_agent: navigator.userAgent.slice(0, 500)
})
this.setButtonState(true)
this.setStatus("Push notifications are enabled on this device.")
} catch (error) {
console.warn("Push notification registration failed", error)
this.setStatus("Push notifications could not be enabled.")
} 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("Push notifications are enabled on this Android device.")
}
this.restoreWebState = async () => {
if (this.native) return
const publicKey = String(this.el.dataset.vapidPublicKey || "")
if (!publicKey || !("serviceWorker" in navigator) || !("PushManager" in window)) {
this.setStatus("Push notifications are unavailable in this browser.")
return
}
if (Notification.permission === "denied") {
this.setStatus("Notification permission was not granted.")
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("Push notifications are enabled on this device.")
} else if (subscription && !currentKey) {
this.setStatus(
"Push keys changed. Enable notifications to reconnect this device securely."
)
} else if (subscription || activeServerDevice) {
this.setStatus("This browser has a push subscription. Enable it to reconnect this device.")
}
} 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
this.el.textContent = new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short"
}).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.mount()
},
updated() {
this.aidMap.applySavedViewport()
this.aidMap.renderMarkers()
this.aidMap.resize()
},
destroyed() {
this.aidMap.destroy()
delete this.el._aidMap
}
},
RequestDiscovery: {
mounted() {
this.storageKey = "wnh:request-discovery-view"
this.allowedViews = ["list", "split", "map"]
this.resizeMap = () => {
document.querySelector("#request-map")?._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.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"
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
}
this.setStatus(
this.mode() === "approximate_public"
? this.el.dataset.areaSelectedLabel
: this.el.dataset.pointSelectedLabel
)
}
this.focusSelection = () => {
const coordinates = this.coordinates()
if (!coordinates || !this.map) return
this.map.flyTo({
center: [coordinates.longitude, coordinates.latitude],
zoom: this.mode() === "exact_public" ? 15 : 13
})
}
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() === "exact_public" ? 15 : 13})
}
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" : "#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
: this.el.dataset.pointMarkerLabel
)
markerElement.setAttribute(
"title",
mode === "approximate_public"
? this.el.dataset.areaMarkerLabel
: 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.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)
}
this.syncMode = () => {
const mode = this.mode()
this.el.dataset.locationMode = mode
if (mode === "hidden") {
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.setStatus(
this.mode() === "approximate_public"
? this.el.dataset.areaSelectedLabel
: this.el.dataset.pointSelectedLabel
)
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
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,
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("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") return
this.setCoordinates(
{latitude: event.lngLat.lat, longitude: event.lngLat.lng},
{center: true}
)
this.setStatus(
this.mode() === "approximate_public"
? this.el.dataset.areaSelectedLabel
: this.el.dataset.pointSelectedLabel
)
})
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 = "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", {})
}
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}
}
}