1358 lines
52 KiB
TypeScript
1358 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
|
|
|
|
// Viewshed depends on freq + score-derived range, so recompute it when
|
|
// the band changes if a point is still selected. update_scores already
|
|
// handles the heatmap, reach polygon, and detail panel refresh.
|
|
if (this.clickedLatLng && this.detailPanel && this.detailPanel.style.display !== "none") {
|
|
this.viewshedLoading = true
|
|
this.pushEvent("compute_viewshed", {
|
|
lat: this.clickedLatLng[0],
|
|
lon: this.clickedLatLng[1]
|
|
})
|
|
}
|
|
})
|
|
|
|
// Prevent control panel / sidebar from eating map clicks/scrolls
|
|
const controls = document.getElementById("map-controls")
|
|
if (controls) {
|
|
L.DomEvent.disableClickPropagation(controls)
|
|
L.DomEvent.disableScrollPropagation(controls)
|
|
}
|
|
const sidebar = document.getElementById("sidebar")
|
|
if (sidebar) {
|
|
L.DomEvent.disableClickPropagation(sidebar)
|
|
L.DomEvent.disableScrollPropagation(sidebar)
|
|
}
|
|
|
|
// Invalidate map size when sidebar is toggled
|
|
window.addEventListener("sidebar-toggle", () => {
|
|
setTimeout(() => this.map.invalidateSize(), 50)
|
|
})
|
|
|
|
// Grid overlay layer
|
|
this.gridLayer = L.layerGroup()
|
|
this.gridVisible = false
|
|
|
|
// Listen for grid toggle from LiveView
|
|
this.handleEvent("toggle_grid", ({ visible }: { visible: boolean }) => {
|
|
this.gridVisible = visible
|
|
if (visible) {
|
|
this.gridLayer.addTo(this.map)
|
|
updateGridOverlay(this.map, this.gridLayer)
|
|
} else {
|
|
this.gridLayer.remove()
|
|
}
|
|
})
|
|
|
|
// Weather radar overlay (ECCC GeoMet WMS — CONUS + Canada composite dBZ).
|
|
// Lazy-created on first toggle so we don't hit the service until asked.
|
|
this.radarLayer = null
|
|
this.radarRefreshTimer = null
|
|
|
|
this.handleEvent("toggle_radar", ({ visible }: { visible: boolean }) => {
|
|
if (visible) {
|
|
if (!this.radarLayer) {
|
|
this.radarLayer = L.tileLayer.wms("https://geo.weather.gc.ca/geomet", {
|
|
layers: "Radar_1km_dBZ-Extrapolation",
|
|
format: "image/png",
|
|
transparent: true,
|
|
opacity: 0.55,
|
|
version: "1.3.0",
|
|
attribution: "Radar © Environment and Climate Change Canada"
|
|
})
|
|
}
|
|
this.radarLayer.addTo(this.map)
|
|
// ECCC radar updates every ~6 minutes; force a tile refresh on that
|
|
// cadence so the overlay stays current without a page reload.
|
|
this.radarRefreshTimer = setInterval(() => {
|
|
if (this.radarLayer) {
|
|
// @ts-expect-error setParams exists on WMS TileLayer but isn't in @types/leaflet yet
|
|
this.radarLayer.setParams({ _: Date.now() })
|
|
}
|
|
}, 6 * 60 * 1000)
|
|
} else {
|
|
if (this.radarLayer) this.radarLayer.remove()
|
|
if (this.radarRefreshTimer) {
|
|
clearInterval(this.radarRefreshTimer)
|
|
this.radarRefreshTimer = null
|
|
}
|
|
}
|
|
})
|
|
|
|
// Click on map to request viewshed + factor detail from server
|
|
this.map.on("click", (e: L.LeafletMouseEvent) => {
|
|
this.rangeCircles.clearLayers()
|
|
this.viewshedLoading = true
|
|
this.lastDetail = null
|
|
|
|
// Remember click location for the center marker
|
|
this.clickedLatLng = [e.latlng.lat, e.latlng.lng]
|
|
|
|
// Show a temporary marker while computing
|
|
L.circleMarker(this.clickedLatLng, {
|
|
radius: 6,
|
|
color: "#fff",
|
|
weight: 2,
|
|
fillColor: "#888",
|
|
fillOpacity: 0.8,
|
|
interactive: false
|
|
}).addTo(this.rangeCircles)
|
|
|
|
// Always request terrain viewshed
|
|
this.pushEvent("compute_viewshed", { lat: e.latlng.lat, lon: e.latlng.lng })
|
|
|
|
// Show panel immediately with whatever we have client-side
|
|
const basic = this.lookupPoint(e.latlng.lat, e.latlng.lng)
|
|
if (basic && this.detailPanel) {
|
|
const merged = { ...basic, ...this.bandInfo, factors: null } as unknown as PointDetail
|
|
this.detailPanel.innerHTML = buildLoadingHTML(merged)
|
|
this.showDetailPanel()
|
|
this.pushEvent("point_detail", { lat: e.latlng.lat, lon: e.latlng.lng })
|
|
}
|
|
})
|
|
|
|
// Detail panel — floating overlay on the map
|
|
this.detailPanel = document.getElementById("detail-panel")
|
|
this.viewshedLoading = false
|
|
|
|
if (this.detailPanel) {
|
|
L.DomEvent.disableClickPropagation(this.detailPanel)
|
|
L.DomEvent.disableScrollPropagation(this.detailPanel)
|
|
|
|
// Close button event delegation
|
|
this.detailPanel.addEventListener("click", (e: MouseEvent) => {
|
|
if ((e.target as HTMLElement).closest(".detail-close-btn")) {
|
|
this.hideDetailPanel()
|
|
}
|
|
})
|
|
}
|
|
this.lastDetail = null
|
|
|
|
this.handleEvent("rain_scatter_update", ({ rain_scatter }: { lat: number; lon: number; rain_scatter: RainScatter }) => {
|
|
if (!this.lastDetail || !this.detailPanel) return
|
|
this.lastDetail.rain_scatter = rain_scatter
|
|
this.detailPanel.innerHTML = buildPopupHTML(this.lastDetail, this.viewshedLoading)
|
|
if (rain_scatter && rain_scatter.cells && rain_scatter.cells.length > 0) {
|
|
this.drawScatterMarkers(rain_scatter)
|
|
}
|
|
})
|
|
|
|
this.handleEvent("point_detail", (detail: PointDetail) => {
|
|
if (detail && this.detailPanel) {
|
|
const merged: PointDetail = { ...detail, ...this.bandInfo }
|
|
this.lastDetail = merged
|
|
this.detailPanel.innerHTML = buildPopupHTML(merged, this.viewshedLoading)
|
|
this.showDetailPanel()
|
|
|
|
// Draw rain scatter cells on the map
|
|
if (this.scatterMarkers) {
|
|
this.scatterMarkers.clearLayers()
|
|
} else {
|
|
this.scatterMarkers = L.layerGroup().addTo(this.map)
|
|
}
|
|
if (detail.rain_scatter && detail.rain_scatter !== "pending" && detail.rain_scatter.cells.length > 0) {
|
|
this.drawScatterMarkers(detail.rain_scatter)
|
|
}
|
|
|
|
// Draw propagation reach polygon from contiguous good cells.
|
|
// Shared helper handles cleanup + empty-hull case so the old
|
|
// polygon doesn't stick around when the new band has no reach.
|
|
this.redrawReachPolygon()
|
|
}
|
|
})
|
|
|
|
this.handleEvent("viewshed_result", ({ origin, points }: ViewshedResult) => {
|
|
this.rangeCircles.clearLayers()
|
|
this.viewshedLoading = false
|
|
|
|
// Re-render panel without loading indicator
|
|
if (this.lastDetail && this.detailPanel) {
|
|
this.detailPanel.innerHTML = buildPopupHTML(this.lastDetail, false)
|
|
}
|
|
|
|
if (points && points.length > 0) {
|
|
const latlngs = points.map(p => [p.lat, p.lon] as [number, number])
|
|
|
|
L.polygon(latlngs, {
|
|
color: "#222",
|
|
weight: 2,
|
|
opacity: 0.8,
|
|
fillColor: "#000",
|
|
fillOpacity: 0.12,
|
|
interactive: false,
|
|
smoothFactor: 1
|
|
}).addTo(this.rangeCircles)
|
|
}
|
|
|
|
// Always redraw center marker at clicked location
|
|
const loc: [number, number] = this.clickedLatLng || [origin.lat, origin.lon]
|
|
const basic = this.lookupPoint(loc[0], loc[1])
|
|
const markerColor = basic ? scoreTier(basic.score).color : "#888"
|
|
|
|
L.circleMarker(loc, {
|
|
radius: 6,
|
|
color: "#fff",
|
|
weight: 2,
|
|
fillColor: markerColor,
|
|
fillOpacity: 1,
|
|
interactive: false
|
|
}).addTo(this.rangeCircles)
|
|
})
|
|
|
|
|
|
// --- Forecast timeline ---
|
|
this.timelineEl = document.getElementById("forecast-timeline")
|
|
this.timelineData = JSON.parse(this.el.dataset.validTimes || "[]")
|
|
this.selectedTime = this.el.dataset.selectedTime || null
|
|
if (this.timelineData.length > 1) {
|
|
this.renderTimeline()
|
|
}
|
|
|
|
this.handleEvent("update_timeline", ({ times, selected }: { times: TimelineEntry[]; selected: string }) => {
|
|
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()
|
|
}
|
|
}
|