Switch esbuild to ESM with --splitting and load app.js as type=module.
Heavy hooks now ship in their own chunk and only download when their
LiveView mounts.
- assets/js/lib/leaflet.ts: extract ensureLeaflet (vendored Leaflet
loader) into a shared module with a typed window.L declaration.
- assets/js/hooks/coverage_hooks.ts: extract CoverageMap,
MultiCoverageMap, and CoverageLocationPicker (~510 lines) with
proper interface types for hook context and tightened typing on
payloads / event handlers (CoveragePayload is now a real type).
- app.ts: replace those three inline hook bodies with lazyHook
stubs that call import('./hooks/coverage_hooks') on first mount,
forwarding lifecycle hooks once the chunk resolves. Also adds a
scoped `declare const L: any` so the remaining Leaflet hooks
type-check.
- root.html.heex: switch the script tag to type=module so the
emitted ESM bundle can use dynamic import().
- esbuild config: add --splitting --format=esm.
Build output now produces:
app.js 1.2 MB
coverage_hooks-<hash>.js 16 KB (lazy)
chunk-<hash>.js 2.4 KB (shared, lazy)
541 lines
20 KiB
TypeScript
541 lines
20 KiB
TypeScript
// Coverage Leaflet hooks. Code-split off the main bundle and dynamically
|
|
// imported by app.ts only when one of these hooks mounts. Strict TS:
|
|
// the Leaflet runtime is treated as `unknown` via a single declared
|
|
// global, and our hooks expose explicit `LiveViewHook<S>` interfaces.
|
|
|
|
import { ensureLeaflet } from "../lib/leaflet"
|
|
|
|
// Leaflet's runtime types live on `window.L`. We only use a small
|
|
// surface so it's typed as `any` once at the boundary.
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
declare const L: any
|
|
|
|
// Phoenix LiveView's `this` inside a hook isn't directly exported from
|
|
// any package this project consumes, so define the shape we care about.
|
|
type LiveViewEl = HTMLElement & {
|
|
dataset: DOMStringMap
|
|
}
|
|
|
|
interface PhxHookContext {
|
|
el: LiveViewEl
|
|
pushEvent: (event: string, payload: Record<string, unknown>) => void
|
|
handleEvent: (event: string, cb: (payload: Record<string, unknown>) => void) => void
|
|
}
|
|
|
|
// CoverageLocationPicker — draggable marker on a small map for picking
|
|
// the radio's lat/lon during coverage form editing.
|
|
type CoverageLocationPickerState = PhxHookContext & {
|
|
map: any | null
|
|
marker: any | null
|
|
programmaticMove: boolean
|
|
initMap: () => void
|
|
syncMarkerFromAttrs: () => void
|
|
markerLat: () => number | null
|
|
markerLon: () => number | null
|
|
}
|
|
|
|
export const CoverageLocationPicker = {
|
|
map: null,
|
|
marker: null,
|
|
programmaticMove: false,
|
|
|
|
mounted(this: CoverageLocationPickerState) {
|
|
ensureLeaflet()
|
|
.then(() => this.initMap())
|
|
.catch((e) => console.error("CoverageLocationPicker: Leaflet failed to load", e))
|
|
},
|
|
|
|
updated(this: CoverageLocationPickerState) {
|
|
if (this.map) this.syncMarkerFromAttrs()
|
|
},
|
|
|
|
destroyed(this: CoverageLocationPickerState) {
|
|
if (this.map) {
|
|
this.map.remove()
|
|
this.map = null
|
|
this.marker = null
|
|
}
|
|
},
|
|
|
|
initMap(this: CoverageLocationPickerState) {
|
|
const lat = this.markerLat()
|
|
const lon = this.markerLon()
|
|
const hasFix = lat !== null && lon !== null
|
|
|
|
const center: [number, number] = hasFix ? [lat as number, lon as number] : [30.27, -97.74]
|
|
const zoom = hasFix ? 15 : 6
|
|
|
|
this.map = L.map(this.el, { zoomControl: true, scrollWheelZoom: true }).setView(center, zoom)
|
|
|
|
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
|
maxZoom: 19,
|
|
}).addTo(this.map)
|
|
|
|
if (hasFix) {
|
|
this.marker = L.marker([lat as number, lon as number], { draggable: true, autoPan: true }).addTo(this.map)
|
|
this.marker.on("dragend", (e: { target: { getLatLng: () => { lat: number; lng: number } } }) => {
|
|
const ll = e.target.getLatLng()
|
|
this.pushEvent("location_picked", { lat: ll.lat, lon: ll.lng })
|
|
})
|
|
}
|
|
},
|
|
|
|
syncMarkerFromAttrs(this: CoverageLocationPickerState) {
|
|
const lat = this.markerLat()
|
|
const lon = this.markerLon()
|
|
if (lat === null || lon === null) return
|
|
|
|
if (!this.marker) {
|
|
this.marker = L.marker([lat, lon], { draggable: true, autoPan: true }).addTo(this.map)
|
|
this.marker.on("dragend", (e: { target: { getLatLng: () => { lat: number; lng: number } } }) => {
|
|
const ll = e.target.getLatLng()
|
|
this.pushEvent("location_picked", { lat: ll.lat, lon: ll.lng })
|
|
})
|
|
this.map.setView([lat, lon], 15)
|
|
return
|
|
}
|
|
|
|
const cur = this.marker.getLatLng()
|
|
if (Math.abs(cur.lat - lat) > 1e-7 || Math.abs(cur.lng - lon) > 1e-7) {
|
|
this.marker.setLatLng([lat, lon])
|
|
this.map.panTo([lat, lon])
|
|
}
|
|
},
|
|
|
|
markerLat(this: CoverageLocationPickerState): number | null {
|
|
const v = this.el.dataset.markerLat
|
|
if (!v) return null
|
|
const n = parseFloat(v)
|
|
return Number.isFinite(n) ? n : null
|
|
},
|
|
|
|
markerLon(this: CoverageLocationPickerState): number | null {
|
|
const v = this.el.dataset.markerLon
|
|
if (!v) return null
|
|
const n = parseFloat(v)
|
|
return Number.isFinite(n) ? n : null
|
|
},
|
|
}
|
|
|
|
// CoverageMap — single-coverage RSSI heatmap with antenna marker.
|
|
type CoverageMapState = PhxHookContext & {
|
|
map: any | null
|
|
marker: any | null
|
|
overlay: any | null
|
|
opacityListener: ((e: Event) => void) | null
|
|
init: () => void
|
|
refreshOverlay: () => void
|
|
bindOpacitySlider: () => void
|
|
currentOpacity: () => number
|
|
}
|
|
|
|
export const CoverageMap = {
|
|
map: null,
|
|
marker: null,
|
|
overlay: null,
|
|
opacityListener: null,
|
|
|
|
mounted(this: CoverageMapState) {
|
|
ensureLeaflet()
|
|
.then(() => this.init())
|
|
.catch((e) => console.error("CoverageMap: Leaflet failed to load", e))
|
|
},
|
|
|
|
updated(this: CoverageMapState) {
|
|
if (this.map) this.refreshOverlay()
|
|
},
|
|
|
|
destroyed(this: CoverageMapState) {
|
|
if (this.opacityListener) {
|
|
const slider = document.getElementById("coverage-opacity-slider")
|
|
if (slider) slider.removeEventListener("input", this.opacityListener)
|
|
this.opacityListener = null
|
|
}
|
|
if (this.map) {
|
|
this.map.remove()
|
|
this.map = null
|
|
this.marker = null
|
|
this.overlay = null
|
|
}
|
|
},
|
|
|
|
init(this: CoverageMapState) {
|
|
const lat = parseFloat(this.el.dataset.lat || "0")
|
|
const lon = parseFloat(this.el.dataset.lon || "0")
|
|
const azimuth = parseFloat(this.el.dataset.azimuth || "0")
|
|
const name = this.el.dataset.name || "Coverage"
|
|
|
|
this.map = L.map(this.el, { zoomControl: true, scrollWheelZoom: true }).setView([lat, lon], 13)
|
|
|
|
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
|
maxZoom: 19,
|
|
}).addTo(this.map)
|
|
|
|
const antennaIcon = L.divIcon({
|
|
className: "coverage-antenna-marker",
|
|
html: `
|
|
<div style="position:relative;width:32px;height:32px">
|
|
<div style="position:absolute;top:50%;left:50%;width:0;height:0;
|
|
transform:translate(-50%,-100%) rotate(${azimuth}deg);
|
|
transform-origin:50% 100%;
|
|
border-left:24px solid transparent;
|
|
border-right:24px solid transparent;
|
|
border-bottom:48px solid rgba(59,130,246,0.35);"></div>
|
|
<div style="position:absolute;top:50%;left:50%;width:14px;height:14px;
|
|
transform:translate(-50%,-50%);
|
|
background:#2563eb;border:2px solid white;border-radius:50%;
|
|
box-shadow:0 0 0 1px rgba(0,0,0,0.2);"></div>
|
|
</div>
|
|
`,
|
|
iconSize: [32, 32],
|
|
iconAnchor: [16, 16],
|
|
})
|
|
|
|
this.marker = L.marker([lat, lon], { icon: antennaIcon, title: name }).addTo(this.map)
|
|
|
|
this.refreshOverlay()
|
|
this.bindOpacitySlider()
|
|
},
|
|
|
|
refreshOverlay(this: CoverageMapState) {
|
|
const png = this.el.dataset.png
|
|
const bboxJson = this.el.dataset.bbox
|
|
|
|
if (this.overlay) {
|
|
this.map.removeLayer(this.overlay)
|
|
this.overlay = null
|
|
}
|
|
|
|
if (!png || !bboxJson) return
|
|
|
|
let bbox: number[]
|
|
try {
|
|
bbox = JSON.parse(bboxJson)
|
|
} catch {
|
|
return
|
|
}
|
|
if (!Array.isArray(bbox) || bbox.length !== 4) return
|
|
|
|
const [minLat, minLon, maxLat, maxLon] = bbox
|
|
const opacity = this.currentOpacity()
|
|
|
|
this.overlay = L.imageOverlay(png, [[minLat, minLon], [maxLat, maxLon]], {
|
|
opacity,
|
|
interactive: false,
|
|
}).addTo(this.map)
|
|
|
|
this.map.fitBounds([[minLat, minLon], [maxLat, maxLon]], { padding: [20, 20] })
|
|
},
|
|
|
|
bindOpacitySlider(this: CoverageMapState) {
|
|
const slider = document.getElementById("coverage-opacity-slider") as HTMLInputElement | null
|
|
if (!slider) return
|
|
this.opacityListener = (e: Event) => {
|
|
const v = parseInt((e.target as HTMLInputElement).value, 10) / 100
|
|
if (this.overlay) this.overlay.setOpacity(v)
|
|
const label = document.getElementById("coverage-opacity-label")
|
|
if (label) label.textContent = `${Math.round(v * 100)}%`
|
|
}
|
|
slider.addEventListener("input", this.opacityListener)
|
|
},
|
|
|
|
currentOpacity(this: CoverageMapState): number {
|
|
const slider = document.getElementById("coverage-opacity-slider") as HTMLInputElement | null
|
|
if (!slider) return 0.7
|
|
return parseInt(slider.value, 10) / 100
|
|
},
|
|
}
|
|
|
|
// MultiCoverageMap — full-bleed cnHeat-style map showing every ready
|
|
// coverage in the org as a layered set of L.imageOverlays.
|
|
type CoveragePayload = {
|
|
id: string
|
|
name: string
|
|
png: string
|
|
bbox: [number, number, number, number]
|
|
tx: { lat: number | null; lon: number | null; azimuth: number | null }
|
|
site: string | null
|
|
}
|
|
|
|
type MultiCoverageMapState = PhxHookContext & {
|
|
map: any | null
|
|
baseStreets: any | null
|
|
baseSatellite: any | null
|
|
overlays: Map<string, unknown>
|
|
markers: Map<string, unknown>
|
|
enabled: Set<string>
|
|
coverages: CoveragePayload[]
|
|
opacityListener: ((e: Event) => void) | null
|
|
searchListener: ((e: KeyboardEvent) => void) | null
|
|
probeMarker: any | null
|
|
searchMarker: any | null
|
|
init: () => void
|
|
computeInitialView: () => { lat: number; lon: number; zoom: number }
|
|
injectToolbar: () => void
|
|
addCoverageLayer: (c: CoveragePayload) => void
|
|
applyEnabled: () => void
|
|
bindOpacitySlider: () => void
|
|
bindBaseToggle: () => void
|
|
bindSearch: () => void
|
|
currentOpacity: () => number
|
|
placeProbeMarker: (lat: number, lon: number) => void
|
|
}
|
|
|
|
export const MultiCoverageMap = {
|
|
map: null,
|
|
baseStreets: null,
|
|
baseSatellite: null,
|
|
overlays: new Map(),
|
|
markers: new Map(),
|
|
enabled: new Set(),
|
|
coverages: [],
|
|
opacityListener: null,
|
|
searchListener: null,
|
|
probeMarker: null,
|
|
searchMarker: null,
|
|
|
|
mounted(this: MultiCoverageMapState) {
|
|
ensureLeaflet()
|
|
.then(() => this.init())
|
|
.catch((e) => console.error("MultiCoverageMap: Leaflet failed to load", e))
|
|
},
|
|
|
|
destroyed(this: MultiCoverageMapState) {
|
|
if (this.opacityListener) {
|
|
const slider = document.getElementById("coverage-opacity-slider")
|
|
if (slider) slider.removeEventListener("input", this.opacityListener)
|
|
this.opacityListener = null
|
|
}
|
|
if (this.searchListener) {
|
|
const input = document.getElementById("coverage-map-search")
|
|
if (input) input.removeEventListener("keydown", this.searchListener)
|
|
this.searchListener = null
|
|
}
|
|
const toolbar = document.getElementById("coverage-map-toolbar")
|
|
if (toolbar) toolbar.remove()
|
|
if (this.map) {
|
|
this.map.remove()
|
|
this.map = null
|
|
this.overlays.clear()
|
|
this.markers.clear()
|
|
this.enabled.clear()
|
|
}
|
|
},
|
|
|
|
init(this: MultiCoverageMapState) {
|
|
this.coverages = JSON.parse(this.el.dataset.coverages || "[]") as CoveragePayload[]
|
|
const enabledList: string[] = JSON.parse(this.el.dataset.enabled || "[]")
|
|
this.enabled = new Set(enabledList)
|
|
|
|
const initial = this.computeInitialView()
|
|
this.injectToolbar()
|
|
|
|
this.map = L.map(this.el, { zoomControl: true, scrollWheelZoom: true, attributionControl: true }).setView(
|
|
[initial.lat, initial.lon],
|
|
initial.zoom
|
|
)
|
|
|
|
this.baseStreets = L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
|
maxZoom: 19,
|
|
})
|
|
|
|
this.baseSatellite = L.tileLayer(
|
|
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
|
|
{ attribution: "Tiles © Esri", maxZoom: 19 }
|
|
)
|
|
this.baseSatellite.addTo(this.map)
|
|
|
|
this.coverages.forEach((c) => this.addCoverageLayer(c))
|
|
this.applyEnabled()
|
|
|
|
this.map.on("click", (e: { latlng: { lat: number; lng: number } }) => {
|
|
this.placeProbeMarker(e.latlng.lat, e.latlng.lng)
|
|
this.pushEvent("probe_point", { lat: e.latlng.lat, lon: e.latlng.lng })
|
|
})
|
|
|
|
this.bindOpacitySlider()
|
|
this.bindBaseToggle()
|
|
this.bindSearch()
|
|
|
|
this.handleEvent("coverage:enabled-changed", (payload) => {
|
|
this.enabled = new Set((payload as { enabled: string[] }).enabled)
|
|
this.applyEnabled()
|
|
})
|
|
|
|
this.handleEvent("coverage:list-changed", (payload) => {
|
|
const p = payload as { coverages: CoveragePayload[]; enabled: string[] }
|
|
this.overlays.forEach((o) => this.map.removeLayer(o))
|
|
this.markers.forEach((m) => this.map.removeLayer(m))
|
|
this.overlays.clear()
|
|
this.markers.clear()
|
|
|
|
this.coverages = p.coverages
|
|
this.enabled = new Set(p.enabled)
|
|
this.coverages.forEach((c) => this.addCoverageLayer(c))
|
|
this.applyEnabled()
|
|
})
|
|
},
|
|
|
|
computeInitialView(this: MultiCoverageMapState) {
|
|
if (this.coverages.length === 0) return { lat: 31.0, lon: -97.0, zoom: 6 }
|
|
const lats: number[] = []
|
|
const lons: number[] = []
|
|
this.coverages.forEach((c) => {
|
|
if (c.tx.lat !== null && c.tx.lon !== null) {
|
|
lats.push(c.tx.lat)
|
|
lons.push(c.tx.lon)
|
|
}
|
|
})
|
|
if (lats.length === 0) {
|
|
const [minLat, minLon, maxLat, maxLon] = this.coverages[0].bbox
|
|
return { lat: (minLat + maxLat) / 2, lon: (minLon + maxLon) / 2, zoom: 12 }
|
|
}
|
|
const lat = lats.reduce((s, v) => s + v, 0) / lats.length
|
|
const lon = lons.reduce((s, v) => s + v, 0) / lons.length
|
|
return { lat, lon, zoom: lats.length === 1 ? 13 : 11 }
|
|
},
|
|
|
|
injectToolbar(this: MultiCoverageMapState) {
|
|
if (document.getElementById("coverage-map-toolbar")) return
|
|
const toolbar = document.createElement("div")
|
|
toolbar.id = "coverage-map-toolbar"
|
|
toolbar.className =
|
|
"pointer-events-none fixed top-32 left-6 z-[1100] flex flex-col gap-2 w-72 max-w-[90vw]"
|
|
toolbar.innerHTML = `
|
|
<div class="pointer-events-auto rounded-lg border border-gray-200 dark:border-white/10 bg-white/95 dark:bg-gray-900/95 backdrop-blur shadow-lg p-2 flex gap-1">
|
|
<button type="button" id="coverage-base-streets" class="flex-1 px-2 py-1 text-xs rounded font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800">Map</button>
|
|
<button type="button" id="coverage-base-satellite" class="flex-1 px-2 py-1 text-xs rounded font-medium text-gray-900 dark:text-white bg-blue-100 dark:bg-blue-900/40">Satellite</button>
|
|
</div>
|
|
<div class="pointer-events-auto rounded-lg border border-gray-200 dark:border-white/10 bg-white/95 dark:bg-gray-900/95 backdrop-blur shadow-lg">
|
|
<input type="text" id="coverage-map-search" placeholder="Search address..." class="w-full px-3 py-2 text-sm bg-transparent text-gray-900 dark:text-gray-100 placeholder-gray-500 focus:outline-none" />
|
|
</div>
|
|
`
|
|
document.body.appendChild(toolbar)
|
|
},
|
|
|
|
addCoverageLayer(this: MultiCoverageMapState, c: CoveragePayload) {
|
|
const [minLat, minLon, maxLat, maxLon] = c.bbox
|
|
const overlay = L.imageOverlay(c.png, [[minLat, minLon], [maxLat, maxLon]], {
|
|
opacity: this.currentOpacity(),
|
|
interactive: false,
|
|
})
|
|
this.overlays.set(c.id, overlay)
|
|
|
|
if (c.tx.lat !== null && c.tx.lon !== null) {
|
|
const az = c.tx.azimuth ?? 0
|
|
const icon = L.divIcon({
|
|
className: "coverage-antenna-marker",
|
|
html: `
|
|
<div style="position:relative;width:32px;height:32px">
|
|
<div style="position:absolute;top:50%;left:50%;width:0;height:0;
|
|
transform:translate(-50%,-100%) rotate(${az}deg);
|
|
transform-origin:50% 100%;
|
|
border-left:18px solid transparent;
|
|
border-right:18px solid transparent;
|
|
border-bottom:36px solid rgba(59,130,246,0.35);"></div>
|
|
<div style="position:absolute;top:50%;left:50%;width:12px;height:12px;
|
|
transform:translate(-50%,-50%);
|
|
background:#2563eb;border:2px solid white;border-radius:50%;
|
|
box-shadow:0 0 0 1px rgba(0,0,0,0.2);"></div>
|
|
</div>
|
|
`,
|
|
iconSize: [32, 32],
|
|
iconAnchor: [16, 16],
|
|
})
|
|
const marker = L.marker([c.tx.lat, c.tx.lon], { icon, title: c.name })
|
|
this.markers.set(c.id, marker)
|
|
}
|
|
},
|
|
|
|
applyEnabled(this: MultiCoverageMapState) {
|
|
this.coverages.forEach((c) => {
|
|
const overlay = this.overlays.get(c.id) as any | undefined
|
|
const marker = this.markers.get(c.id) as any | undefined
|
|
const on = this.enabled.has(c.id)
|
|
if (overlay) {
|
|
if (on && !this.map.hasLayer(overlay)) overlay.addTo(this.map)
|
|
if (!on && this.map.hasLayer(overlay)) this.map.removeLayer(overlay)
|
|
}
|
|
if (marker) {
|
|
if (on && !this.map.hasLayer(marker)) marker.addTo(this.map)
|
|
if (!on && this.map.hasLayer(marker)) this.map.removeLayer(marker)
|
|
}
|
|
})
|
|
},
|
|
|
|
bindOpacitySlider(this: MultiCoverageMapState) {
|
|
const slider = document.getElementById("coverage-opacity-slider") as HTMLInputElement | null
|
|
if (!slider) return
|
|
this.opacityListener = (e: Event) => {
|
|
const v = parseInt((e.target as HTMLInputElement).value, 10) / 100
|
|
this.overlays.forEach((o) => (o as any).setOpacity(v))
|
|
const label = document.getElementById("coverage-opacity-label")
|
|
if (label) label.textContent = `${Math.round(v * 100)}%`
|
|
}
|
|
slider.addEventListener("input", this.opacityListener)
|
|
},
|
|
|
|
currentOpacity(this: MultiCoverageMapState): number {
|
|
const slider = document.getElementById("coverage-opacity-slider") as HTMLInputElement | null
|
|
if (!slider) return 0.7
|
|
return parseInt(slider.value, 10) / 100
|
|
},
|
|
|
|
bindBaseToggle(this: MultiCoverageMapState) {
|
|
const streets = document.getElementById("coverage-base-streets")
|
|
const satellite = document.getElementById("coverage-base-satellite")
|
|
if (!streets || !satellite) return
|
|
streets.addEventListener("click", () => {
|
|
if (this.map.hasLayer(this.baseSatellite)) this.map.removeLayer(this.baseSatellite)
|
|
if (!this.map.hasLayer(this.baseStreets)) this.baseStreets.addTo(this.map)
|
|
streets.classList.add("text-gray-900", "dark:text-white", "bg-blue-100", "dark:bg-blue-900/40")
|
|
satellite.classList.remove("text-gray-900", "dark:text-white", "bg-blue-100", "dark:bg-blue-900/40")
|
|
})
|
|
satellite.addEventListener("click", () => {
|
|
if (this.map.hasLayer(this.baseStreets)) this.map.removeLayer(this.baseStreets)
|
|
if (!this.map.hasLayer(this.baseSatellite)) this.baseSatellite.addTo(this.map)
|
|
satellite.classList.add("text-gray-900", "dark:text-white", "bg-blue-100", "dark:bg-blue-900/40")
|
|
streets.classList.remove("text-gray-900", "dark:text-white", "bg-blue-100", "dark:bg-blue-900/40")
|
|
})
|
|
},
|
|
|
|
bindSearch(this: MultiCoverageMapState) {
|
|
const input = document.getElementById("coverage-map-search") as HTMLInputElement | null
|
|
if (!input) return
|
|
this.searchListener = async (e: KeyboardEvent) => {
|
|
if (e.key !== "Enter") return
|
|
const q = input.value.trim()
|
|
if (!q) return
|
|
try {
|
|
const url = `https://nominatim.openstreetmap.org/search?format=json&limit=1&q=${encodeURIComponent(q)}`
|
|
const res = await fetch(url, { headers: { Accept: "application/json" } })
|
|
if (!res.ok) return
|
|
const data = (await res.json()) as Array<{ lat: string; lon: string; display_name: string }>
|
|
if (!data || data.length === 0) return
|
|
const lat = parseFloat(data[0].lat)
|
|
const lon = parseFloat(data[0].lon)
|
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return
|
|
this.map.setView([lat, lon], 16)
|
|
if (this.searchMarker) this.map.removeLayer(this.searchMarker)
|
|
this.searchMarker = L.marker([lat, lon]).addTo(this.map).bindPopup(data[0].display_name).openPopup()
|
|
} catch (err) {
|
|
console.error("Coverage search failed:", err)
|
|
}
|
|
}
|
|
input.addEventListener("keydown", this.searchListener)
|
|
},
|
|
|
|
placeProbeMarker(this: MultiCoverageMapState, lat: number, lon: number) {
|
|
if (this.probeMarker) this.map.removeLayer(this.probeMarker)
|
|
const icon = L.divIcon({
|
|
className: "coverage-probe-marker",
|
|
html: `<div style="width:14px;height:14px;border-radius:50%;background:#dc2626;border:3px solid white;box-shadow:0 0 0 1px rgba(0,0,0,0.4)"></div>`,
|
|
iconSize: [14, 14],
|
|
iconAnchor: [7, 7],
|
|
})
|
|
this.probeMarker = L.marker([lat, lon], { icon, interactive: false }).addTo(this.map)
|
|
},
|
|
}
|