2745 lines
91 KiB
JavaScript
2745 lines
91 KiB
JavaScript
import maplibregl from "maplibre-gl"
|
||
import {
|
||
clusterExpansionTarget,
|
||
clusterExpansionCenter,
|
||
clusterIconDescriptor,
|
||
pointFeatureCollection,
|
||
pointSourceOptions,
|
||
serverClusterDescriptor
|
||
} from "./map_cluster_features.mjs"
|
||
import {
|
||
mapSizeChanged,
|
||
shouldApplyInitialViewport,
|
||
viewportMatchesBounds
|
||
} from "./map_viewport_sync.mjs"
|
||
import {resolveNativePushState} from "./native_push_state.mjs"
|
||
|
||
const trackingMinTimeMs = 5000
|
||
|
||
const uiLocale = () => document.documentElement.lang || undefined
|
||
const uiText = key => document.documentElement.dataset[key] || ""
|
||
|
||
const mapLocaleByLanguage = {
|
||
ru: {
|
||
"AttributionControl.ToggleAttribution": "Показать сведения об источниках",
|
||
"AttributionControl.MapFeedback": "Сообщить о карте",
|
||
"FullscreenControl.Enter": "На весь экран",
|
||
"FullscreenControl.Exit": "Выйти из полноэкранного режима",
|
||
"GeolocateControl.FindMyLocation": "Найти моё местоположение",
|
||
"GeolocateControl.LocationNotAvailable": "Местоположение недоступно",
|
||
"LogoControl.Title": "Логотип MapLibre",
|
||
"Map.Title": "Карта",
|
||
"Marker.Title": "Маркер на карте",
|
||
"NavigationControl.ResetBearing":
|
||
"Перетащите, чтобы повернуть карту; нажмите, чтобы вернуть север вверх",
|
||
"NavigationControl.ZoomIn": "Увеличить масштаб",
|
||
"NavigationControl.ZoomOut": "Уменьшить масштаб",
|
||
"Popup.Close": "Закрыть",
|
||
"ScaleControl.Feet": "футы",
|
||
"ScaleControl.Meters": "метры",
|
||
"ScaleControl.Kilometers": "километры",
|
||
"ScaleControl.Miles": "мили",
|
||
"ScaleControl.NauticalMiles": "морские мили",
|
||
"GlobeControl.Enable": "Показать глобус",
|
||
"GlobeControl.Disable": "Показать плоскую карту",
|
||
"TerrainControl.Enable": "Показать рельеф",
|
||
"TerrainControl.Disable": "Скрыть рельеф",
|
||
"CooperativeGesturesHandler.WindowsHelpText":
|
||
"Удерживайте Ctrl при прокрутке, чтобы изменить масштаб карты",
|
||
"CooperativeGesturesHandler.MacHelpText":
|
||
"Удерживайте ⌘ при прокрутке, чтобы изменить масштаб карты",
|
||
"CooperativeGesturesHandler.MobileHelpText":
|
||
"Используйте два пальца, чтобы перемещать карту"
|
||
},
|
||
uk: {
|
||
"AttributionControl.ToggleAttribution": "Показати відомості про джерела",
|
||
"AttributionControl.MapFeedback": "Повідомити про карту",
|
||
"FullscreenControl.Enter": "На весь екран",
|
||
"FullscreenControl.Exit": "Вийти з повноекранного режиму",
|
||
"GeolocateControl.FindMyLocation": "Знайти моє місцезнаходження",
|
||
"GeolocateControl.LocationNotAvailable": "Місцезнаходження недоступне",
|
||
"LogoControl.Title": "Логотип MapLibre",
|
||
"Map.Title": "Карта",
|
||
"Marker.Title": "Маркер на карті",
|
||
"NavigationControl.ResetBearing":
|
||
"Перетягніть, щоб повернути карту; натисніть, щоб повернути північ угору",
|
||
"NavigationControl.ZoomIn": "Збільшити масштаб",
|
||
"NavigationControl.ZoomOut": "Зменшити масштаб",
|
||
"Popup.Close": "Закрити",
|
||
"ScaleControl.Feet": "фути",
|
||
"ScaleControl.Meters": "метри",
|
||
"ScaleControl.Kilometers": "кілометри",
|
||
"ScaleControl.Miles": "милі",
|
||
"ScaleControl.NauticalMiles": "морські милі",
|
||
"GlobeControl.Enable": "Показати глобус",
|
||
"GlobeControl.Disable": "Показати пласку карту",
|
||
"TerrainControl.Enable": "Показати рельєф",
|
||
"TerrainControl.Disable": "Сховати рельєф",
|
||
"CooperativeGesturesHandler.WindowsHelpText":
|
||
"Утримуйте Ctrl під час прокручування, щоб змінити масштаб карти",
|
||
"CooperativeGesturesHandler.MacHelpText":
|
||
"Утримуйте ⌘ під час прокручування, щоб змінити масштаб карти",
|
||
"CooperativeGesturesHandler.MobileHelpText":
|
||
"Використовуйте два пальці, щоб переміщувати карту"
|
||
}
|
||
}
|
||
|
||
const mapUiLocale = () => {
|
||
const language = (uiLocale() || "en").toLowerCase().split("-")[0]
|
||
return mapLocaleByLanguage[language] || {}
|
||
}
|
||
|
||
const mapRegionLabel = () => uiText("mapLabel") || mapUiLocale()["Map.Title"] || ""
|
||
|
||
const defaultStyle = {
|
||
version: 8,
|
||
sources: {
|
||
osm: {
|
||
type: "raster",
|
||
tiles: [document.documentElement.dataset.mapTileUrl],
|
||
tileSize: 256,
|
||
maxzoom: 19,
|
||
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 clusterIconImage = descriptor => {
|
||
const pixelRatio = 2
|
||
const shadowPadding = 7
|
||
const logicalSize = descriptor.diameter + shadowPadding * 2
|
||
const canvas = document.createElement("canvas")
|
||
canvas.width = logicalSize * pixelRatio
|
||
canvas.height = logicalSize * pixelRatio
|
||
|
||
const context = canvas.getContext("2d")
|
||
context.scale(pixelRatio, pixelRatio)
|
||
const center = logicalSize / 2
|
||
const radius = descriptor.diameter / 2 - 2
|
||
|
||
context.save()
|
||
context.shadowColor = "rgba(0, 0, 0, 0.24)"
|
||
context.shadowBlur = 6
|
||
context.shadowOffsetY = 3
|
||
context.beginPath()
|
||
context.arc(center, center, radius, 0, Math.PI * 2)
|
||
context.fillStyle = descriptor.urgent ? "#b63b21" : "#16725b"
|
||
context.fill()
|
||
context.restore()
|
||
|
||
context.beginPath()
|
||
context.arc(center, center, radius, 0, Math.PI * 2)
|
||
context.strokeStyle = "#ffffff"
|
||
context.lineWidth = 3
|
||
context.stroke()
|
||
|
||
let fontSize = descriptor.label.length >= 4 ? 11 : 12
|
||
context.font = `800 ${fontSize}px system-ui, sans-serif`
|
||
|
||
while (context.measureText(descriptor.label).width > descriptor.diameter - 12 && fontSize > 8) {
|
||
fontSize -= 1
|
||
context.font = `800 ${fontSize}px system-ui, sans-serif`
|
||
}
|
||
|
||
context.fillStyle = "#ffffff"
|
||
context.textAlign = "center"
|
||
context.textBaseline = "middle"
|
||
context.fillText(descriptor.label, center, center + 0.5)
|
||
|
||
return {
|
||
image: context.getImageData(0, 0, canvas.width, canvas.height),
|
||
pixelRatio
|
||
}
|
||
}
|
||
|
||
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 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 || uiText("mapRetryLabel")
|
||
button.addEventListener("click", retry, {once: true})
|
||
return button
|
||
}
|
||
|
||
const mapRecenterControl = (element, recenter) => {
|
||
let container = null
|
||
let button = null
|
||
|
||
const handleClick = event => {
|
||
event.preventDefault()
|
||
recenter()
|
||
}
|
||
|
||
return {
|
||
onAdd() {
|
||
const label = element.dataset.mapRecenterLabel || uiText("mapRecenterLabel")
|
||
const iconNamespace = "http://www.w3.org/2000/svg"
|
||
|
||
container = document.createElement("div")
|
||
container.className = "maplibregl-ctrl maplibregl-ctrl-group"
|
||
|
||
button = document.createElement("button")
|
||
button.type = "button"
|
||
button.className = "map-recenter-control"
|
||
button.dataset.mapRecenter = "true"
|
||
button.title = label
|
||
button.setAttribute("aria-label", label)
|
||
|
||
const icon = document.createElementNS(iconNamespace, "svg")
|
||
icon.classList.add("map-recenter-icon")
|
||
icon.setAttribute("viewBox", "0 0 24 24")
|
||
icon.setAttribute("aria-hidden", "true")
|
||
|
||
const circle = document.createElementNS(iconNamespace, "circle")
|
||
circle.setAttribute("cx", "12")
|
||
circle.setAttribute("cy", "12")
|
||
circle.setAttribute("r", "4.5")
|
||
|
||
const crosshair = document.createElementNS(iconNamespace, "path")
|
||
crosshair.setAttribute("d", "M12 2v4m0 12v4M2 12h4m12 0h4")
|
||
|
||
icon.append(circle, crosshair)
|
||
button.append(icon)
|
||
button.addEventListener("click", handleClick)
|
||
container.append(button)
|
||
|
||
return container
|
||
},
|
||
onRemove() {
|
||
button?.removeEventListener("click", handleClick)
|
||
container?.remove()
|
||
button = null
|
||
container = null
|
||
}
|
||
}
|
||
}
|
||
|
||
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,
|
||
resizeFrame: null,
|
||
resizeObserver: null,
|
||
renderedSize: null,
|
||
clusterImageIds: new Set(),
|
||
clusterImageRemovalTimers: new Map(),
|
||
markerDataSignature: null,
|
||
points: [],
|
||
selectedItemId: null,
|
||
clusterExpansionPending: false,
|
||
pendingMarkerRender: false,
|
||
initializedViewport: false,
|
||
locationBounds: null,
|
||
recenterControl: null,
|
||
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`,
|
||
clusterIconId: `${element.id || "aid-map"}-cluster-icon`,
|
||
pointCircleId: `${element.id || "aid-map"}-point-circle`,
|
||
pointSelectedId: `${element.id || "aid-map"}-point-selected`,
|
||
statusBadge: element.parentElement?.querySelector("[data-map-status-badge]") || null
|
||
}
|
||
|
||
state.markLoading = () => {
|
||
if (state.element.dataset.mapReady !== "true") {
|
||
state.element.dataset.mapReady = "false"
|
||
}
|
||
|
||
state.element.dataset.mapIdle = "false"
|
||
}
|
||
|
||
state.markReady = () => {
|
||
state.element.dataset.mapReady = "true"
|
||
}
|
||
|
||
state.markIdle = () => {
|
||
state.element.dataset.mapReady = "true"
|
||
state.element.dataset.mapIdle = "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"
|
||
fallback.setAttribute("role", "region")
|
||
fallback.setAttribute("aria-label", mapRegionLabel())
|
||
|
||
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"
|
||
fallback.setAttribute("role", "region")
|
||
fallback.setAttribute("aria-label", mapRegionLabel())
|
||
|
||
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")
|
||
const capturedAt = point.captured_at ? new Date(point.captured_at) : null
|
||
const capturedAtLabel =
|
||
capturedAt && !Number.isNaN(capturedAt.getTime())
|
||
? new Intl.DateTimeFormat(uiLocale(), {
|
||
dateStyle: "short",
|
||
timeStyle: "medium"
|
||
}).format(capturedAt)
|
||
: ""
|
||
location.textContent =
|
||
capturedAtLabel
|
||
? `${point.location_prefix || ""} ${capturedAtLabel}`.trim()
|
||
: point.location || ""
|
||
content.append(location)
|
||
|
||
if (point.id) {
|
||
const link = document.createElement("a")
|
||
const pathPrefix = state.element.dataset.itemPathPrefix || "/requests"
|
||
link.href = `${pathPrefix}/${encodeURIComponent(point.id)}`
|
||
link.textContent =
|
||
state.element.dataset.itemLabel ||
|
||
state.element.dataset.requestLabel ||
|
||
uiText("openRequestLabel")
|
||
content.append(link)
|
||
}
|
||
|
||
return content
|
||
}
|
||
|
||
state.highlightRequest = id => {
|
||
state.selectedItemId = id || null
|
||
|
||
if (state.map?.getLayer(state.areaSelectedId)) {
|
||
state.map.setFilter(state.areaSelectedId, ["==", ["get", "id"], id || ""])
|
||
}
|
||
|
||
if (state.map?.getLayer(state.areaFillId)) {
|
||
state.map.setFilter(state.areaFillId, ["==", ["get", "id"], id || ""])
|
||
}
|
||
|
||
if (state.map?.getLayer(state.pointSelectedId)) {
|
||
state.map.setFilter(state.pointSelectedId, ["==", ["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 (!mapSizeChanged(state.renderedSize, {width, height})) 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()
|
||
const currentSize = {width: current.width, height: current.height}
|
||
|
||
if (
|
||
current.width >= 160 &&
|
||
current.height >= 160 &&
|
||
mapSizeChanged(state.renderedSize, currentSize)
|
||
) {
|
||
state.renderedSize = currentSize
|
||
state.map.resize()
|
||
}
|
||
})
|
||
}
|
||
|
||
state.applyInitialViewport = () => {
|
||
if (
|
||
!state.active ||
|
||
!state.map ||
|
||
!shouldApplyInitialViewport(state.initializedViewport)
|
||
) return
|
||
|
||
const viewport = savedViewport(state.element)
|
||
const serialized = viewport ? JSON.stringify(viewport) : ""
|
||
if (!viewport || state.appliedViewport === serialized) return
|
||
|
||
state.appliedViewport = serialized
|
||
|
||
if (viewportMatchesBounds(viewport, state.map.getBounds())) return
|
||
|
||
state.map.fitBounds(
|
||
[[viewport.west, viewport.south], [viewport.east, viewport.north]],
|
||
{padding: 0, duration: 0}
|
||
)
|
||
}
|
||
|
||
state.fitMarkers = (duration = 0) => {
|
||
if (!state.active || !state.map || !state.locationBounds) return false
|
||
|
||
state.map.fitBounds(state.locationBounds, {
|
||
padding: 52,
|
||
maxZoom: 13,
|
||
duration
|
||
})
|
||
|
||
return true
|
||
}
|
||
|
||
state.expandServerCluster = point => {
|
||
if (!point || !state.map) return
|
||
|
||
if (point.expansion_lookup === true) {
|
||
state.onClusterExpand?.({cluster_id: point.cluster_id})
|
||
return
|
||
}
|
||
|
||
const count = Number(point.count) || 1
|
||
const requestedZoom = Number(point.expansion_zoom)
|
||
const currentZoom = state.map.getZoom()
|
||
const expansionZoom = clusterExpansionTarget(
|
||
currentZoom,
|
||
Number.isFinite(requestedZoom) ? requestedZoom : currentZoom + 2
|
||
)
|
||
|
||
if (currentZoom >= 21.75 && expansionZoom <= 22) {
|
||
const label = (state.element.dataset.clusterLabel || "%{count} items")
|
||
.replace("%{count}", String(count))
|
||
|
||
new maplibregl.Popup({offset: 18})
|
||
.setLngLat([point.longitude, point.latitude])
|
||
.setDOMContent(state.popupContent({
|
||
title: label,
|
||
location: state.element.dataset.clusterMaxZoomLabel || ""
|
||
}))
|
||
.addTo(state.map)
|
||
return
|
||
}
|
||
|
||
state.clusterExpansionPending = true
|
||
state.map.easeTo({
|
||
center: clusterExpansionCenter(point),
|
||
zoom: expansionZoom,
|
||
duration: 300
|
||
})
|
||
}
|
||
|
||
state.applyClusterExpansion = expansion => {
|
||
if (!expansion || !state.map) return
|
||
|
||
const longitude = Number(expansion.longitude)
|
||
const latitude = Number(expansion.latitude)
|
||
const requestedZoom = Number(expansion.zoom)
|
||
const count = Number(expansion.point_count) || 1
|
||
|
||
if (![longitude, latitude, requestedZoom].every(Number.isFinite)) return
|
||
|
||
const currentZoom = state.map.getZoom()
|
||
const expansionZoom = clusterExpansionTarget(currentZoom, requestedZoom)
|
||
|
||
if (currentZoom >= 21.75 && expansionZoom <= 22) {
|
||
const label = (state.element.dataset.clusterLabel || "%{count} items")
|
||
.replace("%{count}", String(count))
|
||
|
||
new maplibregl.Popup({offset: 18})
|
||
.setLngLat([longitude, latitude])
|
||
.setDOMContent(state.popupContent({
|
||
title: label,
|
||
location: state.element.dataset.clusterMaxZoomLabel || ""
|
||
}))
|
||
.addTo(state.map)
|
||
return
|
||
}
|
||
|
||
state.clusterExpansionPending = true
|
||
state.map.easeTo({
|
||
center: [longitude, latitude],
|
||
zoom: expansionZoom,
|
||
duration: 300
|
||
})
|
||
}
|
||
|
||
state.syncClusterImages = points => {
|
||
if (!state.map || state.element.dataset.discoveryMap !== "true") return
|
||
|
||
const descriptors = new Map()
|
||
points.forEach(point => {
|
||
if (!serverClusterDescriptor(point)) return
|
||
const descriptor = clusterIconDescriptor(point, uiLocale())
|
||
descriptors.set(descriptor.id, descriptor)
|
||
})
|
||
|
||
descriptors.forEach((descriptor, imageId) => {
|
||
const removalTimer = state.clusterImageRemovalTimers.get(imageId)
|
||
if (removalTimer !== undefined) {
|
||
window.clearTimeout(removalTimer)
|
||
state.clusterImageRemovalTimers.delete(imageId)
|
||
}
|
||
|
||
if (state.map.hasImage(imageId)) return
|
||
const {image, pixelRatio} = clusterIconImage(descriptor)
|
||
state.map.addImage(imageId, image, {pixelRatio})
|
||
})
|
||
|
||
state.clusterImageIds.forEach(imageId => {
|
||
if (descriptors.has(imageId) || state.clusterImageRemovalTimers.has(imageId)) return
|
||
const timer = window.setTimeout(() => {
|
||
state.clusterImageRemovalTimers.delete(imageId)
|
||
if (!state.active || state.clusterImageIds.has(imageId)) return
|
||
if (state.map?.hasImage(imageId)) state.map.removeImage(imageId)
|
||
}, 100)
|
||
state.clusterImageRemovalTimers.set(imageId, timer)
|
||
})
|
||
|
||
state.clusterImageIds = new Set(descriptors.keys())
|
||
}
|
||
|
||
state.syncVisibleAreas = () => {
|
||
if (!state.map) return
|
||
|
||
const areaSource = state.map.getSource(state.areaSourceId)
|
||
if (!areaSource) return
|
||
|
||
const visiblePoints =
|
||
state.element.dataset.discoveryMap === "true"
|
||
? state.points.filter(point => point.type !== "cluster")
|
||
: state.points
|
||
|
||
const signature = visiblePoints
|
||
.filter(point => !point.exact && Number.isFinite(point.radius_meters))
|
||
.map(point => point.id)
|
||
.sort()
|
||
.join(":")
|
||
|
||
if (signature === state.visibleAreaSignature) return
|
||
state.visibleAreaSignature = signature
|
||
areaSource.setData(areaFeatureCollection(visiblePoints))
|
||
}
|
||
|
||
state.renderMarkers = () => {
|
||
if (!state.map) return
|
||
|
||
const markerDataSignature =
|
||
state.element.dataset.items || state.element.dataset.markers || "[]"
|
||
const existingAreaSource = state.map.getSource(state.areaSourceId)
|
||
const existingPointSource = state.map.getSource(state.clusterSourceId)
|
||
|
||
if (
|
||
state.markerDataSignature === markerDataSignature &&
|
||
existingAreaSource &&
|
||
existingPointSource
|
||
) {
|
||
state.pendingMarkerRender = false
|
||
return
|
||
}
|
||
|
||
const points = markerPoints(state.element)
|
||
state.points = points
|
||
const pointFeatures = pointFeatureCollection(points, uiLocale())
|
||
const areaFeatures =
|
||
state.element.dataset.discoveryMap === "true"
|
||
? emptyFeatureCollection()
|
||
: areaFeatureCollection(points)
|
||
const bounds = new maplibregl.LngLatBounds()
|
||
|
||
points.forEach(point => {
|
||
const clusterBounds = point.bounds
|
||
|
||
if (
|
||
clusterBounds &&
|
||
[clusterBounds.west, clusterBounds.south, clusterBounds.east, clusterBounds.north]
|
||
.every(value => Number.isFinite(Number(value)))
|
||
) {
|
||
bounds.extend([Number(clusterBounds.west), Number(clusterBounds.south)])
|
||
bounds.extend([Number(clusterBounds.east), Number(clusterBounds.north)])
|
||
} else {
|
||
bounds.extend([point.longitude, point.latitude])
|
||
}
|
||
})
|
||
state.locationBounds = points.length > 0 ? bounds : null
|
||
|
||
if (
|
||
(!existingAreaSource || !existingPointSource) &&
|
||
!state.map.isStyleLoaded()
|
||
) {
|
||
state.pendingMarkerRender = true
|
||
return
|
||
}
|
||
|
||
state.pendingMarkerRender = false
|
||
|
||
{
|
||
const areaSource = existingAreaSource
|
||
state.syncClusterImages(points)
|
||
|
||
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,
|
||
minzoom: state.element.dataset.discoveryMap === "true" ? 12 : 0,
|
||
filter: ["==", ["get", "id"], state.selectedItemId || ""],
|
||
paint: {"fill-color": "#278467", "fill-opacity": 0.18}
|
||
})
|
||
state.map.addLayer({
|
||
id: state.areaLineId,
|
||
type: "line",
|
||
source: state.areaSourceId,
|
||
minzoom: state.element.dataset.discoveryMap === "true" ? 12 : 0,
|
||
paint: {
|
||
"line-color": "#16725b",
|
||
"line-width": 1.5,
|
||
"line-opacity": 0.55,
|
||
"line-dasharray": [2, 2]
|
||
}
|
||
})
|
||
state.map.addLayer({
|
||
id: state.areaSelectedId,
|
||
type: "line",
|
||
source: state.areaSourceId,
|
||
minzoom: state.element.dataset.discoveryMap === "true" ? 12 : 0,
|
||
filter: ["==", ["get", "id"], ""],
|
||
paint: {"line-color": "#d6573b", "line-width": 5}
|
||
})
|
||
|
||
}
|
||
|
||
const pointSource = existingPointSource
|
||
|
||
if (pointSource) {
|
||
pointSource.setData(pointFeatures)
|
||
} else {
|
||
state.map.addSource(state.clusterSourceId, pointSourceOptions(pointFeatures))
|
||
state.map.addLayer({
|
||
id: state.clusterIconId,
|
||
type: "symbol",
|
||
source: state.clusterSourceId,
|
||
filter: ["==", ["get", "item_type"], "cluster"],
|
||
layout: {
|
||
"icon-image": ["get", "cluster_icon"],
|
||
"icon-anchor": "center",
|
||
"icon-allow-overlap": true,
|
||
"icon-ignore-placement": true
|
||
}
|
||
})
|
||
state.map.addLayer({
|
||
id: state.pointCircleId,
|
||
type: "circle",
|
||
source: state.clusterSourceId,
|
||
filter: [
|
||
"all",
|
||
["!=", ["get", "item_type"], "cluster"]
|
||
],
|
||
paint: {
|
||
"circle-color": [
|
||
"case",
|
||
["==", ["get", "item_type"], "live"],
|
||
"#2563eb",
|
||
["==", ["get", "exact"], true],
|
||
"#d6573b",
|
||
"#278467"
|
||
],
|
||
"circle-radius": 9,
|
||
"circle-stroke-color": "#ffffff",
|
||
"circle-stroke-width": 3
|
||
}
|
||
})
|
||
state.map.addLayer({
|
||
id: state.pointSelectedId,
|
||
type: "circle",
|
||
source: state.clusterSourceId,
|
||
filter: ["==", ["get", "id"], state.selectedItemId || ""],
|
||
paint: {
|
||
"circle-color": "rgba(0,0,0,0)",
|
||
"circle-radius": 15,
|
||
"circle-stroke-color": "#111827",
|
||
"circle-stroke-width": 3
|
||
}
|
||
})
|
||
|
||
const interactiveLayers = [state.clusterIconId, state.pointCircleId]
|
||
|
||
interactiveLayers.forEach(layerId => {
|
||
state.map.on("mouseenter", layerId, () => {
|
||
state.map.getCanvas().style.cursor = "pointer"
|
||
})
|
||
state.map.on("mouseleave", layerId, () => {
|
||
state.map.getCanvas().style.cursor = ""
|
||
})
|
||
})
|
||
|
||
state.map.on("click", state.clusterIconId, event => {
|
||
const clusterId = event.features?.[0]?.properties?.server_cluster_id
|
||
const point = state.points.find(candidate => candidate.cluster_id === clusterId)
|
||
if (point) state.expandServerCluster(point)
|
||
})
|
||
|
||
state.map.on("click", state.pointCircleId, event => {
|
||
const feature = event.features?.[0]
|
||
const properties = feature?.properties
|
||
if (!feature || !properties) return
|
||
|
||
if (properties.id) state.selectRequest(properties.id)
|
||
|
||
new maplibregl.Popup({offset: 18})
|
||
.setLngLat(feature.geometry.coordinates)
|
||
.setDOMContent(state.popupContent(properties))
|
||
.addTo(state.map)
|
||
})
|
||
}
|
||
}
|
||
|
||
state.markerDataSignature = markerDataSignature
|
||
if (state.element.dataset.autoFit !== "false") state.fitMarkers()
|
||
}
|
||
|
||
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,
|
||
locale: mapUiLocale(),
|
||
attributionControl: true,
|
||
renderWorldCopies: false,
|
||
trackResize: false,
|
||
cooperativeGestures: true,
|
||
dragRotate: !demoMap,
|
||
pitchWithRotate: !demoMap,
|
||
scrollZoom: !demoMap
|
||
})
|
||
} catch (error) {
|
||
state.showUnavailable("initialization-failed", error)
|
||
return
|
||
}
|
||
|
||
// Restore the URL camera before MapLibre's first complete style load. Waiting
|
||
// for `load` briefly exposes the default camera and produces a visible jump
|
||
// when slow or unavailable tiles delay that event.
|
||
state.applyInitialViewport()
|
||
state.initializedViewport = true
|
||
|
||
if (state.element.dataset.discoveryMap === "true" && !savedViewport(state.element)) {
|
||
const viewport = mapViewport(state.map)
|
||
if (viewport) state.onInitializeViewport?.(viewport)
|
||
}
|
||
|
||
if (demoMap) state.map.touchZoomRotate.disableRotation()
|
||
|
||
state.map.addControl(
|
||
new maplibregl.NavigationControl({showCompass: !demoMap}),
|
||
demoMap ? "bottom-right" : "top-right"
|
||
)
|
||
|
||
if (state.element.dataset.mapRecenterLabel) {
|
||
state.recenterControl = mapRecenterControl(state.element, () => state.fitMarkers(450))
|
||
state.map.addControl(state.recenterControl, "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()
|
||
const {width, height} = state.element.getBoundingClientRect()
|
||
state.renderedSize = {width, height}
|
||
|
||
if (state.statusBadge) {
|
||
state.statusBadge.textContent = state.statusBadge.dataset.interactiveLabel
|
||
}
|
||
|
||
state.renderMarkers()
|
||
}
|
||
|
||
state.onMoveEnd = () => {
|
||
if (!state.initializedViewport || state.element.dataset.discoveryMap !== "true") return
|
||
const viewport = mapViewport(state.map)
|
||
if (!viewport) return
|
||
|
||
const clusterExpansion = state.clusterExpansionPending
|
||
state.clusterExpansionPending = false
|
||
|
||
// `moveend` already means that the camera transition has finished. Do
|
||
// not wait for MapLibre's `idle` event here: `idle` also waits for every
|
||
// requested map tile and fade animation, so a slow tile server can delay
|
||
// discovery refreshes for seconds even though the user stopped moving
|
||
// the map earlier.
|
||
if (clusterExpansion) {
|
||
state.onClusterExpand?.(viewport)
|
||
} else {
|
||
state.onViewportChange?.(viewport)
|
||
}
|
||
}
|
||
|
||
state.onIdle = () => {
|
||
state.markIdle()
|
||
|
||
if (state.pendingMarkerRender) {
|
||
state.renderMarkers()
|
||
if (state.pendingMarkerRender) return
|
||
}
|
||
|
||
state.syncVisibleAreas()
|
||
}
|
||
|
||
state.onRequestHighlight = event => state.highlightRequest(event.detail?.id)
|
||
state.onDataLoading = () => {
|
||
if (state.active) state.element.dataset.mapIdle = "false"
|
||
}
|
||
|
||
state.map.on("load", state.onLoad)
|
||
state.map.on("idle", state.onIdle)
|
||
state.map.on("dataloading", state.onDataLoading)
|
||
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
|
||
state.clusterImageRemovalTimers.forEach(timer => window.clearTimeout(timer))
|
||
state.clusterImageRemovalTimers.clear()
|
||
if (state.resizeFrame !== null) window.cancelAnimationFrame(state.resizeFrame)
|
||
state.resizeObserver?.disconnect()
|
||
state.map?.off("load", state.onLoad)
|
||
state.map?.off("idle", state.onIdle)
|
||
state.map?.off("dataloading", state.onDataLoading)
|
||
state.map?.off("moveend", state.onMoveEnd)
|
||
window.removeEventListener("wnh:request-highlight", state.onRequestHighlight)
|
||
state.map?.remove()
|
||
state.map = null
|
||
state.locationBounds = null
|
||
state.recenterControl = 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 = {
|
||
SearchableCategoryPicker: {
|
||
mounted() {
|
||
this.refresh = () => {
|
||
this.el.classList.add("is-enhanced")
|
||
this.source = this.el.querySelector("[data-category-picker-source]")
|
||
this.trigger = this.el.querySelector("[data-category-picker-trigger]")
|
||
this.label = this.el.querySelector("[data-category-picker-label]")
|
||
this.panel = this.el.querySelector("[data-category-picker-panel]")
|
||
this.search = this.el.querySelector("[data-category-picker-search]")
|
||
this.options = [...this.el.querySelectorAll("[data-category-picker-option]")]
|
||
this.groups = [...this.el.querySelectorAll("[data-category-picker-group]")]
|
||
this.empty = this.el.querySelector("[data-category-picker-empty]")
|
||
if (this.source) {
|
||
this.source.hidden = true
|
||
this.source.tabIndex = -1
|
||
this.source.setAttribute("aria-hidden", "true")
|
||
}
|
||
this.syncSelection()
|
||
}
|
||
|
||
this.isOpen = () => this.panel && !this.panel.hidden
|
||
|
||
this.open = () => {
|
||
if (!this.panel || !this.trigger || !this.search) return
|
||
this.panel.hidden = false
|
||
this.trigger.setAttribute("aria-expanded", "true")
|
||
this.search.setAttribute("aria-expanded", "true")
|
||
this.search.focus()
|
||
this.search.select()
|
||
}
|
||
|
||
this.close = ({restoreFocus = false} = {}) => {
|
||
if (!this.panel || !this.trigger || !this.search) return
|
||
this.panel.hidden = true
|
||
this.trigger.setAttribute("aria-expanded", "false")
|
||
this.search.setAttribute("aria-expanded", "false")
|
||
this.search.value = ""
|
||
this.filter("")
|
||
if (restoreFocus) this.trigger.focus()
|
||
}
|
||
|
||
this.filter = rawQuery => {
|
||
const query = rawQuery.trim().toLocaleLowerCase()
|
||
let visibleCount = 0
|
||
|
||
this.groups.forEach(group => {
|
||
let groupVisible = false
|
||
const groupMatches = (group.dataset.searchText || "").includes(query)
|
||
|
||
group.querySelectorAll("[data-category-picker-option]").forEach(option => {
|
||
const visible = query === "" ||
|
||
groupMatches ||
|
||
(option.dataset.searchText || "").includes(query)
|
||
option.hidden = !visible
|
||
groupVisible ||= visible
|
||
if (visible) visibleCount += 1
|
||
})
|
||
|
||
group.hidden = !groupVisible
|
||
})
|
||
|
||
if (this.empty) this.empty.hidden = visibleCount !== 0
|
||
}
|
||
|
||
this.syncSelection = () => {
|
||
if (!this.source || !this.trigger || !this.label) return
|
||
|
||
const selected = this.options.find(option => option.dataset.value === this.source.value)
|
||
const prompt = this.source.options[0]?.textContent?.trim() || ""
|
||
|
||
this.label.textContent = selected?.dataset.label || prompt
|
||
this.label.classList.toggle("category-picker-placeholder", !selected)
|
||
|
||
this.options.forEach(option => {
|
||
const active = option === selected
|
||
option.setAttribute("aria-selected", String(active))
|
||
option.querySelector(".category-picker-check")?.classList.toggle("invisible", !active)
|
||
})
|
||
}
|
||
|
||
this.select = option => {
|
||
if (!this.source) return
|
||
|
||
const value = option.dataset.value || ""
|
||
const changed = this.source.value !== value
|
||
this.source.value = value
|
||
this.syncSelection()
|
||
this.close({restoreFocus: true})
|
||
|
||
if (changed) {
|
||
this.source.dispatchEvent(new Event("input", {bubbles: true}))
|
||
}
|
||
}
|
||
|
||
this.onClick = event => {
|
||
const trigger = event.target.closest("[data-category-picker-trigger]")
|
||
|
||
if (trigger && this.el.contains(trigger)) {
|
||
event.preventDefault()
|
||
this.isOpen() ? this.close() : this.open()
|
||
return
|
||
}
|
||
|
||
const option = event.target.closest("[data-category-picker-option]")
|
||
if (option && this.el.contains(option)) {
|
||
event.preventDefault()
|
||
this.select(option)
|
||
}
|
||
}
|
||
|
||
this.onInput = event => {
|
||
if (!event.target.matches("[data-category-picker-search]")) return
|
||
event.stopPropagation()
|
||
this.filter(event.target.value)
|
||
}
|
||
|
||
this.onKeydown = event => {
|
||
if (
|
||
event.target.matches("[data-category-picker-trigger]") &&
|
||
["ArrowDown", "Enter", " "].includes(event.key)
|
||
) {
|
||
event.preventDefault()
|
||
this.open()
|
||
return
|
||
}
|
||
|
||
if (!event.target.matches("[data-category-picker-search]")) return
|
||
|
||
if (event.key === "Escape") {
|
||
event.preventDefault()
|
||
this.close({restoreFocus: true})
|
||
return
|
||
}
|
||
|
||
if (event.key === "ArrowDown") {
|
||
event.preventDefault()
|
||
this.options.find(option => !option.hidden && !option.closest("section")?.hidden)?.focus()
|
||
}
|
||
}
|
||
|
||
this.onDocumentPointerDown = event => {
|
||
if (this.isOpen() && !this.el.contains(event.target)) this.close()
|
||
}
|
||
|
||
this.el.addEventListener("click", this.onClick)
|
||
this.el.addEventListener("input", this.onInput)
|
||
this.el.addEventListener("keydown", this.onKeydown)
|
||
document.addEventListener("pointerdown", this.onDocumentPointerDown)
|
||
this.refresh()
|
||
},
|
||
updated() {
|
||
this.refresh()
|
||
},
|
||
destroyed() {
|
||
if (this.source) {
|
||
this.source.hidden = false
|
||
this.source.removeAttribute("aria-hidden")
|
||
this.source.removeAttribute("tabindex")
|
||
}
|
||
this.el.removeEventListener("click", this.onClick)
|
||
this.el.removeEventListener("input", this.onInput)
|
||
this.el.removeEventListener("keydown", this.onKeydown)
|
||
document.removeEventListener("pointerdown", this.onDocumentPointerDown)
|
||
}
|
||
},
|
||
|
||
LanguageSwitcher: {
|
||
mounted() {
|
||
this.summary = this.el.querySelector(":scope > summary")
|
||
this.search = this.el.querySelector("[data-language-search]")
|
||
this.options = [...this.el.querySelectorAll("[data-language-option]")]
|
||
this.empty = this.el.querySelector("[data-language-empty]")
|
||
this.optionsList = this.el.querySelector(".language-switcher-options")
|
||
|
||
this.filter = rawQuery => {
|
||
const query = rawQuery.trim().toLocaleLowerCase()
|
||
let visibleCount = 0
|
||
|
||
this.options.forEach(option => {
|
||
const visible = query === "" || (option.dataset.searchText || "").includes(query)
|
||
option.hidden = !visible
|
||
if (visible) visibleCount += 1
|
||
})
|
||
|
||
if (this.empty) this.empty.hidden = visibleCount !== 0
|
||
if (this.optionsList) this.optionsList.hidden = visibleCount === 0
|
||
}
|
||
|
||
this.close = ({restoreFocus = false} = {}) => {
|
||
if (!this.el.open) return
|
||
|
||
this.el.open = false
|
||
if (restoreFocus) this.summary?.focus()
|
||
}
|
||
|
||
this.syncExpanded = () => {
|
||
const expanded = this.el.open
|
||
this.summary?.setAttribute("aria-expanded", String(expanded))
|
||
|
||
if (expanded) {
|
||
requestAnimationFrame(() => {
|
||
this.search?.focus()
|
||
this.search?.select()
|
||
})
|
||
} else if (this.search) {
|
||
this.search.value = ""
|
||
this.filter("")
|
||
}
|
||
}
|
||
|
||
this.onInput = event => {
|
||
if (event.target !== this.search) return
|
||
this.filter(event.target.value)
|
||
}
|
||
|
||
this.onKeydown = event => {
|
||
if (event.key === "Escape" && this.el.open) {
|
||
event.preventDefault()
|
||
event.stopPropagation()
|
||
this.close({restoreFocus: true})
|
||
return
|
||
}
|
||
|
||
if (event.target === this.search && event.key === "ArrowDown") {
|
||
const firstVisibleLink = this.options
|
||
.find(option => !option.hidden)
|
||
?.querySelector("a")
|
||
|
||
if (firstVisibleLink) {
|
||
event.preventDefault()
|
||
firstVisibleLink.focus()
|
||
}
|
||
}
|
||
}
|
||
|
||
this.onPointerDown = event => {
|
||
if (this.el.open && !this.el.contains(event.target)) this.close()
|
||
}
|
||
|
||
this.el.addEventListener("toggle", this.syncExpanded)
|
||
this.el.addEventListener("input", this.onInput)
|
||
this.el.addEventListener("keydown", this.onKeydown)
|
||
document.addEventListener("pointerdown", this.onPointerDown)
|
||
this.syncExpanded()
|
||
},
|
||
destroyed() {
|
||
this.el.removeEventListener("toggle", this.syncExpanded)
|
||
this.el.removeEventListener("input", this.onInput)
|
||
this.el.removeEventListener("keydown", this.onKeydown)
|
||
document.removeEventListener("pointerdown", this.onPointerDown)
|
||
}
|
||
},
|
||
|
||
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
|
||
this.message = key => this.el.dataset[key] || ""
|
||
|
||
this.applyNativePresentation = () => {
|
||
if (!this.native || !this.buttonLabel) return
|
||
|
||
this.buttonLabel.dataset.enableLabel = this.buttonLabel.dataset.nativeEnableLabel
|
||
this.buttonLabel.dataset.enabledLabel = this.buttonLabel.dataset.nativeEnabledLabel
|
||
}
|
||
|
||
this.applyNativePresentation()
|
||
|
||
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.activeServerDeviceIds = () =>
|
||
[...this.el.querySelectorAll("[data-disable-device]")]
|
||
.map(element => element.dataset.disableDevice)
|
||
.filter(Boolean)
|
||
|
||
this.clearStaleNativeRegistration = () => {
|
||
window.localStorage.removeItem("wnh.push.server-device-id")
|
||
this.postNative({action: "disable_push"})
|
||
this.setButtonState(false)
|
||
this.setStatus(this.message("statusAndroidPrompt"))
|
||
if (this.button) this.button.disabled = false
|
||
}
|
||
|
||
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
|
||
|
||
const state = resolveNativePushState({
|
||
configured: true,
|
||
requested: true,
|
||
serverDeviceId: detail.server_device_id,
|
||
activeDeviceIds: this.activeServerDeviceIds()
|
||
})
|
||
|
||
if (state.stale) {
|
||
this.clearStaleNativeRegistration()
|
||
return
|
||
}
|
||
|
||
try {
|
||
await this.registerDevice({
|
||
platform: "android",
|
||
provider: "fcm",
|
||
token: detail.token,
|
||
installation_id: detail.installation_id,
|
||
device_label: String(
|
||
detail.device_label || this.el.dataset.deviceAndroidLabel || "Android · FCM"
|
||
).slice(0, 120),
|
||
user_agent: navigator.userAgent.slice(0, 500)
|
||
})
|
||
this.setButtonState(true)
|
||
this.setStatus(this.message("statusAndroidEnabled"))
|
||
} catch (error) {
|
||
console.warn("Android push registration failed", error)
|
||
this.setButtonState(false)
|
||
this.setStatus(this.message("statusAndroidSaveFailed"))
|
||
} finally {
|
||
if (this.button) this.button.disabled = false
|
||
}
|
||
}
|
||
|
||
this.nativeError = event => {
|
||
const reason = event.detail?.reason
|
||
this.setButtonState(false)
|
||
this.setStatus(
|
||
reason === "permission_denied"
|
||
? this.message("statusPermissionDenied")
|
||
: this.message("statusAndroidUnavailable")
|
||
)
|
||
if (this.button) this.button.disabled = false
|
||
}
|
||
|
||
this.nativeState = event => {
|
||
const detail = event.detail || {}
|
||
const state = resolveNativePushState({
|
||
configured: detail.configured,
|
||
requested: detail.requested,
|
||
serverDeviceId: detail.server_device_id,
|
||
activeDeviceIds: this.activeServerDeviceIds()
|
||
})
|
||
|
||
if (!state.configured) {
|
||
this.setButtonState(false)
|
||
this.setStatus(this.message("statusAndroidUnavailable"))
|
||
if (this.button) this.button.disabled = true
|
||
return
|
||
}
|
||
|
||
if (state.stale) {
|
||
this.clearStaleNativeRegistration()
|
||
return
|
||
}
|
||
|
||
if (state.registered) {
|
||
window.localStorage.setItem("wnh.push.server-device-id", state.serverDeviceId)
|
||
this.setButtonState(true)
|
||
this.setStatus(this.message("statusAndroidEnabled"))
|
||
} else {
|
||
window.localStorage.removeItem("wnh.push.server-device-id")
|
||
this.setButtonState(false)
|
||
this.setStatus(this.message("statusAndroidPrompt"))
|
||
}
|
||
|
||
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(this.message("statusDisabled"))
|
||
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(this.message("statusDisabled"))
|
||
}
|
||
}
|
||
|
||
this.enable = async () => {
|
||
const publicKey = String(this.el.dataset.vapidPublicKey || "")
|
||
|
||
if (this.native) {
|
||
this.button.disabled = true
|
||
this.setStatus(this.message("statusAndroidWaiting"))
|
||
this.postNative({action: "enable_push"})
|
||
return
|
||
}
|
||
|
||
if (!publicKey || !("serviceWorker" in navigator) || !("PushManager" in window)) {
|
||
this.setStatus(this.message("statusBrowserUnavailable"))
|
||
return
|
||
}
|
||
|
||
this.button.disabled = true
|
||
|
||
try {
|
||
const permission = await Notification.requestPermission()
|
||
if (permission !== "granted") {
|
||
this.setStatus(this.message("statusPermissionDenied"))
|
||
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 || this.el.dataset.deviceBrowserLabel || ""} · Web Push`
|
||
.slice(0, 120),
|
||
user_agent: navigator.userAgent.slice(0, 500)
|
||
})
|
||
this.setButtonState(true)
|
||
this.setStatus(this.message("statusBrowserEnabled"))
|
||
} catch (error) {
|
||
console.warn("Push notification registration failed", error)
|
||
this.setStatus(this.message("statusBrowserEnableFailed"))
|
||
} finally {
|
||
this.button.disabled = false
|
||
}
|
||
}
|
||
|
||
this.activeServerDevice = () => {
|
||
const deviceId = window.localStorage.getItem("wnh.push.server-device-id")
|
||
return Boolean(deviceId && this.activeServerDeviceIds().includes(deviceId))
|
||
}
|
||
|
||
this.restoreNativeState = () => {
|
||
if (!this.native || !this.activeServerDevice()) return
|
||
|
||
this.setButtonState(true)
|
||
this.setStatus(this.message("statusAndroidEnabled"))
|
||
}
|
||
|
||
this.restoreWebState = async () => {
|
||
if (this.native) return
|
||
|
||
const publicKey = String(this.el.dataset.vapidPublicKey || "")
|
||
if (!publicKey || !("serviceWorker" in navigator) || !("PushManager" in window)) {
|
||
this.setStatus(this.message("statusBrowserUnavailable"))
|
||
return
|
||
}
|
||
|
||
if (Notification.permission === "denied") {
|
||
this.setStatus(this.message("statusPermissionDenied"))
|
||
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(this.message("statusBrowserEnabled"))
|
||
} else if (subscription && !currentKey) {
|
||
this.setStatus(
|
||
this.message("statusKeysChanged")
|
||
)
|
||
} else if (subscription || activeServerDevice) {
|
||
this.setStatus(this.message("statusReconnectRequired"))
|
||
}
|
||
} 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)
|
||
window.addEventListener("wnh:native-push-state", this.nativeState)
|
||
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]")
|
||
|
||
this.applyNativePresentation()
|
||
|
||
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)
|
||
window.removeEventListener("wnh:native-push-state", this.nativeState)
|
||
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
|
||
|
||
const dateStyle = this.el.dataset.dateStyle || "medium"
|
||
const timeStyle = this.el.dataset.timeStyle || "short"
|
||
const options = {}
|
||
|
||
if (dateStyle !== "none") options.dateStyle = dateStyle
|
||
if (timeStyle !== "none") options.timeStyle = timeStyle
|
||
|
||
this.el.textContent = new Intl.DateTimeFormat(uiLocale(), options).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.onClusterExpand = viewport =>
|
||
this.pushEvent("expand-map-cluster", viewport)
|
||
this.handleEvent("map-cluster-expansion", expansion =>
|
||
this.aidMap.applyClusterExpansion(expansion)
|
||
)
|
||
this.aidMap.mount()
|
||
},
|
||
updated() {
|
||
// A server viewport is only the bounds used to query marker data. Once
|
||
// the map is mounted, the user's camera is authoritative and must never
|
||
// be moved back to bounds from a delayed LiveView response.
|
||
this.aidMap.renderMarkers()
|
||
},
|
||
destroyed() {
|
||
this.aidMap.destroy()
|
||
delete this.el._aidMap
|
||
}
|
||
},
|
||
|
||
RequestDiscovery: {
|
||
mounted() {
|
||
this.storageKey = this.el.dataset.storageKey || "wnh:request-discovery-view"
|
||
this.mapTarget = this.el.dataset.mapTarget || "#request-map"
|
||
this.allowedViews = ["list", "split", "map"]
|
||
|
||
this.resizeMap = () => {
|
||
document.querySelector(this.mapTarget)?._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.preserveHiddenCoordinates = this.el.dataset.preserveHiddenCoordinates === "true"
|
||
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"
|
||
fallback.setAttribute("role", "region")
|
||
fallback.setAttribute("aria-label", mapRegionLabel())
|
||
|
||
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
|
||
}
|
||
|
||
const mode = this.mode()
|
||
const message = mode === "hidden" && this.preserveHiddenCoordinates
|
||
? this.el.dataset.privateSelectedLabel
|
||
: mode === "approximate_public"
|
||
? this.el.dataset.areaSelectedLabel
|
||
: this.el.dataset.pointSelectedLabel
|
||
|
||
this.setStatus(message)
|
||
}
|
||
|
||
this.focusSelection = () => {
|
||
const coordinates = this.coordinates()
|
||
if (!coordinates || !this.map) return
|
||
|
||
this.map.flyTo({
|
||
center: [coordinates.longitude, coordinates.latitude],
|
||
zoom: this.mode() === "approximate_public" ? 13 : 15
|
||
})
|
||
}
|
||
|
||
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() === "approximate_public" ? 13 : 15
|
||
})
|
||
}
|
||
|
||
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"
|
||
: mode === "exact_private"
|
||
? "#6152a3"
|
||
: "#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
|
||
: mode === "exact_private"
|
||
? this.el.dataset.privateMarkerLabel
|
||
: this.el.dataset.pointMarkerLabel
|
||
)
|
||
markerElement.setAttribute(
|
||
"title",
|
||
mode === "approximate_public"
|
||
? this.el.dataset.areaMarkerLabel
|
||
: mode === "exact_private"
|
||
? this.el.dataset.privateMarkerLabel
|
||
: 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.preserveHiddenCoordinates) {
|
||
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 === "hidden" ? "exact_private" : mode
|
||
)
|
||
}
|
||
|
||
this.syncMode = () => {
|
||
const mode = this.mode()
|
||
this.el.dataset.locationMode = mode
|
||
|
||
if (mode === "hidden" && !this.preserveHiddenCoordinates) {
|
||
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.setSelectionStatus()
|
||
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
|
||
|
||
this.mapElement.dataset.mapIdle = "false"
|
||
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,
|
||
locale: mapUiLocale(),
|
||
attributionControl: true,
|
||
cooperativeGestures: 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("dataloading", () => {
|
||
if (this.mapElement) this.mapElement.dataset.mapIdle = "false"
|
||
})
|
||
this.map.on("idle", () => {
|
||
if (this.mapElement) this.mapElement.dataset.mapIdle = "true"
|
||
})
|
||
|
||
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" && !this.preserveHiddenCoordinates) return
|
||
|
||
this.setCoordinates(
|
||
{latitude: event.lngLat.lat, longitude: event.lngLat.lng},
|
||
{center: true}
|
||
)
|
||
this.setSelectionStatus()
|
||
})
|
||
|
||
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 = this.el.dataset.unavailableLabel
|
||
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 = this.el.dataset.addedLabel
|
||
this.el.classList.add("btn-success")
|
||
},
|
||
() => {
|
||
this.el.textContent = this.el.dataset.permissionDeniedLabel
|
||
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.nativeCancelled = () => {
|
||
this.pushEvent("location-cancelled", {})
|
||
}
|
||
window.addEventListener("wnh:native-tracking-error", this.nativeError)
|
||
window.addEventListener("wnh:native-tracking-cancelled", this.nativeCancelled)
|
||
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.nativeCancelled) {
|
||
window.removeEventListener("wnh:native-tracking-cancelled", this.nativeCancelled)
|
||
}
|
||
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}
|
||
}
|
||
}
|