import type { ViewHook } from "phoenix_live_view" interface Station { callsign: string lat: number lon: number selected: boolean } interface Cell { lat: number lon: number score: number tier_color: string } interface RoverMapHook extends ViewHook { map: L.Map osmLayer: L.TileLayer topoLayer: L.TileLayer | null layersControl: L.Control.Layers stations: Station[] stationMarkers: L.LayerGroup homeMarker: L.CircleMarker | null driveCircle: L.Circle | null qualityLayer: L.GridLayer | null cellLookup: Map homeLat: number homeLon: number driveRadiusKm: number visibilityHandler: (() => void) | null renderStations(this: RoverMapHook): void buildQualityLayer(this: RoverMapHook): L.GridLayer setDriveRadius(this: RoverMapHook, km: number): void } const TIER_RGB: Record = { "#16a34a": [22, 163, 74], // Excellent "#eab308": [234, 179, 8], // Good "#f97316": [249, 115, 22] // Marginal } const TIER_ORDER = ["#16a34a", "#eab308", "#f97316"] function kmToM(km: number): number { return km * 1000 } function cellKey(lat: number, lon: number): string { return `${lat.toFixed(3)},${lon.toFixed(3)}` } function tierForScore(score: number): string | null { if (score >= 10) return "#16a34a" if (score >= 3) return "#eab308" if (score >= 0) return "#f97316" return null } function buildStationMarker(station: Station): L.CircleMarker { return L.circleMarker([station.lat, station.lon], { radius: 4, color: "#fff", weight: 1.5, fillColor: station.selected ? "#0ea5e9" : "#64748b", fillOpacity: 0.95, interactive: true }).bindTooltip(station.callsign, { direction: "top", offset: [0, -6] }) } export const RoverMap: Partial = { mounted(this: RoverMapHook) { this.stations = [] this.stationMarkers = L.layerGroup() this.homeMarker = null this.driveCircle = null this.qualityLayer = null this.cellLookup = new Map() this.topoLayer = null const ds = this.el.dataset this.homeLat = parseFloat(ds.homeLat || "32.5") this.homeLon = parseFloat(ds.homeLon || "-97.5") this.driveRadiusKm = parseFloat(ds.driveRadiusKm || "100") const map = L.map(this.el, { center: [this.homeLat, this.homeLon], zoom: 8, zoomControl: true, attributionControl: false }) this.map = map this.osmLayer = L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { maxZoom: 19 }) this.topoLayer = L.tileLayer("https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png", { maxZoom: 17 }) this.osmLayer.addTo(map) this.layersControl = L.control.layers( { "OSM": this.osmLayer, "Topo": this.topoLayer }, undefined, { position: "topright" } ).addTo(map) // Silently fall back if OpenTopoMap is unreachable. this.topoLayer.on("tileerror", () => { if (!this.topoLayer) return if (map.hasLayer(this.topoLayer)) { map.removeLayer(this.topoLayer) this.osmLayer.addTo(map) } this.layersControl.removeLayer(this.topoLayer) this.topoLayer = null }) this.stationMarkers.addTo(map) this.homeMarker = L.circleMarker([this.homeLat, this.homeLon], { radius: 7, color: "#ffffff", weight: 2, fillColor: "#0ea5e9", fillOpacity: 0.95, pane: "markerPane" }).addTo(map) this.homeMarker.bindTooltip("Home", { permanent: true, direction: "top", offset: [0, -10], className: "rover-home-label" }) this.driveCircle = L.circle([this.homeLat, this.homeLon], { radius: kmToM(this.driveRadiusKm), color: "#0ea5e9", weight: 1.5, dashArray: "8 6", fill: false }).addTo(map) L.control.scale({ metric: true, imperial: false, position: "bottomleft" }).addTo(map) // Render initial stations from data attribute try { const initial: Station[] = JSON.parse(this.el.dataset.stations || "[]") this.stations = initial this.renderStations() } catch { this.stations = [] } map.on("click", (e: L.LeafletMouseEvent) => { this.pushEvent("rover_cell_detail", { lat: e.latlng.lat, lon: e.latlng.lng }) }) this.visibilityHandler = () => { if (document.visibilityState === "visible") { this.map.invalidateSize() } } document.addEventListener("visibilitychange", this.visibilityHandler) this.handleEvent("stations_updated", ({ stations }: { stations: Station[] }) => { this.stations = stations this.renderStations() }) this.handleEvent("update_drive_radius", ({ km }: { km: number }) => { this.setDriveRadius(km) }) this.handleEvent("rover_results", ( { cells, drive_radius_km }: { cells: Cell[]; drive_radius_km: number } ) => { this.cellLookup = new Map() for (const c of cells) { this.cellLookup.set(cellKey(c.lat, c.lon), c) } if (!this.qualityLayer) { this.qualityLayer = this.buildQualityLayer() this.qualityLayer.addTo(this.map) } else { this.qualityLayer.redraw() } this.setDriveRadius(drive_radius_km) }) this.handleEvent("focus_cell", ( { lat, lon, zoom }: { lat: number; lon: number; zoom: number } ) => { this.map.flyTo([lat, lon], zoom, { duration: 0.6 }) }) this.handleEvent("show_cell_popup", ( { lat, lon, html }: { lat: number; lon: number; html: string } ) => { L.popup().setLatLng([lat, lon]).setContent(html).openOn(this.map) }) }, reconnected(this: RoverMapHook) { if (this.map) this.map.invalidateSize() }, destroyed(this: RoverMapHook) { if (this.visibilityHandler) { document.removeEventListener("visibilitychange", this.visibilityHandler) this.visibilityHandler = null } }, setDriveRadius(this: RoverMapHook, km: number) { this.driveRadiusKm = km if (this.driveCircle) { this.driveCircle.setRadius(kmToM(km)) this.driveCircle.setLatLng([this.homeLat, this.homeLon]) } }, renderStations(this: RoverMapHook) { this.stationMarkers.clearLayers() for (const s of this.stations) { this.stationMarkers.addLayer(buildStationMarker(s)) } }, buildQualityLayer(this: RoverMapHook): L.GridLayer { const self = this const OverlayClass = L.GridLayer.extend({ createTile(coords: L.Coords) { const tile = document.createElement("canvas") const size = this.getTileSize() tile.width = size.x tile.height = size.y const ctx = tile.getContext("2d")! const w = size.x const h = size.y // tile lat/lon bounds const nwPoint = coords.scaleBy(size) const nw = this._map.unproject(nwPoint, coords.z) const se = this._map.unproject(nwPoint.add(size), coords.z) const latPerPx = (nw.lat - se.lat) / h // positive (lat decreases going down) const lonPerPx = (se.lng - nw.lng) / w if (latPerPx <= 0 || lonPerPx <= 0) return tile // 0.125° grid spacing in pixels const cellPxX = 0.125 / lonPerPx const cellPxY = 0.125 / latPerPx const cellPx = Math.max(1, Math.min(cellPxX, cellPxY)) const splatRadius = Math.ceil(3 * cellPx) const sigma = 1.2 * cellPx const twoSigmaSq = 2 * sigma * sigma // padding cell radius for the candidate filter (3 cells in degrees) const latPad = 3 * 0.125 const lonPad = 3 * 0.125 const minLat = se.lat - latPad const maxLat = nw.lat + latPad const minLon = nw.lng - lonPad const maxLon = se.lng + lonPad const tierBuffers: Record = { "#16a34a": new Float32Array(w * h), "#eab308": new Float32Array(w * h), "#f97316": new Float32Array(w * h) } for (const cell of self.cellLookup.values()) { if (cell.lat < minLat || cell.lat > maxLat) continue if (cell.lon < minLon || cell.lon > maxLon) continue const tier = tierForScore(cell.score) if (!tier) continue // Cells may carry an explicit tier_color from the server; honor it. const useTier = TIER_RGB[cell.tier_color] ? cell.tier_color : tier const buf = tierBuffers[useTier] if (!buf) continue const cx = (cell.lon - nw.lng) / lonPerPx const cy = (nw.lat - cell.lat) / latPerPx const x0 = Math.max(0, Math.floor(cx - splatRadius)) const x1 = Math.min(w - 1, Math.ceil(cx + splatRadius)) const y0 = Math.max(0, Math.floor(cy - splatRadius)) const y1 = Math.min(h - 1, Math.ceil(cy + splatRadius)) for (let py = y0; py <= y1; py++) { const dy = py - cy for (let px = x0; px <= x1; px++) { const dx = px - cx const d2 = dx * dx + dy * dy const a = Math.exp(-d2 / twoSigmaSq) const idx = py * w + px if (a > buf[idx]) buf[idx] = a } } } const img = ctx.createImageData(w, h) const data = img.data for (let i = 0; i < w * h; i++) { let bestAlpha = 0 let bestRgb: [number, number, number] | null = null for (const tierColor of TIER_ORDER) { const a = tierBuffers[tierColor][i] if (a > 0.05 && a > bestAlpha) { bestAlpha = a bestRgb = TIER_RGB[tierColor] } } if (bestRgb && bestAlpha > 0) { const o = i * 4 data[o] = bestRgb[0] data[o + 1] = bestRgb[1] data[o + 2] = bestRgb[2] data[o + 3] = Math.min(255, Math.round(bestAlpha * 0.35 * 255)) } } ctx.putImageData(img, 0, 0) return tile } }) return new OverlayClass({ opacity: 1.0 }) as L.GridLayer } }