New "Weather radar" toggle in /map's control panel (mobile + desktop) layers an ECCC GeoMet WMS composite dBZ mosaic over the propagation heatmap. The Radar_1km_dBZ-Extrapolation layer covers CONUS + Canada at 1 km resolution, updated every ~6 minutes. Tiles are lazy-created on first toggle so visitors who never open the panel never hit the WMS service. Refresh is driven by a setInterval that calls setParams with a cache-busting timestamp, matching ECCC's publish cadence. Rain-scatter display (NEXRAD cell markers + "Rain Scatter" section in the point-detail panel) is disabled for now. The live radar overlay answers "is there rain near this point?" more directly, and the scatter propagation feature needs more work before it's useful. The Elixir start_async, handle_async, and fetch_rain_scatter helpers are removed. The JS drawScatterMarkers / buildScatterBlock paths remain as harmless dead code since they're guarded by a `rain_scatter` field the server no longer sends — easy to re-enable later by restoring the payload key.
1321 lines
51 KiB
TypeScript
1321 lines
51 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
|
|
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
|
|
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)
|
|
|
|
// If a point was selected, refresh its detail with the new data
|
|
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 based on contiguous good cells
|
|
if (this.scoreGrid && this.clickedLatLng) {
|
|
// Use MARGINAL threshold (50) as minimum for propagation reach
|
|
const minScore = 50
|
|
const hull = propagationReach(
|
|
this.scoreGrid, this.clickedLatLng[0], this.clickedLatLng[1], minScore
|
|
)
|
|
if (hull.length >= 3) {
|
|
// Remove any previous reach polygon
|
|
if (this.reachPolygon) {
|
|
this.rangeCircles.removeLayer(this.reachPolygon)
|
|
}
|
|
const tier = scoreTier(detail.score)
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
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.lastDetail = null
|
|
},
|
|
|
|
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.pushEvent("set_selected_time", { time })
|
|
} else {
|
|
// Cache miss — fall back to server roundtrip via the existing
|
|
// update_scores path.
|
|
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()
|
|
}
|
|
}
|