Two bugs on the propagation map: 1. LiveStash reconnect path crashed the LiveView with KeyError on :initial_scores_json. stash_assigns/2 only persists selected_band and selected_time, but the recovered-socket branch returned early without rebuilding any of the other mount-time assigns (bands, bounds, the initial score JSON payload, grid/radar toggles, antenna height). Users on the Phoenix longpoll fallback — or anyone whose websocket reconnected after a brief network blip — saw a blank page and a server-side 500. Refactor mount/3 to always compute ephemeral assigns, using recovered selected_band / selected_time when present and falling back to defaults otherwise. 2. The propagation reach polygon shown after clicking a point never redrew when the user changed bands or scrubbed the forecast timeline. The redraw logic only ran inside the point_detail handler and was gated on `hull.length >= 3`, so the stale polygon from the old band stuck around whenever the new band had no coverage at the clicked point. Extract a redrawReachPolygon/0 helper on the Leaflet hook that always clears the previous polygon first and pulls the tier color from the current score at the clicked location, then call it from update_scores (band change + server time scrub) and the cached timeline-scrub path. The point_detail handler delegates to the same helper so the polygon stays in sync with both the score grid and the detail panel.
1347 lines
52 KiB
TypeScript
1347 lines
52 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[]>
|
|
|
|
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
|
|
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
|
|
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.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
|
|
})
|
|
|
|
// 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 }) => {
|
|
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"
|
|
|
|
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);">
|
|
<span style="font-size:${mobile ? "9px" : "10px"};color:rgba(255,255,255,0.5);margin-right:4px;white-space:nowrap;">${labelText}</span>
|
|
${buttons}
|
|
</div>`
|
|
|
|
// Attach click handlers
|
|
this.timelineEl.querySelectorAll<HTMLButtonElement>("button[data-time]").forEach(btn => {
|
|
btn.addEventListener("click", () => {
|
|
const time = btn.dataset.time!
|
|
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 })
|
|
}
|
|
})
|
|
})
|
|
},
|
|
|
|
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.map) this.map.remove()
|
|
}
|
|
}
|