/* global React, ReactDOM, WeatherData */
const { useState, useEffect, useRef, useMemo, useCallback } = React;

// Common UI atoms
// Widget — tile shell. View mode: optional head (icon + title + sub) and
// nothing that changes the tile — settings, size, order and hiding are edit
// mode only. Edit mode: an overlay bar (grip,
// title, settings) that never changes the tile's size, so switching modes
// doesn't move anything; drag the bar (or the tile, with a mouse) to reorder,
// the left / right / bottom edge or the corner to resize — edges only on the
// full desktop grid (resizable); narrower layouts use the settings popover.
// The grip is also a keyboard handle (arrows move, Shift+arrows resize).
// Hiding lives in the popover. Height is a MINIMUM — rows grow to the tallest
// tile, so content is never clipped.
const SIZE_FRAC = { 3: '¼', 4: '⅓', 6: '½', 8: '⅔', 9: '¾', 12: 'celá šířka' };
const rowsWord = (n) => (n === 1 ? 'řádek' : n < 5 ? 'řádky' : 'řádků');
window.Widget = function Widget({ id, title, sub, icon, span = 6, place, shownSpan, rows = 1, onSetRows, minSpan = 3, defaultMinH, editing, placeholder,
  resizable = true, sizeCtl = true, rowsStack, onResizeStart, onResizeEnd, isNew,
  onStartDrag, onHide, onMove, canMoveBack, canMoveFwd, onSetSpan, userHeight, onSetHeight,
  widgetConfig, onWidgetConfigChange, children, headerExtras, station, hourlyRange, onHourlyRangeChange,
  chartCfg, onChartCfgChange }) {
  const [popOpen, setPopOpen] = React.useState(false);
  const [resizeInfo, setResizeInfo] = React.useState(null);   // { span, rows, h } while resizing
  const gearRef = React.useRef(null);
  const widgetRef = React.useRef(null);

  // Resize with Pointer Events (mouse, pen, finger), desktop 12-column grid
  // only (tablets/phones remap widths).
  // • Width snaps to grid columns, never below the widget's minSpan. The right
  //   and the left edge move the divider to the neighbour on that side.
  // • The bottom edge first tries rows: spanning 2–4 rows, so the smaller
  //   tiles after it stack beside it (bento) — but only where that really
  //   happens. Otherwise it sets the tile's minimum height in px, following
  //   the pointer, magnetic to the bottom edges of the other tiles (rows end
  //   evenly); a minimum the content is taller than anyway is dropped.
  // Column guides, a guide line and a size badge show what you'll get; the
  // whole drag is one undo step.
  const startResize = (axis) => (e) => {
    if (!editing || !resizable) return;
    e.preventDefault();
    e.stopPropagation();
    const handle = e.currentTarget, pid = e.pointerId;
    try { handle.setPointerCapture?.(pid); } catch {}
    const el = widgetRef.current;
    const grid = el?.closest('.dash-grid');
    if (!grid) return;
    const gcs = getComputedStyle(grid);
    const gap = parseFloat(gcs.columnGap) || 0;
    const colW = (grid.clientWidth - gap * 11) / 12;
    const canWidth = axis !== 'y';
    const canHeight = axis === 'y' || axis === 'xy';
    const side = axis === 'w' ? 'left' : 'right';
    const r0 = el.getBoundingClientRect();
    const startX = e.clientX, startY = e.clientY;
    let lastSpan = shownSpan || span, lastRows = rows, lastH = userHeight || null, movedY = false;
    // rows size to their content, so one row ≈ the tile's current height per row
    const rowGap = parseFloat(gcs.rowGap) || 0;
    const rowH = Math.max(60, (r0.height + rowGap) / Math.max(1, rows) - rowGap);
    const floorH = Math.max(80, defaultMinH || 0);
    const bottoms = [...grid.querySelectorAll(':scope > .widget')]
      .filter(t => t !== el).map(t => t.offsetTop + t.offsetHeight);
    const guide = canHeight ? document.createElement('div') : null;
    if (guide) { guide.className = 'dash-guide-h'; grid.appendChild(guide); }
    onResizeStart?.();
    grid.style.setProperty('--col-w', colW + 'px');
    grid.style.setProperty('--col-gap', gap + 'px');
    grid.classList.add('show-cols');
    document.body.classList.add('widget-resizing');
    document.body.style.cursor = canWidth && canHeight ? 'nwse-resize' : canWidth ? 'ew-resize' : 'ns-resize';
    setResizeInfo({ span: lastSpan, rows: lastRows, h: lastH, axis });
    const onMoveP = (ev) => {
      if (canWidth) {
        const dx = ev.clientX - startX;
        const w = r0.width + (side === 'left' ? -dx : dx);
        const s = Math.max(minSpan, Math.min(12, Math.round((w + gap) / (colW + gap))));
        if (s !== lastSpan) { lastSpan = s; onSetSpan?.(s, side); }
      }
      // a few px of hand shake on a width drag must not pin the height
      if (canHeight && Math.abs(ev.clientY - startY) >= 8) movedY = true;
      if (canHeight && movedY) {
        const want = r0.height + (ev.clientY - startY);
        let n = Math.max(1, Math.min(4, Math.round((want + rowGap) / (rowH + rowGap))));
        while (n > 1 && rowsStack && !rowsStack(n)) n--;
        if (n !== lastRows) { lastRows = n; onSetRows?.(n); }
        let h = null, snapAt = null;
        if (n === 1) {
          const top = el.offsetTop;
          const near = bottoms.filter(b => b - top >= floorH && Math.abs(top + want - b) < 16)
            .sort((a, b) => Math.abs(top + want - a) - Math.abs(top + want - b))[0];
          if (near != null) { h = Math.round(near - top); snapAt = near; }
          else h = Math.max(floorH, Math.round(want / 10) * 10);
          // without a minimum the tile already is r0 tall (content or row) —
          // a minimum up to that would change nothing
          if (!userHeight && h <= r0.height + 3) h = null;
        }
        if (h !== lastH) { lastH = h; onSetHeight?.(h); }
        guide.style.top = snapAt != null ? snapAt + 'px' : '';
        guide.classList.toggle('on', snapAt != null);
      }
      setResizeInfo({ span: lastSpan, rows: lastRows, h: lastH, axis });
    };
    const onUp = () => {
      window.removeEventListener('pointermove', onMoveP);
      window.removeEventListener('pointerup', onUp);
      window.removeEventListener('pointercancel', onUp);
      try { handle.releasePointerCapture?.(pid); } catch {}
      grid.classList.remove('show-cols');
      guide?.remove();
      document.body.classList.remove('widget-resizing');
      document.body.style.cursor = '';
      setResizeInfo(null);
      // once the last step is on screen: a minimum the tile is taller than
      // anyway (its content or the row is) does nothing — drop it
      const hSet = movedY ? lastH : null;
      setTimeout(() => {
        if (hSet != null && widgetRef.current && widgetRef.current.getBoundingClientRect().height > hSet + 6) onSetHeight?.(null);
        onResizeEnd?.();
      }, 80);
    };
    window.addEventListener('pointermove', onMoveP);
    window.addEventListener('pointerup', onUp);
    window.addEventListener('pointercancel', onUp);
  };
  // Double-click a handle → one row, automatic height.
  const resetHeight = (e) => {
    e.preventDefault(); e.stopPropagation();
    onResizeStart?.(); onSetRows?.(1); onSetHeight?.(null); onResizeEnd?.();
  };
  // Keyboard on the grip: ← → (↑ ↓) move one place, Shift+← → width by one
  // column, Shift+↑ ↓ rows. Focus stays on the grip, so it can be repeated.
  const onGripKey = (e) => {
    const k = e.key;
    if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(k)) return;
    e.preventDefault();
    const w = shownSpan || span;
    if (e.shiftKey && sizeCtl) {
      if (k === 'ArrowLeft') onSetSpan?.(Math.max(minSpan, w - 1));
      else if (k === 'ArrowRight') onSetSpan?.(Math.min(12, w + 1));
      else if (k === 'ArrowUp') onSetRows?.(Math.max(1, rows - 1));
      else onSetRows?.(Math.min(4, rows + 1));
    } else if (!e.shiftKey) {
      const dir = k === 'ArrowLeft' || k === 'ArrowUp' ? -1 : 1;
      if (dir < 0 ? canMoveBack : canMoveFwd) onMove?.(dir);
    }
  };

  // One settings popover at a time across all tiles.
  React.useEffect(() => {
    if (!popOpen) return;
    document.dispatchEvent(new CustomEvent('widget-popover-open', { detail: id }));
    const onOther = (e) => { if (e.detail !== id) setPopOpen(false); };
    document.addEventListener('widget-popover-open', onOther);
    return () => document.removeEventListener('widget-popover-open', onOther);
  }, [popOpen, id]);
  React.useEffect(() => { if (placeholder || !editing) setPopOpen(false); }, [placeholder, editing]);

  // Mouse can grab the whole tile in edit mode; touch only the edit bar
  // (so a finger on the tile still scrolls the page).
  const onPointerDown = editing ? (e) => {
    if (e.target.closest('button:not(.wt-grip), a, input, select, textarea, .widget-resize-edge, .widget-resize-corner')) return;
    if (e.pointerType !== 'mouse' && !e.target.closest('.widget-tools')) return;
    onStartDrag?.(e);
  } : undefined;
  const stop = (e) => e.stopPropagation();
  const minH = userHeight || defaultMinH;
  const style = {};
  if (place) { style.gridColumn = `${place.c + 1} / span ${place.w}`; style.gridRow = `${place.r + 1} / span ${place.h}`; }
  if (minH) style.minHeight = minH + 'px';
  // „½ · 6/12 · 2 řádky“ / „⅓ · 4/12 · výška 320 px“ / „… · výška podle obsahu“
  let sizeLabel = null;
  if (resizeInfo) {
    const s = resizeInfo.span;
    const wTxt = SIZE_FRAC[s] && s !== 12 ? `${SIZE_FRAC[s]} · ${s}/12` : SIZE_FRAC[s] || `${s}/12`;
    const hTxt = resizeInfo.rows > 1 ? `${resizeInfo.rows} ${rowsWord(resizeInfo.rows)}`
      : resizeInfo.h ? `výška ${resizeInfo.h} px` : 'výška podle obsahu';
    sizeLabel = resizeInfo.axis === 'x' || resizeInfo.axis === 'w' ? wTxt
      : resizeInfo.axis === 'y' ? hTxt : `${wTxt} · ${hTxt}`;
  }
  // the left edge is the divider to a tile on the left — only when there is one
  const leftEdge = place && place.c > 0;

  return (
    <section ref={widgetRef}
             className={`widget span-${span} ${editing ? 'is-editing' : ''} ${placeholder ? 'is-placeholder' : ''} ${popOpen ? 'config-open' : ''} ${resizeInfo ? 'is-resizing' : ''} ${isNew ? 'is-new' : ''}`}
             data-widget-id={id} data-span={span}
             style={style}
             onPointerDown={onPointerDown}>
      <div className="widget-head">
        <div className="ico"><i className={`bi bi-${icon}`}></i></div>
        <div className="widget-title">{title}</div>
        {sub && <div className="widget-sub">{sub}</div>}
        {headerExtras && <div className="widget-header-extras">{headerExtras}</div>}
      </div>
      <div className="widget-body">{children}</div>
      {editing && (
        <div className="widget-tools">
          <button type="button" className="wt-grip" onKeyDown={onGripKey}
                  title={sizeCtl ? 'Táhni na jiné místo · šipky posunou · Shift+šipky mění velikost' : 'Táhni na jiné místo · šipky posunou'}
                  aria-label={`Přesunout: ${title}`}>
            <i className="bi bi-grip-vertical" aria-hidden="true"></i>
          </button>
          <span className="wt-title"><i className={`bi bi-${icon}`} aria-hidden="true"></i>{title}</span>
          <button ref={gearRef} type="button" className={`wt-btn ${popOpen ? 'on' : ''}`}
                  onPointerDown={stop} onClick={(e) => { e.stopPropagation(); setPopOpen(o => !o); }}
                  title="Nastavení widgetu" aria-label={`Nastavení: ${title}`} aria-expanded={popOpen}>
            <i className="bi bi-sliders2"></i>
          </button>
        </div>
      )}
      {sizeLabel && <div className="wt-size">{sizeLabel}</div>}
      {editing && popOpen && window.WidgetConfigPop && (
        <window.WidgetConfigPop
          widgetId={id} title={title} icon={icon}
          span={shownSpan || span} minSpan={minSpan} onSetSpan={sizeCtl ? (v) => onSetSpan?.(v) : null}
          rows={rows} onSetRows={sizeCtl ? onSetRows : null} rowsStack={resizable ? rowsStack : null}
          userHeight={userHeight} onSetHeight={onSetHeight}
          onMove={onMove} canMoveBack={canMoveBack} canMoveFwd={canMoveFwd}
          onHide={() => { setPopOpen(false); onHide?.(); }}
          config={widgetConfig || {}}
          onConfigChange={(patch) => onWidgetConfigChange?.(patch)}
          onClose={() => setPopOpen(false)}
          gearRef={gearRef}
          station={station}
          hourlyRange={hourlyRange}
          onHourlyRangeChange={onHourlyRangeChange}
          chartCfg={chartCfg}
          onChartCfgChange={onChartCfgChange}
        />
      )}
      {editing && resizable && (
        <>
          {leftEdge && (
            <div className="widget-resize-edge widget-resize-w" onPointerDown={startResize('w')}
                 title="Táhni: šířka (příčka k levému sousedovi)" aria-label="Změnit šířku widgetu zleva" />
          )}
          <div className="widget-resize-edge widget-resize-e" onPointerDown={startResize('x')}
               title="Táhni: šířka" aria-label="Změnit šířku widgetu" />
          <div className="widget-resize-edge widget-resize-s" onPointerDown={startResize('y')}
               onDoubleClick={resetHeight} title="Táhni: výška, případně přes víc řádků · dvojklik = podle obsahu"
               aria-label="Změnit výšku widgetu" />
          <div className="widget-resize-corner" onPointerDown={startResize('xy')}
               onDoubleClick={resetHeight} title="Táhni: šířka i výška · dvojklik = výška podle obsahu"
               aria-label="Změnit velikost widgetu" />
        </>
      )}
    </section>
  );
};

window.Segmented = function Segmented({ value, options, onChange }) {
  return (
    <div className="segmented">
      {options.map(o => (
        <button key={o.value} className={value === o.value ? 'active' : ''} onClick={() => onChange(o.value)}>
          {o.label}
        </button>
      ))}
    </div>
  );
};

window.RangeTabs = function RangeTabs({ value, options, onChange }) {
  return (
    <div className="range-tabs">
      {options.map(o => (
        <button key={o.value} className={value === o.value ? 'active' : ''} onClick={() => onChange(o.value)}>
          {o.label}
        </button>
      ))}
    </div>
  );
};

// Generic modal — used by hero spark, webcam, sensor tile chart drill-downs.
// Renders through a portal to document.body so it isn't trapped inside any
// widget's stacking/containing context (e.g. `.widget` has backdrop-filter,
// which would otherwise pin a fixed-positioned descendant to the widget box).
window.Modal = function Modal({ title, onClose, children, wide, kind, bodyClass }) {
  useEffect(() => {
    const onKey = (e) => { if (e.key === 'Escape') onClose(); };
    document.addEventListener('keydown', onKey);
    const prev = document.body.style.overflow;
    document.body.style.overflow = 'hidden';
    return () => { document.removeEventListener('keydown', onKey); document.body.style.overflow = prev; };
  }, [onClose]);
  const cls = ['chart-modal', wide && 'wide', kind && `${kind}-modal`].filter(Boolean).join(' ');
  const bodyCls = ['chart-modal-body', bodyClass].filter(Boolean).join(' ');
  const node = (
    <div className="chart-modal-backdrop" onClick={(e)=>{ if (e.target === e.currentTarget) onClose(); }}>
      <div className={cls}>
        <div className="chart-modal-head">
          <h2>{title}</h2>
          <button className="chart-modal-close" onClick={onClose} aria-label="Zavřít">×</button>
        </div>
        <div className={bodyCls}>{children}</div>
      </div>
    </div>
  );
  return ReactDOM.createPortal(node, document.body);
};

// toFixed (not Math.round) so trailing zeros are preserved — otherwise a
// whole-degree station reading like 12.0 °C renders as "12" with no decimals.
// Czech number format, same as the station page (/ms): decimal comma, a real
// minus sign and no "−0,0".
window.czNum = (n, d=0) => {
  let s = Number(n).toFixed(d);
  if (/^-0(?:\.0+)?$/.test(s)) s = s.slice(1);
  return s.replace('.', ',').replace(/^-/, '−');
};
window.fmtTemp = (t, d=0) => (t == null || isNaN(t)) ? '—' : window.czNum(t, d) + '°';
window.fmtNum  = (n, d=0) => (n == null || isNaN(n)) ? '—' : window.czNum(n, d);

// Smooth path through a sequence of [x, y] points using Catmull-Rom →
// cubic Bezier conversion. Unlike the simpler midpoint-Bezier approach
// (which forces a horizontal tangent at every datapoint and creates
// visible plateaus), this respects the slope formed by neighbours so the
// curve flows through the points naturally. `tension` 1 = standard, lower
// = flatter. Empty / single-point input degenerates to an empty path.
window.smoothPath = function smoothPath(pts, tension = 1) {
  if (!pts || pts.length < 2) return pts && pts.length === 1 ? `M ${pts[0][0]} ${pts[0][1]}` : '';
  let p = `M ${pts[0][0]} ${pts[0][1]}`;
  for (let i = 0; i < pts.length - 1; i++) {
    const p0 = pts[i - 1] || pts[i];
    const p1 = pts[i];
    const p2 = pts[i + 1];
    const p3 = pts[i + 2] || p2;
    const t = tension / 6;
    const c1x = p1[0] + (p2[0] - p0[0]) * t;
    const c1y = p1[1] + (p2[1] - p0[1]) * t;
    const c2x = p2[0] - (p3[0] - p1[0]) * t;
    const c2y = p2[1] - (p3[1] - p1[1]) * t;
    p += ` C ${c1x} ${c1y}, ${c2x} ${c2y}, ${p2[0]} ${p2[1]}`;
  }
  return p;
};

// 16-point compass direction in Czech abbreviations (S, SSV, SV, VSV, …).
// The finer resolution catches intermediate headings — a 112° wind reads
// as "VJV" (východ-jihovýchod) instead of being rounded to plain "V".
window.windDir = function windDir(deg) {
  if (deg == null || isNaN(deg)) return '';
  const dirs = ['S','SSV','SV','VSV','V','VJV','JV','JJV','J','JJZ','JZ','ZJZ','Z','ZSZ','SZ','SSZ'];
  const norm = ((deg % 360) + 360) % 360;
  const idx = Math.round(norm / 22.5) % 16;
  return dirs[idx];
};

// =========================================================
// Chart helpers shared by the data charts (Teplota ±24 h, Graf předpovědi).
// Charts draw in REAL pixels (viewBox = measured box) — no stretched text,
// no squashed icons — and follow the station page's chart language: solid
// hairline grid, labels in text colours, measured data solid, forecast
// (a projection) dashed only where it continues measured data.
// =========================================================

// Measured size of an element; re-measures on resize. The element must get
// its size from layout (flex/grid), not from what is drawn inside it.
// The same with a callback ref: `const [ref, box] = useBoxRef()` — also
// measures an element that appears only after the first render (a widget that
// shows a placeholder until its data arrive), which useBox(ref) would miss.
window.useBoxRef = function useBoxRef() {
  const [box, setBox] = React.useState(null);
  const ro = React.useRef(null);
  const ref = React.useCallback((el) => {
    if (ro.current) { ro.current.disconnect(); ro.current = null; }
    if (!el) return;
    const upd = () => {
      const r = el.getBoundingClientRect();
      const w = Math.round(r.width), h = Math.round(r.height);
      setBox(b => (b && b.w === w && b.h === h) ? b : { w, h });
    };
    upd();
    ro.current = new ResizeObserver(upd);
    ro.current.observe(el);
  }, []);
  React.useEffect(() => () => ro.current && ro.current.disconnect(), []);
  return [ref, box];
};
window.useBox = function useBox(ref) {
  const [box, setBox] = React.useState(null);
  React.useLayoutEffect(() => {
    const el = ref.current;
    if (!el) return undefined;
    const upd = () => {
      const r = el.getBoundingClientRect();
      const w = Math.round(r.width), h = Math.round(r.height);
      setBox(b => (b && b.w === w && b.h === h) ? b : { w, h });
    };
    upd();
    const ro = new ResizeObserver(upd);
    ro.observe(el);
    return () => ro.disconnect();
  }, []);
  return box;
};

// "Nice" axis ticks (1 / 2 / 5 × 10^n steps) covering [min, max].
window.niceTicks = function niceTicks(min, max, count = 4) {
  if (!isFinite(min) || !isFinite(max)) return { lo: 0, hi: 1, ticks: [0, 1] };
  if (max - min < 1e-9) { min -= 1; max += 1; }
  const raw = (max - min) / Math.max(1, count);
  const mag = Math.pow(10, Math.floor(Math.log10(raw)));
  const step = [1, 2, 2.5, 5, 10].map(m => m * mag).find(s => s >= raw) || 10 * mag;
  const lo = Math.floor(min / step) * step, hi = Math.ceil(max / step) * step;
  const ticks = [];
  for (let v = lo; v <= hi + step / 2; v += step) ticks.push(+v.toFixed(6));
  return { lo, hi, ticks, step };
};

// Clock-aligned time ticks every `stepH` hours between t0 and t1 (ms).
window.timeTicks = function timeTicks(t0, t1, stepH) {
  const d = new Date(t0);
  d.setMinutes(0, 0, 0);
  while (d.getTime() < t0 || d.getHours() % stepH) d.setHours(d.getHours() + 1);
  const out = [];
  for (; d.getTime() <= t1; d.setHours(d.getHours() + stepH)) out.push(new Date(d));
  return out;
};

// <linearGradient> stops colouring a line/area by temperature along the y
// axis (same scale as the station page). y(v) maps °C to px.
window.TempGradient = function TempGradient({ id, y, lo, hi, dark, opacity = 1 }) {
  const stops = [];
  const n = 8;
  for (let i = 0; i <= n; i++) {
    const v = lo + (hi - lo) * (i / n);
    stops.push(<stop key={i} offset={`${(i / n) * 100}%`} stopColor={window.tempColor ? window.tempColor(v, dark) : 'var(--accent)'} stopOpacity={opacity} />);
  }
  return (
    <linearGradient id={id} gradientUnits="userSpaceOnUse" x1="0" x2="0" y1={y(lo)} y2={y(hi)}>{stops}</linearGradient>
  );
};

window.hhmm = (d) => `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
window.isDarkTheme = () => document.documentElement.getAttribute('data-theme') !== 'light';
// Čas v tooltipech grafů — stejný na stanici i předpovědi.
window.fmtTipTime = function (d) {
  const t = new Date(d), now = new Date();
  const hm = t.toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' });
  const day = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
  const diff = Math.round((day(t) - day(now)) / 86400e3);
  if (diff === 0) return `dnes ${hm}`;
  if (diff === -1) return `včera ${hm}`;
  if (diff === 1) return `zítra ${hm}`;
  return `${['Ne', 'Po', 'Út', 'St', 'Čt', 'Pá', 'So'][t.getDay()]} ${t.getDate()}. ${t.getMonth() + 1}. ${hm}`;
};
// posuvník „Velikost písma“ (--fs) — grafy podle něj zvětší okraje pro popisky
window.fontScale = () => parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--font-scale')) || 1;

// ChartHover — the crosshair, value dots and tooltip as their OWN thin layer
// over a chart. Hover state lives here, so moving the pointer re-renders only
// this layer (a line, a few dots, a tooltip), never the chart's paths — the
// dots keep up with the cursor even on charts with hundreds of points.
// Pointer moves are coalesced to one update per animation frame.
//   xs      x position (px) of every data index, ascending
//   marks(i) → [{ y, color }] dots at index i
//   tip(i)   → tooltip content for index i
//   top / bottom  vertical extent of the crosshair
//   vb       { w, h } when the chart is a uniformly scaled viewBox (xs, marks,
//            top/bottom in viewBox units); otherwise everything is in px
// The tooltip sits BESIDE the crosshair (right of it, left near the right
// edge, centred only when neither fits), so it never covers the points read,
// and vertically just UNDER the pointer (above it when there's no room, and
// above a finger on touch). It follows the pointer without re-rendering.
window.ChartHover = function ChartHover({ W, H, xs, top = 0, bottom, marks, tip, vb }) {
  const [i, setI] = React.useState(null);
  const ref = React.useRef(null);
  const tipRef = React.useRef(null);
  const iRef = React.useRef(null);
  const lastY = React.useRef(0);
  const touch = React.useRef(false);
  const place = () => {
    const t = tipRef.current, el = ref.current, k = iRef.current;
    if (!t || !el || k == null) return;
    const r = el.getBoundingClientRect();
    const cw = r.width, ch = r.height;
    const px = vb ? (xs[k] / vb.w) * cw : xs[k];
    const w = t.offsetWidth, h = t.offsetHeight, gap = 14;
    let left = px + gap;
    if (left + w > cw - 2) left = px - gap - w;
    if (left < 2) left = Math.max(2, Math.min(cw - w - 2, px - w / 2));
    const py = lastY.current - r.top;
    let tp = touch.current ? py - h - 28 : py + 18;
    if (tp + h > ch - 2) tp = py - h - 14;
    tp = Math.max(2, Math.min(ch - h - 2, tp));
    t.style.left = left + 'px';
    t.style.top = tp + 'px';
    t.style.visibility = 'visible';
  };
  const placeRef = React.useRef(place);
  placeRef.current = place;
  iRef.current = i;
  React.useLayoutEffect(() => { place(); });
  const raf = React.useRef(0);
  const lastX = React.useRef(0);
  const hideT = React.useRef(null);
  React.useEffect(() => () => { cancelAnimationFrame(raf.current); clearTimeout(hideT.current); }, []);
  const nearest = (mx) => {
    let lo = 0, hi = xs.length - 1;
    if (hi < 0) return null;
    while (hi - lo > 1) { const mid = (lo + hi) >> 1; if (xs[mid] < mx) lo = mid; else hi = mid; }
    return Math.abs(xs[lo] - mx) <= Math.abs(xs[hi] - mx) ? lo : hi;
  };
  const onMove = (e) => {
    clearTimeout(hideT.current);
    lastX.current = e.clientX;
    lastY.current = e.clientY;
    touch.current = e.pointerType !== 'mouse';
    if (raf.current) return;
    raf.current = requestAnimationFrame(() => {
      raf.current = 0;
      const el = ref.current;
      if (!el) return;
      const r = el.getBoundingClientRect();
      const mx = lastX.current - r.left;
      const k = nearest(vb ? (mx / Math.max(1, r.width)) * vb.w : mx);
      if (k === iRef.current) placeRef.current();      // same point: only follow the pointer
      else setI(k);
    });
  };
  const onLeave = (e) => {
    clearTimeout(hideT.current);
    if (e.pointerType === 'mouse') setI(null);
    else hideT.current = setTimeout(() => setI(null), 2500);   // touch: let it be read
  };
  const x = i != null ? xs[i] : 0;
  const m = i != null ? (marks(i) || []) : [];
  return (
    <div ref={ref} className="ch-layer" onPointerMove={onMove} onPointerDown={onMove}
         onPointerLeave={onLeave} onPointerCancel={onLeave}>
      {i != null && (
        <svg viewBox={vb ? `0 0 ${vb.w} ${vb.h}` : `0 0 ${W} ${H}`} className="ch-svg"
             {...(vb ? { width: '100%', height: '100%' } : { width: W, height: H })}>
          <line x1={x} x2={x} y1={top} y2={bottom ?? (vb ? vb.h : H)} className="ch-cross" vectorEffect="non-scaling-stroke" />
          {m.map((d, k) => d && d.y != null && !isNaN(d.y) && (
            <circle key={k} cx={x} cy={d.y} r="4.5" fill={d.color} stroke="var(--card-strong)" strokeWidth="2" />
          ))}
        </svg>
      )}
      {i != null && (
        <div ref={tipRef} className="pm-tip" style={{ left: 0, top: 0, visibility: 'hidden' }}>{tip(i)}</div>
      )}
    </div>
  );
};
