import topbar from "../vendor/topbar" import { formatDistanceKm } from "./format" import { updateGridOverlay } from "./maidenhead_grid" const KM2MI = 0.621371 // Decode the binary "PSCR" cell-pack served by /scores/cells. // See lib/microwaveprop_web/controllers/scores_controller.ex for the // authoritative format spec. function decodeScorePack(buf: ArrayBuffer): ScorePoint[] { const view = new DataView(buf) const magic = String.fromCharCode(view.getUint8(0), view.getUint8(1), view.getUint8(2), view.getUint8(3)) if (magic !== "PSCR") throw new Error(`bad magic: ${magic}`) const version = view.getUint8(4) if (version !== 1) throw new Error(`unsupported version: ${version}`) const cellCount = view.getUint32(8, true) if (cellCount === 0) return [] const lats = new Float32Array(buf, 12, cellCount) const lons = new Float32Array(buf, 12 + cellCount * 4, cellCount) const scores = new Uint8Array(buf, 12 + cellCount * 8, cellCount) const out: ScorePoint[] = new Array(cellCount) for (let i = 0; i < cellCount; i++) { out[i] = [lats[i], lons[i], scores[i]] } return out } function kmRangeToMi(lowKm: number, highKm: number): string { const lowMi = Math.round(lowKm * KM2MI) const highMi = Math.round(highKm * KM2MI) return `${lowMi}\u2013${highMi} mi (${Math.round(lowKm)}\u2013${Math.round(highKm)} km)` } function kmRangeOpenToMi(lowKm: number): string { const lowMi = Math.round(lowKm * KM2MI) return `${lowMi}+ mi (${Math.round(lowKm)}+ km)` } function kmCeilToMi(highKm: number): string { const highMi = Math.round(highKm * KM2MI) return `<${highMi} mi (<${Math.round(highKm)} km)` } // --- Interfaces --- export interface FactorMeta { label: string weight: number unit: string } export interface ScoreTier { label: string color: string } interface ColorScaleEntry { min: number r: number g: number b: number } interface RGB { r: number g: number b: number } interface LatLon { lat: number lon: number } // Wire format from the server: [lat, lon, score] per point. Flat // 3-tuples avoid repeating the three JSON keys across ~95k points // (~45% payload shrink vs the old {lat, lon, score} object form). type ScorePoint = [number, number, number] // Object shape returned by lookupPoint — merges a resolved cell's // coordinates + score with the active band metadata for UI display. type LookupPointResult = { lat: number; lon: number; score: number } & BandInfo interface ForecastPoint { score: number time: string } interface SvgPoint { x: number y: number score: number time: Date } interface ScatterCell { lat: number lon: number dbz: number bearing: number distance_km: number scatter_db: number } interface RainScatter { classification: string cells: ScatterCell[] } interface DuctLayer { base_m: number top_m: number thickness_m: number min_freq_ghz: number | null } interface DuctInfo { duct_count: number ducts: DuctLayer[] } interface PointDetail { score: number lat: number lon: number valid_time: string band_label: string humidity_effect: string typical_range_km: number extended_range_km: number exceptional_range_km: number factors: Record & { duct_info?: DuctInfo } rain_scatter?: RainScatter | "pending" forecast?: ForecastPoint[] [key: string]: unknown } interface BandInfo { band_label?: string humidity_effect?: string typical_range_km?: number extended_range_km?: number exceptional_range_km?: number [key: string]: unknown } interface TimelineEntry { time: string [key: string]: unknown } interface TimelineItem extends TimelineEntry { dt: Date isSelected: boolean isPast: boolean offsetH: number idx: number label: string } interface ViewshedResult { origin: LatLon points: LatLon[] } /** Hook state that extends Phoenix LiveView's ViewHook */ interface PropagationMapHook extends ViewHook { map: L.Map scoreOverlay: L.GridLayer | null scores: ScorePoint[] colorScale: ColorScaleEntry[] scoreGrid: ScoreGrid bandInfo: BandInfo rangeCircles: L.LayerGroup gridLayer: L.LayerGroup gridVisible: boolean radarLayer: L.TileLayer.WMS | null radarRefreshTimer: ReturnType | null detailPanel: HTMLElement | null viewshedLoading: boolean lastDetail: PointDetail | null clickedLatLng: [number, number] | null scatterMarkers: L.LayerGroup | null reachPolygon: L.Polygon | null timelineEl: HTMLElement | null timelineData: TimelineEntry[] selectedBand: number selectedTime: string | null // Cache key: `${band}|${time}|${roundedBoundsKey}` → ScorePoint[] scoresCache: Map scoresBoundsKey: string inflightScoresAbort: AbortController | null playbackTimer: ReturnType | null moveDebounce: ReturnType | null visibilityHandler: (() => void) | null showDetailPanel(this: PropagationMapHook): void hideDetailPanel(this: PropagationMapHook): void redrawReachPolygon(this: PropagationMapHook): void renderScores(this: PropagationMapHook, scores: ScorePoint[]): void fetchScores(this: PropagationMapHook, band: number, time: string | null, signal?: AbortSignal): Promise loadAndRender(this: PropagationMapHook, opts?: { force?: boolean }): Promise prefetchForecast(this: PropagationMapHook): Promise drawTileCanvas(this: PropagationMapHook, canvas: HTMLCanvasElement, coords: L.Coords, layer: L.GridLayer): void interpolateScore(this: PropagationMapHook, lat: number, lon: number, step: number): number | null scoreColorRGB(this: PropagationMapHook, score: number): RGB lookupPoint(this: PropagationMapHook, lat: number, lon: number): LookupPointResult | null drawScatterMarkers(this: PropagationMapHook, scatter: RainScatter): void renderTimeline(this: PropagationMapHook): void selectTimelineTime(this: PropagationMapHook, time: string): void startPlayback(this: PropagationMapHook): void stopPlayback(this: PropagationMapHook): void sendBounds(this: PropagationMapHook): void colorCache: string[] } // --- Constants --- // CONUS propagation grid (must match Microwaveprop.Propagation.Grid in Elixir). const GRID_LAT_MIN = 25.0 const GRID_LON_MIN = -125.0 const GRID_STEP = 0.125 const GRID_LAT_COUNT = 201 // (50 - 25) / 0.125 + 1 const GRID_LON_COUNT = 473 // (-66 - -125) / 0.125 + 1 const GRID_MISSING = -1 // Int8Array sentinel for empty cell /** * Flat typed-array grid storing propagation scores at 0.125° resolution across * CONUS. Replaces a string-keyed Map — zero allocations on per-pixel reads, * which matters because `interpolateScore` is called thousands of times per * rendered canvas tile. */ class ScoreGrid { private readonly data: Int8Array constructor() { this.data = new Int8Array(GRID_LAT_COUNT * GRID_LON_COUNT) this.data.fill(GRID_MISSING) } reset(): void { this.data.fill(GRID_MISSING) } put(lat: number, lon: number, score: number): void { const latIdx = Math.round((lat - GRID_LAT_MIN) / GRID_STEP) const lonIdx = Math.round((lon - GRID_LON_MIN) / GRID_STEP) if (latIdx < 0 || latIdx >= GRID_LAT_COUNT) return if (lonIdx < 0 || lonIdx >= GRID_LON_COUNT) return this.data[latIdx * GRID_LON_COUNT + lonIdx] = score } get(lat: number, lon: number): number | null { const latIdx = Math.round((lat - GRID_LAT_MIN) / GRID_STEP) const lonIdx = Math.round((lon - GRID_LON_MIN) / GRID_STEP) return this.getByIdx(latIdx, lonIdx) } getByIdx(latIdx: number, lonIdx: number): number | null { if (latIdx < 0 || latIdx >= GRID_LAT_COUNT) return null if (lonIdx < 0 || lonIdx >= GRID_LON_COUNT) return null const v = this.data[latIdx * GRID_LON_COUNT + lonIdx] return v === GRID_MISSING ? null : v } indexOf(lat: number, lon: number): number { const latIdx = Math.round((lat - GRID_LAT_MIN) / GRID_STEP) const lonIdx = Math.round((lon - GRID_LON_MIN) / GRID_STEP) if (latIdx < 0 || latIdx >= GRID_LAT_COUNT) return -1 if (lonIdx < 0 || lonIdx >= GRID_LON_COUNT) return -1 return latIdx * GRID_LON_COUNT + lonIdx } } // Weights must match BandConfig.weights() in band_config.ex // Recalibrated 2026-04-11 via gradient descent (loss 0.42 → 0.12) export const FACTOR_META: Record = { rain: { label: "Rain", weight: 0.1362, unit: "" }, humidity: { label: "Humidity", weight: 0.1243, unit: "g/m\u00b3" }, pwat: { label: "PWAT", weight: 0.1128, unit: "mm" }, season: { label: "Season", weight: 0.1112, unit: "" }, refractivity: { label: "Refractivity", weight: 0.1049, unit: "N/km" }, pressure: { label: "Pressure", weight: 0.1032, unit: "mb" }, td_depression: { label: "Td Depression", weight: 0.0978, unit: "\u00b0F" }, sky: { label: "Sky Cover", weight: 0.08, unit: "%" }, wind: { label: "Wind", weight: 0.08, unit: "kts" }, time_of_day: { label: "Time of Day", weight: 0.0496, unit: "" } } export const FACTOR_ORDER: string[] = [ "rain", "humidity", "pwat", "season", "refractivity", "pressure", "td_depression", "sky", "wind", "time_of_day" ] // --- Helper functions --- export function scoreTier(score: number): ScoreTier { if (score >= 80) return { label: "EXCELLENT", color: "#00ffa3" } if (score >= 65) return { label: "GOOD", color: "#7dffd4" } if (score >= 50) return { label: "MARGINAL", color: "#ffe566" } if (score >= 33) return { label: "POOR", color: "#ff9044" } return { label: "NEGLIGIBLE", color: "#ff4f4f" } } export function factorBar(score: number): string { const filled = Math.round(score / 5) const empty = 20 - filled const tier = scoreTier(score) return `${"\u2588".repeat(filled)}${"\u2591".repeat(empty)}` } export function factorExplanation( key: string, score: number, ctx: { humidity_effect?: string } ): string { const beneficial = ctx.humidity_effect === "beneficial" switch (key) { case "humidity": if (beneficial) { if (score >= 80) return "High moisture — strong ducting potential" if (score >= 55) return "Moderate moisture — some ducting possible" return "Dry air — ducting unlikely" } else { if (score >= 80) return "Low humidity — minimal absorption" if (score >= 50) return "Moderate humidity — some absorption" return "High humidity — significant absorption at this frequency" } case "time_of_day": if (score >= 80) return "Sunrise window — peak inversion strength" if (score >= 65) return "Evening/post-sunrise — inversions forming or eroding" if (score >= 40) return "Night — gradual cooling, weak inversions" return "Afternoon — convective mixing destroys inversions" case "td_depression": if (beneficial) { if (score >= 75) return "Tight depression — moist boundary layer favors ducting" if (score >= 50) return "Moderate spread — some moisture present" return "Wide spread — dry air limits ducting formation" } else { if (score >= 80) return "Wide spread — dry air reduces absorption" if (score >= 50) return "Moderate spread" return "Tight depression — moisture increasing absorption" } case "refractivity": if (score >= 80) return "Strong inversion gradient — ducting layer detected" if (score >= 60) return "Moderate gradient — enhanced refraction" if (score >= 45) return "Weak gradient — near standard atmosphere" return "No significant inversion — standard refraction" case "sky": if (score >= 80) return "Clear skies — radiative cooling strengthens inversions" if (score >= 50) return "Partly cloudy — some radiative cooling" return "Overcast — clouds prevent inversion formation" case "season": if (beneficial) { if (score >= 70) return "Peak ducting season (summer months)" if (score >= 40) return "Transitional season — moderate ducting" return "Weakest season for ducting at this frequency" } else { if (score >= 70) return "Peak season — cool, dry conditions" if (score >= 40) return "Transitional season" return "Summer humidity degrades propagation at this frequency" } case "wind": if (score >= 80) return "Calm winds — inversions preserved" if (score >= 55) return "Light winds — minor mixing" return "Strong winds — turbulent mixing destroys inversions" case "rain": if (score >= 90) return "No rain — no path attenuation" if (score >= 50) return "Light rain — minor attenuation" return "Heavy rain — significant path loss" case "pwat": if (beneficial) { if (score >= 80) return "High column moisture — strong refractivity" if (score >= 50) return "Moderate PWAT — some refractivity enhancement" return "Low PWAT — limited moisture for ducting" } else { if (score >= 80) return "Low column moisture — minimal absorption" if (score >= 50) return "Moderate PWAT" return "High PWAT — significant absorption at this frequency" } case "pressure": if (score >= 70) return "Low pressure — frontal boundaries favor ducting" if (score >= 45) return "Normal pressure — average conditions" return "High pressure — stable ridge, inversions cap at wrong altitude" default: return "" } } function rangeEstimate(score: number, detail: PointDetail): string { const typical = detail.typical_range_km if (score >= 80) return kmRangeOpenToMi(detail.extended_range_km) if (score >= 65) return kmRangeToMi(typical, detail.extended_range_km) if (score >= 50) return kmRangeToMi(typical * 0.7, typical) if (score >= 33) return kmRangeToMi(typical * 0.4, typical * 0.7) return kmCeilToMi(typical * 0.4) } /** * Flood-fill outward from a grid point, collecting all contiguous cells * with score >= minScore. Returns array of {lat, lon} boundary points * as a convex hull for drawing a polygon. */ function propagationReach(grid: ScoreGrid, startLat: number, startLon: number, minScore: number): LatLon[] { const maxKm = 300 const snap = (v: number): number => Math.round(v / GRID_STEP) * GRID_STEP const snapLat = snap(startLat) const snapLon = snap(startLon) const startScore = grid.get(snapLat, snapLon) if (startScore == null || startScore < minScore) return [] const cosLat = Math.cos(startLat * Math.PI / 180) const kmPerDegLat = 111.0 const kmPerDegLon = 111.0 * cosLat const visited = new Set() const reachable: LatLon[] = [] const queue: [number, number][] = [[snapLat, snapLon]] visited.add(grid.indexOf(snapLat, snapLon)) // BFS flood fill through grid, capped at maxKm from origin while (queue.length > 0) { const [lat, lon] = queue.shift()! const dLat = (lat - startLat) * kmPerDegLat const dLon = (lon - startLon) * kmPerDegLon if (Math.sqrt(dLat * dLat + dLon * dLon) > maxKm) continue reachable.push({ lat, lon }) const neighbors: [number, number][] = [ [lat + GRID_STEP, lon], [lat - GRID_STEP, lon], [lat, lon + GRID_STEP], [lat, lon - GRID_STEP] ] for (const [nlat, nlon] of neighbors) { const nidx = grid.indexOf(nlat, nlon) if (nidx < 0 || visited.has(nidx)) continue visited.add(nidx) const score = grid.get(nlat, nlon) if (score != null && score >= minScore) { queue.push([nlat, nlon]) } } } if (reachable.length < 3) return reachable return convexHull(reachable) } function convexHull(points: LatLon[]): LatLon[] { // Graham scan points.sort((a, b) => a.lon - b.lon || a.lat - b.lat) const cross = (o: LatLon, a: LatLon, b: LatLon): number => (a.lon - o.lon) * (b.lat - o.lat) - (a.lat - o.lat) * (b.lon - o.lon) const lower: LatLon[] = [] for (const p of points) { while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) lower.pop() lower.push(p) } const upper: LatLon[] = [] for (let i = points.length - 1; i >= 0; i--) { const p = points[i] while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) upper.pop() upper.push(p) } upper.pop() lower.pop() return lower.concat(upper) } function isMobile(): boolean { return window.innerWidth < 768 } function mobileHandleHTML(): string { if (!isMobile()) return "" return `
` } function buildLoadingHTML(detail: PointDetail): string { const tier = scoreTier(detail.score) return `
${mobileHandleHTML()}
${detail.score}/100 ${tier.label}
Loading…
` } function buildForecastSvg(forecast: ForecastPoint[]): string { if (!forecast || forecast.length < 2) return "" const marginL = 28, marginR = 6, marginT = 4, marginB = 16 const w = isMobile() ? Math.min(260, window.innerWidth - 48) : 260, h = 72 const plotW = w - marginL - marginR const plotH = h - marginT - marginB const n = forecast.length const now = new Date() const points: SvgPoint[] = forecast.map((f, i) => { const x = marginL + (i / (n - 1)) * plotW const y = marginT + plotH * (1 - f.score / 100) return { x, y, score: f.score, time: new Date(f.time) } }) const polyline = points.map(p => `${p.x},${p.y}`).join(" ") // "Now" dot const nowIdx = points.reduce((best, p, i) => Math.abs(p.time.getTime() - now.getTime()) < Math.abs(points[best].time.getTime() - now.getTime()) ? i : best, 0) const nowPt = points[nowIdx] // Trend (from now onward) const futurePoints = points.filter(p => p.time >= now) const firstScore = futurePoints.length > 0 ? futurePoints[0].score : points[0].score const lastScore = points[points.length - 1].score const diff = lastScore - firstScore let trend = "" if (diff > 5) trend = `▲ Improving` else if (diff < -5) trend = `▼ Declining` else trend = `→ Steady` // Best time (only future points) const bestPool = futurePoints.length > 0 ? futurePoints : points const bestPt = bestPool.reduce((best, p) => p.score > best.score ? p : best, bestPool[0]) const bestUtc = `${bestPt.time.getUTCHours().toString().padStart(2, "0")}:00 UTC` const bestTier = scoreTier(bestPt.score) // Y-axis: score labels const yLabels = [0, 50, 100].map(s => { const y = marginT + plotH * (1 - s / 100) return `${s} ` }).join("") // X-axis: time labels (first, middle, last) const timeIdxs = [0, Math.floor(n / 2), n - 1] const xLabels = timeIdxs.map(i => { const p = points[i] const label = `${p.time.getUTCHours().toString().padStart(2, "0")}:00` return `${label}` }).join("") return `
Forecast (${n}h) ${trend}
${yLabels} ${xLabels} ${bestPt.score}
Best at ${bestUtc} — score ${bestPt.score} (${bestTier.label})
` } function buildPopupHTML(detail: PointDetail, viewshedLoading: boolean): string { const tier = scoreTier(detail.score) const range = rangeEstimate(detail.score, detail) const time = new Date(detail.valid_time).toISOString().replace("T", " ").slice(0, 16) + " UTC" let rows = "" let explanations = "" for (const key of FACTOR_ORDER) { const meta = FACTOR_META[key] const value = detail.factors[key] as number | undefined if (value === undefined) continue const pct = Math.round(meta.weight * 100) rows += ` ${meta.label} ${factorBar(value)} ${value} (${pct}%) ` const expl = factorExplanation(key, value, detail) const eTier = scoreTier(value) explanations += `
\u25CF ${meta.label}: ${expl}
` } const viewshedStatus = viewshedLoading ? `
Computing terrain coverage…
` : "" const mobile = isMobile() const minW = mobile ? "auto" : "260px" return `
${mobileHandleHTML()}
${detail.score}/100 ${tier.label}
${detail.band_label} — Est. range: ${range} (CW)
${detail.lat.toFixed(3)}\u00b0N, ${Math.abs(detail.lon).toFixed(3)}\u00b0W — ${time}
${rows}
Analysis
${explanations}
${detail.factors.duct_info ? buildDuctInfoHTML(detail.factors.duct_info) : ""} ${buildScatterBlock(detail.rain_scatter)} ${detail.forecast ? buildForecastSvg(detail.forecast) : ""} ${viewshedStatus}
` } function buildScatterBlock(scatter: RainScatter | "pending" | undefined): string { if (scatter === "pending") { return `
Rain Scatter
Checking NEXRAD\u2026
` } if (scatter && scatter.cells.length > 0) return buildScatterHTML(scatter) return "" } function buildScatterHTML(scatter: RainScatter): string { const cls = scatter.classification const cells = scatter.cells const clsColors: Record = { excellent: "#059669", good: "#0d9488", marginal: "#ca8a04", none: "#666" } const clsColor = clsColors[cls] || "#666" const clsLabel = cls.charAt(0).toUpperCase() + cls.slice(1) const top3 = cells.slice(0, 3) const rows = top3.map(c => { const dir = bearingLabel(c.bearing) return `
${c.dbz} dBZ at ${formatDistanceKm(c.distance_km)} ${dir} (${c.scatter_db} dB)
` }).join("") return `
Rain Scatter ${clsLabel}
${rows} ${cells.length > 3 ? `
+${cells.length - 3} more cells
` : ""}
` } function bearingLabel(deg: number): string { const dirs = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"] return dirs[Math.round(deg / 22.5) % 16] } function buildDuctInfoHTML(info: DuctInfo): string { const layers = info.ducts || [] let layerRows = "" if (layers.length > 0) { layerRows = layers.map((d, i) => { const baseFt = Math.round(d.base_m * 3.281) const topFt = Math.round(d.top_m * 3.281) const freq = d.min_freq_ghz != null ? `\u2265${d.min_freq_ghz.toFixed(1)} GHz` : "" return `
Layer ${i + 1}: ${baseFt}\u2013${topFt} ft (${d.thickness_m} m) ${freq}
` }).join("") } return `
Ducting — ${info.duct_count} layer${info.duct_count !== 1 ? "s" : ""}
${layerRows}
` } // --- Hook --- export const PropagationMap: Record & { mounted(this: PropagationMapHook): void showDetailPanel(this: PropagationMapHook): void hideDetailPanel(this: PropagationMapHook): void redrawReachPolygon(this: PropagationMapHook): void renderScores(this: PropagationMapHook, scores: ScorePoint[]): void interpolateScore(this: PropagationMapHook, lat: number, lon: number, step: number): number | null scoreColorRGB(this: PropagationMapHook, score: number): RGB lookupPoint(this: PropagationMapHook, lat: number, lon: number): LookupPointResult | null drawScatterMarkers(this: PropagationMapHook, scatter: RainScatter): void renderTimeline(this: PropagationMapHook): void selectTimelineTime(this: PropagationMapHook, time: string): void startPlayback(this: PropagationMapHook): void stopPlayback(this: PropagationMapHook): void sendBounds(this: PropagationMapHook): void reconnected(this: PropagationMapHook): void destroyed(this: PropagationMapHook): void } = { mounted(this: PropagationMapHook) { const centerLat = parseFloat(this.el.dataset.centerLat!) const centerLon = parseFloat(this.el.dataset.centerLon!) const zoom = parseInt(this.el.dataset.zoom!, 10) const center: [number, number] = Number.isFinite(centerLat) && Number.isFinite(centerLon) ? [centerLat, centerLon] : [32.897, -97.038] this.map = L.map(this.el, { center, zoom: Number.isFinite(zoom) ? zoom : 7, minZoom: 4, maxZoom: 13 }) L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { attribution: "© OpenStreetMap contributors", maxZoom: 19 }).addTo(this.map) this.scoreOverlay = null this.scores = [] this.scoresCache = new Map() this.scoresBoundsKey = "" this.inflightScoresAbort = null this.playbackTimer = null this.moveDebounce = null this.selectedBand = parseInt(this.el.dataset.selectedBand || "10000", 10) this.colorScale = [ { min: 80, r: 0, g: 255, b: 163 }, { min: 65, r: 125, g: 255, b: 212 }, { min: 50, r: 255, g: 229, b: 102 }, { min: 33, r: 255, g: 144, b: 68 }, { min: 0, r: 255, g: 79, b: 79 } ] // The score-data flow used to be a LiveView push_event from the // server (`update_scores` + `preload_forecast`). Now the client // pulls scores from /scores/cells as binary cell-packs: // • mount + reconnect + bounds change → loadAndRender for the // current band/time, then prefetchForecast for the rest of the // timeline in the background. // • band change → server pushes update_band_info; client triggers // loadAndRender + prefetch. // • time change → if the cache has it, render instantly; else // fetch and render. this.bandInfo = JSON.parse(this.el.dataset.bandInfo || "{}") this.rangeCircles = L.layerGroup().addTo(this.map) this.handleEvent("update_band_info", ({ band_info, band_mhz }: { band_info: BandInfo; band_mhz?: number }) => { this.bandInfo = band_info // Server tells us the new band when select_band lands. Update // local state and refetch scores via HTTP. if (typeof band_mhz === "number" && band_mhz !== this.selectedBand) { this.selectedBand = band_mhz // Discard cache entries for the previous band — different keys // wouldn't collide but they bloat the map for no benefit. this.scoresCache.clear() void this.loadAndRender({ force: true }) } // Viewshed depends on freq + score-derived range, so recompute it when // the band changes or antenna height moves if a point is still selected. if (this.clickedLatLng && this.detailPanel && this.detailPanel.style.display !== "none") { this.viewshedLoading = true this.pushEvent("compute_viewshed", { lat: this.clickedLatLng[0], lon: this.clickedLatLng[1] }) this.pushEvent("point_detail", { lat: this.clickedLatLng[0], lon: this.clickedLatLng[1] }) } }) // Prevent control panel / sidebar from eating map clicks/scrolls const controls = document.getElementById("map-controls") if (controls) { L.DomEvent.disableClickPropagation(controls) L.DomEvent.disableScrollPropagation(controls) } const sidebar = document.getElementById("sidebar") if (sidebar) { L.DomEvent.disableClickPropagation(sidebar) L.DomEvent.disableScrollPropagation(sidebar) } // Invalidate map size when sidebar is toggled window.addEventListener("sidebar-toggle", () => { setTimeout(() => this.map.invalidateSize(), 50) }) // Grid overlay layer this.gridLayer = L.layerGroup() this.gridVisible = false // Listen for grid toggle from LiveView this.handleEvent("toggle_grid", ({ visible }: { visible: boolean }) => { this.gridVisible = visible localStorage.setItem("propagationMap.gridVisible", String(visible)) if (visible) { this.gridLayer.addTo(this.map) updateGridOverlay(this.map, this.gridLayer) } else { this.gridLayer.remove() } }) if (localStorage.getItem("propagationMap.gridVisible") === "true") { this.pushEvent("toggle_grid", {}) } // Weather radar overlay (ECCC GeoMet WMS — CONUS + Canada composite dBZ). // Lazy-created on first toggle so we don't hit the service until asked. this.radarLayer = null this.radarRefreshTimer = null this.handleEvent("toggle_radar", ({ visible }: { visible: boolean }) => { localStorage.setItem("propagationMap.radarVisible", String(visible)) if (visible) { if (!this.radarLayer) { this.radarLayer = L.tileLayer.wms("https://geo.weather.gc.ca/geomet", { layers: "Radar_1km_dBZ-Extrapolation", format: "image/png", transparent: true, opacity: 0.55, version: "1.3.0", attribution: "Radar © Environment and Climate Change Canada" }) } this.radarLayer.addTo(this.map) // ECCC radar updates every ~6 minutes; force a tile refresh on that // cadence so the overlay stays current without a page reload. this.radarRefreshTimer = setInterval(() => { if (this.radarLayer) { // @ts-expect-error setParams exists on WMS TileLayer but isn't in @types/leaflet yet this.radarLayer.setParams({ _: Date.now() }) } }, 6 * 60 * 1000) } else { if (this.radarLayer) this.radarLayer.remove() if (this.radarRefreshTimer) { clearInterval(this.radarRefreshTimer) this.radarRefreshTimer = null } } }) if (localStorage.getItem("propagationMap.radarVisible") === "true") { this.pushEvent("toggle_radar", {}) } // Click on map to request viewshed + factor detail from server this.map.on("click", (e: L.LeafletMouseEvent) => { this.rangeCircles.clearLayers() this.viewshedLoading = true this.lastDetail = null // Remember click location for the center marker this.clickedLatLng = [e.latlng.lat, e.latlng.lng] localStorage.setItem("propagationMap.clickedPoint", JSON.stringify(this.clickedLatLng)) // Show a temporary marker while computing L.circleMarker(this.clickedLatLng, { radius: 6, color: "#fff", weight: 2, fillColor: "#888", fillOpacity: 0.8, interactive: false }).addTo(this.rangeCircles) // Always request terrain viewshed this.pushEvent("compute_viewshed", { lat: e.latlng.lat, lon: e.latlng.lng }) // Show panel immediately with whatever we have client-side const basic = this.lookupPoint(e.latlng.lat, e.latlng.lng) if (basic && this.detailPanel) { const merged = { ...basic, ...this.bandInfo, factors: null } as unknown as PointDetail this.detailPanel.innerHTML = buildLoadingHTML(merged) this.showDetailPanel() this.pushEvent("point_detail", { lat: e.latlng.lat, lon: e.latlng.lng }) } }) // Detail panel — floating overlay on the map this.detailPanel = document.getElementById("detail-panel") this.viewshedLoading = false if (this.detailPanel) { L.DomEvent.disableClickPropagation(this.detailPanel) L.DomEvent.disableScrollPropagation(this.detailPanel) // Close button event delegation this.detailPanel.addEventListener("click", (e: MouseEvent) => { if ((e.target as HTMLElement).closest(".detail-close-btn")) { this.hideDetailPanel() } }) } this.lastDetail = null // Restore a previously-clicked point across page navigation: replay the // click side-effects so the user lands back on the same detail panel // they had open, with fresh server-side factors + viewshed. const storedPoint = localStorage.getItem("propagationMap.clickedPoint") if (storedPoint) { try { const parsed = JSON.parse(storedPoint) if (Array.isArray(parsed) && typeof parsed[0] === "number" && typeof parsed[1] === "number") { const [lat, lon] = parsed as [number, number] this.clickedLatLng = [lat, lon] this.viewshedLoading = true L.circleMarker([lat, lon], { radius: 6, color: "#fff", weight: 2, fillColor: "#888", fillOpacity: 0.8, interactive: false }).addTo(this.rangeCircles) this.pushEvent("compute_viewshed", { lat, lon }) const basic = this.lookupPoint(lat, lon) if (basic && this.detailPanel) { const merged = { ...basic, ...this.bandInfo, factors: null } as unknown as PointDetail this.detailPanel.innerHTML = buildLoadingHTML(merged) this.showDetailPanel() } this.pushEvent("point_detail", { lat, lon }) } } catch { localStorage.removeItem("propagationMap.clickedPoint") } } this.handleEvent("rain_scatter_update", ({ rain_scatter }: { lat: number; lon: number; rain_scatter: RainScatter }) => { if (!this.lastDetail || !this.detailPanel) return this.lastDetail.rain_scatter = rain_scatter this.detailPanel.innerHTML = buildPopupHTML(this.lastDetail, this.viewshedLoading) if (rain_scatter && rain_scatter.cells && rain_scatter.cells.length > 0) { this.drawScatterMarkers(rain_scatter) } }) this.handleEvent("point_detail", (detail: PointDetail) => { if (detail && this.detailPanel) { const merged: PointDetail = { ...detail, ...this.bandInfo } this.lastDetail = merged this.detailPanel.innerHTML = buildPopupHTML(merged, this.viewshedLoading) this.showDetailPanel() // Draw rain scatter cells on the map if (this.scatterMarkers) { this.scatterMarkers.clearLayers() } else { this.scatterMarkers = L.layerGroup().addTo(this.map) } if (detail.rain_scatter && detail.rain_scatter !== "pending" && detail.rain_scatter.cells.length > 0) { this.drawScatterMarkers(detail.rain_scatter) } // Draw propagation reach polygon from contiguous good cells. // Shared helper handles cleanup + empty-hull case so the old // polygon doesn't stick around when the new band has no reach. this.redrawReachPolygon() } }) this.handleEvent("viewshed_result", ({ origin, points }: ViewshedResult) => { this.rangeCircles.clearLayers() this.viewshedLoading = false // Re-render panel without loading indicator if (this.lastDetail && this.detailPanel) { this.detailPanel.innerHTML = buildPopupHTML(this.lastDetail, false) } if (points && points.length > 0) { const latlngs = points.map(p => [p.lat, p.lon] as [number, number]) L.polygon(latlngs, { color: "#222", weight: 2, opacity: 0.8, fillColor: "#000", fillOpacity: 0.12, interactive: false, smoothFactor: 1 }).addTo(this.rangeCircles) } // Always redraw center marker at clicked location const loc: [number, number] = this.clickedLatLng || [origin.lat, origin.lon] const basic = this.lookupPoint(loc[0], loc[1]) const markerColor = basic ? scoreTier(basic.score).color : "#888" L.circleMarker(loc, { radius: 6, color: "#fff", weight: 2, fillColor: markerColor, fillOpacity: 1, interactive: false }).addTo(this.rangeCircles) }) // --- Forecast timeline --- this.timelineEl = document.getElementById("forecast-timeline") this.timelineData = JSON.parse(this.el.dataset.validTimes || "[]") this.selectedTime = this.el.dataset.selectedTime || null if (this.timelineData.length > 1) { this.renderTimeline() } this.handleEvent("update_timeline", ({ times, selected }: { times: TimelineEntry[]; selected: string }) => { // Band change or new forecast hour arrived — whatever playback // was iterating through no longer matches the new set, so drop // the timer before we rewrite timelineData under it. if (this.playbackTimer !== null) { clearInterval(this.playbackTimer) this.playbackTimer = null } const timeChanged = selected !== this.selectedTime this.timelineData = times this.selectedTime = selected this.renderTimeline() if (timeChanged) { // The cache may already have this time (forecast prefetch hit); // loadAndRender will use it and skip the network. void this.loadAndRender() } }) // Prevent timeline from eating map clicks if (this.timelineEl) { L.DomEvent.disableClickPropagation(this.timelineEl) L.DomEvent.disableScrollPropagation(this.timelineEl) } // Escape key closes detail panel and range circles document.addEventListener("keydown", (e: KeyboardEvent) => { if (e.key === "Escape") { this.hideDetailPanel() } }) this.el.addEventListener("show-loading", () => topbar.show(300)) requestAnimationFrame(() => this.sendBounds()) // Re-check bounds after layout settles (sidebar, fonts, etc.) setTimeout(() => { this.map.invalidateSize(); this.sendBounds() }, 500) // When the tab is hidden, Leaflet may create or drop score tiles while the // server-side assigns (bounds, scores) are gone. On return, the retained // scoreGrid covers the old viewport and any freshly-created tiles outside // it paint blank. Re-fetch bounds so update_scores repaints all tiles with // fresh data. reconnected() handles the socket-drop case on top of this. this.visibilityHandler = () => { if (document.visibilityState === "visible") { this.map.invalidateSize() this.sendBounds() } } document.addEventListener("visibilitychange", this.visibilityHandler) this.map.on("moveend", () => { this.sendBounds() if (this.gridVisible) { updateGridOverlay(this.map, this.gridLayer) } }) // Legend lives in the top-right on every viewport so the // forecast timeline at the bottom can stretch to full width // without the legend covering the later hours. const legend = L.control({ position: "topright" }) legend.onAdd = () => { const div = L.DomUtil.create("div") const mobile = isMobile() const rows: [string, string, string][] = [ ["#00ffa3", "Excellent", "80+"], ["#7dffd4", "Good", "65"], ["#ffe566", "Marginal", "50"], ["#ff9044", "Poor", "33"], ["#ff4f4f", "Negligible", "0"] ] if (mobile) { div.style.cssText = "background:#fff;color:#333;padding:4px 6px;border-radius:6px;box-shadow:0 1px 4px rgba(0,0,0,0.3);font-size:10px;line-height:1.6;" div.innerHTML = rows.map(([c, , score]) => `${score}` ).join("
") } else { div.style.cssText = "background:#fff;color:#333;padding:10px 14px;border-radius:8px;box-shadow:0 2px 8px rgba(0,0,0,0.3);font-size:13px;line-height:2;" div.innerHTML = "Propagation
" + rows.map(([c, label, score]) => `${label} (${score === "80+" ? "80-100" : score === "0" ? "0-32" : score + "-" + (parseInt(score) === 65 ? "79" : parseInt(score) === 50 ? "64" : "49")})` ).join("
") } return div } legend.addTo(this.map) }, showDetailPanel(this: PropagationMapHook) { if (!this.detailPanel) return this.detailPanel.style.display = "block" }, hideDetailPanel(this: PropagationMapHook) { if (this.detailPanel) { this.detailPanel.style.display = "none" this.detailPanel.innerHTML = "" } this.rangeCircles.clearLayers() if (this.scatterMarkers) this.scatterMarkers.clearLayers() this.clickedLatLng = null this.reachPolygon = null this.lastDetail = null localStorage.removeItem("propagationMap.clickedPoint") }, // Redraws the propagation reach polygon around the currently-clicked // point. Safe to call repeatedly — always removes the previous polygon // first so we don't leave a stale shape from an earlier band on the map // when the new band's reach is too small to hull. The polygon is a pure // function of this.scoreGrid and this.clickedLatLng, so it can be // refreshed on update_scores without waiting for the server's detail. redrawReachPolygon(this: PropagationMapHook) { if (this.reachPolygon) { this.rangeCircles.removeLayer(this.reachPolygon) this.reachPolygon = null } if (!this.scoreGrid || !this.clickedLatLng) return const minScore = 50 // MARGINAL — minimum useful propagation const hull = propagationReach( this.scoreGrid, this.clickedLatLng[0], this.clickedLatLng[1], minScore ) if (hull.length < 3) return const basic = this.lookupPoint(this.clickedLatLng[0], this.clickedLatLng[1]) const centerScore = basic ? basic.score : 50 const tier = scoreTier(centerScore) this.reachPolygon = L.polygon( hull.map(p => [p.lat, p.lon] as [number, number]), { color: tier.color, weight: 2, opacity: 0.6, fillColor: tier.color, fillOpacity: 0.08, interactive: false, smoothFactor: 1.5, dashArray: "6 4" } ).addTo(this.rangeCircles) }, renderScores(this: PropagationMapHook, scores: ScorePoint[]) { this.scores = scores if (scores.length === 0) { if (this.scoreOverlay) { this.map.removeLayer(this.scoreOverlay) this.scoreOverlay = null } return } if (!this.scoreGrid) { this.scoreGrid = new ScoreGrid() } else { this.scoreGrid.reset() } scores.forEach(([lat, lon, score]) => this.scoreGrid.put(lat, lon, score)) if (!this.colorCache) { this.colorCache = new Array(101) for (let i = 0; i <= 100; i++) { const c = this.scoreColorRGB(i) this.colorCache[i] = `rgba(${c.r},${c.g},${c.b},0.55)` } } if (this.scoreOverlay) { // Repaint each existing tile canvas in place. Leaflet's redraw() calls // _removeAllTiles() which briefly leaves the overlay empty — visible as // a flash on every update_scores event (mount sendBounds, invalidateSize // moveend, preload_forecast, etc.). Writing fresh pixels onto existing // canvases keeps the DOM stable and eliminates the blink. const tiles = (this.scoreOverlay as unknown as { _tiles: Record })._tiles || {} for (const key in tiles) { const t = tiles[key] if (t && t.el && t.coords) { this.drawTileCanvas(t.el, t.coords, this.scoreOverlay) } } return } // First render — build the GridLayer once and reuse it for every update. // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this this.scoreOverlay = new (L.GridLayer.extend({ createTile(this: L.GridLayer, coords: L.Coords) { const tile = document.createElement("canvas") const size = this.getTileSize() tile.width = size.x tile.height = size.y self.drawTileCanvas(tile, coords, this) return tile } }))({ opacity: 1.0 }) as L.GridLayer this.scoreOverlay.addTo(this.map) }, drawTileCanvas(this: PropagationMapHook, canvas: HTMLCanvasElement, coords: L.Coords, layer: L.GridLayer) { const ctx = canvas.getContext("2d")! const size = layer.getTileSize() ctx.clearRect(0, 0, size.x, size.y) const step = 0.125 const nwPoint = coords.scaleBy(size) const map = (layer as unknown as { _map: L.Map })._map if (!map) return const nw = map.unproject(nwPoint, coords.z) as L.LatLng const se = map.unproject(nwPoint.add(size), coords.z) as L.LatLng const latPerPx = (nw.lat - se.lat) / size.y const lonPerPx = (se.lng - nw.lng) / size.x const cellPxX = Math.max(1, Math.ceil(step / lonPerPx)) const cellPxY = Math.max(1, Math.ceil(step / Math.abs(latPerPx))) const absLatPerPx = Math.abs(latPerPx) let lastColor: string | null = null for (let py = 0; py < size.y; py += cellPxY) { const lat = nw.lat - py * absLatPerPx for (let px = 0; px < size.x; px += cellPxX) { const lon = nw.lng + px * lonPerPx const score = this.interpolateScore(lat, lon, step) if (score !== null) { const color = this.colorCache[Math.max(0, Math.min(100, Math.round(score)))] if (color !== lastColor) { ctx.fillStyle = color lastColor = color } ctx.fillRect(px, py, cellPxX, cellPxY) } } } }, interpolateScore(this: PropagationMapHook, lat: number, lon: number, _step: number): number | null { // Snapped cell — hot path, zero allocations const latIdxR = Math.round((lat - GRID_LAT_MIN) / GRID_STEP) const lonIdxR = Math.round((lon - GRID_LON_MIN) / GRID_STEP) const snapped = this.scoreGrid.getByIdx(latIdxR, lonIdxR) if (snapped !== null) return snapped // Bilinear interpolation across 4 neighbors const latFloor = Math.floor((lat - GRID_LAT_MIN) / GRID_STEP) const lonFloor = Math.floor((lon - GRID_LON_MIN) / GRID_STEP) const tLat = ((lat - GRID_LAT_MIN) / GRID_STEP) - latFloor const tLon = ((lon - GRID_LON_MIN) / GRID_STEP) - lonFloor const s00 = this.scoreGrid.getByIdx(latFloor, lonFloor) const s10 = this.scoreGrid.getByIdx(latFloor + 1, lonFloor) const s01 = this.scoreGrid.getByIdx(latFloor, lonFloor + 1) const s11 = this.scoreGrid.getByIdx(latFloor + 1, lonFloor + 1) if (s00 !== null && s10 !== null && s01 !== null && s11 !== null) { return Math.round( s00 * (1 - tLat) * (1 - tLon) + s10 * tLat * (1 - tLon) + s01 * (1 - tLat) * tLon + s11 * tLat * tLon ) } // Partial corners — average whatever we have (minimum 2 for a reasonable value) let sum = 0 let count = 0 if (s00 !== null) { sum += s00; count++ } if (s10 !== null) { sum += s10; count++ } if (s01 !== null) { sum += s01; count++ } if (s11 !== null) { sum += s11; count++ } if (count < 2) return null return Math.round(sum / count) }, scoreColorRGB(this: PropagationMapHook, score: number): RGB { for (let i = 0; i < this.colorScale.length - 1; i++) { const upper = this.colorScale[i] const lower = this.colorScale[i + 1] if (score >= lower.min) { const range = upper.min - lower.min const t = Math.min(1, (score - lower.min) / range) return { r: Math.round(lower.r + (upper.r - lower.r) * t), g: Math.round(lower.g + (upper.g - lower.g) * t), b: Math.round(lower.b + (upper.b - lower.b) * t) } } } const last = this.colorScale[this.colorScale.length - 1] return { r: last.r, g: last.g, b: last.b } }, lookupPoint(this: PropagationMapHook, lat: number, lon: number): LookupPointResult | null { if (!this.scoreGrid) return null const rlat = Math.round(lat / GRID_STEP) * GRID_STEP const rlon = Math.round(lon / GRID_STEP) * GRID_STEP const score = this.scoreGrid.get(rlat, rlon) if (score == null) return null return { lat: rlat, lon: rlon, score, ...this.bandInfo } }, drawScatterMarkers(this: PropagationMapHook, scatter: RainScatter) { if (!this.scatterMarkers) this.scatterMarkers = L.layerGroup().addTo(this.map) this.scatterMarkers.clearLayers() for (const c of scatter.cells) { const opacity = Math.min(0.9, Math.max(0.3, (c.scatter_db + 30) / 30)) const color = c.dbz >= 45 ? "#dc2626" : c.dbz >= 35 ? "#ea580c" : "#ca8a04" const marker = L.circleMarker([c.lat, c.lon], { radius: Math.min(12, Math.max(4, c.dbz / 5)), color, fillColor: color, fillOpacity: opacity * 0.5, weight: 1.5, opacity, interactive: false }) this.scatterMarkers.addLayer(marker) } }, renderTimeline(this: PropagationMapHook) { if (!this.timelineEl || this.timelineData.length === 0) { if (this.timelineEl) this.timelineEl.style.display = "none" return } // Single time: show as a simple data timestamp, no buttons if (this.timelineData.length === 1) { const dt = new Date(this.timelineData[0].time) const utcLabel = `${dt.getUTCHours().toString().padStart(2, "0")}:00 UTC` const dateLabel = `${dt.getUTCFullYear()}-${(dt.getUTCMonth() + 1).toString().padStart(2, "0")}-${dt.getUTCDate().toString().padStart(2, "0")}` this.timelineEl.style.display = "block" this.timelineEl.innerHTML = `
Data: ${dateLabel} ${utcLabel}
` return } // Hide timeline if all times are within 1 hour (no real forecast spread) const firstDt = new Date(this.timelineData[0].time) const lastDt = new Date(this.timelineData[this.timelineData.length - 1].time) if (lastDt.getTime() - firstDt.getTime() < 3600000) { const dt = firstDt const utcLabel = `${dt.getUTCHours().toString().padStart(2, "0")}:00 UTC` const dateLabel = `${dt.getUTCFullYear()}-${(dt.getUTCMonth() + 1).toString().padStart(2, "0")}-${dt.getUTCDate().toString().padStart(2, "0")}` this.timelineEl.style.display = "block" this.timelineEl.innerHTML = `
Data: ${dateLabel} ${utcLabel}
` return } const now = new Date() const items: TimelineItem[] = this.timelineData.map((t, idx) => { const dt = new Date(t.time) const isSelected = t.time === this.selectedTime const isPast = dt.getTime() < now.getTime() - 1800000 // 30min grace return { ...t, dt, isSelected, isPast, offsetH: 0, idx, label: "" } }) // "Now" = the latest forecast hour at or before wall-clock. Picking // by absolute distance rounds up past the half-hour and labels a // future slot "Now" (e.g. at 18:35 UTC, 19:00 is 25 min away vs. // 18:00 at 35 min). When every available forecast hour is in the // future (e.g. a pipeline gap where f00-f05 got discarded and only // f06+ made it onto disk), leave everything as "+Nh" labels rather // than lying with a "Now" tag on a future slot. const nowMs = now.getTime() const pastOrNow = items.filter(t => t.dt.getTime() <= nowMs) const nowIdx = pastOrNow.length > 0 ? items.indexOf(pastOrNow.reduce((latest, t) => t.dt.getTime() > latest.dt.getTime() ? t : latest)) : -1 // Label offsets relative to the "Now" slot, not wall-clock. If we // round from wall-clock, the hour after "Now" rounds to "0h" when // the clock is past the half-hour, which reads as a second "now". const anchorMs = nowIdx >= 0 ? items[nowIdx].dt.getTime() : nowMs items.forEach((t, i) => { const diff = Math.round((t.dt.getTime() - anchorMs) / 3600000) t.offsetH = diff if (i === nowIdx) { t.label = "Now" } else { t.label = diff > 0 ? `+${diff}h` : `${diff}h` } }) const mobile = isMobile() const btnPad = mobile ? "3px 5px" : "4px 8px" const btnMinW = mobile ? "32px" : "38px" const btnFont = mobile ? "10px" : "11px" const subFont = mobile ? "8px" : "9px" const buttons = items.map(t => { const bg = t.isSelected ? "#00ffa3" : (t.isPast ? "rgba(255,255,255,0.08)" : "rgba(255,255,255,0.15)") const fg = t.isSelected ? "#000" : (t.isPast ? "rgba(255,255,255,0.4)" : "#fff") const border = t.isSelected ? "2px solid #00ffa3" : "1px solid rgba(255,255,255,0.2)" const weight = t.isSelected ? "700" : "400" const utcLabel = `${t.dt.getUTCHours().toString().padStart(2, "0")}:00` return `` }).join("") const labelText = mobile ? "Forecast" : "Propagation Forecast" const isPlaying = this.playbackTimer !== null const playBg = isPlaying ? "#00ffa3" : "rgba(255,255,255,0.15)" const playFg = isPlaying ? "#000" : "#fff" const ctrlFont = mobile ? "10px" : "11px" const ctrlPad = mobile ? "1px 5px" : "2px 7px" this.timelineEl.style.display = "block" this.timelineEl.innerHTML = `
${labelText}
${buttons}
` // Attach click handlers this.timelineEl.querySelectorAll("button[data-time]").forEach(btn => { btn.addEventListener("click", () => { const time = btn.dataset.time! // A manual click interrupts playback — the user has taken over. this.stopPlayback() this.selectTimelineTime(time) }) }) const playBtn = this.timelineEl.querySelector("button[data-timeline-play]") if (playBtn) { playBtn.addEventListener("click", () => this.startPlayback()) } const stopBtn = this.timelineEl.querySelector("button[data-timeline-stop]") if (stopBtn) { stopBtn.addEventListener("click", () => this.stopPlayback()) } }, selectTimelineTime(this: PropagationMapHook, time: string) { this.selectedTime = time this.renderTimeline() // Inform the server of the selected time for URL/state tracking // (point_detail and forecast lookups still need it server-side). this.pushEvent("set_selected_time", { time }) // loadAndRender uses the local cache when possible (forecast // prefetch hit) and falls back to a single HTTP fetch otherwise. void this.loadAndRender() }, startPlayback(this: PropagationMapHook) { // Restarting re-seeds at "now" so repeated clicks don't resume // mid-loop — the user's mental model is "play from the start". if (this.playbackTimer !== null) { clearInterval(this.playbackTimer) this.playbackTimer = null } if (this.timelineData.length < 2) return const now = Date.now() const nowIdx = this.timelineData.reduce((best, t, i) => { const d = Math.abs(new Date(t.time).getTime() - now) const bestD = Math.abs(new Date(this.timelineData[best].time).getTime() - now) return d < bestD ? i : best }, 0) // Iterate through "now" + every later forecast hour on disk, then // loop back. Past forecast hours (items before nowIdx) are excluded // so the animation only steps forward in time. const playable = this.timelineData.slice(nowIdx) if (playable.length < 2) return let cursor = 0 const step = () => { this.selectTimelineTime(playable[cursor].time) cursor = (cursor + 1) % playable.length } // Assign the interval handle *before* the first step so the // renderTimeline() call inside selectTimelineTime sees // playbackTimer !== null and paints the Play button as active. this.playbackTimer = setInterval(step, 1000) step() }, stopPlayback(this: PropagationMapHook) { if (this.playbackTimer !== null) { clearInterval(this.playbackTimer) this.playbackTimer = null } if (this.timelineData.length === 0) { this.renderTimeline() return } // "Stop" returns the map to "now" — the timeline entry closest to // the current wall clock. const now = Date.now() const nowIdx = this.timelineData.reduce((best, t, i) => { const d = Math.abs(new Date(t.time).getTime() - now) const bestD = Math.abs(new Date(this.timelineData[best].time).getTime() - now) return d < bestD ? i : best }, 0) this.selectTimelineTime(this.timelineData[nowIdx].time) }, sendBounds(this: PropagationMapHook) { // The viewport changed: drop the per-viewport scores cache and // refetch for the current band/time. Server is told about the new // bounds (and center/zoom) so URL patching and reconnects keep // working, but it no longer pushes scores back — the client pulls // them from /scores/cells. this.scoresCache.clear() const b = this.map.getBounds().pad(0.1) const c = this.map.getCenter() this.pushEvent("map_bounds", { south: b.getSouth(), north: b.getNorth(), west: b.getWest(), east: b.getEast(), center_lat: c.lat, center_lon: c.lng, zoom: this.map.getZoom() }) void this.loadAndRender({ force: true }) }, async fetchScores(this: PropagationMapHook, band: number, time: string | null, signal?: AbortSignal): Promise { const b = this.map.getBounds().pad(0.1) const south = b.getSouth().toFixed(4) const north = b.getNorth().toFixed(4) const west = b.getWest().toFixed(4) const east = b.getEast().toFixed(4) let url = `/scores/cells?band=${band}&south=${south}&north=${north}&west=${west}&east=${east}` if (time) url += `&time=${encodeURIComponent(time)}` const resp = await fetch(url, { signal }) if (!resp.ok) throw new Error(`scores fetch failed: ${resp.status}`) const buf = await resp.arrayBuffer() return decodeScorePack(buf) }, async loadAndRender(this: PropagationMapHook, opts: { force?: boolean } = {}): Promise { if (!this.selectedTime) return const b = this.map.getBounds().pad(0.1) const boundsKey = `${b.getSouth().toFixed(2)},${b.getNorth().toFixed(2)},${b.getWest().toFixed(2)},${b.getEast().toFixed(2)}` if (boundsKey !== this.scoresBoundsKey) { this.scoresBoundsKey = boundsKey // New viewport — drop any cache entries from the old viewport. this.scoresCache.clear() } const cacheKey = `${this.selectedBand}|${this.selectedTime}|${boundsKey}` const cached = this.scoresCache.get(cacheKey) if (cached && !opts.force) { this.renderScores(cached) this.redrawReachPolygon() void this.prefetchForecast() return } if (this.inflightScoresAbort) this.inflightScoresAbort.abort() this.inflightScoresAbort = new AbortController() topbar.show() try { const scores = await this.fetchScores(this.selectedBand, this.selectedTime, this.inflightScoresAbort.signal) // Stale-response guard: if anything moved/switched while this // fetch was in flight, ignore it. if (cacheKey !== `${this.selectedBand}|${this.selectedTime}|${this.scoresBoundsKey}`) return this.scoresCache.set(cacheKey, scores) this.renderScores(scores) this.redrawReachPolygon() topbar.hide() void this.prefetchForecast() } catch (e) { const err = e as Error if (err.name === "AbortError") return console.warn("scores fetch failed", err) topbar.hide() } }, async prefetchForecast(this: PropagationMapHook): Promise { if (this.timelineData.length <= 1) return const band = this.selectedBand const boundsKey = this.scoresBoundsKey const others = this.timelineData .map(t => t.time) .filter(t => t !== this.selectedTime && !this.scoresCache.has(`${band}|${t}|${boundsKey}`)) if (others.length === 0) return // Fire all prefetches in parallel — HTTP/2 multiplexes them and // the server's ScoreCache + ScoresFile mmap make per-time reads // ~ms after the first one warms NFS cache. await Promise.all(others.map(async time => { const cacheKey = `${band}|${time}|${boundsKey}` // Race protection: another loadAndRender may have already filled // this entry. Skip in that case. if (this.scoresCache.has(cacheKey)) return try { const scores = await this.fetchScores(band, time) // Drop the response if the viewport changed under us. if (this.scoresBoundsKey !== boundsKey || this.selectedBand !== band) return this.scoresCache.set(cacheKey, scores) } catch (e) { const err = e as Error if (err.name === "AbortError") return // Prefetch failures are non-fatal — log and move on. console.warn(`forecast prefetch failed for ${time}`, err) } })) }, reconnected(this: PropagationMapHook) { // Server assigns (bounds, scores) reset on reconnect. Re-send bounds so // update_scores refreshes the client scoreGrid and repaints all tiles, // fixing partial/blank overlay after the tab comes back. if (this.map) { this.map.invalidateSize() this.sendBounds() } }, destroyed(this: PropagationMapHook) { if (this.playbackTimer !== null) { clearInterval(this.playbackTimer) this.playbackTimer = null } if (this.visibilityHandler) { document.removeEventListener("visibilitychange", this.visibilityHandler) this.visibilityHandler = null } if (this.map) this.map.remove() } }