414 lines
11 KiB
JavaScript
414 lines
11 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.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
|
|
}
|
|
}
|