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 emptyFeatureCollection = () => ({type: "FeatureCollection", features: []}) const areaPolygon = ({longitude, latitude, radius_meters: radiusMeters}) => { 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", properties: {}, geometry: {type: "Polygon", coordinates: [coordinates]} } } const areaFeatureCollection = points => ({ type: "FeatureCollection", features: points .filter(point => !point.exact && Number.isFinite(point.radius_meters)) .map(areaPolygon) }) 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 createAidMap = element => { const state = { element, map: null, markers: [], areaSourceId: `${element.id || "aid-map"}-areas`, areaFillId: `${element.id || "aid-map"}-area-fill`, areaLineId: `${element.id || "aid-map"}-area-line` } 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) const exactPoints = points.filter(point => point.exact || !Number.isFinite(point.radius_meters)) const areaFeatures = areaFeatureCollection(points) if (state.map.isStyleLoaded()) { const source = state.map.getSource(state.areaSourceId) if (source) { source.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] } }) } } exactPoints.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 > 0) { const bounds = new maplibregl.LngLatBounds() exactPoints.forEach(point => bounds.extend([point.longitude, point.latitude])) areaFeatures.features.forEach(feature => extendBoundsWithFeature(bounds, feature)) state.map.fitBounds(bounds, {padding: 52, maxZoom: 13, duration: 0}) } } 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.onLoad = () => { state.markReady() state.renderMarkers() } state.map.on("dataloading", state.markLoading) state.map.on("load", state.onLoad) state.map.on("idle", state.markReady) state.renderMarkers() } state.destroy = () => { state.map?.off("dataloading", state.markLoading) state.map?.off("load", state.onLoad) 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 = { 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() { 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() } }, 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.aidMap.mount() }, updated() { this.aidMap.renderMarkers() }, destroyed() { this.aidMap.destroy() } }, 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.renderMapUnavailable = () => { if (!this.mapElement) return this.mapElement.dataset.mapUnavailable = "true" this.mapElement.textContent = this.mapElement.dataset.mapUnavailableLabel } this.mode = () => this.el.querySelector("[data-location-mode-input]:checked")?.value || this.el.dataset.locationMode || "approximate_public" this.radius = () => { const value = Number( this.el.querySelector("[data-location-radius-input]:checked")?.value || 1000 ) return [500, 1000, 2000].includes(value) ? value : 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" ? 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) if (!this.mapElement || !supportsMapCanvas()) { if (this.mapElement) { this.mapUnavailable = true this.renderMapUnavailable() } this.syncMode() return } const initialCoordinates = this.coordinates() 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 }) this.map.touchZoomRotate.disableRotation() this.map.addControl(new maplibregl.NavigationControl({showCompass: false}), "top-left") this.map.on("load", () => { this.mapLoaded = true this.mapElement.dataset.mapReady = "true" 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() }, updated() { this.cacheElements() if (this.mapUnavailable) this.renderMapUnavailable() 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", {}) 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 } }