loadLayers did `if (!resp.ok) return`, so a 413 or 400 from /weather/cells left the map blank with no console error and no UI feedback — indistinguishable from "no data for this forecast hour". This is reachable in normal use: the hook sets minZoom: 4, and a z=4 viewport on a wide window clamps to ~4500 sq deg, over GridBounds' 4000 sq deg cap, so zooming all the way out silently drops the overlay. Zooming back in restores it, which makes the failure look intermittent and untraceable. Log the status, requested layers and URL so the next report is diagnosable from the browser console.
1496 lines
58 KiB
TypeScript
1496 lines
58 KiB
TypeScript
// assets/js/weather_map_hook.ts
|
|
|
|
import { updateGridOverlay } from "./maidenhead_grid"
|
|
|
|
// --- Type definitions ---
|
|
|
|
interface RGB {
|
|
r: number
|
|
g: number
|
|
b: number
|
|
}
|
|
|
|
interface Breakpoint extends RGB {
|
|
value: number
|
|
}
|
|
|
|
interface ContinuousColorScale {
|
|
breakpoints: Breakpoint[]
|
|
trueColor?: never
|
|
format: (v: number | null) => string
|
|
label: string
|
|
unit: string
|
|
}
|
|
|
|
interface BooleanColorScale {
|
|
breakpoints: null
|
|
trueColor: RGB
|
|
format: (v: boolean | null) => string
|
|
label: string
|
|
unit: string
|
|
}
|
|
|
|
// Discrete band-step scale: each step is (upper-bound GHz, RGB). The
|
|
// first step whose `upTo` >= value wins. Used by the duct-cutoff
|
|
// layer so cells snap to amateur-band bins instead of interpolating.
|
|
interface BandStepsColorScale {
|
|
breakpoints: "band_steps"
|
|
steps: Array<{ upTo: number } & RGB>
|
|
format: (v: number | null) => string
|
|
label: string
|
|
unit: string
|
|
}
|
|
|
|
type ColorScale = ContinuousColorScale | BooleanColorScale | BandStepsColorScale
|
|
|
|
type LayerId = keyof typeof COLOR_SCALES
|
|
|
|
// "hrrr" is the default (CONUS); "hrdps" is the Canadian sibling read
|
|
// from `<dir>.hrdps/` chunks. /weather-ca pins to hrdps; /weather (no
|
|
// data-source attr) shows both.
|
|
type SourceName = "hrrr" | "hrdps"
|
|
|
|
interface WeatherPoint {
|
|
lat: number
|
|
lon: number
|
|
valid_time: string | null
|
|
temperature: number | null
|
|
dewpoint_depression: number | null
|
|
surface_rh: number | null
|
|
surface_pressure_mb: number | null
|
|
surface_refractivity: number | null
|
|
refractivity_gradient: number | null
|
|
bl_height: number | null
|
|
pwat: number | null
|
|
temp_850mb: number | null
|
|
dewpoint_850mb: number | null
|
|
temp_700mb: number | null
|
|
dewpoint_700mb: number | null
|
|
lapse_rate: number | null
|
|
mid_lapse_rate: number | null
|
|
inversion_strength: number | null
|
|
inversion_base_m: number | null
|
|
ducting: boolean | null
|
|
duct_base_m: number | null
|
|
duct_strength: number | null
|
|
duct_cutoff_ghz: number | null
|
|
[key: string]: unknown
|
|
}
|
|
|
|
interface DetailRow {
|
|
label?: string
|
|
value?: string
|
|
color?: string
|
|
separator?: boolean
|
|
}
|
|
|
|
// Columnar viewport pack: lats/lons shared, one Float32Array per loaded
|
|
// layer. NaN = null. Layer switches re-paint from the same pack, no
|
|
// server round-trip.
|
|
interface CellPack {
|
|
cellCount: number
|
|
lats: Float32Array
|
|
lons: Float32Array
|
|
layers: Map<string, Float32Array>
|
|
}
|
|
|
|
interface WeatherMapHook extends ViewHook {
|
|
map: L.Map
|
|
weatherOverlays: Map<SourceName, WeatherCanvasOverlay>
|
|
weatherCellsUrl: string
|
|
source: string | null
|
|
// Active sources for this map. Pinned to ["hrdps"] on /weather-ca,
|
|
// otherwise ["hrrr", "hrdps"] so the main map paints both grids.
|
|
sources: SourceName[]
|
|
selectedLayer: LayerId
|
|
bandFilter: number | null
|
|
packs: Map<SourceName, CellPack>
|
|
packKey: string | null
|
|
inflightAborts: Map<SourceName, AbortController>
|
|
moveDebounce: ReturnType<typeof setTimeout> | null
|
|
viewportDebounce: ReturnType<typeof setTimeout> | null
|
|
detailPanel: HTMLElement | null
|
|
escHint: HTMLDivElement
|
|
clickMarker: L.LayerGroup
|
|
legend: L.Control
|
|
legendContent: HTMLElement
|
|
gridLayer: L.LayerGroup
|
|
gridVisible: boolean
|
|
radarLayer: L.TileLayer.WMS | null
|
|
radarRefreshTimer: ReturnType<typeof setInterval> | null
|
|
timelineEl: HTMLElement | null
|
|
timelineData: string[]
|
|
selectedTime: string | null
|
|
// Whether the currently rendered timeline DOM matches `timelineData`.
|
|
// Selection-only updates can patch button state without rebuilding;
|
|
// a data change forces a full rebuild.
|
|
timelineRenderedKey: string
|
|
playbackTimer: ReturnType<typeof setInterval> | null
|
|
_escHandler: (e: KeyboardEvent) => void
|
|
_layerObserver: MutationObserver
|
|
visibilityHandler: (() => void) | null
|
|
|
|
mounted(this: WeatherMapHook): void
|
|
destroyed(this: WeatherMapHook): void
|
|
reconnected(this: WeatherMapHook): void
|
|
positionDetailPanel(this: WeatherMapHook): void
|
|
renderLayer(this: WeatherMapHook): void
|
|
fetchAndPaintCells(this: WeatherMapHook, opts?: { force?: boolean }): Promise<void>
|
|
loadLayers(this: WeatherMapHook, source: SourceName, layers: LayerId[], cacheKey: string): Promise<void>
|
|
paintOverlay(this: WeatherMapHook): void
|
|
refreshLegend(this: WeatherMapHook): void
|
|
buildLegendContent(this: WeatherMapHook): string
|
|
renderTimeline(this: WeatherMapHook): void
|
|
applyTimelineSelection(this: WeatherMapHook): void
|
|
selectTimelineTime(this: WeatherMapHook, time: string): void
|
|
startPlayback(this: WeatherMapHook): void
|
|
stopPlayback(this: WeatherMapHook): void
|
|
pushViewport(this: WeatherMapHook): void
|
|
}
|
|
|
|
// --- Helpers ---
|
|
|
|
function isMobile(): boolean {
|
|
return window.innerWidth < 768
|
|
}
|
|
|
|
function parseBandFilter(raw: string | undefined): number | null {
|
|
if (!raw) return null
|
|
const n = Number.parseInt(raw, 10)
|
|
return Number.isFinite(n) && n > 0 ? n : null
|
|
}
|
|
|
|
const COLOR_SCALES = {
|
|
refractivity_gradient: {
|
|
breakpoints: [
|
|
{ value: -500, r: 0, g: 255, b: 163 },
|
|
{ value: -300, r: 125, g: 255, b: 212 },
|
|
{ value: -200, r: 255, g: 229, b: 102 },
|
|
{ value: -100, r: 255, g: 144, b: 68 },
|
|
{ value: 0, r: 255, g: 79, b: 79 }
|
|
],
|
|
format: (v: number | null): string => v != null ? `${v.toFixed(0)} N/km` : "N/A",
|
|
label: "Refractivity Gradient",
|
|
unit: "N/km"
|
|
},
|
|
bl_height: {
|
|
breakpoints: [
|
|
{ value: 0, r: 0, g: 255, b: 163 },
|
|
{ value: 500, r: 125, g: 255, b: 212 },
|
|
{ value: 1000, r: 255, g: 229, b: 102 },
|
|
{ value: 2000, r: 255, g: 144, b: 68 },
|
|
{ value: 3000, r: 255, g: 79, b: 79 }
|
|
],
|
|
format: (v: number | null): string => v != null ? `${v.toFixed(0)} m` : "N/A",
|
|
label: "Boundary Layer Height",
|
|
unit: "m"
|
|
},
|
|
dewpoint_depression: {
|
|
breakpoints: [
|
|
{ value: 0, r: 0, g: 255, b: 163 },
|
|
{ value: 5, r: 125, g: 255, b: 212 },
|
|
{ value: 10, r: 255, g: 229, b: 102 },
|
|
{ value: 20, r: 255, g: 144, b: 68 },
|
|
{ value: 30, r: 255, g: 79, b: 79 }
|
|
],
|
|
format: (v: number | null): string => v != null ? `${v.toFixed(1)} \u00b0C` : "N/A",
|
|
label: "Dewpoint Depression (T - Td)",
|
|
unit: "\u00b0C"
|
|
},
|
|
pwat: {
|
|
breakpoints: [
|
|
{ value: 0, r: 139, g: 90, b: 43 },
|
|
{ value: 15, r: 194, g: 165, b: 116 },
|
|
{ value: 30, r: 173, g: 216, b: 230 },
|
|
{ value: 45, r: 65, g: 105, b: 225 },
|
|
{ value: 60, r: 0, g: 0, b: 180 }
|
|
],
|
|
format: (v: number | null): string => v != null ? `${v.toFixed(1)} mm` : "N/A",
|
|
label: "Precipitable Water",
|
|
unit: "mm"
|
|
},
|
|
temperature: {
|
|
breakpoints: [
|
|
{ value: -20, r: 0, g: 0, b: 200 },
|
|
{ value: 0, r: 100, g: 149, b: 237 },
|
|
{ value: 15, r: 255, g: 255, b: 150 },
|
|
{ value: 30, r: 255, g: 140, b: 0 },
|
|
{ value: 45, r: 200, g: 0, b: 0 }
|
|
],
|
|
format: (v: number | null): string => v != null ? `${v.toFixed(1)} \u00b0C (${(v * 9/5 + 32).toFixed(0)} \u00b0F)` : "N/A",
|
|
label: "Surface Temperature",
|
|
unit: "\u00b0C"
|
|
},
|
|
ducting: {
|
|
breakpoints: null,
|
|
trueColor: { r: 0, g: 255, b: 163 },
|
|
format: (v: boolean | null): string => v ? "Yes" : "No",
|
|
label: "Ducting Detected",
|
|
unit: ""
|
|
},
|
|
surface_rh: {
|
|
breakpoints: [
|
|
{ value: 0, r: 255, g: 144, b: 68 },
|
|
{ value: 30, r: 255, g: 229, b: 102 },
|
|
{ value: 60, r: 125, g: 255, b: 212 },
|
|
{ value: 80, r: 0, g: 255, b: 163 },
|
|
{ value: 100, r: 0, g: 200, b: 130 }
|
|
],
|
|
format: (v: number | null): string => v != null ? `${v.toFixed(0)}%` : "N/A",
|
|
label: "Relative Humidity",
|
|
unit: "%"
|
|
},
|
|
surface_refractivity: {
|
|
breakpoints: [
|
|
{ value: 250, r: 255, g: 79, b: 79 },
|
|
{ value: 300, r: 255, g: 144, b: 68 },
|
|
{ value: 330, r: 255, g: 229, b: 102 },
|
|
{ value: 360, r: 125, g: 255, b: 212 },
|
|
{ value: 400, r: 0, g: 255, b: 163 }
|
|
],
|
|
format: (v: number | null): string => v != null ? `${v.toFixed(1)} N` : "N/A",
|
|
label: "Surface Refractivity",
|
|
unit: "N"
|
|
},
|
|
temp_850mb: {
|
|
breakpoints: [
|
|
{ value: -20, r: 0, g: 0, b: 200 },
|
|
{ value: -5, r: 100, g: 149, b: 237 },
|
|
{ value: 5, r: 255, g: 255, b: 150 },
|
|
{ value: 15, r: 255, g: 140, b: 0 },
|
|
{ value: 30, r: 200, g: 0, b: 0 }
|
|
],
|
|
format: (v: number | null): string => v != null ? `${v.toFixed(1)} °C` : "N/A",
|
|
label: "Temperature at 850mb",
|
|
unit: "°C"
|
|
},
|
|
dewpoint_850mb: {
|
|
breakpoints: [
|
|
{ value: -20, r: 139, g: 90, b: 43 },
|
|
{ value: -5, r: 194, g: 165, b: 116 },
|
|
{ value: 5, r: 173, g: 216, b: 230 },
|
|
{ value: 15, r: 65, g: 105, b: 225 },
|
|
{ value: 25, r: 0, g: 0, b: 180 }
|
|
],
|
|
format: (v: number | null): string => v != null ? `${v.toFixed(1)} °C` : "N/A",
|
|
label: "Dewpoint at 850mb",
|
|
unit: "°C"
|
|
},
|
|
// 700 mb T is the canonical cap indicator over the southern Plains.
|
|
// Cool greens (no cap) → yellow at ~8°C → orange at the cap edge
|
|
// (10°C) → red for strong caps (≥12°C). Warm caps stand out clearly
|
|
// against the cooler "no cap" base map.
|
|
temp_700mb: {
|
|
breakpoints: [
|
|
{ value: -10, r: 0, g: 100, b: 200 },
|
|
{ value: 0, r: 100, g: 180, b: 230 },
|
|
{ value: 5, r: 200, g: 230, b: 200 },
|
|
{ value: 8, r: 255, g: 229, b: 102 },
|
|
{ value: 10, r: 255, g: 144, b: 68 },
|
|
{ value: 14, r: 200, g: 0, b: 0 }
|
|
],
|
|
format: (v: number | null): string => v != null ? `${v.toFixed(1)} °C` : "N/A",
|
|
label: "Temperature at 700mb",
|
|
unit: "°C"
|
|
},
|
|
dewpoint_700mb: {
|
|
breakpoints: [
|
|
{ value: -30, r: 139, g: 90, b: 43 },
|
|
{ value: -15, r: 194, g: 165, b: 116 },
|
|
{ value: -5, r: 173, g: 216, b: 230 },
|
|
{ value: 5, r: 65, g: 105, b: 225 },
|
|
{ value: 15, r: 0, g: 0, b: 180 }
|
|
],
|
|
format: (v: number | null): string => v != null ? `${v.toFixed(1)} °C` : "N/A",
|
|
label: "Dewpoint at 700mb",
|
|
unit: "°C"
|
|
},
|
|
lapse_rate: {
|
|
breakpoints: [
|
|
{ value: 0, r: 0, g: 255, b: 163 },
|
|
{ value: 4, r: 125, g: 255, b: 212 },
|
|
{ value: 6.5, r: 255, g: 229, b: 102 },
|
|
{ value: 8, r: 255, g: 144, b: 68 },
|
|
{ value: 10, r: 255, g: 79, b: 79 }
|
|
],
|
|
format: (v: number | null): string => v != null ? `${v.toFixed(1)} °C/km` : "N/A",
|
|
label: "Lapse Rate",
|
|
unit: "°C/km"
|
|
},
|
|
// EML signature: a steep lapse rate across the 850→700 layer above
|
|
// a moist surface = elevated mixed layer = textbook cap. Red end of
|
|
// the scale (≥7 °C/km) is the cap-mechanism trigger.
|
|
mid_lapse_rate: {
|
|
breakpoints: [
|
|
{ value: 0, r: 0, g: 100, b: 200 },
|
|
{ value: 4, r: 100, g: 200, b: 200 },
|
|
{ value: 6, r: 255, g: 229, b: 102 },
|
|
{ value: 7, r: 255, g: 144, b: 68 },
|
|
{ value: 9, r: 200, g: 0, b: 0 }
|
|
],
|
|
format: (v: number | null): string => v != null ? `${v.toFixed(1)} °C/km` : "N/A",
|
|
label: "Lapse 850→700",
|
|
unit: "°C/km"
|
|
},
|
|
inversion_strength: {
|
|
breakpoints: [
|
|
{ value: 0, r: 80, g: 80, b: 80 },
|
|
{ value: 1, r: 255, g: 229, b: 102 },
|
|
{ value: 3, r: 125, g: 255, b: 212 },
|
|
{ value: 5, r: 0, g: 255, b: 163 },
|
|
{ value: 10, r: 0, g: 200, b: 130 }
|
|
],
|
|
format: (v: number | null): string => v != null ? (v > 0 ? `+${v.toFixed(1)} °C` : "None") : "N/A",
|
|
label: "Inversion Strength",
|
|
unit: "°C"
|
|
},
|
|
inversion_base_m: {
|
|
breakpoints: [
|
|
{ value: 0, r: 0, g: 255, b: 163 },
|
|
{ value: 500, r: 125, g: 255, b: 212 },
|
|
{ value: 1000, r: 255, g: 229, b: 102 },
|
|
{ value: 2000, r: 255, g: 144, b: 68 },
|
|
{ value: 3000, r: 255, g: 79, b: 79 }
|
|
],
|
|
format: (v: number | null): string => v != null ? `${v.toFixed(0)} m AGL` : "None",
|
|
label: "Inversion Base Height",
|
|
unit: "m"
|
|
},
|
|
duct_base_m: {
|
|
breakpoints: [
|
|
{ value: 0, r: 0, g: 255, b: 163 },
|
|
{ value: 200, r: 125, g: 255, b: 212 },
|
|
{ value: 500, r: 255, g: 229, b: 102 },
|
|
{ value: 1000, r: 255, g: 144, b: 68 },
|
|
{ value: 2000, r: 255, g: 79, b: 79 }
|
|
],
|
|
format: (v: number | null): string => v != null ? `${v.toFixed(0)} m AGL` : "None",
|
|
label: "Duct Base Height",
|
|
unit: "m"
|
|
},
|
|
duct_strength: {
|
|
breakpoints: [
|
|
{ value: 0, r: 80, g: 80, b: 80 },
|
|
{ value: 5, r: 255, g: 229, b: 102 },
|
|
{ value: 10, r: 125, g: 255, b: 212 },
|
|
{ value: 20, r: 0, g: 255, b: 163 },
|
|
{ value: 40, r: 0, g: 200, b: 130 }
|
|
],
|
|
format: (v: number | null): string => v != null ? (v > 0 ? `${v.toFixed(1)} M-units` : "None") : "N/A",
|
|
label: "Duct Strength",
|
|
unit: "M"
|
|
},
|
|
// Lowest band the duct supports. A low cutoff means even the
|
|
// popular lower bands work; a high cutoff means the duct is so thin
|
|
// only millimeter-wave gets trapped. Greener = broader operational
|
|
// utility.
|
|
duct_cutoff_ghz: {
|
|
breakpoints: "band_steps",
|
|
steps: [
|
|
{ upTo: 3.4, r: 0, g: 200, b: 130 },
|
|
{ upTo: 5.7, r: 0, g: 255, b: 163 },
|
|
{ upTo: 10, r: 125, g: 255, b: 212 },
|
|
{ upTo: 24, r: 255, g: 229, b: 102 },
|
|
{ upTo: 47, r: 255, g: 180, b: 80 },
|
|
{ upTo: 76, r: 255, g: 144, b: 68 },
|
|
{ upTo: 122, r: 255, g: 100, b: 80 },
|
|
{ upTo: 241, r: 255, g: 79, b: 79 },
|
|
{ upTo: Infinity, r: 200, g: 50, b: 120 }
|
|
],
|
|
format: (v: number | null): string => v != null ? `≥ ${v.toFixed(1)} GHz` : "No duct",
|
|
label: "Duct Cutoff Band",
|
|
unit: "GHz"
|
|
}
|
|
} as const satisfies Record<string, ColorScale>
|
|
|
|
// Round a lat/lon viewport edge out to the cell-grid resolution so two
|
|
// nearby pans hit the same fetch URL (and the browser cache).
|
|
function quantizeBounds(b: L.LatLngBounds, pad = 0.5): { south: number; north: number; west: number; east: number } {
|
|
const south = Math.floor((b.getSouth() - pad) * 8) / 8
|
|
const north = Math.ceil((b.getNorth() + pad) * 8) / 8
|
|
const west = Math.floor((b.getWest() - pad) * 8) / 8
|
|
const east = Math.ceil((b.getEast() + pad) * 8) / 8
|
|
return { south, north, west, east }
|
|
}
|
|
|
|
function lerp(a: number, b: number, t: number): number {
|
|
return a + (b - a) * t
|
|
}
|
|
|
|
// HRRR is on the native 0.125° grid (0.0625 half-step); HRDPS is
|
|
// downsampled server-side to 0.5° (0.25 half-step). Each source paints
|
|
// its cells at its own native size — mixing them in one pack would
|
|
// require either a per-cell size or an artificial common grid.
|
|
function halfStepForSource(source: SourceName): number {
|
|
return source === "hrdps" ? 0.25 : 0.0625
|
|
}
|
|
|
|
function queryParamForSource(source: SourceName): string {
|
|
return source === "hrdps" ? "&source=hrdps" : ""
|
|
}
|
|
|
|
// Decode the WCEL binary cell pack (see weather_tile_controller.ex).
|
|
function decodeCellPack(buf: ArrayBuffer): { cellCount: number; layerNames: string[]; lats: Float32Array; lons: Float32Array; values: Float32Array[] } {
|
|
const view = new DataView(buf)
|
|
const magic = String.fromCharCode(view.getUint8(0), view.getUint8(1), view.getUint8(2), view.getUint8(3))
|
|
if (magic !== "WCEL") throw new Error(`bad magic: ${magic}`)
|
|
const version = view.getUint8(4)
|
|
if (version !== 1) throw new Error(`unsupported version: ${version}`)
|
|
const cellCount = view.getUint32(5, true)
|
|
const layerCount = view.getUint8(9)
|
|
|
|
let off = 10
|
|
const layerNames: string[] = []
|
|
const decoder = new TextDecoder()
|
|
for (let i = 0; i < layerCount; i++) {
|
|
const len = view.getUint8(off)
|
|
off += 1
|
|
layerNames.push(decoder.decode(new Uint8Array(buf, off, len)))
|
|
off += len
|
|
}
|
|
// Pad to 4-byte boundary to align the f32 arrays.
|
|
off = Math.ceil(off / 4) * 4
|
|
|
|
const lats = new Float32Array(buf, off, cellCount)
|
|
off += cellCount * 4
|
|
const lons = new Float32Array(buf, off, cellCount)
|
|
off += cellCount * 4
|
|
|
|
const values: Float32Array[] = []
|
|
for (let i = 0; i < layerCount; i++) {
|
|
values.push(new Float32Array(buf, off, cellCount))
|
|
off += cellCount * 4
|
|
}
|
|
|
|
return { cellCount, layerNames, lats, lons, values }
|
|
}
|
|
|
|
interface RgbAndAlpha {
|
|
r: number
|
|
g: number
|
|
b: number
|
|
}
|
|
|
|
function interpolateRgb(value: number, bps: Breakpoint[]): RgbAndAlpha {
|
|
if (value <= bps[0].value) return { r: bps[0].r, g: bps[0].g, b: bps[0].b }
|
|
const last = bps[bps.length - 1]
|
|
if (value >= last.value) return { r: last.r, g: last.g, b: last.b }
|
|
for (let i = 0; i < bps.length - 1; i++) {
|
|
const a = bps[i]
|
|
const b = bps[i + 1]
|
|
if (value >= a.value && value <= b.value) {
|
|
const t = (value - a.value) / (b.value - a.value)
|
|
return {
|
|
r: Math.round(lerp(a.r, b.r, t)),
|
|
g: Math.round(lerp(a.g, b.g, t)),
|
|
b: Math.round(lerp(a.b, b.b, t))
|
|
}
|
|
}
|
|
}
|
|
return { r: last.r, g: last.g, b: last.b }
|
|
}
|
|
|
|
// Build a 256-bucket lookup table over a continuous breakpoint scale.
|
|
// Each bucket stores RGB packed as a CSS color string. Lets the canvas
|
|
// paint avoid re-interpolating colors per cell.
|
|
function buildContinuousLut(bps: Breakpoint[]): { lo: number; hi: number; palette: string[] } {
|
|
const lo = bps[0].value
|
|
const hi = bps[bps.length - 1].value
|
|
const palette: string[] = new Array(256)
|
|
for (let i = 0; i < 256; i++) {
|
|
const v = lo + (hi - lo) * (i / 255)
|
|
const c = interpolateRgb(v, bps)
|
|
palette[i] = `rgb(${c.r},${c.g},${c.b})`
|
|
}
|
|
return { lo, hi, palette }
|
|
}
|
|
|
|
interface PaintScale {
|
|
kind: "continuous"
|
|
lo: number
|
|
hi: number
|
|
palette: string[]
|
|
}
|
|
|
|
interface BoolPaintScale {
|
|
kind: "bool"
|
|
trueColor: string
|
|
}
|
|
|
|
// Discrete steps with an optional ≤ threshold filter ("band picker
|
|
// mode"). When `mask` is set, only paint cells whose value is ≤ mask;
|
|
// when null, paint all cells using their step color.
|
|
interface BandStepsPaintScale {
|
|
kind: "band_steps"
|
|
steps: Array<{ upTo: number; color: string }>
|
|
mask: number | null
|
|
}
|
|
|
|
type AnyPaintScale = PaintScale | BoolPaintScale | BandStepsPaintScale
|
|
|
|
function buildPaintScale(layerId: LayerId, bandFilter: number | null): AnyPaintScale | null {
|
|
const scale = COLOR_SCALES[layerId]
|
|
if (!scale) return null
|
|
if (scale.breakpoints === null) {
|
|
const c = scale.trueColor
|
|
return { kind: "bool", trueColor: `rgb(${c.r},${c.g},${c.b})` }
|
|
}
|
|
if (scale.breakpoints === "band_steps") {
|
|
return {
|
|
kind: "band_steps",
|
|
steps: scale.steps.map(s => ({ upTo: s.upTo, color: `rgb(${s.r},${s.g},${s.b})` })),
|
|
mask: bandFilter
|
|
}
|
|
}
|
|
return { kind: "continuous", ...buildContinuousLut(scale.breakpoints) }
|
|
}
|
|
|
|
// --- Canvas overlay layer ---
|
|
//
|
|
// A custom Leaflet layer that paints viewport cells onto a single
|
|
// HTML canvas. The canvas tracks the map's container; pan repositions
|
|
// it, zoom triggers a full repaint. Replaces the old per-tile <rect>
|
|
// SVG overlay — at low zoom (whole-CONUS, ~80k cells) SVG paint cost
|
|
// dominated; canvas is one fillRect per cell with a precomputed LUT.
|
|
const WeatherCanvasOverlayProto: L.LayerOptions & {
|
|
initialize: (this: WeatherCanvasOverlay, pack: CellPack | null, layerId: LayerId, halfStep: number) => void
|
|
onAdd: (this: WeatherCanvasOverlay, map: L.Map) => WeatherCanvasOverlay
|
|
onRemove: (this: WeatherCanvasOverlay, map: L.Map) => WeatherCanvasOverlay
|
|
setPack: (this: WeatherCanvasOverlay, pack: CellPack | null) => void
|
|
setLayer: (this: WeatherCanvasOverlay, layerId: LayerId) => void
|
|
setBandFilter: (this: WeatherCanvasOverlay, band: number | null) => void
|
|
_reset: (this: WeatherCanvasOverlay) => void
|
|
_draw: (this: WeatherCanvasOverlay) => void
|
|
} = {
|
|
initialize(pack: CellPack | null, layerId: LayerId, halfStep: number): void {
|
|
this.pack = pack
|
|
this.layerId = layerId
|
|
this.halfStep = halfStep
|
|
this.bandFilter = null
|
|
},
|
|
onAdd(map: L.Map): WeatherCanvasOverlay {
|
|
this._map = map
|
|
const canvas = L.DomUtil.create("canvas", "weather-canvas-overlay leaflet-zoom-hide") as HTMLCanvasElement
|
|
canvas.style.pointerEvents = "none"
|
|
canvas.style.opacity = "0.55"
|
|
this._canvas = canvas
|
|
map.getPanes().overlayPane.appendChild(canvas)
|
|
map.on("moveend", this._reset, this)
|
|
map.on("zoomend", this._reset, this)
|
|
this._reset()
|
|
return this
|
|
},
|
|
onRemove(map: L.Map): WeatherCanvasOverlay {
|
|
map.off("moveend", this._reset, this)
|
|
map.off("zoomend", this._reset, this)
|
|
L.DomUtil.remove(this._canvas)
|
|
return this
|
|
},
|
|
setPack(pack: CellPack | null): void {
|
|
this.pack = pack
|
|
this._reset()
|
|
},
|
|
setLayer(layerId: LayerId): void {
|
|
this.layerId = layerId
|
|
this._reset()
|
|
},
|
|
setBandFilter(band: number | null): void {
|
|
this.bandFilter = band
|
|
this._reset()
|
|
},
|
|
_reset(): void {
|
|
if (!this._map) return
|
|
const map = this._map
|
|
const size = map.getSize()
|
|
const topLeft = map.containerPointToLayerPoint([0, 0])
|
|
L.DomUtil.setPosition(this._canvas, topLeft)
|
|
this._canvas.width = size.x
|
|
this._canvas.height = size.y
|
|
this._draw()
|
|
},
|
|
_draw(): void {
|
|
const ctx = this._canvas.getContext("2d")
|
|
if (!ctx) return
|
|
ctx.clearRect(0, 0, this._canvas.width, this._canvas.height)
|
|
|
|
if (!this.pack) return
|
|
|
|
const values = this.pack.layers.get(this.layerId)
|
|
if (!values) return
|
|
|
|
const paintScale = buildPaintScale(this.layerId, this.bandFilter)
|
|
if (!paintScale) return
|
|
|
|
const map = this._map
|
|
const halfStep = this.halfStep
|
|
const lats = this.pack.lats
|
|
const lons = this.pack.lons
|
|
const n = this.pack.cellCount
|
|
|
|
// Cache mapping of (lat, lon, halfStep) → container points across
|
|
// a single draw. Two adjacent cells share an edge in container
|
|
// space, so we project each cell-edge once.
|
|
let prevColor = ""
|
|
|
|
for (let i = 0; i < n; i++) {
|
|
const v = values[i]
|
|
if (!Number.isFinite(v)) continue
|
|
|
|
let color: string
|
|
if (paintScale.kind === "bool") {
|
|
if (v < 0.5) continue
|
|
color = paintScale.trueColor
|
|
} else if (paintScale.kind === "band_steps") {
|
|
// Band-picker mode: only paint cells whose cutoff is at or
|
|
// below the selected band — that's the operational "this band
|
|
// works here" mask. Overview mode (mask null) paints every
|
|
// cell by its step color.
|
|
if (paintScale.mask != null && v > paintScale.mask) continue
|
|
const step = paintScale.steps.find(s => v <= s.upTo)
|
|
if (!step) continue
|
|
color = step.color
|
|
} else {
|
|
const t = (v - paintScale.lo) / (paintScale.hi - paintScale.lo)
|
|
const idx = t <= 0 ? 0 : t >= 1 ? 255 : Math.round(t * 255)
|
|
color = paintScale.palette[idx]
|
|
}
|
|
|
|
const lat = lats[i]
|
|
const lon = lons[i]
|
|
const ne = map.latLngToContainerPoint([lat + halfStep, lon + halfStep])
|
|
const sw = map.latLngToContainerPoint([lat - halfStep, lon - halfStep])
|
|
|
|
if (color !== prevColor) {
|
|
ctx.fillStyle = color
|
|
prevColor = color
|
|
}
|
|
ctx.fillRect(sw.x, ne.y, ne.x - sw.x, sw.y - ne.y)
|
|
}
|
|
}
|
|
}
|
|
|
|
interface WeatherCanvasOverlay extends L.Layer {
|
|
pack: CellPack | null
|
|
layerId: LayerId
|
|
halfStep: number
|
|
bandFilter: number | null
|
|
_map: L.Map
|
|
_canvas: HTMLCanvasElement
|
|
setPack(pack: CellPack | null): void
|
|
setLayer(layerId: LayerId): void
|
|
setBandFilter(band: number | null): void
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const WeatherCanvasOverlayCtor: new (pack: CellPack | null, layerId: LayerId, halfStep: number) => WeatherCanvasOverlay = (L.Layer as any).extend(WeatherCanvasOverlayProto)
|
|
|
|
function buildDetailHTML(point: WeatherPoint): string {
|
|
if (!point) return ""
|
|
|
|
const rows: DetailRow[] = [
|
|
{ label: "Temperature", value: COLOR_SCALES.temperature.format(point.temperature), color: "#ff9044" },
|
|
{ label: "Td Depression", value: COLOR_SCALES.dewpoint_depression.format(point.dewpoint_depression), color: "#7dffd4" },
|
|
{ label: "Rel. Humidity", value: COLOR_SCALES.surface_rh.format(point.surface_rh), color: "#00ffa3" },
|
|
{ label: "Sfc Pressure", value: point.surface_pressure_mb != null ? `${point.surface_pressure_mb.toFixed(1)} mb` : "N/A", color: "#aaa" },
|
|
{ label: "Refractivity", value: COLOR_SCALES.surface_refractivity.format(point.surface_refractivity), color: "#00ffa3" },
|
|
{ label: "N-Gradient", value: COLOR_SCALES.refractivity_gradient.format(point.refractivity_gradient), color: "#00ffa3" },
|
|
{ label: "BL Height", value: COLOR_SCALES.bl_height.format(point.bl_height), color: "#ffe566" },
|
|
{ label: "PWAT", value: COLOR_SCALES.pwat.format(point.pwat), color: "#4169e1" },
|
|
{ separator: true },
|
|
{ label: "T @ 850mb", value: COLOR_SCALES.temp_850mb.format(point.temp_850mb), color: "#ff9044" },
|
|
{ label: "Td @ 850mb", value: COLOR_SCALES.dewpoint_850mb.format(point.dewpoint_850mb), color: "#4169e1" },
|
|
{ label: "T @ 700mb", value: COLOR_SCALES.temp_700mb.format(point.temp_700mb), color: "#ff9044" },
|
|
{ label: "Td @ 700mb", value: COLOR_SCALES.dewpoint_700mb.format(point.dewpoint_700mb), color: "#4169e1" },
|
|
{ label: "Lapse Rate", value: COLOR_SCALES.lapse_rate.format(point.lapse_rate), color: "#ffe566" },
|
|
{ label: "Lapse 850→700", value: COLOR_SCALES.mid_lapse_rate.format(point.mid_lapse_rate), color: "#ffe566" },
|
|
{ label: "Inversion", value: COLOR_SCALES.inversion_strength.format(point.inversion_strength), color: point.inversion_strength != null && point.inversion_strength > 0 ? "#00ffa3" : "#666" },
|
|
{ label: "Inv. Base", value: COLOR_SCALES.inversion_base_m.format(point.inversion_base_m), color: point.inversion_base_m != null ? "#7dffd4" : "#666" },
|
|
{ separator: true },
|
|
{ label: "Ducting", value: COLOR_SCALES.ducting.format(point.ducting), color: point.ducting ? "#00ffa3" : "#ff4f4f" },
|
|
{ label: "Duct Base", value: COLOR_SCALES.duct_base_m.format(point.duct_base_m), color: point.duct_base_m != null ? "#00ffa3" : "#666" },
|
|
{ label: "Duct Strength", value: COLOR_SCALES.duct_strength.format(point.duct_strength), color: point.duct_strength != null ? "#00ffa3" : "#666" },
|
|
{ label: "Duct Cutoff", value: COLOR_SCALES.duct_cutoff_ghz.format(point.duct_cutoff_ghz), color: point.duct_cutoff_ghz != null ? "#00ffa3" : "#666" }
|
|
]
|
|
|
|
const mobile = isMobile()
|
|
const closeBtn = `<button class="detail-close-btn" title="Close" style="position:absolute;top:8px;right:10px;background:rgba(255,255,255,0.15);border:none;color:#fff;font-size:18px;line-height:1;width:24px;height:24px;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:1;">×</button>`
|
|
const handle = mobile
|
|
? `<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>`
|
|
: ""
|
|
|
|
const tableRows = rows.map(r => {
|
|
if (r.separator) {
|
|
return `<tr><td colspan="2" style="padding:2px 0;"><hr style="border:none;border-top:1px solid rgba(255,255,255,0.1);"></td></tr>`
|
|
}
|
|
return `<tr>
|
|
<td style="padding:3px 8px 3px 0;font-size:12px;white-space:nowrap;"><span style="color:${r.color};">\u25cf</span> ${r.label}</td>
|
|
<td style="padding:3px 0;font-size:12px;text-align:right;font-weight:600;">${r.value}</td>
|
|
</tr>`
|
|
}).join("")
|
|
|
|
const time = point.valid_time
|
|
? new Date(point.valid_time).toISOString().replace("T", " ").slice(0, 16) + " UTC"
|
|
: ""
|
|
|
|
return `<div style="min-width:${mobile ? 'auto' : '240px'};position:relative;">
|
|
${handle}${closeBtn}
|
|
<div style="background:rgba(255,255,255,0.1);padding:6px 10px;">
|
|
<strong style="font-size:14px;">Weather Details</strong>
|
|
</div>
|
|
<div style="padding:6px 10px;">
|
|
<div style="font-size:11px;opacity:0.6;margin-bottom:6px;">
|
|
${point.lat.toFixed(3)}\u00b0N, ${Math.abs(point.lon).toFixed(3)}\u00b0W — ${time}
|
|
</div>
|
|
<table style="width:100%;border-collapse:collapse;">${tableRows}</table>
|
|
</div>
|
|
</div>`
|
|
}
|
|
|
|
// --- Hook ---
|
|
|
|
export const WeatherMap: WeatherMapHook = {
|
|
mounted(this: WeatherMapHook) {
|
|
// Initial center/zoom comes from `?lat=&lon=&zoom=` URL params via
|
|
// the LiveView, falling back to DFW@z7 when missing. Reload-friendly:
|
|
// the hook below pushes `viewport_changed` on every moveend/zoomend
|
|
// so the URL stays in sync.
|
|
const dataLat = parseFloat(this.el.dataset.initialLat || "")
|
|
const dataLon = parseFloat(this.el.dataset.initialLon || "")
|
|
const dataZoom = parseInt(this.el.dataset.initialZoom || "", 10)
|
|
const initialLat = Number.isFinite(dataLat) ? dataLat : 32.897
|
|
const initialLon = Number.isFinite(dataLon) ? dataLon : -97.038
|
|
const initialZoom = Number.isFinite(dataZoom) ? dataZoom : 7
|
|
|
|
this.map = L.map(this.el, {
|
|
center: [initialLat, initialLon],
|
|
zoom: initialZoom,
|
|
minZoom: 4,
|
|
maxZoom: 10
|
|
})
|
|
|
|
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
|
attribution: "© OpenStreetMap contributors",
|
|
maxZoom: 19
|
|
}).addTo(this.map)
|
|
|
|
this.weatherOverlays = new Map()
|
|
this.weatherCellsUrl = "/weather/cells"
|
|
this.source = this.el.dataset.source || null
|
|
// /weather-ca pins to HRDPS-only via data-source. The default main
|
|
// map fetches both — server-side bounds filtering at the chunk
|
|
// level already prunes payloads outside each grid's domain, so a
|
|
// CONUS-only viewport gets an empty HRDPS pack and vice versa.
|
|
this.sources = this.source === "hrdps" ? ["hrdps"] : ["hrrr", "hrdps"]
|
|
this.selectedLayer = (this.el.dataset.selectedLayer || "temperature") as LayerId
|
|
this.bandFilter = parseBandFilter(this.el.dataset.bandFilter)
|
|
this.packs = new Map()
|
|
this.packKey = null
|
|
this.inflightAborts = new Map()
|
|
this.moveDebounce = null
|
|
this.viewportDebounce = null
|
|
|
|
const storedLayer = localStorage.getItem("weatherMap.selectedLayer")
|
|
if (storedLayer && storedLayer in COLOR_SCALES && storedLayer !== this.selectedLayer) {
|
|
this.selectedLayer = storedLayer as LayerId
|
|
this.pushEvent("select_layer", { layer: storedLayer })
|
|
}
|
|
|
|
this.timelineData = JSON.parse(this.el.dataset.validTimes || "[]")
|
|
this.selectedTime = this.el.dataset.selectedTime || null
|
|
|
|
// Initial paint — fetches cells for the current viewport + time and
|
|
// paints them via L.svgOverlay. Layer switches afterwards re-color
|
|
// these same cells client-side without going to the server.
|
|
this.fetchAndPaintCells()
|
|
|
|
this.handleEvent("update_weather_overlay", ({ selected }: { selected: string | null }) => {
|
|
this.selectedTime = selected
|
|
this.fetchAndPaintCells({ force: true })
|
|
})
|
|
|
|
this.handleEvent("set_band_filter", ({ band }: { band: number | null }) => {
|
|
this.bandFilter = (typeof band === "number" && Number.isFinite(band)) ? band : null
|
|
for (const overlay of this.weatherOverlays.values()) {
|
|
overlay.setBandFilter(this.bandFilter)
|
|
}
|
|
})
|
|
|
|
this.detailPanel = document.getElementById("weather-detail-panel")
|
|
if (this.detailPanel) {
|
|
L.DomEvent.disableClickPropagation(this.detailPanel)
|
|
L.DomEvent.disableScrollPropagation(this.detailPanel)
|
|
|
|
this.detailPanel.addEventListener("click", (e: MouseEvent) => {
|
|
if ((e.target as HTMLElement).closest(".detail-close-btn") && this.detailPanel) {
|
|
this.detailPanel.style.display = "none"
|
|
this.detailPanel.innerHTML = ""
|
|
this.clickMarker?.clearLayers()
|
|
if (this.escHint) this.escHint.style.opacity = "0"
|
|
}
|
|
})
|
|
}
|
|
|
|
// ESC hint
|
|
this.escHint = document.createElement("div")
|
|
this.escHint.className = "hidden md:block"
|
|
this.escHint.style.cssText = "position:absolute;top:12px;right:12px;z-index:1100;background:rgba(0,0,0,0.6);color:#fff;padding:4px 10px;border-radius:6px;font-size:12px;opacity:0;transition:opacity 0.2s;pointer-events:none;"
|
|
this.escHint.textContent = "Press ESC to close"
|
|
this.el.appendChild(this.escHint)
|
|
|
|
this.handleEvent("point_detail", (detail: WeatherPoint) => {
|
|
if (detail && detail.lat && this.detailPanel) {
|
|
this.detailPanel.innerHTML = buildDetailHTML(detail)
|
|
this.detailPanel.style.display = "block"
|
|
this.escHint.style.opacity = "1"
|
|
this.positionDetailPanel()
|
|
}
|
|
})
|
|
|
|
this.clickMarker = L.layerGroup().addTo(this.map)
|
|
|
|
this.map.on("click", (e: L.LeafletMouseEvent) => {
|
|
this.clickMarker.clearLayers()
|
|
L.circleMarker([e.latlng.lat, e.latlng.lng], {
|
|
radius: 6, color: "#fff", weight: 2,
|
|
fillColor: "#888", fillOpacity: 0.8, interactive: false
|
|
}).addTo(this.clickMarker)
|
|
this.pushEvent("point_detail", { lat: e.latlng.lat, lon: e.latlng.lng })
|
|
})
|
|
|
|
this._escHandler = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape" && this.detailPanel) {
|
|
this.detailPanel.style.display = "none"
|
|
this.detailPanel.innerHTML = ""
|
|
this.clickMarker.clearLayers()
|
|
this.escHint.style.opacity = "0"
|
|
}
|
|
}
|
|
document.addEventListener("keydown", this._escHandler)
|
|
|
|
const controls = document.getElementById("weather-controls")
|
|
if (controls) {
|
|
L.DomEvent.disableClickPropagation(controls)
|
|
L.DomEvent.disableScrollPropagation(controls)
|
|
}
|
|
|
|
// Maidenhead grid overlay
|
|
this.gridLayer = L.layerGroup()
|
|
this.gridVisible = false
|
|
|
|
this.handleEvent("toggle_grid", ({ visible }: { visible: boolean }) => {
|
|
this.gridVisible = visible
|
|
localStorage.setItem("weatherMap.gridVisible", String(visible))
|
|
if (visible) {
|
|
this.gridLayer.addTo(this.map)
|
|
updateGridOverlay(this.map, this.gridLayer)
|
|
} else {
|
|
this.gridLayer.remove()
|
|
}
|
|
})
|
|
|
|
if (localStorage.getItem("weatherMap.gridVisible") === "true") {
|
|
this.pushEvent("toggle_grid", {})
|
|
}
|
|
|
|
// 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 }) => {
|
|
localStorage.setItem("weatherMap.radarVisible", String(visible))
|
|
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
|
|
}
|
|
}
|
|
})
|
|
|
|
if (localStorage.getItem("weatherMap.radarVisible") === "true") {
|
|
this.pushEvent("toggle_radar", {})
|
|
}
|
|
|
|
this.map.on("moveend", () => {
|
|
if (this.gridVisible) {
|
|
updateGridOverlay(this.map, this.gridLayer)
|
|
}
|
|
// Debounce so a rapid pan-zoom only fires one fetch.
|
|
if (this.moveDebounce) clearTimeout(this.moveDebounce)
|
|
this.moveDebounce = setTimeout(() => this.fetchAndPaintCells(), 250)
|
|
|
|
// Sync the URL with the current viewport so a reload lands the
|
|
// user at the same place. Separate debounce from the cells fetch
|
|
// — the URL push is cheap (server-side push_patch with replace:
|
|
// true) but we still don't want one per pixel of pan.
|
|
if (this.viewportDebounce) clearTimeout(this.viewportDebounce)
|
|
this.viewportDebounce = setTimeout(() => this.pushViewport(), 400)
|
|
})
|
|
|
|
// When the tab becomes visible again, refresh the overlay for the
|
|
// now-current viewport.
|
|
this.visibilityHandler = () => {
|
|
if (document.visibilityState === "visible") {
|
|
this.map.invalidateSize()
|
|
this.fetchAndPaintCells()
|
|
}
|
|
}
|
|
document.addEventListener("visibilitychange", this.visibilityHandler)
|
|
|
|
this._layerObserver = new MutationObserver(() => {
|
|
let changed = false
|
|
const newLayer = this.el.dataset.selectedLayer as LayerId | undefined
|
|
if (newLayer && newLayer !== this.selectedLayer) {
|
|
this.selectedLayer = newLayer
|
|
localStorage.setItem("weatherMap.selectedLayer", newLayer)
|
|
changed = true
|
|
}
|
|
const newBand = parseBandFilter(this.el.dataset.bandFilter)
|
|
if (newBand !== this.bandFilter) {
|
|
this.bandFilter = newBand
|
|
for (const overlay of this.weatherOverlays.values()) {
|
|
overlay.bandFilter = newBand
|
|
}
|
|
changed = true
|
|
}
|
|
if (changed) {
|
|
// Layer or band switch: re-color the cells we already have. No network.
|
|
this.paintOverlay()
|
|
this.refreshLegend()
|
|
}
|
|
})
|
|
this._layerObserver.observe(this.el, {
|
|
attributes: true,
|
|
attributeFilter: ["data-selected-layer", "data-band-filter"]
|
|
})
|
|
|
|
// Mount the legend control once and update its content in place.
|
|
// Removing + re-adding the control on every renderLayer was a
|
|
// visible source of jank during layer switches and time scrubs.
|
|
this.legend = L.control({ position: "topright" })
|
|
this.legend.onAdd = () => {
|
|
this.legendContent = L.DomUtil.create("div")
|
|
this.legendContent.innerHTML = this.buildLegendContent()
|
|
return this.legendContent
|
|
}
|
|
this.legend.addTo(this.map)
|
|
|
|
// --- Forecast timeline ---
|
|
// timelineData and selectedTime are seeded earlier (before the
|
|
// initial renderLayer); the timeline DOM hookup happens here.
|
|
this.timelineEl = document.getElementById("weather-forecast-timeline")
|
|
this.playbackTimer = null
|
|
this.timelineRenderedKey = ""
|
|
|
|
if (this.timelineData.length > 1) {
|
|
this.renderTimeline()
|
|
}
|
|
|
|
this.handleEvent("update_timeline", ({ times, selected }: { times: string[]; selected: string | null }) => {
|
|
// Force a full rebuild when the time set changes — invalidate
|
|
// the rendered key so renderTimeline doesn't take the
|
|
// selection-only patch path against stale buttons.
|
|
const dataChanged = times.join("|") !== this.timelineRenderedKey
|
|
const timeChanged = selected !== this.selectedTime
|
|
this.timelineData = times
|
|
this.selectedTime = selected
|
|
if (dataChanged) this.timelineRenderedKey = ""
|
|
if (timeChanged) this.fetchAndPaintCells({ force: true })
|
|
this.renderTimeline()
|
|
})
|
|
|
|
// Prevent timeline from eating map clicks
|
|
if (this.timelineEl) {
|
|
L.DomEvent.disableClickPropagation(this.timelineEl)
|
|
L.DomEvent.disableScrollPropagation(this.timelineEl)
|
|
}
|
|
},
|
|
|
|
destroyed(this: WeatherMapHook) {
|
|
if (this._layerObserver) this._layerObserver.disconnect()
|
|
if (this._escHandler) document.removeEventListener("keydown", this._escHandler)
|
|
if (this.radarRefreshTimer) clearInterval(this.radarRefreshTimer)
|
|
if (this.playbackTimer !== null) clearInterval(this.playbackTimer)
|
|
if (this.moveDebounce) clearTimeout(this.moveDebounce)
|
|
if (this.viewportDebounce) clearTimeout(this.viewportDebounce)
|
|
for (const ab of this.inflightAborts.values()) ab.abort()
|
|
this.inflightAborts.clear()
|
|
if (this.visibilityHandler) {
|
|
document.removeEventListener("visibilitychange", this.visibilityHandler)
|
|
this.visibilityHandler = null
|
|
}
|
|
},
|
|
|
|
// Push the current viewport (lat, lon, zoom) to the LiveView so the
|
|
// URL can be patched with `?lat=&lon=&zoom=`. Called from a debounced
|
|
// moveend handler so a rapid pan doesn't spam the channel. The
|
|
// server-side push_patch uses replace: true, so the browser history
|
|
// doesn't grow an entry per pan tick.
|
|
pushViewport(this: WeatherMapHook) {
|
|
if (!this.map) return
|
|
const center = this.map.getCenter()
|
|
const zoom = this.map.getZoom()
|
|
if (!Number.isFinite(center.lat) || !Number.isFinite(center.lng)) return
|
|
this.pushEvent("viewport_changed", {
|
|
lat: center.lat,
|
|
lon: center.lng,
|
|
zoom: zoom
|
|
})
|
|
},
|
|
|
|
reconnected(this: WeatherMapHook) {
|
|
if (this.map) {
|
|
this.map.invalidateSize()
|
|
this.fetchAndPaintCells()
|
|
}
|
|
},
|
|
|
|
positionDetailPanel(this: WeatherMapHook) {
|
|
if (!this.detailPanel || isMobile()) return
|
|
const controls = document.getElementById("weather-controls")
|
|
if (controls) {
|
|
const rect = controls.getBoundingClientRect()
|
|
this.detailPanel.style.left = rect.left + "px"
|
|
this.detailPanel.style.top = (rect.bottom + 8) + "px"
|
|
}
|
|
},
|
|
|
|
renderLayer(this: WeatherMapHook) {
|
|
// Kept for back-compat with code paths that still call it.
|
|
this.paintOverlay()
|
|
this.refreshLegend()
|
|
},
|
|
|
|
async fetchAndPaintCells(this: WeatherMapHook, opts: { force?: boolean } = {}) {
|
|
if (!this.selectedTime) {
|
|
this.packs.clear()
|
|
this.packKey = null
|
|
this.paintOverlay()
|
|
return
|
|
}
|
|
|
|
const { south, north, west, east } = quantizeBounds(this.map.getBounds())
|
|
const cacheKey = `${this.selectedTime}|${south},${north},${west},${east}`
|
|
|
|
// New viewport or time → cancel inflights and drop existing packs.
|
|
if (cacheKey !== this.packKey || opts.force) {
|
|
for (const ab of this.inflightAborts.values()) ab.abort()
|
|
this.inflightAborts.clear()
|
|
this.packs.clear()
|
|
this.packKey = cacheKey
|
|
}
|
|
|
|
// Step 1: ensure the currently selected layer is loaded for every
|
|
// active source, then paint. Sources fetch in parallel.
|
|
await Promise.all(this.sources.map((src) => {
|
|
const existing = this.packs.get(src)
|
|
if (existing && existing.layers.has(this.selectedLayer)) return Promise.resolve()
|
|
return this.loadLayers(src, [this.selectedLayer], cacheKey)
|
|
}))
|
|
this.paintOverlay()
|
|
|
|
// Step 2: lazy-load every other layer per source so toggling
|
|
// between layers later is instant. One request per source batches
|
|
// its remaining layers so the server's GridCache lookup runs once.
|
|
if (this.packKey !== cacheKey) return
|
|
for (const src of this.sources) {
|
|
const p = this.packs.get(src)
|
|
if (!p) continue
|
|
const remaining = (Object.keys(COLOR_SCALES) as LayerId[]).filter(
|
|
(l) => !p.layers.has(l)
|
|
)
|
|
if (remaining.length > 0) {
|
|
void this.loadLayers(src, remaining, cacheKey)
|
|
}
|
|
}
|
|
},
|
|
|
|
async loadLayers(this: WeatherMapHook, source: SourceName, layers: LayerId[], cacheKey: string) {
|
|
if (!this.selectedTime || layers.length === 0) return
|
|
if (cacheKey !== this.packKey) return
|
|
|
|
const { south, north, west, east } = quantizeBounds(this.map.getBounds())
|
|
const url =
|
|
`${this.weatherCellsUrl}?format=bin&time=${encodeURIComponent(this.selectedTime)}` +
|
|
`&south=${south}&north=${north}&west=${west}&east=${east}` +
|
|
`&layers=${layers.join(",")}` +
|
|
queryParamForSource(source)
|
|
|
|
const abort = new AbortController()
|
|
this.inflightAborts.set(source, abort)
|
|
|
|
try {
|
|
const resp = await fetch(url, { signal: abort.signal })
|
|
if (!resp.ok) {
|
|
// Never swallow a failed tile fetch. A 413 (viewport too large —
|
|
// reachable by zooming to minZoom on a wide window) or a 400
|
|
// leaves the map blank with no other signal, which is
|
|
// indistinguishable from "no data for this hour" when someone
|
|
// reports a missing overlay.
|
|
console.warn(
|
|
`weather cells fetch failed (${source}): HTTP ${resp.status}`,
|
|
{ status: resp.status, layers, url }
|
|
)
|
|
return
|
|
}
|
|
const buf = await resp.arrayBuffer()
|
|
if (cacheKey !== this.packKey) return
|
|
|
|
const decoded = decodeCellPack(buf)
|
|
// Skip empty responses — leaving the source out of `packs`
|
|
// suppresses both its overlay and any further lazy fetches for
|
|
// this viewport (paintOverlay's empty-pack branch removes a
|
|
// stale overlay if one exists).
|
|
if (decoded.cellCount === 0) return
|
|
|
|
const existing = this.packs.get(source)
|
|
if (!existing) {
|
|
const layerMap = new Map<string, Float32Array>()
|
|
decoded.layerNames.forEach((name, i) => layerMap.set(name, decoded.values[i]))
|
|
this.packs.set(source, {
|
|
cellCount: decoded.cellCount,
|
|
lats: decoded.lats,
|
|
lons: decoded.lons,
|
|
layers: layerMap
|
|
})
|
|
} else if (decoded.cellCount === existing.cellCount) {
|
|
decoded.layerNames.forEach((name, i) => existing.layers.set(name, decoded.values[i]))
|
|
} else {
|
|
// Cell count drifted (rare — viewport quantization mismatch).
|
|
// Replace the pack rather than mix incompatible arrays.
|
|
const layerMap = new Map<string, Float32Array>()
|
|
decoded.layerNames.forEach((name, i) => layerMap.set(name, decoded.values[i]))
|
|
this.packs.set(source, {
|
|
cellCount: decoded.cellCount,
|
|
lats: decoded.lats,
|
|
lons: decoded.lons,
|
|
layers: layerMap
|
|
})
|
|
}
|
|
} catch (e) {
|
|
const err = e as Error
|
|
if (err.name === "AbortError") return
|
|
console.warn(`weather cells fetch failed (${source})`, err)
|
|
}
|
|
},
|
|
|
|
paintOverlay(this: WeatherMapHook) {
|
|
// Order matters: HRDPS goes first so HRRR's higher-resolution
|
|
// canvas paints on top along the US/Canada border where both have
|
|
// coverage. Leaflet stacks overlayPane children in DOM (add) order.
|
|
const renderOrder: SourceName[] = ["hrdps", "hrrr"]
|
|
|
|
for (const src of renderOrder) {
|
|
const overlay = this.weatherOverlays.get(src)
|
|
const active = this.sources.includes(src)
|
|
const pack = active ? this.packs.get(src) : undefined
|
|
|
|
if (!pack || pack.cellCount === 0) {
|
|
if (overlay) {
|
|
this.map.removeLayer(overlay)
|
|
this.weatherOverlays.delete(src)
|
|
}
|
|
continue
|
|
}
|
|
|
|
if (!overlay) {
|
|
const newOverlay = new WeatherCanvasOverlayCtor(pack, this.selectedLayer, halfStepForSource(src))
|
|
newOverlay.bandFilter = this.bandFilter
|
|
newOverlay.addTo(this.map)
|
|
this.weatherOverlays.set(src, newOverlay)
|
|
} else {
|
|
overlay.pack = pack
|
|
overlay.bandFilter = this.bandFilter
|
|
overlay.setLayer(this.selectedLayer)
|
|
}
|
|
}
|
|
},
|
|
|
|
refreshLegend(this: WeatherMapHook) {
|
|
if (this.legendContent) {
|
|
this.legendContent.innerHTML = this.buildLegendContent()
|
|
}
|
|
},
|
|
|
|
buildLegendContent(this: WeatherMapHook): string {
|
|
const layerId = this.selectedLayer
|
|
const scale = COLOR_SCALES[layerId]
|
|
if (!scale) return ""
|
|
|
|
const mobile = isMobile()
|
|
|
|
if (layerId === "ducting") {
|
|
const c = (scale as BooleanColorScale).trueColor
|
|
const wrapStyle = `background:#fff;color:#333;padding:${mobile ? '4px 6px' : '10px 14px'};border-radius:${mobile ? '6px' : '8px'};box-shadow:0 2px 8px rgba(0,0,0,0.3);font-size:${mobile ? '10px' : '13px'};line-height:2;`
|
|
return `<div style="${wrapStyle}"><strong style="color:#333;">${scale.label}</strong><br>` +
|
|
`<span style="background:rgb(${c.r},${c.g},${c.b});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>Ducting<br>` +
|
|
`<span style="background:transparent;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>None</div>`
|
|
}
|
|
|
|
if (scale.breakpoints === "band_steps") {
|
|
const steps = (scale as BandStepsColorScale).steps
|
|
const heading = this.bandFilter != null
|
|
? `${scale.label} · ≤ ${this.bandFilter} GHz`
|
|
: scale.label
|
|
const rowFor = (s: typeof steps[number], i: number) => {
|
|
const prev = i > 0 ? steps[i - 1].upTo : 0
|
|
const label = !Number.isFinite(s.upTo)
|
|
? `> ${prev} GHz`
|
|
: prev === 0
|
|
? `≤ ${s.upTo} GHz`
|
|
: `≤ ${s.upTo} GHz`
|
|
const dim = this.bandFilter != null && prev > this.bandFilter ? "opacity:0.3;" : ""
|
|
return `<span style="${dim}"><span style="background:rgb(${s.r},${s.g},${s.b});width:${mobile ? '10px' : '14px'};height:${mobile ? '10px' : '14px'};display:inline-block;border-radius:50%;vertical-align:middle;margin-right:${mobile ? '3px' : '6px'};border:1px solid rgba(0,0,0,0.15);"></span><span style="color:#333;">${label}</span></span>`
|
|
}
|
|
const wrapStyle = mobile
|
|
? "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;"
|
|
: "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;"
|
|
const header = mobile ? "" : `<strong style="color:#333;">${heading}</strong><br>`
|
|
return `<div style="${wrapStyle}">${header}${steps.map(rowFor).join("<br>")}</div>`
|
|
}
|
|
|
|
const bp = (scale as ContinuousColorScale).breakpoints
|
|
|
|
if (mobile) {
|
|
const wrapStyle = "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;"
|
|
return `<div style="${wrapStyle}">` + bp.map(b =>
|
|
`<span style="background:rgb(${b.r},${b.g},${b.b});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>${b.value}`
|
|
).join("<br>") + "</div>"
|
|
}
|
|
|
|
const wrapStyle = "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;"
|
|
return `<div style="${wrapStyle}"><strong style="color:#333;">${scale.label}</strong><br>` +
|
|
bp.map(b =>
|
|
`<span style="background:rgb(${b.r},${b.g},${b.b});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;">${b.value} ${scale.unit}</span>`
|
|
).join("<br>") + "</div>"
|
|
},
|
|
|
|
renderTimeline(this: WeatherMapHook) {
|
|
if (!this.timelineEl || this.timelineData.length === 0) {
|
|
if (this.timelineEl) this.timelineEl.style.display = "none"
|
|
this.timelineRenderedKey = ""
|
|
return
|
|
}
|
|
|
|
// Selection-only change: skip the full innerHTML rebuild, just
|
|
// restyle the active vs inactive buttons. The rendered button set
|
|
// is keyed by the timeline data — if the data is unchanged, the
|
|
// existing buttons + listeners stay attached.
|
|
const dataKey = this.timelineData.join("|")
|
|
if (this.timelineRenderedKey === dataKey && this.timelineData.length > 1) {
|
|
this.applyTimelineSelection()
|
|
return
|
|
}
|
|
this.timelineRenderedKey = dataKey
|
|
|
|
if (this.timelineData.length === 1) {
|
|
const dt = new Date(this.timelineData[0])
|
|
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()
|
|
type TItem = { time: string; dt: Date; isSelected: boolean; isPast: boolean; offsetH: number; label: string }
|
|
const items: TItem[] = this.timelineData.map((time) => {
|
|
const dt = new Date(time)
|
|
const isSelected = time === this.selectedTime
|
|
const isPast = dt.getTime() < now.getTime() - 1800000
|
|
return { time, dt, isSelected, isPast, offsetH: 0, label: "" }
|
|
})
|
|
|
|
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)
|
|
|
|
// Label offsets relative to the "Now" slot, not wall-clock. If we
|
|
// round from wall-clock, the hour after "Now" rounds to "0h" when
|
|
// the clock is past the half-hour, which reads as a second "now".
|
|
const anchorMs = items[closestIdx].dt.getTime()
|
|
items.forEach((t, i) => {
|
|
const diff = Math.round((t.dt.getTime() - anchorMs) / 3600000)
|
|
t.offsetH = diff
|
|
if (i === closestIdx) {
|
|
t.label = "Now"
|
|
} else {
|
|
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 ? "#7dffd4" : (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 #7dffd4" : "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" : "Weather Forecast"
|
|
const isPlaying = this.playbackTimer !== null
|
|
const playBg = isPlaying ? "#7dffd4" : "rgba(255,255,255,0.15)"
|
|
const playFg = isPlaying ? "#000" : "#fff"
|
|
const ctrlFont = mobile ? "10px" : "11px"
|
|
const ctrlPad = mobile ? "1px 5px" : "2px 7px"
|
|
|
|
this.timelineEl.style.display = "block"
|
|
this.timelineEl.innerHTML = `
|
|
<div style="background:rgba(0,0,0,0.85);border-radius:12px;padding:${mobile ? "4px 6px" : "6px 10px"};display:flex;gap:${mobile ? "2px" : "3px"};align-items:center;box-shadow:0 2px 12px rgba(0,0,0,0.4);overflow-x:auto;max-width:calc(100vw - 1rem);">
|
|
<div style="display:flex;flex-direction:column;align-items:center;gap:3px;margin-right:6px;flex-shrink:0;">
|
|
<span style="font-size:${mobile ? "9px" : "10px"};color:rgba(255,255,255,0.5);white-space:nowrap;">${labelText}</span>
|
|
<div style="display:flex;gap:3px;">
|
|
<button data-timeline-play style="
|
|
padding:${ctrlPad};font-size:${ctrlFont};line-height:1;
|
|
background:${playBg};color:${playFg};
|
|
border:1px solid rgba(255,255,255,0.2);border-radius:4px;
|
|
cursor:pointer;" title="Play forecast animation">▶</button>
|
|
<button data-timeline-stop style="
|
|
padding:${ctrlPad};font-size:${ctrlFont};line-height:1;
|
|
background:rgba(255,255,255,0.15);color:#fff;
|
|
border:1px solid rgba(255,255,255,0.2);border-radius:4px;
|
|
cursor:pointer;" title="Stop and return to now">■</button>
|
|
</div>
|
|
</div>
|
|
${buttons}
|
|
</div>`
|
|
|
|
this.timelineEl.querySelectorAll<HTMLButtonElement>("button[data-time]").forEach(btn => {
|
|
btn.addEventListener("click", () => {
|
|
const time = btn.dataset.time!
|
|
// A manual click interrupts playback — the user has taken over.
|
|
this.stopPlayback()
|
|
this.selectTimelineTime(time)
|
|
})
|
|
})
|
|
|
|
const playBtn = this.timelineEl.querySelector<HTMLButtonElement>("button[data-timeline-play]")
|
|
if (playBtn) playBtn.addEventListener("click", () => this.startPlayback())
|
|
|
|
const stopBtn = this.timelineEl.querySelector<HTMLButtonElement>("button[data-timeline-stop]")
|
|
if (stopBtn) stopBtn.addEventListener("click", () => this.stopPlayback())
|
|
},
|
|
|
|
// Update the active/inactive styling on existing timeline buttons
|
|
// without re-running `innerHTML`. Called when only `selectedTime`
|
|
// changes; `renderTimeline` falls through to a full rebuild whenever
|
|
// `timelineData` itself changes.
|
|
applyTimelineSelection(this: WeatherMapHook) {
|
|
if (!this.timelineEl) return
|
|
const now = Date.now()
|
|
|
|
this.timelineEl.querySelectorAll<HTMLButtonElement>("button[data-time]").forEach(btn => {
|
|
const time = btn.dataset.time!
|
|
const dt = new Date(time)
|
|
const isSelected = time === this.selectedTime
|
|
const isPast = dt.getTime() < now - 1800000
|
|
|
|
btn.style.background = isSelected ? "#7dffd4" : (isPast ? "rgba(255,255,255,0.08)" : "rgba(255,255,255,0.15)")
|
|
btn.style.color = isSelected ? "#000" : (isPast ? "rgba(255,255,255,0.4)" : "#fff")
|
|
btn.style.border = isSelected ? "2px solid #7dffd4" : "1px solid rgba(255,255,255,0.2)"
|
|
btn.style.fontWeight = isSelected ? "700" : "400"
|
|
})
|
|
|
|
const playBtn = this.timelineEl.querySelector<HTMLButtonElement>("button[data-timeline-play]")
|
|
if (playBtn) {
|
|
const isPlaying = this.playbackTimer !== null
|
|
playBtn.style.background = isPlaying ? "#7dffd4" : "rgba(255,255,255,0.15)"
|
|
playBtn.style.color = isPlaying ? "#000" : "#fff"
|
|
}
|
|
},
|
|
|
|
selectTimelineTime(this: WeatherMapHook, time: string) {
|
|
this.selectedTime = time
|
|
this.renderLayer()
|
|
this.renderTimeline()
|
|
this.pushEvent("select_time", { time })
|
|
},
|
|
|
|
startPlayback(this: WeatherMapHook) {
|
|
// Restarting re-seeds at "now" so repeated clicks don't resume
|
|
// mid-loop — users expect "play from the start".
|
|
if (this.playbackTimer !== null) {
|
|
clearInterval(this.playbackTimer)
|
|
this.playbackTimer = null
|
|
}
|
|
|
|
if (this.timelineData.length < 2) return
|
|
|
|
const now = Date.now()
|
|
const nowIdx = this.timelineData.reduce((best, time, i) => {
|
|
const d = Math.abs(new Date(time).getTime() - now)
|
|
const bestD = Math.abs(new Date(this.timelineData[best]).getTime() - now)
|
|
return d < bestD ? i : best
|
|
}, 0)
|
|
|
|
// Iterate "now" forward through the end of the forecast then loop.
|
|
const playable = this.timelineData.slice(nowIdx)
|
|
if (playable.length < 2) return
|
|
|
|
let cursor = 0
|
|
const step = () => {
|
|
this.selectTimelineTime(playable[cursor])
|
|
cursor = (cursor + 1) % playable.length
|
|
}
|
|
|
|
// Assign the handle before the first step so renderTimeline sees
|
|
// playbackTimer !== null and paints Play as active.
|
|
this.playbackTimer = setInterval(step, 1000)
|
|
step()
|
|
},
|
|
|
|
stopPlayback(this: WeatherMapHook) {
|
|
if (this.playbackTimer !== null) {
|
|
clearInterval(this.playbackTimer)
|
|
this.playbackTimer = null
|
|
}
|
|
|
|
if (this.timelineData.length === 0) {
|
|
this.renderTimeline()
|
|
return
|
|
}
|
|
|
|
const now = Date.now()
|
|
const nowIdx = this.timelineData.reduce((best, time, i) => {
|
|
const d = Math.abs(new Date(time).getTime() - now)
|
|
const bestD = Math.abs(new Date(this.timelineData[best]).getTime() - now)
|
|
return d < bestD ? i : best
|
|
}, 0)
|
|
this.selectTimelineTime(this.timelineData[nowIdx])
|
|
}
|
|
} as unknown as WeatherMapHook
|