build(assets): split coverage hooks into a lazy-loaded chunk
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)
This commit is contained in:
parent
39a5892d09
commit
e97748437c
5 changed files with 626 additions and 553 deletions
583
assets/js/app.ts
583
assets/js/app.ts
|
|
@ -1643,46 +1643,35 @@ const LeafletMap = {
|
|||
}
|
||||
}
|
||||
|
||||
// Lazy-loads the vendored Leaflet bundle (priv/static/vendor/leaflet/)
|
||||
// the first time any Leaflet hook mounts. Per AGENTS.md we can't put
|
||||
// inline <script> tags in templates, so we inject the <link>/<script>
|
||||
// from JS instead. Returns a cached promise so concurrent hooks share
|
||||
// one load attempt.
|
||||
let leafletLoad: Promise<void> | null = null
|
||||
function ensureLeaflet(): Promise<void> {
|
||||
if (typeof (window as any).L !== 'undefined') return Promise.resolve()
|
||||
if (leafletLoad) return leafletLoad
|
||||
import { ensureLeaflet } from "./lib/leaflet"
|
||||
|
||||
leafletLoad = new Promise<void>((resolve, reject) => {
|
||||
if (!document.getElementById('leaflet-css')) {
|
||||
const css = document.createElement('link')
|
||||
css.id = 'leaflet-css'
|
||||
css.rel = 'stylesheet'
|
||||
css.href = '/vendor/leaflet/leaflet.css'
|
||||
document.head.appendChild(css)
|
||||
}
|
||||
// Leaflet attaches itself to `window.L` when loaded. We treat that
|
||||
// runtime as `any` here so the hooks that consume it don't have to
|
||||
// chase upstream Leaflet typings. (Strict-TS exception, scoped.)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
declare const L: any
|
||||
|
||||
const existing = document.getElementById('leaflet-js') as HTMLScriptElement | null
|
||||
if (existing) {
|
||||
if (typeof (window as any).L !== 'undefined') {
|
||||
resolve()
|
||||
} else {
|
||||
existing.addEventListener('load', () => resolve(), { once: true })
|
||||
existing.addEventListener('error', (e) => reject(e), { once: true })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const script = document.createElement('script')
|
||||
script.id = 'leaflet-js'
|
||||
script.src = '/vendor/leaflet/leaflet.js'
|
||||
script.async = true
|
||||
script.onload = () => resolve()
|
||||
script.onerror = (e) => reject(e)
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
|
||||
return leafletLoad
|
||||
// lazyHook wraps a Phoenix LiveView hook so its implementation is
|
||||
// loaded via dynamic import() only when the hook first mounts. esbuild
|
||||
// will emit each lazy module as its own chunk under
|
||||
// /assets/js/chunks/, keeping app.js lean.
|
||||
function lazyHook(loader: () => Promise<Record<string, any>>, exportName: string) {
|
||||
return {
|
||||
mounted(this: any) {
|
||||
const self = this
|
||||
loader().then((mod) => {
|
||||
const real = mod[exportName]
|
||||
if (!real) {
|
||||
console.error(`Lazy hook ${exportName} missing from module`)
|
||||
return
|
||||
}
|
||||
// Copy real methods onto `self` so future updated/destroyed
|
||||
// calls from the LiveView land on the real hook impl.
|
||||
for (const key of Object.keys(real)) self[key] = real[key]
|
||||
if (typeof real.mounted === "function") real.mounted.call(self)
|
||||
}).catch((e) => console.error(`Failed to lazy-load ${exportName}`, e))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Sites Map hook for Leaflet.js geographic visualization
|
||||
|
|
@ -1816,518 +1805,12 @@ const SitesMap = {
|
|||
}
|
||||
}
|
||||
|
||||
// CoverageLocationPicker shows a draggable marker on a Leaflet map so
|
||||
// (continued)
|
||||
// the user can pick the radio's exact location. Initial marker comes
|
||||
// from data-marker-lat/lon attributes (set by the LiveView from the
|
||||
// chosen site or from latitude_override/longitude_override). Dragging
|
||||
// the marker pushes a `location_picked` event with the new {lat, lon}.
|
||||
const CoverageLocationPicker = {
|
||||
map: null as any,
|
||||
marker: null as any,
|
||||
programmaticMove: false,
|
||||
|
||||
mounted(this: any) {
|
||||
ensureLeaflet().then(() => this.initMap()).catch((e) => {
|
||||
console.error('CoverageLocationPicker: Leaflet failed to load', e)
|
||||
})
|
||||
},
|
||||
|
||||
updated(this: any) {
|
||||
if (this.map) this.syncMarkerFromAttrs()
|
||||
},
|
||||
|
||||
destroyed(this: any) {
|
||||
if (this.map) {
|
||||
this.map.remove()
|
||||
this.map = null
|
||||
this.marker = null
|
||||
}
|
||||
},
|
||||
|
||||
initMap(this: any) {
|
||||
const lat = this.markerLat()
|
||||
const lon = this.markerLon()
|
||||
const hasFix = lat !== null && lon !== null
|
||||
|
||||
const center: [number, number] = hasFix ? [lat, lon] : [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, lon], {
|
||||
draggable: true,
|
||||
autoPan: true,
|
||||
}).addTo(this.map)
|
||||
|
||||
this.marker.on('dragend', (e: any) => {
|
||||
const ll = e.target.getLatLng()
|
||||
this.pushEvent('location_picked', { lat: ll.lat, lon: ll.lng })
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
syncMarkerFromAttrs(this: any) {
|
||||
const lat = this.markerLat()
|
||||
const lon = this.markerLon()
|
||||
if (lat === null || lon === null) return
|
||||
|
||||
if (!this.marker) {
|
||||
// Site got picked after mount — create the marker now.
|
||||
this.marker = L.marker([lat, lon], {
|
||||
draggable: true,
|
||||
autoPan: true,
|
||||
}).addTo(this.map)
|
||||
this.marker.on('dragend', (e: any) => {
|
||||
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: any): number | null {
|
||||
const v = this.el.dataset.markerLat
|
||||
if (!v) return null
|
||||
const n = parseFloat(v)
|
||||
return Number.isFinite(n) ? n : null
|
||||
},
|
||||
|
||||
markerLon(this: any): number | null {
|
||||
const v = this.el.dataset.markerLon
|
||||
if (!v) return null
|
||||
const n = parseFloat(v)
|
||||
return Number.isFinite(n) ? n : null
|
||||
},
|
||||
}
|
||||
|
||||
// CoverageMap renders a single coverage's RSSI heatmap as a Leaflet
|
||||
// imageOverlay, with a marker at the antenna location and a configurable
|
||||
// opacity slider. Data attributes on the host element:
|
||||
// data-lat / data-lon: antenna location
|
||||
// data-azimuth: antenna boresight in degrees (true north)
|
||||
// data-png: relative URL to the coverage PNG
|
||||
// data-bbox: JSON [min_lat, min_lon, max_lat, max_lon]
|
||||
// data-name: coverage display name
|
||||
const CoverageMap = {
|
||||
map: null as any,
|
||||
marker: null as any,
|
||||
overlay: null as any,
|
||||
opacityListener: null as ((e: Event) => void) | null,
|
||||
|
||||
mounted(this: any) {
|
||||
ensureLeaflet().then(() => this.init()).catch((e) => {
|
||||
console.error('CoverageMap: Leaflet failed to load', e)
|
||||
})
|
||||
},
|
||||
|
||||
updated(this: any) {
|
||||
// Re-render the overlay when the coverage status flips ready and
|
||||
// the data attributes change.
|
||||
if (this.map) {
|
||||
this.refreshOverlay()
|
||||
}
|
||||
},
|
||||
|
||||
destroyed(this: any) {
|
||||
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: any) {
|
||||
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)
|
||||
|
||||
// Antenna marker with a directional wedge derived from azimuth.
|
||||
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: any) {
|
||||
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 (e) {
|
||||
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: any) {
|
||||
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: any): number {
|
||||
const slider = document.getElementById('coverage-opacity-slider') as HTMLInputElement | null
|
||||
if (!slider) return 0.7
|
||||
return parseInt(slider.value, 10) / 100
|
||||
},
|
||||
}
|
||||
|
||||
// MultiCoverageMap renders every coverage in the org as a layered set
|
||||
// of L.imageOverlays on a single map. The server pushes enabled-id
|
||||
// changes via the "coverage:enabled-changed" event and full reloads
|
||||
// via "coverage:list-changed". User clicks on the map fire a
|
||||
// "probe_point" LiveView event with {lat, lon}.
|
||||
type CoveragePayload = {
|
||||
id: string
|
||||
name: string
|
||||
png: string
|
||||
bbox: [number, number, number, number] // [min_lat, min_lon, max_lat, max_lon]
|
||||
tx: { lat: number | null, lon: number | null, azimuth: number | null }
|
||||
site: string | null
|
||||
}
|
||||
const MultiCoverageMap = {
|
||||
map: null as any,
|
||||
baseStreets: null as any,
|
||||
baseSatellite: null as any,
|
||||
layerCtl: null as any,
|
||||
searchControl: null as any,
|
||||
overlays: new Map<string, any>(),
|
||||
markers: new Map<string, any>(),
|
||||
enabled: new Set<string>(),
|
||||
coverages: [] as CoveragePayload[],
|
||||
opacityListener: null as ((e: Event) => void) | null,
|
||||
searchListener: null as ((e: KeyboardEvent) => void) | null,
|
||||
probeMarker: null as any,
|
||||
searchMarker: null as any,
|
||||
|
||||
mounted(this: any) {
|
||||
ensureLeaflet().then(() => this.init()).catch((e) => {
|
||||
console.error('MultiCoverageMap: Leaflet failed to load', e)
|
||||
})
|
||||
},
|
||||
|
||||
destroyed(this: any) {
|
||||
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 as any)
|
||||
this.searchListener = null
|
||||
}
|
||||
if (this.map) {
|
||||
this.map.remove()
|
||||
this.map = null
|
||||
this.overlays.clear()
|
||||
this.markers.clear()
|
||||
this.enabled.clear()
|
||||
}
|
||||
},
|
||||
|
||||
init(this: any) {
|
||||
this.coverages = JSON.parse(this.el.dataset.coverages || '[]')
|
||||
const enabledList: string[] = JSON.parse(this.el.dataset.enabled || '[]')
|
||||
this.enabled = new Set(enabledList)
|
||||
|
||||
// Determine an initial view: average of TX positions, or first bbox.
|
||||
const init = this.computeInitialView()
|
||||
|
||||
// Inject the floating top-left toolbar (base toggle + search) outside
|
||||
// Phoenix-managed DOM so phx-update="ignore" plays well.
|
||||
this.injectToolbar()
|
||||
|
||||
this.map = L.map(this.el, { zoomControl: true, scrollWheelZoom: true, attributionControl: true })
|
||||
.setView([init.lat, init.lon], init.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: any) => {
|
||||
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', ({ enabled }: { enabled: string[] }) => {
|
||||
this.enabled = new Set(enabled)
|
||||
this.applyEnabled()
|
||||
})
|
||||
|
||||
this.handleEvent('coverage:list-changed', (payload: { coverages: CoveragePayload[], enabled: string[] }) => {
|
||||
// Refresh the layer set entirely.
|
||||
this.overlays.forEach((o) => this.map.removeLayer(o))
|
||||
this.markers.forEach((m) => this.map.removeLayer(m))
|
||||
this.overlays.clear()
|
||||
this.markers.clear()
|
||||
|
||||
this.coverages = payload.coverages
|
||||
this.enabled = new Set(payload.enabled)
|
||||
this.coverages.forEach((c) => this.addCoverageLayer(c))
|
||||
this.applyEnabled()
|
||||
})
|
||||
},
|
||||
|
||||
computeInitialView(this: any): { lat: number, lon: number, zoom: number } {
|
||||
if (this.coverages.length === 0) return { lat: 31.0, lon: -97.0, zoom: 6 }
|
||||
const lats: number[] = []
|
||||
const lons: number[] = []
|
||||
this.coverages.forEach((c: CoveragePayload) => {
|
||||
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: any) {
|
||||
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)
|
||||
|
||||
this.cleanupToolbar = () => {
|
||||
const el = document.getElementById('coverage-map-toolbar')
|
||||
if (el) el.remove()
|
||||
}
|
||||
},
|
||||
|
||||
addCoverageLayer(this: any, 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: any) {
|
||||
this.coverages.forEach((c: CoveragePayload) => {
|
||||
const overlay = this.overlays.get(c.id)
|
||||
const marker = this.markers.get(c.id)
|
||||
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: any) {
|
||||
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: any) => o.setOpacity(v))
|
||||
const label = document.getElementById('coverage-opacity-label')
|
||||
if (label) label.textContent = `${Math.round(v * 100)}%`
|
||||
}
|
||||
slider.addEventListener('input', this.opacityListener)
|
||||
},
|
||||
|
||||
currentOpacity(this: any): number {
|
||||
const slider = document.getElementById('coverage-opacity-slider') as HTMLInputElement | null
|
||||
if (!slider) return 0.7
|
||||
return parseInt(slider.value, 10) / 100
|
||||
},
|
||||
|
||||
bindBaseToggle(this: any) {
|
||||
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: any) {
|
||||
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()
|
||||
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 as any)
|
||||
},
|
||||
|
||||
placeProbeMarker(this: any, 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)
|
||||
},
|
||||
|
||||
cleanupToolbar: null as null | (() => void),
|
||||
}
|
||||
// Coverage hooks live in a separately-emitted chunk loaded only on
|
||||
// pages that mount one of these hooks (coverage form, coverage show,
|
||||
// coverage map). Reduces the main bundle by ~40 KB minified.
|
||||
const CoverageLocationPicker = lazyHook(() => import("./hooks/coverage_hooks"), "CoverageLocationPicker")
|
||||
const CoverageMap = lazyHook(() => import("./hooks/coverage_hooks"), "CoverageMap")
|
||||
const MultiCoverageMap = lazyHook(() => import("./hooks/coverage_hooks"), "MultiCoverageMap")
|
||||
|
||||
const csrfToken = document.querySelector<HTMLMetaElement>("meta[name='csrf-token']")?.getAttribute("content")
|
||||
if (!csrfToken) {
|
||||
|
|
|
|||
541
assets/js/hooks/coverage_hooks.ts
Normal file
541
assets/js/hooks/coverage_hooks.ts
Normal file
|
|
@ -0,0 +1,541 @@
|
|||
// 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)
|
||||
},
|
||||
}
|
||||
49
assets/js/lib/leaflet.ts
Normal file
49
assets/js/lib/leaflet.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
// Lazy-loads the vendored Leaflet bundle (priv/static/vendor/leaflet/)
|
||||
// the first time any Leaflet hook mounts. Per AGENTS.md we can't put
|
||||
// inline <script> tags in templates, so we inject the <link>/<script>
|
||||
// from JS instead. Returns a cached promise so concurrent hooks share
|
||||
// one load attempt.
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
L?: unknown
|
||||
}
|
||||
}
|
||||
|
||||
let leafletLoad: Promise<void> | null = null
|
||||
|
||||
export function ensureLeaflet(): Promise<void> {
|
||||
if (typeof window.L !== "undefined") return Promise.resolve()
|
||||
if (leafletLoad) return leafletLoad
|
||||
|
||||
leafletLoad = new Promise<void>((resolve, reject) => {
|
||||
if (!document.getElementById("leaflet-css")) {
|
||||
const css = document.createElement("link")
|
||||
css.id = "leaflet-css"
|
||||
css.rel = "stylesheet"
|
||||
css.href = "/vendor/leaflet/leaflet.css"
|
||||
document.head.appendChild(css)
|
||||
}
|
||||
|
||||
const existing = document.getElementById("leaflet-js") as HTMLScriptElement | null
|
||||
if (existing) {
|
||||
if (typeof window.L !== "undefined") {
|
||||
resolve()
|
||||
} else {
|
||||
existing.addEventListener("load", () => resolve(), { once: true })
|
||||
existing.addEventListener("error", (e) => reject(e), { once: true })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const script = document.createElement("script")
|
||||
script.id = "leaflet-js"
|
||||
script.src = "/vendor/leaflet/leaflet.js"
|
||||
script.async = true
|
||||
script.onload = () => resolve()
|
||||
script.onerror = (e) => reject(e)
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
|
||||
return leafletLoad
|
||||
}
|
||||
|
|
@ -20,7 +20,7 @@ config :esbuild,
|
|||
version: "0.25.4",
|
||||
towerops: [
|
||||
args:
|
||||
~w(js/app.ts --bundle --target=es2022 --outdir=../priv/static/assets/js --external:/fonts/* --external:/images/* --alias:@=.),
|
||||
~w(js/app.ts --bundle --splitting --format=esm --target=es2022 --outdir=../priv/static/assets/js --external:/fonts/* --external:/images/* --alias:@=.),
|
||||
cd: Path.expand("../assets", __DIR__),
|
||||
env: %{"NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()]}
|
||||
]
|
||||
|
|
@ -148,7 +148,7 @@ config :towerops, :scopes,
|
|||
# Agent Docker Image
|
||||
# Override this in runtime.exs or environment-specific config
|
||||
config :towerops,
|
||||
agent_docker_image: "ghcr.io/towerops-app/towerops-agent:latest"
|
||||
agent_docker_image: "codeberg.org/towerops-agent/towerops-agent:latest"
|
||||
|
||||
# Import environment specific config. This must remain at the bottom
|
||||
# of this file so it overrides the configuration defined above.
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@
|
|||
{status_title(assigns)}
|
||||
</.live_title>
|
||||
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
|
||||
<script defer phx-track-static type="text/javascript" src={~p"/assets/js/app.js"}>
|
||||
<script phx-track-static type="module" src={~p"/assets/js/app.js"}>
|
||||
</script>
|
||||
<script>
|
||||
(() => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue