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.
1470 lines
57 KiB
TypeScript
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">×</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…
|
|
</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;">▲ Improving</span>`
|
|
else if (diff < -5) trend = `<span style="color:#ff4f4f;">▼ Declining</span>`
|
|
else trend = `<span style="color:#ffe566;">→ 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} — 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…</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">×</button>
|
|
</div>
|
|
<div style="padding:6px 10px;">
|
|
<div style="font-size:12px;opacity:0.7;margin-bottom:6px;">
|
|
${detail.band_label} — Est. range: ${range} (CW)<br>
|
|
${detail.lat.toFixed(3)}\u00b0N, ${Math.abs(detail.lon).toFixed(3)}\u00b0W — ${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 — ${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: "© 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 © 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()
|
|
}
|
|
}
|