who_need_help/assets/js/hooks.js

606 lines
18 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 = {
DateTimePicker: {
mounted() {
this.hiddenInput = this.el.querySelector("[data-datetime-value]")
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.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.syncHidden()
}
this.dateInput.addEventListener("input", this.syncHidden)
this.timeInput.addEventListener("input", this.syncHidden)
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.syncHidden)
this.timeInput?.removeEventListener("input", this.syncHidden)
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()
}
},
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
}
}