// 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
}

type ColorScale = ContinuousColorScale | BooleanColorScale

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
  [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
  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
}

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"
  }
} 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
}

function buildPaintScale(layerId: LayerId): PaintScale | BoolPaintScale | 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})` }
  }
  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
  _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
  },
  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()
  },
  _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)
    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 {
        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
  _map: L.Map
  _canvas: HTMLCanvasElement
  setPack(pack: CellPack | null): void
  setLayer(layerId: LayerId): 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" }
  ]

  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;">&times;</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 &mdash; ${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: "&copy; 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.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.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 &copy; 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(() => {
      const newLayer = this.el.dataset.selectedLayer as LayerId | undefined
      if (newLayer && newLayer !== this.selectedLayer) {
        this.selectedLayer = newLayer
        localStorage.setItem("weatherMap.selectedLayer", newLayer)
        // Layer 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"] })

    // 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.addTo(this.map)
        this.weatherOverlays.set(src, newOverlay)
      } else {
        overlay.pack = pack
        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>`
    }

    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
