prop/assets/js/propagation_map_hook.ts
Graham McIntire 1a9df52102
Add play/stop controls to the propagation forecast timeline
New play and stop buttons live directly under the "Propagation
Forecast" label in the bottom timeline bar. Play starts iterating
one forecast hour per second from "Now" forward through every
loadable future hour, then loops back to "Now" and repeats. Stop
clears the interval and returns the map to "Now". A manual click
on any time button cancels playback so the user's selection isn't
overwritten by the next tick.

The existing click handler body moved into a shared
selectTimelineTime() so playback and manual clicks go through the
same cache-or-roundtrip path. A server-driven update_timeline event
(band change or new forecast hour) also tears the timer down before
overwriting timelineData underneath it.
2026-04-15 10:03:14 -05:00

1470 lines
57 KiB
TypeScript

import topbar from "../vendor/topbar"
import { updateGridOverlay } from "./maidenhead_grid"
// --- Interfaces ---
interface FactorMeta {
label: string
weight: number
unit: string
}
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
}
interface ScorePoint {
lat: number
lon: number
score: number
[key: string]: unknown
}
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<string, number> & { 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<typeof setInterval> | 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[]
selectedTime: string | null
forecastCache: Map<string, ScorePoint[]>
playbackTimer: ReturnType<typeof setInterval> | null
showDetailPanel(this: PropagationMapHook): void
hideDetailPanel(this: PropagationMapHook): void
redrawReachPolygon(this: PropagationMapHook): void
renderScores(this: PropagationMapHook, scores: ScorePoint[]): void
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): (ScorePoint & BandInfo) | 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)
const FACTOR_META: Record<string, FactorMeta> = {
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: "" }
}
const FACTOR_ORDER: string[] = [
"rain", "humidity", "pwat", "season", "refractivity",
"pressure", "td_depression", "sky", "wind", "time_of_day"
]
// --- Helper functions ---
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" }
}
function factorBar(score: number): string {
const filled = Math.round(score / 5)
const empty = 20 - filled
const tier = scoreTier(score)
return `<span style="color:${tier.color}">${"\u2588".repeat(filled)}</span><span style="color:rgba(255,255,255,0.2)">${"\u2591".repeat(empty)}</span>`
}
function factorExplanation(key: string, score: number, detail: PointDetail): string {
const beneficial = detail.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 {
if (score >= 80) return `${detail.extended_range_km}\u2013${detail.exceptional_range_km}+ km`
if (score >= 65) return `${detail.typical_range_km}\u2013${detail.extended_range_km} km`
if (score >= 50) return `${Math.round(detail.typical_range_km * 0.7)}\u2013${detail.typical_range_km} km`
if (score >= 33) return `${Math.round(detail.typical_range_km * 0.4)}\u2013${Math.round(detail.typical_range_km * 0.7)} km`
return `<${Math.round(detail.typical_range_km * 0.4)} km`
}
/**
* 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<number>()
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 `<div style="display:flex;justify-content:center;padding:6px 0 2px;"><div style="width:32px;height:4px;border-radius:2px;background:rgba(255,255,255,0.3);"></div></div>`
}
function buildLoadingHTML(detail: PointDetail): string {
const tier = scoreTier(detail.score)
return `<div style="position:relative;">
${mobileHandleHTML()}
<div style="background:${tier.color};color:#000;padding:6px 10px;display:flex;justify-content:space-between;align-items:center;">
<div style="display:flex;align-items:baseline;gap:8px;">
<strong style="font-size:16px;">${detail.score}/100</strong>
<span style="font-size:13px;font-weight:700;">${tier.label}</span>
</div>
<button class="detail-close-btn" style="background:rgba(0,0,0,0.2);border:none;color:#000;font-size:16px;line-height:1;width:24px;height:24px;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;" title="Close">&times;</button>
</div>
<div style="padding:8px 10px;font-size:12px;color:#999;display:flex;align-items:center;gap:6px;">
<span class="loading loading-spinner loading-xs"></span> Loading&hellip;
</div>
</div>`
}
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 = `<span style="color:#00ffa3;">&#9650; Improving</span>`
else if (diff < -5) trend = `<span style="color:#ff4f4f;">&#9660; Declining</span>`
else trend = `<span style="color:#ffe566;">&#8594; Steady</span>`
// 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 `<text x="${marginL - 4}" y="${y + 3}" font-size="8" fill="rgba(255,255,255,0.4)" text-anchor="end">${s}</text>
<line x1="${marginL}" y1="${y}" x2="${marginL + plotW}" y2="${y}" stroke="rgba(255,255,255,0.08)" stroke-width="1"/>`
}).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 `<text x="${p.x}" y="${h - 2}" font-size="8" fill="rgba(255,255,255,0.4)" text-anchor="middle">${label}</text>`
}).join("")
return `
<div style="padding:6px 10px;border-top:1px solid rgba(255,255,255,0.15);">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;">
<span style="font-size:10px;font-weight:700;opacity:0.5;text-transform:uppercase;letter-spacing:0.5px;">Forecast (${n}h)</span>
<span style="font-size:11px;">${trend}</span>
</div>
<svg width="${w}" height="${h}" style="display:block;">
${yLabels}
${xLabels}
<polyline points="${polyline}" fill="none" stroke="#00ffa3" stroke-width="2" stroke-linejoin="round"/>
<circle cx="${nowPt.x}" cy="${nowPt.y}" r="3" fill="#fff" stroke="#00ffa3" stroke-width="1.5"/>
<circle cx="${bestPt.x}" cy="${bestPt.y}" r="3" fill="${bestTier.color}" stroke="#fff" stroke-width="1"/>
<text x="${bestPt.x}" y="${bestPt.y - 6}" font-size="8" fill="${bestTier.color}" text-anchor="middle" font-weight="700">${bestPt.score}</text>
</svg>
<div style="font-size:11px;margin-top:3px;color:${bestTier.color};">Best at ${bestUtc} &mdash; score ${bestPt.score} (${bestTier.label})</div>
</div>`
}
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 += `<tr>
<td style="padding:1px 6px 1px 0;white-space:nowrap;font-size:11px;">${meta.label}</td>
<td style="padding:1px 4px;font-family:monospace;font-size:11px;letter-spacing:-0.5px;">${factorBar(value)}</td>
<td style="padding:1px 4px;text-align:right;font-size:11px;font-weight:600;">${value}</td>
<td style="padding:1px 4px;font-size:10px;opacity:0.5;">(${pct}%)</td>
</tr>`
const expl = factorExplanation(key, value, detail)
const eTier = scoreTier(value)
explanations += `<div style="padding:2px 0;font-size:12px;display:flex;gap:6px;">
<span style="color:${eTier.color};flex-shrink:0;">\u25CF</span>
<span><strong>${meta.label}:</strong> ${expl}</span>
</div>`
}
const viewshedStatus = viewshedLoading
? `<div style="padding:4px 10px 6px;font-size:11px;opacity:0.6;border-top:1px solid rgba(255,255,255,0.15);display:flex;align-items:center;gap:6px;"><span class="loading loading-spinner loading-xs"></span> Computing terrain coverage&hellip;</div>`
: ""
const mobile = isMobile()
const minW = mobile ? "auto" : "260px"
return `<div style="min-width:${minW};position:relative;">
${mobileHandleHTML()}
<div style="background:${tier.color};color:#000;padding:6px 10px;display:flex;justify-content:space-between;align-items:center;">
<div style="display:flex;align-items:baseline;gap:8px;">
<strong style="font-size:16px;">${detail.score}/100</strong>
<span style="font-size:13px;font-weight:700;">${tier.label}</span>
</div>
<button class="detail-close-btn" style="background:rgba(0,0,0,0.2);border:none;color:#000;font-size:16px;line-height:1;width:24px;height:24px;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;" title="Close">&times;</button>
</div>
<div style="padding:6px 10px;">
<div style="font-size:12px;opacity:0.7;margin-bottom:6px;">
${detail.band_label} &mdash; Est. range: ${range} (CW)<br>
${detail.lat.toFixed(3)}\u00b0N, ${Math.abs(detail.lon).toFixed(3)}\u00b0W &mdash; ${time}
</div>
<table style="width:100%;border-collapse:collapse;border-top:1px solid rgba(255,255,255,0.15);padding-top:4px;">
${rows}
</table>
</div>
<div style="padding:6px 10px;border-top:1px solid rgba(255,255,255,0.15);">
<div style="font-size:10px;font-weight:700;opacity:0.5;margin-bottom:4px;text-transform:uppercase;letter-spacing:0.5px;">Analysis</div>
${explanations}
</div>
${detail.factors.duct_info ? buildDuctInfoHTML(detail.factors.duct_info) : ""}
${buildScatterBlock(detail.rain_scatter)}
${detail.forecast ? buildForecastSvg(detail.forecast) : ""}
${viewshedStatus}
</div>`
}
function buildScatterBlock(scatter: RainScatter | "pending" | undefined): string {
if (scatter === "pending") {
return `
<div style="padding:4px 10px 6px;border-top:1px solid rgba(255,255,255,0.15);">
<div style="font-size:10px;font-weight:700;opacity:0.5;margin-bottom:3px;text-transform:uppercase;letter-spacing:0.5px;">Rain Scatter</div>
<div style="font-size:11px;opacity:0.6;">Checking NEXRAD\u2026</div>
</div>`
}
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<string, string> = { 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 `<div style="font-size:11px;padding:1px 0;">
${c.dbz} dBZ at ${Math.round(c.distance_km)} km ${dir} (${c.scatter_db} dB)
</div>`
}).join("")
return `
<div style="padding:4px 10px 6px;border-top:1px solid rgba(255,255,255,0.15);">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:3px;">
<span style="font-size:10px;font-weight:700;opacity:0.5;text-transform:uppercase;letter-spacing:0.5px;">Rain Scatter</span>
<span style="font-size:11px;font-weight:700;color:${clsColor};">${clsLabel}</span>
</div>
${rows}
${cells.length > 3 ? `<div style="font-size:10px;opacity:0.5;">+${cells.length - 3} more cells</div>` : ""}
</div>`
}
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 `<div style="font-size:11px;padding:1px 0;">
Layer ${i + 1}: <strong>${baseFt}\u2013${topFt} ft</strong> (${d.thickness_m} m) ${freq}
</div>`
}).join("")
}
return `
<div style="padding:4px 10px 6px;border-top:1px solid rgba(255,255,255,0.15);">
<div style="font-size:10px;font-weight:700;opacity:0.5;margin-bottom:3px;text-transform:uppercase;letter-spacing:0.5px;">Ducting &mdash; ${info.duct_count} layer${info.duct_count !== 1 ? "s" : ""}</div>
${layerRows}
</div>`
}
// --- Hook ---
export const PropagationMap: Record<string, unknown> & {
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): (ScorePoint & BandInfo) | 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
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: "&copy; OpenStreetMap contributors",
maxZoom: 19
}).addTo(this.map)
this.scoreOverlay = null
this.scores = []
this.forecastCache = new Map()
this.playbackTimer = null
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 }
]
// Render pre-loaded scores immediately (no round-trip needed)
const initialScores: ScorePoint[] = JSON.parse(this.el.dataset.scores || "[]")
if (initialScores.length > 0) {
this.renderScores(initialScores)
}
this.handleEvent("update_scores", ({ scores }: { scores: ScorePoint[] }) => {
topbar.hide()
this.renderScores(scores)
if (this.selectedTime) this.forecastCache.set(this.selectedTime, scores)
// Reach polygon is a pure function of scoreGrid + clickedLatLng, so we
// can refresh it immediately client-side without waiting on the server
// round-trip. Handles the case where the new band has no reach at all
// (hull empty → stale polygon from the old band is cleared).
this.redrawReachPolygon()
// If the detail panel is open, refresh its factors/forecast for the
// new band via the server. The polygon is already up-to-date above.
if (this.clickedLatLng && this.detailPanel && this.detailPanel.style.display !== "none") {
this.pushEvent("point_detail", { lat: this.clickedLatLng[0], lon: this.clickedLatLng[1] })
}
})
// Preloaded forecast hours arrive after mount/band change — stash them so
// future timeline scrubs can render instantly from the local cache.
this.handleEvent("preload_forecast", ({ hours }: { hours: { time: string; scores: ScorePoint[] }[] }) => {
for (const h of hours) {
this.forecastCache.set(h.time, h.scores)
}
})
this.bandInfo = JSON.parse(this.el.dataset.bandInfo || "{}")
this.rangeCircles = L.layerGroup().addTo(this.map)
this.handleEvent("update_band_info", ({ band_info }: { band_info: BandInfo }) => {
this.bandInfo = band_info
// Viewshed depends on freq + score-derived range, so recompute it when
// the band changes if a point is still selected. update_scores already
// handles the heatmap, reach polygon, and detail panel refresh.
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]
})
}
})
// 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
if (visible) {
this.gridLayer.addTo(this.map)
updateGridOverlay(this.map, this.gridLayer)
} else {
this.gridLayer.remove()
}
})
// 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 }) => {
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 &copy; 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
}
}
})
// 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]
// 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
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
}
this.timelineData = times
this.selectedTime = selected
this.renderTimeline()
})
// 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)
this.map.on("moveend", () => {
this.sendBounds()
if (this.gridVisible) {
updateGridOverlay(this.map, this.gridLayer)
}
})
// Legend — compact on mobile, moved to top-right to avoid timeline overlap
const legend = L.control({ position: isMobile() ? "topright" : "bottomright" })
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]) =>
`<span style="background:${c};width:10px;height:10px;display:inline-block;border-radius:50%;vertical-align:middle;margin-right:3px;border:1px solid rgba(0,0,0,0.15);"></span>${score}`
).join("<br>")
} 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 = "<strong style='color:#333;'>Propagation</strong><br>" +
rows.map(([c, label, score]) =>
`<span style="background:${c};width:14px;height:14px;display:inline-block;border-radius:50%;vertical-align:middle;margin-right:6px;border:1px solid rgba(0,0,0,0.15);"></span><span style="color:#333;">${label} (${score === "80+" ? "80-100" : score === "0" ? "0-32" : score + "-" + (parseInt(score) === 65 ? "79" : parseInt(score) === 50 ? "64" : "49")})</span>`
).join("<br>")
}
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
},
// 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((s) => this.scoreGrid.put(s.lat, s.lon, s.score))
if (!this.colorCache) {
this.colorCache = new Array<string>(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<string, { el: HTMLCanvasElement; coords: L.Coords }> })._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): (ScorePoint & BandInfo) | 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 = `
<div style="background:rgba(0,0,0,0.85);border-radius:12px;padding:6px 14px;display:flex;gap:8px;align-items:center;box-shadow:0 2px 12px rgba(0,0,0,0.4);font-size:11px;color:rgba(255,255,255,0.6);">
<span>Data: ${dateLabel} ${utcLabel}</span>
</div>`
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 = `
<div style="background:rgba(0,0,0,0.85);border-radius:12px;padding:6px 14px;display:flex;gap:8px;align-items:center;box-shadow:0 2px 12px rgba(0,0,0,0.4);font-size:11px;color:rgba(255,255,255,0.6);">
<span>Data: ${dateLabel} ${utcLabel}</span>
</div>`
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
const offsetH = Math.round((dt.getTime() - now.getTime()) / 3600000)
return { ...t, dt, isSelected, isPast, offsetH, idx, label: "" }
})
// "Now" = the item closest to current time
const closestIdx = items.reduce((best, item, i) =>
Math.abs(item.dt.getTime() - now.getTime()) < Math.abs(items[best].dt.getTime() - now.getTime()) ? i : best, 0)
items.forEach((t, i) => {
if (i === closestIdx) {
t.label = "Now"
} else {
const diff = t.offsetH
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 `<button data-time="${t.time}" style="
padding:${btnPad};font-size:${btnFont};font-weight:${weight};
background:${bg};color:${fg};border:${border};border-radius:6px;
cursor:pointer;white-space:nowrap;min-width:${btnMinW};
transition:background 0.15s;
">${t.label}<br><span style="font-size:${subFont};opacity:0.7;">${utcLabel}</span></button>`
}).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 = `
<div style="background:rgba(0,0,0,0.85);border-radius:12px;padding:${mobile ? "4px 6px" : "6px 10px"};display:flex;gap:${mobile ? "2px" : "3px"};align-items:center;box-shadow:0 2px 12px rgba(0,0,0,0.4);overflow-x:auto;max-width:calc(100vw - 1rem);">
<div style="display:flex;flex-direction:column;align-items:center;gap:3px;margin-right:6px;flex-shrink:0;">
<span style="font-size:${mobile ? "9px" : "10px"};color:rgba(255,255,255,0.5);white-space:nowrap;">${labelText}</span>
<div style="display:flex;gap:3px;">
<button data-timeline-play style="
padding:${ctrlPad};font-size:${ctrlFont};line-height:1;
background:${playBg};color:${playFg};
border:1px solid rgba(255,255,255,0.2);border-radius:4px;
cursor:pointer;" title="Play forecast animation">▶</button>
<button data-timeline-stop style="
padding:${ctrlPad};font-size:${ctrlFont};line-height:1;
background:rgba(255,255,255,0.15);color:#fff;
border:1px solid rgba(255,255,255,0.2);border-radius:4px;
cursor:pointer;" title="Stop and return to now">■</button>
</div>
</div>
${buttons}
</div>`
// Attach click handlers
this.timelineEl.querySelectorAll<HTMLButtonElement>("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<HTMLButtonElement>("button[data-timeline-play]")
if (playBtn) {
playBtn.addEventListener("click", () => this.startPlayback())
}
const stopBtn = this.timelineEl.querySelector<HTMLButtonElement>("button[data-timeline-stop]")
if (stopBtn) {
stopBtn.addEventListener("click", () => this.stopPlayback())
}
},
selectTimelineTime(this: PropagationMapHook, time: string) {
this.selectedTime = time
this.renderTimeline()
const cached = this.forecastCache.get(time)
if (cached) {
// Fast path — render instantly from preloaded cache, just inform
// the server of the new selected time for state tracking.
this.renderScores(cached)
this.redrawReachPolygon()
this.pushEvent("set_selected_time", { time })
} else {
// Cache miss — fall back to server roundtrip via the existing
// update_scores path (which also redraws the polygon).
topbar.show()
this.pushEvent("select_time", { time })
}
},
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) {
topbar.show()
// Bounds changed — preloaded forecast hours are now for stale viewport.
// Drop them so timeline scrubs re-fetch for the new viewport.
this.forecastCache.clear()
// Pad bounds by ~0.5° so Leaflet edge tiles always have data to render
const b = this.map.getBounds().pad(0.1)
this.pushEvent("map_bounds", {
south: b.getSouth(),
north: b.getNorth(),
west: b.getWest(),
east: b.getEast()
})
},
destroyed(this: PropagationMapHook) {
if (this.playbackTimer !== null) {
clearInterval(this.playbackTimer)
this.playbackTimer = null
}
if (this.map) this.map.remove()
}
}