Show on /weather where the detected duct geometry traps each microwave band. Overview mode bins cells by their lowest trapped frequency (Bean & Dutton cutoff); band-picker mode masks cells whose duct doesn't reach the selected band. Cutoff is pre-computed per cell in both writers (Elixir derive + Rust derive_row reads best_duct_freq_ghz from CellValues), persisted to ScalarFile, and shipped to the JS hook via the existing binary cell pack. Also cleans up six pre-existing length/1 credo warnings in unrelated test files.
1485 lines
58 KiB
TypeScript
1485 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) 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
|