1064 lines
32 KiB
JavaScript
1064 lines
32 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 emptyFeatureCollection = () => ({type: "FeatureCollection", features: []})
|
|
|
|
const areaPolygon = ({longitude, latitude, radius_meters: radiusMeters}) => {
|
|
const earthRadiusMeters = 6371008.8
|
|
const angularDistance = radiusMeters / earthRadiusMeters
|
|
const latitudeRadians = latitude * Math.PI / 180
|
|
const longitudeRadians = longitude * Math.PI / 180
|
|
const coordinates = []
|
|
|
|
for (let step = 0; step <= 64; step += 1) {
|
|
const bearing = step / 64 * Math.PI * 2
|
|
const pointLatitude = Math.asin(
|
|
Math.sin(latitudeRadians) * Math.cos(angularDistance) +
|
|
Math.cos(latitudeRadians) * Math.sin(angularDistance) * Math.cos(bearing)
|
|
)
|
|
const pointLongitude = longitudeRadians + Math.atan2(
|
|
Math.sin(bearing) * Math.sin(angularDistance) * Math.cos(latitudeRadians),
|
|
Math.cos(angularDistance) - Math.sin(latitudeRadians) * Math.sin(pointLatitude)
|
|
)
|
|
|
|
coordinates.push([pointLongitude * 180 / Math.PI, pointLatitude * 180 / Math.PI])
|
|
}
|
|
|
|
return {
|
|
type: "Feature",
|
|
properties: {},
|
|
geometry: {type: "Polygon", coordinates: [coordinates]}
|
|
}
|
|
}
|
|
|
|
const areaFeatureCollection = points => ({
|
|
type: "FeatureCollection",
|
|
features: points
|
|
.filter(point => !point.exact && Number.isFinite(point.radius_meters))
|
|
.map(areaPolygon)
|
|
})
|
|
|
|
const extendBoundsWithFeature = (bounds, feature) => {
|
|
feature.geometry.coordinates[0].forEach(coordinate => bounds.extend(coordinate))
|
|
}
|
|
|
|
const privacySafeAreaCenter = (latitude, longitude, radiusMeters) => {
|
|
const latitudeStep = radiusMeters * Math.SQRT2 / 111320
|
|
const longitudeScale = Math.max(Math.cos(latitude * Math.PI / 180), 0.2)
|
|
const longitudeStep = latitudeStep / longitudeScale
|
|
|
|
return {
|
|
latitude: Math.round(latitude / latitudeStep) * latitudeStep,
|
|
longitude: Math.round(longitude / longitudeStep) * longitudeStep
|
|
}
|
|
}
|
|
|
|
const createAidMap = element => {
|
|
const state = {
|
|
element,
|
|
map: null,
|
|
markers: [],
|
|
areaSourceId: `${element.id || "aid-map"}-areas`,
|
|
areaFillId: `${element.id || "aid-map"}-area-fill`,
|
|
areaLineId: `${element.id || "aid-map"}-area-line`
|
|
}
|
|
|
|
state.markLoading = () => {
|
|
if (state.element.dataset.mapReady !== "true") {
|
|
state.element.dataset.mapReady = "false"
|
|
}
|
|
}
|
|
|
|
state.markReady = () => {
|
|
state.element.dataset.mapReady = "true"
|
|
}
|
|
|
|
state.showUnavailable = () => {
|
|
state.map = null
|
|
state.element.dataset.mapUnavailable = "true"
|
|
state.markReady()
|
|
|
|
if (state.element.dataset.demoMap !== "true") {
|
|
const fallback = document.createElement("p")
|
|
fallback.className = "grid h-full place-items-center p-6 text-center text-sm"
|
|
fallback.dataset.mapFallback = "true"
|
|
fallback.textContent = state.element.dataset.mapUnavailableLabel
|
|
state.element.replaceChildren(fallback)
|
|
return
|
|
}
|
|
|
|
const fallback = document.createElement("div")
|
|
fallback.className = "home-demo-map-fallback"
|
|
fallback.dataset.mapFallback = "true"
|
|
|
|
const note = document.createElement("p")
|
|
note.className = "home-demo-map-fallback-note"
|
|
note.textContent = state.element.dataset.mapUnavailableLabel
|
|
fallback.append(note)
|
|
|
|
const points = document.createElement("div")
|
|
points.className = "home-demo-map-fallback-points"
|
|
|
|
markerPoints(state.element).forEach((point, index) => {
|
|
const item = document.createElement("div")
|
|
item.className = "home-demo-map-fallback-point"
|
|
|
|
const marker = document.createElement("span")
|
|
marker.className = "home-demo-map-fallback-marker"
|
|
marker.setAttribute("aria-hidden", "true")
|
|
marker.textContent = String(index + 1)
|
|
|
|
const copy = document.createElement("span")
|
|
copy.className = "home-demo-map-fallback-copy"
|
|
|
|
const title = document.createElement("strong")
|
|
title.textContent = point.title || ""
|
|
|
|
const location = document.createElement("small")
|
|
location.textContent = point.location || ""
|
|
|
|
copy.append(title, location)
|
|
item.append(marker, copy)
|
|
points.append(item)
|
|
})
|
|
|
|
fallback.append(points)
|
|
state.element.replaceChildren(fallback)
|
|
}
|
|
|
|
state.renderMarkers = () => {
|
|
if (!state.map) return
|
|
|
|
state.markers.forEach(marker => marker.remove())
|
|
state.markers = []
|
|
|
|
const points = markerPoints(state.element)
|
|
const exactPoints = points.filter(point => point.exact || !Number.isFinite(point.radius_meters))
|
|
const areaFeatures = areaFeatureCollection(points)
|
|
|
|
if (state.map.isStyleLoaded()) {
|
|
const source = state.map.getSource(state.areaSourceId)
|
|
|
|
if (source) {
|
|
source.setData(areaFeatures)
|
|
} else {
|
|
state.map.addSource(state.areaSourceId, {type: "geojson", data: areaFeatures})
|
|
state.map.addLayer({
|
|
id: state.areaFillId,
|
|
type: "fill",
|
|
source: state.areaSourceId,
|
|
paint: {"fill-color": "#278467", "fill-opacity": 0.18}
|
|
})
|
|
state.map.addLayer({
|
|
id: state.areaLineId,
|
|
type: "line",
|
|
source: state.areaSourceId,
|
|
paint: {
|
|
"line-color": "#16725b",
|
|
"line-width": 2,
|
|
"line-dasharray": [2, 2]
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
exactPoints.forEach(point => {
|
|
const popupContent = document.createElement("div")
|
|
const title = document.createElement("strong")
|
|
title.textContent = point.title || ""
|
|
popupContent.append(title, document.createElement("br"))
|
|
popupContent.append(document.createTextNode(point.location || ""))
|
|
|
|
const popup = new maplibregl.Popup({offset: 18}).setDOMContent(popupContent)
|
|
const marker = new maplibregl.Marker({color: point.exact ? "#d6573b" : "#278467"})
|
|
.setLngLat([point.longitude, point.latitude])
|
|
.setPopup(popup)
|
|
.addTo(state.map)
|
|
state.markers.push(marker)
|
|
})
|
|
|
|
if (points.length > 0) {
|
|
const bounds = new maplibregl.LngLatBounds()
|
|
exactPoints.forEach(point => bounds.extend([point.longitude, point.latitude]))
|
|
areaFeatures.features.forEach(feature => extendBoundsWithFeature(bounds, feature))
|
|
state.map.fitBounds(bounds, {padding: 52, maxZoom: 13, duration: 0})
|
|
}
|
|
}
|
|
|
|
state.mount = () => {
|
|
state.markLoading()
|
|
const demoMap = state.element.dataset.demoMap === "true"
|
|
|
|
if (!supportsMapCanvas()) {
|
|
state.showUnavailable()
|
|
return
|
|
}
|
|
|
|
try {
|
|
state.map = new maplibregl.Map({
|
|
container: state.element,
|
|
style: defaultStyle,
|
|
center: [30.5234, 50.4501],
|
|
zoom: 5,
|
|
attributionControl: true,
|
|
dragRotate: !demoMap,
|
|
pitchWithRotate: !demoMap,
|
|
scrollZoom: !demoMap
|
|
})
|
|
} catch (_error) {
|
|
state.showUnavailable()
|
|
return
|
|
}
|
|
|
|
if (demoMap) state.map.touchZoomRotate.disableRotation()
|
|
|
|
state.map.addControl(
|
|
new maplibregl.NavigationControl({showCompass: !demoMap}),
|
|
demoMap ? "bottom-right" : "top-right"
|
|
)
|
|
state.onLoad = () => {
|
|
state.markReady()
|
|
state.renderMarkers()
|
|
}
|
|
|
|
state.map.on("dataloading", state.markLoading)
|
|
state.map.on("load", state.onLoad)
|
|
state.map.on("idle", state.markReady)
|
|
state.renderMarkers()
|
|
}
|
|
|
|
state.destroy = () => {
|
|
state.map?.off("dataloading", state.markLoading)
|
|
state.map?.off("load", state.onLoad)
|
|
state.map?.off("idle", state.markReady)
|
|
state.map?.remove()
|
|
}
|
|
|
|
return state
|
|
}
|
|
|
|
export const mountStaticAidMaps = root => {
|
|
root.querySelectorAll("[data-static-aid-map]").forEach(element => {
|
|
if (element.dataset.staticMapMounted === "true") return
|
|
|
|
const mount = () => {
|
|
element.dataset.staticMapMounted = "true"
|
|
element._aidMap = createAidMap(element)
|
|
element._aidMap.mount()
|
|
}
|
|
|
|
if (!("IntersectionObserver" in window)) {
|
|
mount()
|
|
return
|
|
}
|
|
|
|
const observer = new IntersectionObserver(entries => {
|
|
if (!entries.some(entry => entry.isIntersecting)) return
|
|
|
|
observer.disconnect()
|
|
mount()
|
|
}, {rootMargin: "240px"})
|
|
|
|
observer.observe(element)
|
|
})
|
|
}
|
|
|
|
export const Hooks = {
|
|
DateTimePicker: {
|
|
mounted() {
|
|
this.hiddenInput = this.el.querySelector("[data-datetime-value]")
|
|
this.unusedInput = this.el.querySelector("[data-datetime-unused]")
|
|
this.dateInput = this.el.querySelector("[data-datetime-date]")
|
|
this.timeInput = this.el.querySelector("[data-datetime-time]")
|
|
this.timezoneLabel = this.el.querySelector("[data-datetime-timezone]")
|
|
this.quickButtons = [...this.el.querySelectorAll("[data-datetime-offset]")]
|
|
|
|
const pad = value => String(value).padStart(2, "0")
|
|
const localParts = date => ({
|
|
date: `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`,
|
|
time: `${pad(date.getHours())}:${pad(date.getMinutes())}`
|
|
})
|
|
const utcValue = date =>
|
|
`${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}` +
|
|
`T${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}`
|
|
|
|
this.parseUtc = value => {
|
|
const match = String(value || "").match(
|
|
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::\d{2})?/
|
|
)
|
|
if (!match) return null
|
|
|
|
const [, year, month, day, hour, minute] = match
|
|
const date = new Date(Date.UTC(year, Number(month) - 1, day, hour, minute))
|
|
return Number.isNaN(date.getTime()) ? null : date
|
|
}
|
|
|
|
this.applyLocalDate = date => {
|
|
const parts = localParts(date)
|
|
this.dateInput.value = parts.date
|
|
this.timeInput.value = parts.time
|
|
this.updateMinimum()
|
|
}
|
|
|
|
this.updateMinimum = () => {
|
|
const minimum = this.parseUtc(this.el.dataset.minValue)
|
|
if (!minimum) return
|
|
|
|
const parts = localParts(minimum)
|
|
this.dateInput.min = parts.date
|
|
this.timeInput.min = this.dateInput.value === parts.date ? parts.time : ""
|
|
}
|
|
|
|
this.syncHidden = () => {
|
|
this.updateMinimum()
|
|
|
|
let value = ""
|
|
if (this.dateInput.value && this.timeInput.value) {
|
|
const [year, month, day] = this.dateInput.value.split("-").map(Number)
|
|
const [hour, minute] = this.timeInput.value.split(":").map(Number)
|
|
const date = new Date(year, month - 1, day, hour, minute)
|
|
if (!Number.isNaN(date.getTime())) value = utcValue(date)
|
|
}
|
|
|
|
if (this.hiddenInput.value === value) return
|
|
this.hiddenInput.value = value
|
|
this.hiddenInput.dispatchEvent(new Event("input", {bubbles: true}))
|
|
}
|
|
|
|
this.syncUserInput = () => {
|
|
if (this.unusedInput) this.unusedInput.disabled = true
|
|
this.syncHidden()
|
|
}
|
|
|
|
this.setQuickTime = event => {
|
|
const minutes = Number(event.currentTarget.dataset.datetimeOffset)
|
|
if (!Number.isFinite(minutes)) return
|
|
|
|
const date = new Date(Date.now() + minutes * 60_000)
|
|
date.setSeconds(0, 0)
|
|
this.applyLocalDate(date)
|
|
this.syncUserInput()
|
|
}
|
|
|
|
this.dateInput.addEventListener("input", this.syncUserInput)
|
|
this.timeInput.addEventListener("input", this.syncUserInput)
|
|
this.quickButtons.forEach(button => button.addEventListener("click", this.setQuickTime))
|
|
|
|
const initial = this.parseUtc(this.hiddenInput.value)
|
|
if (initial) this.applyLocalDate(initial)
|
|
this.updateMinimum()
|
|
|
|
if (this.timezoneLabel) {
|
|
this.timezoneLabel.textContent =
|
|
Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"
|
|
}
|
|
},
|
|
destroyed() {
|
|
this.dateInput?.removeEventListener("input", this.syncUserInput)
|
|
this.timeInput?.removeEventListener("input", this.syncUserInput)
|
|
this.quickButtons?.forEach(button => button.removeEventListener("click", this.setQuickTime))
|
|
}
|
|
},
|
|
|
|
LocalTime: {
|
|
mounted() {
|
|
const value = this.el.dateTime || this.el.dataset.datetime
|
|
const date = new Date(value)
|
|
if (Number.isNaN(date.getTime())) return
|
|
|
|
this.el.textContent = new Intl.DateTimeFormat(undefined, {
|
|
dateStyle: "medium",
|
|
timeStyle: "short"
|
|
}).format(date)
|
|
this.el.title = date.toISOString()
|
|
}
|
|
},
|
|
|
|
ChatMessages: {
|
|
mounted() {
|
|
this.wasNearBottom = true
|
|
this.el.scrollTop = this.el.scrollHeight
|
|
this.isIntersecting = false
|
|
this.markingRead = false
|
|
this.recordPosition = () => {
|
|
this.wasNearBottom =
|
|
this.el.scrollHeight - this.el.scrollTop - this.el.clientHeight < 80
|
|
}
|
|
this.markRead = () => {
|
|
if (
|
|
this.el.dataset.readReceipts !== "true" ||
|
|
this.el.dataset.hasUnread !== "true" ||
|
|
document.visibilityState !== "visible" ||
|
|
!this.isIntersecting ||
|
|
this.markingRead
|
|
) return
|
|
|
|
this.markingRead = true
|
|
this.pushEvent("mark-messages-read", {}, () => {
|
|
this.markingRead = false
|
|
})
|
|
}
|
|
this.visibilityChanged = () => this.markRead()
|
|
this.el.addEventListener("scroll", this.recordPosition, {passive: true})
|
|
document.addEventListener("visibilitychange", this.visibilityChanged)
|
|
|
|
if (this.el.dataset.readReceipts === "true" && "IntersectionObserver" in window) {
|
|
this.visibilityObserver = new IntersectionObserver(entries => {
|
|
this.isIntersecting = entries.some(entry => entry.isIntersecting)
|
|
this.markRead()
|
|
}, {threshold: 0.1})
|
|
this.visibilityObserver.observe(this.el)
|
|
} else {
|
|
this.isIntersecting = true
|
|
this.markRead()
|
|
}
|
|
},
|
|
beforeUpdate() {
|
|
this.recordPosition()
|
|
const firstMessage = this.el.querySelector("[data-chat-message]")
|
|
this.anchorId = firstMessage?.id
|
|
this.anchorOffset = firstMessage?.offsetTop
|
|
},
|
|
updated() {
|
|
if (this.wasNearBottom) {
|
|
this.el.scrollTop = this.el.scrollHeight
|
|
} else if (this.anchorId) {
|
|
const anchor = document.getElementById(this.anchorId)
|
|
if (anchor && Number.isFinite(this.anchorOffset)) {
|
|
this.el.scrollTop += anchor.offsetTop - this.anchorOffset
|
|
}
|
|
}
|
|
this.markRead()
|
|
},
|
|
destroyed() {
|
|
this.el.removeEventListener("scroll", this.recordPosition)
|
|
document.removeEventListener("visibilitychange", this.visibilityChanged)
|
|
this.visibilityObserver?.disconnect()
|
|
}
|
|
},
|
|
|
|
ChatComposer: {
|
|
mounted() {
|
|
this.resize = () => {
|
|
this.el.style.height = "auto"
|
|
this.el.style.height = `${Math.min(this.el.scrollHeight, 160)}px`
|
|
}
|
|
this.submitOnEnter = event => {
|
|
if (event.key !== "Enter" || event.shiftKey || event.isComposing) return
|
|
event.preventDefault()
|
|
this.el.form?.requestSubmit()
|
|
}
|
|
this.reset = () => window.requestAnimationFrame(this.resize)
|
|
|
|
this.el.addEventListener("input", this.resize)
|
|
this.el.addEventListener("keydown", this.submitOnEnter)
|
|
this.el.form?.addEventListener("reset", this.reset)
|
|
this.resize()
|
|
},
|
|
destroyed() {
|
|
this.el.removeEventListener("input", this.resize)
|
|
this.el.removeEventListener("keydown", this.submitOnEnter)
|
|
this.el.form?.removeEventListener("reset", this.reset)
|
|
}
|
|
},
|
|
|
|
CopyToClipboard: {
|
|
mounted() {
|
|
this.resetLabel = () => {
|
|
this.el.textContent = this.el.dataset.defaultLabel
|
|
}
|
|
|
|
this.copy = async () => {
|
|
const value = this.el.dataset.copyValue || ""
|
|
|
|
const fallbackCopy = () => {
|
|
const input = document.createElement("textarea")
|
|
input.value = value
|
|
input.setAttribute("readonly", "")
|
|
input.style.position = "fixed"
|
|
input.style.opacity = "0"
|
|
document.body.append(input)
|
|
input.select()
|
|
const copied = document.execCommand("copy")
|
|
input.remove()
|
|
|
|
if (!copied) throw new Error("Clipboard copy failed")
|
|
}
|
|
|
|
try {
|
|
if (navigator.clipboard?.writeText) {
|
|
await navigator.clipboard.writeText(value)
|
|
} else {
|
|
fallbackCopy()
|
|
}
|
|
this.el.textContent = this.el.dataset.copiedLabel
|
|
} catch (_error) {
|
|
try {
|
|
fallbackCopy()
|
|
this.el.textContent = this.el.dataset.copiedLabel
|
|
} catch (_fallbackError) {
|
|
this.el.textContent = this.el.dataset.errorLabel
|
|
}
|
|
}
|
|
|
|
window.clearTimeout(this.labelTimer)
|
|
this.labelTimer = window.setTimeout(this.resetLabel, 1800)
|
|
}
|
|
|
|
this.el.addEventListener("click", this.copy)
|
|
},
|
|
destroyed() {
|
|
this.el.removeEventListener("click", this.copy)
|
|
window.clearTimeout(this.labelTimer)
|
|
}
|
|
},
|
|
|
|
HandoverCode: {
|
|
mounted() {
|
|
this.normalize = () => {
|
|
this.el.value = this.el.value.replace(/[^0-9]/g, "").slice(0, 6)
|
|
}
|
|
|
|
this.el.addEventListener("input", this.normalize)
|
|
},
|
|
destroyed() {
|
|
this.el.removeEventListener("input", this.normalize)
|
|
}
|
|
},
|
|
|
|
AidMap: {
|
|
mounted() {
|
|
this.aidMap = createAidMap(this.el)
|
|
this.aidMap.mount()
|
|
},
|
|
updated() {
|
|
this.aidMap.renderMarkers()
|
|
},
|
|
destroyed() {
|
|
this.aidMap.destroy()
|
|
}
|
|
},
|
|
|
|
RequestLocationPicker: {
|
|
mounted() {
|
|
this.map = null
|
|
this.marker = null
|
|
this.markerMode = null
|
|
this.mapLoaded = false
|
|
this.mapUnavailable = false
|
|
this.areaSourceId = `${this.el.id}-selection-area`
|
|
this.areaFillId = `${this.el.id}-selection-fill`
|
|
this.areaLineId = `${this.el.id}-selection-line`
|
|
|
|
this.cacheElements = () => {
|
|
this.mapElement = this.el.querySelector(this.el.dataset.mapTarget)
|
|
this.latitudeInput = this.el.querySelector(this.el.dataset.latitudeTarget)
|
|
this.longitudeInput = this.el.querySelector(this.el.dataset.longitudeTarget)
|
|
this.statusElement = this.el.querySelector(this.el.dataset.statusTarget)
|
|
}
|
|
|
|
this.renderMapUnavailable = () => {
|
|
if (!this.mapElement) return
|
|
|
|
this.mapElement.dataset.mapUnavailable = "true"
|
|
this.mapElement.textContent = this.mapElement.dataset.mapUnavailableLabel
|
|
}
|
|
|
|
this.mode = () =>
|
|
this.el.querySelector("[data-location-mode-input]:checked")?.value ||
|
|
this.el.dataset.locationMode ||
|
|
"approximate_public"
|
|
|
|
this.radius = () => {
|
|
const value = Number(
|
|
this.el.querySelector("[data-location-radius-input]:checked")?.value || 1000
|
|
)
|
|
return [500, 1000, 2000].includes(value) ? value : 1000
|
|
}
|
|
|
|
this.coordinates = () => {
|
|
const latitudeValue = String(this.latitudeInput?.value || "").trim()
|
|
const longitudeValue = String(this.longitudeInput?.value || "").trim()
|
|
if (latitudeValue === "" || longitudeValue === "") return null
|
|
|
|
const latitude = Number(latitudeValue)
|
|
const longitude = Number(longitudeValue)
|
|
|
|
if (
|
|
!Number.isFinite(latitude) ||
|
|
!Number.isFinite(longitude) ||
|
|
latitude < -90 ||
|
|
latitude > 90 ||
|
|
longitude < -180 ||
|
|
longitude > 180
|
|
) return null
|
|
|
|
return {latitude, longitude}
|
|
}
|
|
|
|
this.setStatus = message => {
|
|
if (this.statusElement) this.statusElement.textContent = message
|
|
}
|
|
|
|
this.setSelectionStatus = () => {
|
|
if (!this.coordinates()) {
|
|
this.setStatus(this.el.dataset.mapReadyLabel)
|
|
return
|
|
}
|
|
|
|
this.setStatus(
|
|
this.mode() === "approximate_public"
|
|
? this.el.dataset.areaSelectedLabel
|
|
: this.el.dataset.pointSelectedLabel
|
|
)
|
|
}
|
|
|
|
this.focusSelection = () => {
|
|
const coordinates = this.coordinates()
|
|
if (!coordinates || !this.map) return
|
|
|
|
this.map.flyTo({
|
|
center: [coordinates.longitude, coordinates.latitude],
|
|
zoom: this.mode() === "exact_public" ? 15 : 13
|
|
})
|
|
}
|
|
|
|
this.notifyCoordinatesChanged = () => {
|
|
this.longitudeInput?.dispatchEvent(new Event("input", {bubbles: true}))
|
|
}
|
|
|
|
this.setCoordinates = ({latitude, longitude}, {notify = true, center = false} = {}) => {
|
|
if (!this.latitudeInput || !this.longitudeInput) return
|
|
|
|
this.latitudeInput.value = latitude.toFixed(6)
|
|
this.longitudeInput.value = longitude.toFixed(6)
|
|
this.renderSelection()
|
|
|
|
if (center && this.map) {
|
|
this.map.flyTo({center: [longitude, latitude], zoom: this.mode() === "exact_public" ? 15 : 13})
|
|
}
|
|
|
|
if (notify) this.notifyCoordinatesChanged()
|
|
}
|
|
|
|
this.clearCoordinates = ({notify = false} = {}) => {
|
|
if (this.latitudeInput) this.latitudeInput.value = ""
|
|
if (this.longitudeInput) this.longitudeInput.value = ""
|
|
this.renderSelection()
|
|
if (notify) this.notifyCoordinatesChanged()
|
|
}
|
|
|
|
this.ensureAreaLayers = () => {
|
|
if (!this.mapLoaded || this.map.getSource(this.areaSourceId)) return
|
|
|
|
this.map.addSource(this.areaSourceId, {type: "geojson", data: emptyFeatureCollection()})
|
|
this.map.addLayer({
|
|
id: this.areaFillId,
|
|
type: "fill",
|
|
source: this.areaSourceId,
|
|
paint: {"fill-color": "#278467", "fill-opacity": 0.2}
|
|
})
|
|
this.map.addLayer({
|
|
id: this.areaLineId,
|
|
type: "line",
|
|
source: this.areaSourceId,
|
|
paint: {
|
|
"line-color": "#16725b",
|
|
"line-width": 2,
|
|
"line-dasharray": [2, 2]
|
|
}
|
|
})
|
|
}
|
|
|
|
this.setAreaData = data => {
|
|
if (!this.mapLoaded) return
|
|
this.ensureAreaLayers()
|
|
this.map.getSource(this.areaSourceId)?.setData(data)
|
|
}
|
|
|
|
this.removeMarker = () => {
|
|
this.marker?.remove()
|
|
this.marker = null
|
|
this.markerMode = null
|
|
}
|
|
|
|
this.ensureSelectionMarker = (coordinates, mode) => {
|
|
if (this.marker && this.markerMode !== mode) this.removeMarker()
|
|
|
|
if (!this.marker) {
|
|
this.markerMode = mode
|
|
this.marker = new maplibregl.Marker({
|
|
color: mode === "approximate_public" ? "#16725b" : "#d6573b",
|
|
draggable: true
|
|
})
|
|
.setLngLat([coordinates.longitude, coordinates.latitude])
|
|
.addTo(this.map)
|
|
|
|
const markerElement = this.marker.getElement()
|
|
markerElement.setAttribute(
|
|
"aria-label",
|
|
mode === "approximate_public"
|
|
? this.el.dataset.areaMarkerLabel
|
|
: this.el.dataset.pointMarkerLabel
|
|
)
|
|
markerElement.setAttribute(
|
|
"title",
|
|
mode === "approximate_public"
|
|
? this.el.dataset.areaMarkerLabel
|
|
: this.el.dataset.pointMarkerLabel
|
|
)
|
|
|
|
this.marker.on("drag", () => {
|
|
const point = this.marker.getLngLat()
|
|
|
|
if (this.latitudeInput) this.latitudeInput.value = point.lat.toFixed(6)
|
|
if (this.longitudeInput) this.longitudeInput.value = point.lng.toFixed(6)
|
|
|
|
if (this.markerMode === "approximate_public") {
|
|
this.setAreaData(areaFeatureCollection([{
|
|
latitude: point.lat,
|
|
longitude: point.lng,
|
|
exact: false,
|
|
radius_meters: this.radius()
|
|
}]))
|
|
}
|
|
})
|
|
|
|
this.marker.on("dragend", () => {
|
|
const point = this.marker.getLngLat()
|
|
this.setCoordinates({latitude: point.lat, longitude: point.lng})
|
|
this.setSelectionStatus()
|
|
})
|
|
} else {
|
|
this.marker.setLngLat([coordinates.longitude, coordinates.latitude])
|
|
}
|
|
}
|
|
|
|
this.renderSelection = () => {
|
|
const mode = this.mode()
|
|
const coordinates = this.coordinates()
|
|
this.el.dataset.locationMode = mode
|
|
|
|
if (!this.mapLoaded) return
|
|
|
|
if (mode === "hidden") {
|
|
this.removeMarker()
|
|
this.setAreaData(emptyFeatureCollection())
|
|
return
|
|
}
|
|
|
|
if (!coordinates) {
|
|
this.removeMarker()
|
|
this.setAreaData(emptyFeatureCollection())
|
|
return
|
|
}
|
|
|
|
if (mode === "approximate_public") {
|
|
this.ensureSelectionMarker(coordinates, mode)
|
|
this.setAreaData(areaFeatureCollection([{
|
|
...coordinates,
|
|
exact: false,
|
|
radius_meters: this.radius()
|
|
}]))
|
|
return
|
|
}
|
|
|
|
this.setAreaData(emptyFeatureCollection())
|
|
this.ensureSelectionMarker(coordinates, mode)
|
|
}
|
|
|
|
this.syncMode = () => {
|
|
const mode = this.mode()
|
|
this.el.dataset.locationMode = mode
|
|
|
|
if (mode === "hidden") {
|
|
this.clearCoordinates()
|
|
this.setStatus(this.el.dataset.hiddenLabel)
|
|
} else {
|
|
this.renderSelection()
|
|
this.setSelectionStatus()
|
|
window.requestAnimationFrame(() => this.map?.resize())
|
|
}
|
|
}
|
|
|
|
this.onChange = event => {
|
|
if (event.target.matches("[data-location-mode-input]")) {
|
|
this.syncMode()
|
|
return
|
|
}
|
|
|
|
if (event.target.matches("[data-location-radius-input]")) {
|
|
this.renderSelection()
|
|
this.setSelectionStatus()
|
|
return
|
|
}
|
|
|
|
if (event.target.matches("[data-coordinate-input]")) {
|
|
this.renderSelection()
|
|
|
|
if (this.coordinates()) {
|
|
this.focusSelection()
|
|
this.setSelectionStatus()
|
|
}
|
|
}
|
|
}
|
|
|
|
this.onClick = event => {
|
|
const button = event.target.closest("[data-use-current-location]")
|
|
if (!button) return
|
|
|
|
if (!navigator.geolocation) {
|
|
this.setStatus(this.el.dataset.locationUnavailableLabel)
|
|
return
|
|
}
|
|
|
|
button.disabled = true
|
|
navigator.geolocation.getCurrentPosition(
|
|
position => {
|
|
const raw = {
|
|
latitude: position.coords.latitude,
|
|
longitude: position.coords.longitude
|
|
}
|
|
const selected = this.mode() === "approximate_public"
|
|
? privacySafeAreaCenter(raw.latitude, raw.longitude, this.radius())
|
|
: raw
|
|
|
|
this.setCoordinates(selected, {center: true})
|
|
this.setStatus(
|
|
this.mode() === "approximate_public"
|
|
? this.el.dataset.areaSelectedLabel
|
|
: this.el.dataset.pointSelectedLabel
|
|
)
|
|
button.disabled = false
|
|
},
|
|
() => {
|
|
this.setStatus(this.el.dataset.locationDeniedLabel)
|
|
button.disabled = false
|
|
},
|
|
{enableHighAccuracy: true, timeout: 10000}
|
|
)
|
|
}
|
|
|
|
this.cacheElements()
|
|
this.el.addEventListener("change", this.onChange)
|
|
this.el.addEventListener("click", this.onClick)
|
|
|
|
if (!this.mapElement || !supportsMapCanvas()) {
|
|
if (this.mapElement) {
|
|
this.mapUnavailable = true
|
|
this.renderMapUnavailable()
|
|
}
|
|
this.syncMode()
|
|
return
|
|
}
|
|
|
|
const initialCoordinates = this.coordinates()
|
|
this.map = new maplibregl.Map({
|
|
container: this.mapElement,
|
|
style: defaultStyle,
|
|
center: initialCoordinates
|
|
? [initialCoordinates.longitude, initialCoordinates.latitude]
|
|
: [30.5234, 50.4501],
|
|
zoom: initialCoordinates ? 13 : 5,
|
|
attributionControl: true,
|
|
dragRotate: false,
|
|
pitchWithRotate: false
|
|
})
|
|
this.map.touchZoomRotate.disableRotation()
|
|
this.map.addControl(new maplibregl.NavigationControl({showCompass: false}), "top-left")
|
|
|
|
this.map.on("load", () => {
|
|
this.mapLoaded = true
|
|
this.mapElement.dataset.mapReady = "true"
|
|
this.ensureAreaLayers()
|
|
this.renderSelection()
|
|
})
|
|
|
|
this.map.on("click", event => {
|
|
if (this.mode() === "hidden") return
|
|
|
|
this.setCoordinates(
|
|
{latitude: event.lngLat.lat, longitude: event.lngLat.lng},
|
|
{center: true}
|
|
)
|
|
this.setStatus(
|
|
this.mode() === "approximate_public"
|
|
? this.el.dataset.areaSelectedLabel
|
|
: this.el.dataset.pointSelectedLabel
|
|
)
|
|
})
|
|
|
|
this.syncMode()
|
|
},
|
|
updated() {
|
|
this.cacheElements()
|
|
if (this.mapUnavailable) this.renderMapUnavailable()
|
|
this.syncMode()
|
|
},
|
|
destroyed() {
|
|
this.el.removeEventListener("change", this.onChange)
|
|
this.el.removeEventListener("click", this.onClick)
|
|
this.removeMarker()
|
|
this.map?.remove()
|
|
}
|
|
},
|
|
|
|
LocationPicker: {
|
|
mounted() {
|
|
this.el.addEventListener("click", () => {
|
|
const latitudeInput = document.querySelector(this.el.dataset.latitudeTarget)
|
|
const longitudeInput = document.querySelector(this.el.dataset.longitudeTarget)
|
|
|
|
if (!latitudeInput || !longitudeInput || !navigator.geolocation) {
|
|
this.el.textContent = "Location is unavailable"
|
|
this.el.classList.add("btn-error")
|
|
return
|
|
}
|
|
|
|
navigator.geolocation.getCurrentPosition(
|
|
position => {
|
|
latitudeInput.value = position.coords.latitude
|
|
longitudeInput.value = position.coords.longitude
|
|
latitudeInput.dispatchEvent(new Event("input", {bubbles: true}))
|
|
longitudeInput.dispatchEvent(new Event("input", {bubbles: true}))
|
|
this.el.textContent = "Location added"
|
|
this.el.classList.add("btn-success")
|
|
},
|
|
() => {
|
|
this.el.textContent = "Location permission denied"
|
|
this.el.classList.add("btn-error")
|
|
},
|
|
{enableHighAccuracy: true, timeout: 10000}
|
|
)
|
|
})
|
|
}
|
|
},
|
|
|
|
LiveTracking: {
|
|
mounted() {
|
|
this.destroying = false
|
|
this.lastLocationSentAt = 0
|
|
this.pendingLocation = null
|
|
this.locationTimer = undefined
|
|
|
|
this.sendLocation = payload => {
|
|
this.lastLocationSentAt = Date.now()
|
|
this.pendingLocation = null
|
|
this.pushEvent("location-update", payload)
|
|
}
|
|
|
|
this.queueLocation = payload => {
|
|
const elapsed = Date.now() - this.lastLocationSentAt
|
|
|
|
if (this.lastLocationSentAt === 0 || elapsed >= trackingMinTimeMs) {
|
|
if (this.locationTimer !== undefined) {
|
|
window.clearTimeout(this.locationTimer)
|
|
this.locationTimer = undefined
|
|
}
|
|
|
|
this.sendLocation(payload)
|
|
return
|
|
}
|
|
|
|
this.pendingLocation = payload
|
|
|
|
if (this.locationTimer === undefined) {
|
|
this.locationTimer = window.setTimeout(() => {
|
|
this.locationTimer = undefined
|
|
|
|
if (!this.destroying && this.pendingLocation) {
|
|
this.sendLocation(this.pendingLocation)
|
|
}
|
|
}, trackingMinTimeMs - elapsed)
|
|
}
|
|
}
|
|
|
|
if (typeof window.WhoNeedHelpAndroid?.postMessage === "function") {
|
|
this.nativeError = () => {
|
|
this.pushEvent("location-error", {})
|
|
this.pushEvent("stop-tracking", {})
|
|
}
|
|
window.addEventListener("wnh:native-tracking-error", this.nativeError)
|
|
return
|
|
}
|
|
|
|
this.watchId = navigator.geolocation.watchPosition(
|
|
position => this.queueLocation({
|
|
latitude: position.coords.latitude,
|
|
longitude: position.coords.longitude,
|
|
accuracy_meters: position.coords.accuracy
|
|
}),
|
|
() => {
|
|
if (!this.destroying) this.pushEvent("location-error", {})
|
|
},
|
|
{enableHighAccuracy: true, maximumAge: 5000, timeout: 15000}
|
|
)
|
|
},
|
|
destroyed() {
|
|
this.destroying = true
|
|
this.pendingLocation = null
|
|
|
|
if (this.locationTimer !== undefined) {
|
|
window.clearTimeout(this.locationTimer)
|
|
this.locationTimer = undefined
|
|
}
|
|
|
|
if (this.nativeError) {
|
|
window.removeEventListener("wnh:native-tracking-error", this.nativeError)
|
|
}
|
|
if (this.watchId !== undefined) navigator.geolocation.clearWatch(this.watchId)
|
|
}
|
|
}
|
|
}
|
|
|
|
function supportsMapCanvas() {
|
|
const canvas = document.createElement("canvas")
|
|
const attributes = {
|
|
alpha: true,
|
|
depth: true,
|
|
stencil: true,
|
|
premultipliedAlpha: true
|
|
}
|
|
|
|
try {
|
|
const context =
|
|
canvas.getContext("webgl2", attributes) ||
|
|
canvas.getContext("webgl", attributes)
|
|
|
|
if (!context) return false
|
|
|
|
context.getExtension("WEBGL_lose_context")?.loseContext()
|
|
return true
|
|
} catch (_error) {
|
|
return false
|
|
}
|
|
}
|