import type { ViewHook } from "phoenix_live_view" import { updateGridOverlay } from "./maidenhead_grid" 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 satelliteLayer: L.TileLayer hillshadeLayer: L.TileLayer layersControl: L.Control.Layers stations: Station[] stationMarkers: L.LayerGroup homeMarker: L.CircleMarker | null driveCircle: L.Circle | null qualityLayer: L.GridLayer | null selectedCandidateMarker: L.CircleMarker | null candidatePathLayer: L.LayerGroup candidateBuildingsLayer: L.LayerGroup gridLayer: L.LayerGroup gridVisible: boolean cellLookup: Map homeLat: number homeLon: number driveRadiusKm: number visibilityHandler: (() => void) | null driveRadiusListener: ((e: Event) => 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, { permanent: true, direction: "right", offset: [6, 0], className: "rover-station-label" }) } export const RoverMap: Partial = { mounted(this: RoverMapHook) { this.stations = [] this.stationMarkers = L.layerGroup() this.homeMarker = null this.driveCircle = null this.qualityLayer = null this.selectedCandidateMarker = null this.candidatePathLayer = L.layerGroup() this.candidateBuildingsLayer = L.layerGroup() this.gridLayer = L.layerGroup() this.gridVisible = true 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.satelliteLayer = L.tileLayer( "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", { maxZoom: 19 } ) this.hillshadeLayer = L.tileLayer( "https://services.arcgisonline.com/ArcGIS/rest/services/Elevation/World_Hillshade/MapServer/tile/{z}/{y}/{x}", { maxZoom: 16, opacity: 0.45 } ) this.osmLayer.addTo(map) this.hillshadeLayer.addTo(map) this.layersControl = L.control.layers( { "OSM": this.osmLayer, "Topo": this.topoLayer, "Satellite": this.satelliteLayer }, { "Hillshade": this.hillshadeLayer, "Grid squares": this.gridLayer }, { position: "topright" } ).addTo(map) map.on("overlayadd", (e: L.LayersControlEvent) => { if (e.layer === this.gridLayer) { this.gridVisible = true updateGridOverlay(this.map, this.gridLayer) } }) map.on("overlayremove", (e: L.LayersControlEvent) => { if (e.layer === this.gridLayer) this.gridVisible = false }) map.on("moveend", () => { if (this.gridVisible) updateGridOverlay(this.map, this.gridLayer) }) // 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) // Frame the initial view around the drive-radius circle so the // user starts zoomed in at the configured max-distance level. map.fitBounds(this.driveCircle.getBounds(), { padding: [20, 20] }) this.candidatePathLayer.addTo(map) this.candidateBuildingsLayer.addTo(map) this.gridLayer.addTo(map) updateGridOverlay(map, this.gridLayer) L.control.scale({ metric: false, imperial: true, 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 = [] } this.visibilityHandler = () => { if (document.visibilityState === "visible") { this.map.invalidateSize() } } document.addEventListener("visibilitychange", this.visibilityHandler) this.driveRadiusListener = (e: Event) => { const km = (e as CustomEvent<{ km: number }>).detail?.km if (typeof km === "number") this.setDriveRadius(km) } window.addEventListener("rover:drive-radius", this.driveRadiusListener) 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("update_home", ({ lat, lon }: { lat: number; lon: number }) => { this.homeLat = lat this.homeLon = lon if (this.homeMarker) this.homeMarker.setLatLng([lat, lon]) if (this.driveCircle) { this.driveCircle.setLatLng([lat, lon]) this.map.flyToBounds(this.driveCircle.getBounds(), { padding: [20, 20], duration: 0.6 }) } else { this.map.flyTo([lat, lon], Math.max(this.map.getZoom(), 8), { duration: 0.6 }) } }) 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) } // Update radius before (re)drawing so the per-pixel clip uses the // new value, otherwise the first paint can leak past the circle. this.setDriveRadius(drive_radius_km) if (!this.qualityLayer) { this.qualityLayer = this.buildQualityLayer() this.qualityLayer.addTo(this.map) } else { this.qualityLayer.redraw() } }) this.handleEvent("focus_cell", ( { lat, lon, zoom }: { lat: number; lon: number; zoom: number } ) => { // Drop a single highlight marker at the picked spot, replacing // any previous one — clicking another candidate moves it. if (this.selectedCandidateMarker) { this.map.removeLayer(this.selectedCandidateMarker) } this.selectedCandidateMarker = L.circleMarker([lat, lon], { radius: 8, color: "#0ea5e9", weight: 2, fillColor: "#0ea5e9", fillOpacity: 0.6, pane: "markerPane" }).addTo(this.map) this.map.flyTo([lat, lon], zoom, { duration: 0.6 }) }) this.handleEvent("candidate_paths", ( { candidate, paths }: { candidate: { lat: number; lon: number } | null paths: { callsign: string; station_lat: number; station_lon: number; margin_db: number | null }[] } ) => { this.candidatePathLayer.clearLayers() if (!candidate) { if (this.selectedCandidateMarker) { this.map.removeLayer(this.selectedCandidateMarker) this.selectedCandidateMarker = null } return } if (!paths || paths.length === 0) return for (const p of paths) { L.polyline( [[candidate.lat, candidate.lon], [p.station_lat, p.station_lon]], { color: "#6366f1", weight: 2, dashArray: "6 4", opacity: 0.85 } ).addTo(this.candidatePathLayer) } }) this.handleEvent("candidate_buildings", ( { buildings }: { buildings: { lat: number; lon: number; radius_m: number; height_m: number }[] } ) => { this.candidateBuildingsLayer.clearLayers() if (!buildings || buildings.length === 0) return for (const b of buildings) { // Color by height: tall = red, medium = orange, low = yellow. const color = b.height_m >= 30 ? "#dc2626" : b.height_m >= 10 ? "#f97316" : "#facc15" L.circle([b.lat, b.lon], { radius: Math.max(b.radius_m, 8), color, weight: 1, fillColor: color, fillOpacity: 0.45, interactive: true }) .bindTooltip(`${Math.round(b.height_m * 3.28084)} ft`, { direction: "top", offset: [0, -4] }) .addTo(this.candidateBuildingsLayer) } }) }, reconnected(this: RoverMapHook) { if (this.map) this.map.invalidateSize() }, destroyed(this: RoverMapHook) { if (this.visibilityHandler) { document.removeEventListener("visibilitychange", this.visibilityHandler) this.visibilityHandler = null } if (this.driveRadiusListener) { window.removeEventListener("rover:drive-radius", this.driveRadiusListener) this.driveRadiusListener = 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 } } } // Clip the heatmap to the drive-radius circle: pixels farther // than driveRadiusKm from home stay transparent so the overlay // never extends past the dashed circle. const cosLat = Math.cos(self.homeLat * Math.PI / 180) const kmPerPxX = lonPerPx * 111.0 * Math.max(cosLat, 0.1) const kmPerPxY = latPerPx * 111.0 const homePxX = (self.homeLon - nw.lng) / lonPerPx const homePxY = (nw.lat - self.homeLat) / latPerPx const radiusKmSq = self.driveRadiusKm * self.driveRadiusKm const img = ctx.createImageData(w, h) const data = img.data for (let py = 0; py < h; py++) { const dyKm = (py - homePxY) * kmPerPxY for (let px = 0; px < w; px++) { const dxKm = (px - homePxX) * kmPerPxX if (dxKm * dxKm + dyKm * dyKm > radiusKmSq) continue const i = py * w + px 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 } }