/* ══ OM Group ERP — Analysis Studio · Analytical Canvas ═══════════════════
   A measurement workspace, not a drawing layer.

   Two rules govern this file:

   1 · ANCHORED IN DATA, NEVER IN PIXELS. Every object stores a bucket label
       plus a value. New transactions, a rescaled axis, a different grain or
       a fresh cross-filter re-resolve the anchor against the current bucket
       list and the object lands back on the same business event.

   2 · EVERY OBJECT MEASURES. The readout beside a drawing is recomputed by
       StudioMeasure from the CURRENT series on every render — start, end,
       change, slope, angle, duration in days and billing cycles, channel
       width, volatility, confidence bands, and the revenue / profit /
       orders / customers / tonnage sitting inside a region. Nothing is
       cached, nothing is frozen; edit a sale and the numbers on the drawing
       move with it.

   Interaction engine: click locks an anchor immediately and permanently —
   it never drifts. Hover previews the unfinished point at 60fps. A drag
   (press, move, release) works exactly like a sequence of clicks: press
   locks point A, release (if it moved) locks point B. A snap engine pulls
   the cursor gently onto data points and existing anchors within ~16px,
   with a small ring marking the engaged target; hold Alt to draw free.
   Selected objects show draggable handles — drag a handle to move that
   anchor, drag the body to move the whole object.

   Nothing here ever writes to the ERP. Annotations live in the workspace.  */
const { useState: caSt, useEffect: caEf, useMemo: caMemo, useRef: caRef } = React;

const CA_TOOLS = [
  { id: 'select', label: 'Select', pts: 0, k: 'V', grp: 'Edit', d: 'Pick, move and edit objects already on the chart.', icon: 'M4 4l7 16 2-6 6-2z' },
  { id: 'trend', label: 'Trend line', pts: 2, k: 'T', grp: 'Draw', d: 'Two anchors — reads change, slope, angle and duration.', icon: 'M3 18L21 6' },
  { id: 'ray', label: 'Ray', pts: 2, k: 'Y', grp: 'Draw', d: 'A trend that keeps running past the second anchor.', icon: 'M3 18l9-6M12 12h9' },
  { id: 'extend', label: 'Extended line', pts: 2, k: 'E', grp: 'Draw', d: 'Extends across the full period in both directions.', icon: 'M2 19L22 5M2 19h.01M22 5h.01' },
  { id: 'arrow', label: 'Arrow', pts: 2, k: 'A', grp: 'Draw', d: 'Points at a moment worth calling out.', icon: 'M4 20L20 4M20 4h-7M20 4v7' },
  { id: 'channel', label: 'Parallel channel', pts: 2, k: 'C', grp: 'Statistical', d: 'Equal-width band around a trend — reads containment.', icon: 'M3 20L21 8M3 14L21 2' },
  { id: 'regression', label: 'Regression channel', pts: 3, k: 'R', grp: 'Statistical', d: 'Least-squares fit — two anchors set the span, a third sets the band width. Reads R² and volatility.', icon: 'M3 19L21 7M4 21h16M6 16l3-2 3 1 4-4' },
  { id: 'pitchfork', label: 'Pitchfork', pts: 3, k: 'K', grp: 'Statistical', d: 'Three anchors project a median and two tines forward.', icon: 'M3 20L14 9M8 3l11 11M8 3L3 8M19 14l-5 5M14 9l5 5' },
  { id: 'forecast', label: 'Forecast projection', pts: 2, k: 'P', grp: 'Projection', d: 'Extends the drawn slope forward with a confidence cone.', icon: 'M3 17l6-5 4 3M13 15l8-6M17 9h4v4' },
  { id: 'measure', label: 'Measure', pts: 2, k: 'M', grp: 'Measurement', d: '₹ and quantity difference, %, days, slope, average per day.', icon: 'M4 16l4-4 4 4 4-4 4 4M4 8h16' },
  { id: 'hline', label: 'Horizontal level', pts: 1, k: 'H', grp: 'Measurement', d: 'A price or volume level with distance to the series.', icon: 'M3 12h18' },
  { id: 'vline', label: 'Vertical marker', pts: 1, k: 'B', grp: 'Measurement', d: 'Marks one bucket and everything booked inside it.', icon: 'M12 3v18' },
  { id: 'rect', label: 'Rectangle region', pts: 2, k: 'Q', grp: 'Shapes', d: 'Totals the revenue, profit, orders and tonnage inside.', icon: 'M4 6h16v12H4z' },
  { id: 'ellipse', label: 'Circle region', pts: 2, k: 'O', grp: 'Shapes', d: 'Softer highlight over a cluster of buckets.', icon: 'M12 6a6 6 0 100 12 6 6 0 100-12z' },
  { id: 'zoneRisk', label: 'Risk zone', pts: 2, k: 'X', grp: 'Shapes', d: 'Flags a window as exposure — full height of the plot.', icon: 'M4 4h6v16H4zM14 9v2M14 15h.01' },
  { id: 'zoneOpp', label: 'Opportunity zone', pts: 2, k: 'Z', grp: 'Shapes', d: 'Flags a window as upside — full height of the plot.', icon: 'M14 4h6v16h-6zM4 12l3 3 4-6' },
  { id: 'note', label: 'Text note', pts: 1, k: 'N', grp: 'Annotation', d: 'Pins a comment to one bucket and value.', icon: 'M5 4h14v11l-5 5H5z' },
];
const CA_GRPS = ['Edit', 'Draw', 'Statistical', 'Projection', 'Measurement', 'Shapes', 'Annotation'];
const CA_WEIGHTS = [1, 1.7, 2.5];
const CA_COLORS = ['#F97316', '#2563EB', '#DC2626', '#16A34A', window.OM_CHART_COLORS.neutral];
const CA_ZOOM_STEP = 1.35, CA_ZOOM_TWEEN = 170;
const CA_WS = 'omStudioWorkspaces';
function caLoadWs() { try { return JSON.parse(localStorage.getItem(CA_WS) || '[]'); } catch (e) { return []; } }
function caSaveWs(l) { try { localStorage.setItem(CA_WS, JSON.stringify(l.slice(0, 20))); } catch (e) { } }
let caSeq = 0;
const caId = () => 'd' + (Date.now() % 1e7) + '-' + (++caSeq);
const caName = d => d.name || ((CA_TOOLS.find(t => t.id === d.type) || {}).label || d.type);

/* ── precision cursors ═══════════════════════════════════════════════════
   Small, sharp, soft-charcoal reticle — a tight crosshair at the exact
   hotspot plus a faint tool glyph beside it, cased in a thin white halo so
   it reads on white plot, gridline or coloured fill alike. */
const CA_CUR_GLYPH = {
  trend: 'M22 22L30 14', ray: 'M22 22l4-4M26 18h4', extend: 'M20 24L30 14',
  channel: 'M21 25L29 17M23 27L31 19', regression: 'M21 26L30 15M20 29h11',
  pitchfork: 'M20 28L27 21M24 16l6 6M24 16l-3 3M30 22l-3 3',
  forecast: 'M20 27l4-4 3 2M27 25l4-4M28 21h3v3',
  measure: 'M20 26l3-3 3 3 3-3M20 20h10',
  hline: 'M19 22h12', vline: 'M25 16v12',
  arrow: 'M20 28L30 18M30 18h-5M30 18v5',
  rect: 'M20 19h11v10H20z', ellipse: 'M25.5 18.5a5 5 0 100 10 5 5 0 100-10z',
  zoneRisk: 'M21 18h4v11h-4zM29 20v3M29 27h.01', zoneOpp: 'M27 18h4v11h-4zM20 24l2 2 3-4',
  note: 'M20 17h10v8l-4 4h-6z', select: '',
};
function caCursorCss(tool) {
  const cross = 'M12 7v3.4M12 13.6v3.4M7 12h3.4M13.6 12h3.4';
  const glyph = CA_CUR_GLYPH[tool];
  const svg =
    '<svg xmlns="http://www.w3.org/2000/svg" width="34" height="34" viewBox="0 0 34 34">' +
    '<g fill="none" stroke="#ffffff" stroke-width="3.1" stroke-linecap="round" stroke-linejoin="round" opacity=".92">' +
    '<path d="' + cross + '"/>' + (glyph ? '<path d="' + glyph + '"/>' : '') + '</g>' +
    '<g fill="none" stroke="#3A3733" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">' +
    '<path d="' + cross + '"/>' + (glyph ? '<path d="' + glyph + '" opacity=".82"/>' : '') + '</g>' +
    '<circle cx="12" cy="12" r=".9" fill="#3A3733"/></svg>';
  return 'url("data:image/svg+xml,' + encodeURIComponent(svg) + '") 12 12, crosshair';
}
const CA_CURSORS = (() => { const m = {}; CA_TOOLS.forEach(t => { if (t.id !== 'select') m[t.id] = caCursorCss(t.id); }); return m; })();

/* re-resolve a stored anchor against the CURRENT bucket labels */
function caResolve(anchor, labels) {
  if (!anchor) return { ix: 0, val: 0 };
  if (anchor.label != null) {
    const i = labels.indexOf(anchor.label);
    if (i >= 0) return { ix: i + (anchor.frac || 0), val: anchor.val };
  }
  return { ix: stClamp(anchor.ix, 0, Math.max(0, labels.length - 1)), val: anchor.val };
}
function caAnchor(ix, val, labels) {
  const i = Math.round(stClamp(ix, 0, Math.max(0, labels.length - 1)));
  return { ix, frac: ix - i, label: labels[i], val };
}

/* ── snap engine ────────────────────────────────────────────────────
   Magnetism with a hierarchy, not a nearest-neighbour search: peaks and
   troughs pull hardest, then exact datapoints, then anchors already placed,
   then the grid and the bucket baseline. The bias is subtracted from the
   pixel distance so a slightly further peak still wins over a nearer
   gridline — the cursor assists, it never fights.                         */
const CA_BIAS = { peak: 13, low: 13, data: 8, anchor: 10, grid: 0, bucket: 1 };
const CA_SNAP_R = { peak: 26, low: 26, data: 20, anchor: 22, grid: 11, bucket: 11 };
function caSnap(ix, val, labels, values, draws, g) {
  let best = null, bestScore = Infinity;
  const consider = (cix, cval, kind) => {
    if (cval == null || !isFinite(cval)) return;
    const dist = Math.hypot(g.X(cix) - g.X(ix), g.Y(cval) - g.Y(val));
    if (dist > (CA_SNAP_R[kind] || 16)) return;
    const score = dist - (CA_BIAS[kind] || 0);
    if (score < bestScore) { bestScore = score; best = { ix: cix, val: cval, kind }; }
  };
  for (let i = 0; i < labels.length; i++) {
    const v = values[i]; if (v == null) continue;
    const p = values[i - 1], n = values[i + 1];
    const peak = p != null && n != null && v > p && v > n;
    const low = p != null && n != null && v < p && v < n;
    consider(i, v, peak ? 'peak' : low ? 'low' : 'data');
  }
  draws.forEach(dw => { ['a', 'b', 'c'].forEach(k => { const a = dw[k]; if (a) { const r = caResolve(a, labels); consider(r.ix, r.val, 'anchor'); } }); });
  /* grid intersections — major time buckets × the axis levels */
  const step = (g.max - g.min) / 4;
  const nearI = Math.round(ix);
  if (nearI >= 0 && nearI < labels.length) {
    for (let t = 0; t <= 4; t++) consider(nearI, g.min + step * t, 'grid');
    consider(nearI, val, 'bucket');
  }
  if (best) return { ix: best.ix, val: best.val, snapped: true, kind: best.kind };
  return { ix, val, snapped: false };
}
const CA_SNAP_LBL = { peak: 'peak', low: 'low', data: 'data point', anchor: 'anchor', grid: 'grid', bucket: 'bucket' };

/* ── the statistics card ═════════════════════════════════════════════════
   ONE component, used by every analytical object — regression, channel,
   trend, measure, pitchfork, forecast, zone, note. It measures its own
   content and sizes to it: labels left on a common edge, values right on a
   common edge, one fixed row height, guaranteed padding, and a flip so it
   never covers the object it belongs to, the handles, the axis or the
   toolbar. Nothing clips, nothing overflows, nothing is hidden.
   ax / ay are the object's reference point — the card chooses its side.  */
const CA_PAD = 13, CA_ROW = 14.2, CA_HEAD = 22, CA_COLGAP = 20, CA_WMIN = 190, CA_WMAX = 344;
const caTw = (s, px, bold) => String(s == null ? '' : s).length * px * (bold ? 0.575 : 0.535);
function CaPanel({ st, ax, ay, g, color, avoid }) {
  if (!st) return null;
  const rows = st.rows || [];
  const badge = st.badge && st.badge.text ? 22 : 0;
  /* uppercase + letter-spacing runs ~18% wider than the mixed-case rows, and
     the badge pill eats 9px of inset each side before any padding */
  let need = caTw(st.title, 9.2, true) * 1.18 + 36;
  rows.forEach(r => { need = Math.max(need, caTw(r.k, 8.9, true) + CA_COLGAP + caTw(r.v, 9.4, true) + CA_PAD * 2); });
  if (badge) need = Math.max(need, caTw(st.badge.text, 8.8, true) * 1.16 + 18 + CA_PAD * 2);
  const room = Math.max(150, g.iw - 20);
  const W = Math.round(stClamp(need, Math.min(CA_WMIN, room), Math.min(CA_WMAX, room)));
  const H = CA_HEAD + rows.length * CA_ROW + 10 + badge;
  const L = g.padL, R = g.padL + g.iw, T = g.padT, B = g.padT + g.ih;
  let px = ax + 14;
  if (px + W > R - 4) px = ax - 14 - W;                       /* flip sides */
  px = stClamp(px, L + 4, Math.max(L + 4, R - W - 4));
  let py = ay;
  if (py + H > B - 4) py = ay - H - 10;                       /* flip above */
  py = stClamp(py, T + 4, Math.max(T + 4, B - H - 4));
  /* last resort: if the card would still sit on top of its own object, lift it
     clear — above the object if there is room, otherwise below it */
  if (avoid && px < avoid.x1 && px + W > avoid.x0 && py < avoid.y1 && py + H > avoid.y0) {
    const up = avoid.y0 - H - 8, dnp = avoid.y1 + 8;
    if (up >= T + 4) py = up;
    else if (dnp + H <= B - 4) py = dnp;
  }
  const tone = st.badge ? st.badge.tone : 'n';
  const tc = tone === 'up' ? '#16A34A' : tone === 'dn' ? '#DC2626' : '#6B7068';
  /* a label only ever shortens when the value beside it needs the room */
  const label = r => {
    const avail = W - CA_PAD * 2 - caTw(r.v, 9.4, true) - 10;
    const k = String(r.k);
    if (caTw(k, 8.9, true) <= avail) return k;
    const max = Math.max(3, Math.floor(avail / (8.9 * 0.575)) - 1);
    return k.slice(0, max).replace(/[\s·—-]+$/, '') + '…';
  };
  return (
    <g className="ca-panel" pointerEvents="none">
      <rect x={px} y={py} width={W} height={H} rx="10" fill="#fff" stroke="#E7E4E0" strokeWidth="1"
        style={{ filter: 'drop-shadow(0 8px 22px rgba(20,18,15,.14))' }} />
      <rect x={px} y={py} width={W} height={CA_HEAD} rx="10" fill={color} fillOpacity=".07" />
      <rect x={px} y={py + CA_HEAD - 1} width={W} height="1" fill="#F1EFEC" />
      <rect x={px + 10} y={py + 8} width="3" height="7" rx="1.5" fill={color} />
      <text x={px + 18} y={py + 14.5} fontSize="9.2" fontWeight="800" fill={color} letterSpacing=".07em">{st.title.toUpperCase()}</text>
      {rows.map((r, i) => {
        const ty = py + CA_HEAD + 13.5 + i * CA_ROW;
        return (
          <g key={i}>
            <text x={px + CA_PAD} y={ty} fontSize="8.9" fontWeight="600" fill="#8B8781">{label(r)}</text>
            <text x={px + W - CA_PAD} y={ty} fontSize="9.4" fontWeight={r.em ? 800 : 700} textAnchor="end"
              fill={r.em ? '#1A1917' : '#4B4F49'} style={{ fontVariantNumeric: 'tabular-nums' }}>{r.v}</text>
          </g>
        );
      })}
      {badge > 0 && (
        <g>
          <rect x={px + 9} y={py + H - 25} width={W - 18} height="18" rx="5" fill={tc} fillOpacity=".09" />
          <text x={px + W / 2} y={py + H - 12.5} fontSize="8.8" fontWeight="800" textAnchor="middle" fill={tc} letterSpacing=".05em">{st.badge.text.toUpperCase()}</text>
        </g>
      )}
    </g>
  );
}

/* Full screen and the object menu are position:fixed, but several ancestors
   animate (transform), which would make them a containing block and trap the
   overlay inside the panel. Both are therefore portalled to a host appended
   to <body> that carries the Intelligence Center's token scope.          */
function caHost() {
  let el = document.getElementById('ca-portal-host');
  if (!el) { el = document.createElement('div'); el.id = 'ca-portal-host'; document.body.appendChild(el); }
  return el;
}
function CaPortal({ on, mode, children }) {
  const host = caMemo(caHost, []);
  caEf(() => { host.className = 'intel'; host.setAttribute('data-mode', mode || 'sales'); }, [host, mode]);
  return on ? ReactDOM.createPortal(children, host) : children;
}

/* handles that can be dragged to move a single anchor, or the whole object */
function CaHandle({ x, y, isSel, color, active, onPointerDown }) {
  return (
    <circle cx={x} cy={y} r={active ? 6.2 : (isSel ? 4.6 : 3.2)} fill="#fff" stroke={color} strokeWidth={active ? 2.8 : 2.2}
      style={{ cursor: 'grab', transition: 'r .12s cubic-bezier(.2,.8,.2,1), stroke-width .12s ease' }}
      onPointerDown={onPointerDown} />
  );
}

function StudioCanvas({ ctx, store, onStore }) {
  const IEc2 = window.IntelEngine;
  const SM = window.StudioMeasure;
  const opts = ctx.mode === 'purchase'
    ? ['purchaseValue', 'qtyPurchased', 'app', 'dieselCost', 'transportCost']
    : ['revenue', 'grossProfit', 'netProfit', 'qtySold', 'asp', 'margin'];
  const metric = opts.indexOf(store.metric) >= 0 ? store.metric : opts[0];
  const draws = store.draws || [];
  const setMetric = m => onStore({ metric: m });
  const setDraws = v => onStore({ draws: typeof v === 'function' ? v(draws) : v });
  const [tool, setTool] = caSt('select');
  const [color, setColor] = caSt(CA_COLORS[0]);
  const [noteText, setNoteText] = caSt('');
  const [draft, setDraft] = caSt(null);
  const draftRef = caRef(null);
  const setDraftBoth = d => { draftRef.current = d; setDraft(d); };
  const [sel, setSel] = caSt(null);
  const [showAll, setShowAll] = caSt(false);
  const [ws, setWs] = caSt(caLoadWs);
  const [wsName, setWsName] = caSt('');
  const [active, setActive] = caSt(store.active || null);
  const [snapVis, setSnapVis] = caSt(null);
  const [cur, setCur] = caSt(null); /* live cursor readout for the full-screen status bar */
  const curT = caRef(0);
  const [editDraft, setEditDraft] = caSt(null);
  const editRef = caRef(null);
  const setEditBoth = v => { editRef.current = v; setEditDraft(v); };
  const dragMeta = caRef(null); /* {id, mode:'anchor'|'move', key?, start?, orig?} — dragging an existing object */
  const downRef = caRef(null); /* {px,py} of the click/press that opened the current draft stage */
  const geomRef = caRef(null);
  const snapAllowed = caRef(true);
  const hist = caRef({ past: [], future: [] });
  const [fs, setFs] = caSt(false);
  const [layers, setLayers] = caSt(false);
  const [menu, setMenu] = caSt(null); /* {x,y,id} — object context menu */
  const [vh, setVh] = caSt(() => window.innerHeight);
  const [guide, setGuide] = caSt(() => { try { return !localStorage.getItem('omCaGuide'); } catch (e) { return false; } });
  const closeGuide = () => { setGuide(false); try { localStorage.setItem('omCaGuide', '1'); } catch (e) { } };

  /* ── viewport navigation — pan and zoom the CHART, never the layout ────
     `view` is the visible domain rectangle (buckets on X, values on Y) that
     the plot's fixed pixel box maps. Zooming moves that rectangle around the
     cursor; the container, toolbar, axes and panels never move, and StPlot
     clips every drawn object to the plot frame. Targets live in a ref so
     rapid wheel bursts compound off the latest value, while the displayed
     rectangle eases toward it over ~170ms. */
  const viewRef = caRef(null);      /* the target */
  const shownRef = caRef(null);     /* what is on screen right now */
  const baseRef = caRef(null);      /* fit-to-data domain, reported by the plot */
  const viewRaf = caRef(null);
  const [view, setView] = caSt(null);
  const tweenView = (from, to, done) => {
    if (viewRaf.current) cancelAnimationFrame(viewRaf.current);
    if (!from) { shownRef.current = to; setView(to); if (done) done(); return; }
    const t0 = performance.now();
    const step = now => {
      const p = Math.min(1, (now - t0) / CA_ZOOM_TWEEN), e = 1 - Math.pow(1 - p, 3);
      const v = { x0: from.x0 + (to.x0 - from.x0) * e, x1: from.x1 + (to.x1 - from.x1) * e, y0: from.y0 + (to.y0 - from.y0) * e, y1: from.y1 + (to.y1 - from.y1) * e };
      shownRef.current = v; setView(v);
      if (p < 1) viewRaf.current = requestAnimationFrame(step);
      else { viewRaf.current = null; if (done) done(); }
    };
    viewRaf.current = requestAnimationFrame(step);
  };
  const applyView = (next, animate) => {
    const b = baseRef.current;
    if (next == null) {
      viewRef.current = null;
      const from = shownRef.current;
      if (!from || !b || !animate) { if (viewRaf.current) cancelAnimationFrame(viewRaf.current); viewRaf.current = null; shownRef.current = null; setView(null); return; }
      tweenView(from, b, () => { shownRef.current = null; setView(null); });
      return;
    }
    const cur = viewRef.current || shownRef.current || b;
    if (!cur) return;
    let t = typeof next === 'function' ? next(cur, b) : next;
    if (!t || !isFinite(t.x0) || !isFinite(t.y0)) return;
    if (b && window.stClampView) t = window.stClampView(t, b);
    viewRef.current = t;
    if (!animate) { if (viewRaf.current) cancelAnimationFrame(viewRaf.current); viewRaf.current = null; shownRef.current = t; setView(t); return; }
    tweenView(shownRef.current || cur, t, null);
  };
  const zoomBy = k => applyView(cur => {
    const cx = (cur.x0 + cur.x1) / 2, cy = (cur.y0 + cur.y1) / 2;
    const xs = (cur.x1 - cur.x0) / k, ys = (cur.y1 - cur.y0) / k;
    return { x0: cx - xs / 2, x1: cx + xs / 2, y0: cy - ys / 2, y1: cy + ys / 2 };
  }, true);
  const zoomIn = () => zoomBy(CA_ZOOM_STEP);
  const zoomOut = () => zoomBy(1 / CA_ZOOM_STEP);
  const zoomReset = () => applyView(null, true);
  const vb = baseRef.current;
  const zoomShown = view && vb ? (vb.x1 - vb.x0) / Math.max(1e-9, view.x1 - view.x0) : 1;
  const zoomMax = vb ? Math.max(1.2, (vb.x1 - vb.x0) / 1.5) : 12;
  const zoomMin = 1 / 20;
  const zoomLbl = (zoomShown < 0.995 ? (zoomShown * 100).toFixed(zoomShown < 0.095 ? 1 : 0) : Math.round(zoomShown * 100)) + '%';

  caEf(() => { const on = () => setVh(window.innerHeight); window.addEventListener('resize', on); return () => window.removeEventListener('resize', on); }, []);
  caEf(() => () => { if (viewRaf.current) cancelAnimationFrame(viewRaf.current); }, []);
  caEf(() => {
    if (!fs) return;
    const prev = document.body.style.overflow;
    document.body.style.overflow = 'hidden';
    return () => { document.body.style.overflow = prev; };
  }, [fs]);
  caEf(() => {
    if (!menu) return;
    const close = e => { if (!e.target.closest || !e.target.closest('.ca-menu')) setMenu(null); };
    const t = setTimeout(() => window.addEventListener('pointerdown', close), 0);
    return () => { clearTimeout(t); window.removeEventListener('pointerdown', close); };
  }, [menu]);

  const meta = IEc2.METRICS[metric] || IEc2.METRICS.revenue;
  const labels = ctx.ser.points.map(p => p.label);
  const values = ctx.ser.points.map(p => p.m[metric]);

  /* the live measurement environment — rebuilt from the current series on
     every data change, which is what keeps every readout honest. */
  const env = caMemo(() => ({
    labels, values, points: ctx.ser.points, meta, metric,
    grain: ctx.ser.grain, mode: ctx.mode, ctx,
    resolve: a => caResolve(a, labels),
  }), [ctx, metric, JSON.stringify(values)]);

  const mark = () => { const h = hist.current; h.past.push(draws); if (h.past.length > 80) h.past.shift(); h.future = []; };
  const push = d => { mark(); setDraws(l => l.concat([d])); setSel(d.id); };
  const undo = () => { const h = hist.current; if (!h.past.length) return; h.future.push(draws); setDraws(h.past.pop()); setSel(null); setDraftBoth(null); };
  const redo = () => { const h = hist.current; if (!h.future.length) return; h.past.push(draws); setDraws(h.future.pop()); };
  const remove = id => { mark(); setDraws(l => l.filter(d => d.id !== id)); setSel(null); setMenu(null); };
  const clear = () => { if (!draws.length) return; mark(); setDraws([]); setSel(null); setMenu(null); };
  const patch = (id, p) => { mark(); setDraws(l => l.map(d => d.id === id ? Object.assign({}, d, p) : d)); };
  const patchQ = (id, p) => setDraws(l => l.map(d => d.id === id ? Object.assign({}, d, p) : d)); /* no history — live sliders, renames */
  const duplicate = id => {
    const d = draws.find(x => x.id === id); if (!d) return;
    const off = a => a ? Object.assign({}, a, { ix: a.ix + 0.4 }) : a;
    const cp = Object.assign({}, d, { id: caId(), name: caName(d) + ' copy', a: off(d.a), b: off(d.b), c: off(d.c) });
    mark(); setDraws(l => l.concat([cp])); setSel(cp.id); setMenu(null);
  };
  const reorder = (id, dir) => {
    const i = draws.findIndex(d => d.id === id); if (i < 0) return;
    const l = draws.slice(), x = l.splice(i, 1)[0];
    l.splice(dir === 'front' ? l.length : 0, 0, x);
    mark(); setDraws(l); setMenu(null);
  };
  const nudge = (dxIx, dyPx) => {
    if (!sel) return;
    const g = geomRef.current;
    const dv = g ? (g.max - g.min) / g.ih * dyPx : 0;
    mark();
    setDraws(l => l.map(d => {
      if (d.id !== sel || d.locked) return d;
      const shift = {};
      ['a', 'b', 'c'].forEach(k => { if (!d[k]) return; const r = caResolve(d[k], labels); shift[k] = caAnchor(r.ix + dxIx, r.val + dv, labels); });
      return Object.assign({}, d, shift);
    }));
  };

  function commitEdit() {
    const dm = dragMeta.current; if (!dm) return;
    const e = editRef.current;
    if (e) { mark(); setDraws(list => list.map(x => x.id === e.id ? Object.assign({}, x, e) : x)); }
    dragMeta.current = null; setEditBoth(null); setSnapVis(null);
  }
  function cancelEdit() { dragMeta.current = null; setEditBoth(null); setSnapVis(null); }

  caEf(() => {
    const up = () => { if (dragMeta.current) commitEdit(); };
    window.addEventListener('pointerup', up);
    return () => window.removeEventListener('pointerup', up);
  }, [draws]);

  caEf(() => {
    const field = t => /input|textarea|select/i.test((t && t.tagName) || '') || (t && t.isContentEditable);
    const h = e => {
      if (field(e.target)) { if (e.key === 'Escape') e.target.blur(); return; }
      const meta = e.metaKey || e.ctrlKey;
      if (e.key === 'Escape') {
        if (dragMeta.current) { cancelEdit(); return; }
        if (draftRef.current) { setDraftBoth(null); downRef.current = null; setTool('select'); setSnapVis(null); return; }
        if (guide) { closeGuide(); return; }
        if (menu) { setMenu(null); return; }
        if (sel) { setSel(null); return; }
        if (fs) setFs(false);
        return;
      }
      if (e.key === '?' || (e.key === '/' && e.shiftKey)) { e.preventDefault(); if (guide) closeGuide(); else setGuide(true); return; }
      if (meta && (e.key === 'z' || e.key === 'Z')) { e.preventDefault(); e.shiftKey ? redo() : undo(); return; }
      if (meta && (e.key === 'y' || e.key === 'Y')) { e.preventDefault(); redo(); return; }
      if (meta && (e.key === 'd' || e.key === 'D')) { if (sel) { e.preventDefault(); duplicate(sel); } return; }
      if (meta && (e.key === '=' || e.key === '+')) { e.preventDefault(); zoomIn(); return; }
      if (meta && (e.key === '-' || e.key === '_')) { e.preventDefault(); zoomOut(); return; }
      if (meta && e.key === '0') { e.preventDefault(); zoomReset(); return; }
      if (meta) return;
      if ((e.key === 'Delete' || e.key === 'Backspace') && sel && !dragMeta.current && !draftRef.current) { e.preventDefault(); remove(sel); return; }
      if (sel && /^Arrow/.test(e.key)) {
        e.preventDefault();
        const big = e.shiftKey ? 5 : 1;
        if (e.key === 'ArrowLeft') nudge(-0.06 * big, 0);
        if (e.key === 'ArrowRight') nudge(0.06 * big, 0);
        if (e.key === 'ArrowUp') nudge(0, -2 * big);
        if (e.key === 'ArrowDown') nudge(0, 2 * big);
        return;
      }
      if (e.key === 'Alt') { snapAllowed.current = false; return; }
      const k = String(e.key || '').toUpperCase();
      if (k === 'F') { e.preventDefault(); setFs(f => !f); return; }
      if (k === 'L') { e.preventDefault(); setLayers(v => !v); return; }
      const t = CA_TOOLS.find(x => x.k === k);
      if (t) { e.preventDefault(); setTool(t.id); setDraftBoth(null); setSnapVis(null); }
    };
    const hu = e => { if (e.key === 'Alt') snapAllowed.current = true; };
    document.addEventListener('keydown', h, true); document.addEventListener('keyup', hu, true);
    return () => { document.removeEventListener('keydown', h, true); document.removeEventListener('keyup', hu, true); };
  }, [sel, draws, fs, menu, guide]);

  const toolDef = CA_TOOLS.find(t => t.id === tool) || CA_TOOLS[0];

  function resolveSnap(d, e) {
    const g = geomRef.current;
    if (e.altKey || !snapAllowed.current || !g) { setSnapVis(null); return { ix: d.ix, val: d.val }; }
    const s = caSnap(d.ix, d.val, labels, values, draws, g);
    setSnapVis(s.snapped ? s : null);
    return s;
  }

  /* An anchor lands exactly where it was clicked — nothing is ever pulled onto
     the series afterwards. A regression channel's anchors choose the DATE
     RANGE; the dashed full-height guides show that window, and the band inside
     it is the least-squares fit of the data. */
  const anchorFor = (type, key, ix, val) => caAnchor(ix, val, labels);

  /* Shift constrains the unfinished leg to 0°/45°/90° off the locked anchor */
  function lockAngle(from, ix, val) {
    const g = geomRef.current; if (!g || !from) return { ix, val };
    const A = caResolve(from, labels);
    const ax = g.X(A.ix), ay = g.Y(A.val);
    const dx = g.X(ix) - ax, dy = g.Y(val) - ay;
    const len = Math.hypot(dx, dy); if (len < 1) return { ix, val };
    const step = Math.PI / 4, a = Math.round(Math.atan2(dy, dx) / step) * step;
    return { ix: g.invX(ax + Math.cos(a) * len), val: g.invY(ay + Math.sin(a) * len) };
  }

  /* place the moving point: snap, unless Shift is asking for a locked angle */
  function place(e, d, from, key) {
    if (e.shiftKey && from) { setSnapVis(null); const L = lockAngle(from, d.ix, d.val); return anchorFor(tool, key, L.ix, L.val); }
    const sp = resolveSnap(d, e);
    return anchorFor(tool, key, sp.ix, sp.val);
  }

  /* The regression channel's third stage sets WIDTH, not a position: the
     handle is pinned to the second anchor's bucket and only its value moves,
     free of magnetism (snapping a width to a data point makes it jump). */
  function widthAnchor(dr, d) {
    const B = caResolve(dr.b, labels);
    return caAnchor(B.ix, d.val, labels);
  }
  const isWidthStage = dr => dr && dr.stage === 2 && dr.type === 'regression';

  function startAnchorDrag(ev, dObj, key) { ev.stopPropagation(); setSel(dObj.id); dragMeta.current = { id: dObj.id, mode: 'anchor', key }; }
  function startMoveDrag(ev, dObj) { ev.stopPropagation(); setSel(dObj.id); dragMeta.current = { id: dObj.id, mode: 'move', start: null, orig: { a: dObj.a, b: dObj.b, c: dObj.c } }; }

  const pointer = {
    down: (e, d) => {
      if (menu) setMenu(null);
      if (tool === 'select') { setSel(null); return; }
      const sp = resolveSnap(d, e);
      if (toolDef.pts === 1) {
        push({ id: caId(), type: tool, color, a: caAnchor(sp.ix, sp.val, labels), text: tool === 'note' ? (noteText.trim() || 'Note') : null });
        setTool('select'); setSnapVis(null);
        return;
      }
      const dr = draftRef.current;
      if (!dr) { downRef.current = { px: d.px, py: d.py }; const a0 = anchorFor(tool, 'a', sp.ix, sp.val); setDraftBoth({ id: caId(), type: tool, color, a: a0, b: a0, stage: 1 }); return; }
      if (dr.stage === 1) {
        downRef.current = { px: d.px, py: d.py };
        const bAnchor = place(e, d, dr.a, 'b');
        if (toolDef.pts === 3) { setDraftBoth(Object.assign({}, dr, { b: bAnchor, c: bAnchor, stage: 2 })); return; }
        push(Object.assign({}, dr, { b: bAnchor })); setDraftBoth(null); setTool('select'); setSnapVis(null); return;
      }
      if (dr.stage === 2) { push(Object.assign({}, dr, { c: isWidthStage(dr) ? widthAnchor(dr, d) : place(e, d, dr.b, 'c') })); setDraftBoth(null); setTool('select'); setSnapVis(null); }
    },
    move: (e, d) => {
      if (fs) { const now = performance.now(); if (now - curT.current > 70) { curT.current = now; setCur({ ix: d.ix, val: d.val }); } }
      const dm = dragMeta.current;
      if (dm) {
        const sp = resolveSnap(d, e);
        if (dm.mode === 'anchor') {
          const obj = draws.find(x => x.id === dm.id);
          const base = editRef.current && editRef.current.id === dm.id ? editRef.current : { id: dm.id };
          setEditBoth(Object.assign({}, base, { id: dm.id, [dm.key]: anchorFor(obj && obj.type, dm.key, sp.ix, sp.val) }));
        } else {
          if (!dm.start) { dm.start = { ix: d.ix, val: d.val }; return; }
          const deltaIx = d.ix - dm.start.ix, deltaVal = d.val - dm.start.val;
          const shifted = { id: dm.id };
          ['a', 'b', 'c'].forEach(k => { const o = dm.orig[k]; if (!o) return; const r = caResolve(o, labels); shifted[k] = caAnchor(r.ix + deltaIx, r.val + deltaVal, labels); });
          setEditBoth(shifted);
        }
        return;
      }
      const dr = draftRef.current; if (!dr) return;
      if (dr.stage === 2) { if (isWidthStage(dr)) { setSnapVis(null); setDraftBoth(Object.assign({}, dr, { c: widthAnchor(dr, d) })); } else setDraftBoth(Object.assign({}, dr, { c: place(e, d, dr.b, 'c') })); }
      else setDraftBoth(Object.assign({}, dr, { b: place(e, d, dr.a, 'b') }));
    },
    up: (e, d) => {
      if (dragMeta.current) { commitEdit(); return; }
      const dr = draftRef.current;
      if (!dr || !downRef.current) return;
      const dist = Math.hypot(d.px - downRef.current.px, d.py - downRef.current.py);
      downRef.current = null;
      if (dist < 6) return; /* a plain click — wait for the next click to place the point */
      if (dr.stage === 1) {
        const bAnchor = place(e, d, dr.a, 'b');
        if (toolDef.pts === 3) { setDraftBoth(Object.assign({}, dr, { b: bAnchor, c: bAnchor, stage: 2 })); return; }
        push(Object.assign({}, dr, { b: bAnchor })); setDraftBoth(null); setTool('select'); setSnapVis(null); return;
      }
      if (dr.stage === 2) { push(Object.assign({}, dr, { c: isWidthStage(dr) ? widthAnchor(dr, d) : place(e, d, dr.b, 'c') })); setDraftBoth(null); setTool('select'); setSnapVis(null); }
    },
    leave: () => { const dr = draftRef.current; if (dr && dr.stage === 0) setDraftBoth(null); setSnapVis(null); setCur(null); },
  };

  function saveWorkspace() {
    const nm = (wsName || '').trim() || 'Analysis ' + (ws.length + 1);
    const entry = { name: nm, metric, draws, grain: ctx.ser.grain, period: { from: ctx.period.from, to: ctx.period.to, kind: ctx.period.kind, label: ctx.period.label }, at: Date.now() };
    const next = [entry].concat(ws.filter(w => w.name !== nm));
    setWs(next); caSaveWs(next); setWsName(''); setActive(nm);
    window.toast && window.toast('Workspace “' + nm + '” saved', 'ok');
  }
  function loadWorkspace(w) {
    hist.current.push(draws);
    onStore({ draws: w.draws || [], metric: opts.indexOf(w.metric) >= 0 ? w.metric : opts[0], active: w.name });
    setActive(w.name); setSel(null);
    window.toast && window.toast('“' + w.name + '” restored — every measurement recomputed against live data', 'ok');
  }
  function delWorkspace(nm) { const next = ws.filter(w => w.name !== nm); setWs(next); caSaveWs(next); if (active === nm) setActive(null); }

  const base = draft ? draws.concat([draft]) : draws;
  const all = editDraft ? base.map(dw => dw.id === editDraft.id ? Object.assign({}, dw, editDraft) : dw) : base;

  function overlay(g) {
    geomRef.current = g;
    baseRef.current = g.base;
    const nodes = [], panels = [];
    all.forEach(d => {
      if (d.hidden) return;
      const A = caResolve(d.a, labels);
      const B = d.b ? caResolve(d.b, labels) : null;
      const C = d.c ? caResolve(d.c, labels) : null;
      const x1 = g.X(A.ix), y1 = g.Y(A.val);
      const x2 = B ? g.X(B.ix) : x1, y2 = B ? g.Y(B.val) : y1;
      const c = d.color || '#F97316';
      const isSel = sel === d.id || (draft && draft.id === d.id);
      const isEditing = dragMeta.current && dragMeta.current.id === d.id;
      const pick = ev => { ev.stopPropagation(); if (d.locked) return; if (tool === 'select') startMoveDrag(ev, d); };
      const ctx = ev => { ev.preventDefault(); ev.stopPropagation(); setSel(d.id); setMenu({ x: ev.clientX, y: ev.clientY, id: d.id }); };
      const common = {
        className: 'ca-obj' + (isSel ? ' on' : ''), 'data-lw': (d.w && d.w !== 1) ? String(d.w) : null,
        onPointerDown: pick, onContextMenu: ctx,
        /* While a drawing tool is armed nothing on the canvas may swallow a
           click — including the in-progress draft itself. Clicking the third
           point of a channel or pitchfork usually lands ON the preview band,
           and an object that stopped propagation there left the drawing
           permanently unfinished. */
        style: { cursor: tool === 'select' ? (d.locked ? 'default' : 'move') : 'inherit', pointerEvents: tool === 'select' ? null : 'none', '--lw': d.w || 1, opacity: d.opacity == null ? 1 : d.opacity },
      };
      const canHandle = tool === 'select' && isSel && !d.locked;
      const key = d.id;
      const st = SM ? SM.stats(d, env, g) : null;
      const wantPanel = st && (isSel || showAll);

      if (d.type === 'trend' || d.type === 'arrow' || d.type === 'measure' || d.type === 'ray' || d.type === 'extend' || d.type === 'forecast') {
        let sx = x1, sy = y1, ex = x2, ey = y2;
        const dx = x2 - x1, dy = y2 - y1;
        if ((d.type === 'ray' || d.type === 'forecast' || d.type === 'extend') && dx !== 0) {
          const at = X => y1 + dy * (X - x1) / dx;
          const L = g.padL, Rr = g.padL + g.iw;
          if (d.type === 'extend') { sx = L; sy = at(L); ex = Rr; ey = at(Rr); }
          else { const tx = dx > 0 ? Rr : L; ex = tx; ey = at(tx); }
        }
        nodes.push(<g key={key} {...common}>
          {d.type === 'forecast' && st && st.geo && (() => {
            const ci = Math.abs(g.Y(0) - g.Y(st.geo.ci)) || 0;
            return <path d={'M' + x2 + ' ' + (y2 - 1) + 'L' + ex + ' ' + (ey - ci) + 'L' + ex + ' ' + (ey + ci) + 'L' + x2 + ' ' + (y2 + 1) + 'Z'} fill={c} fillOpacity=".09" stroke="none" style={{ transition: 'opacity .15s ease' }} />;
          })()}
          <line x1={sx} y1={sy} x2={ex} y2={ey} stroke={c} strokeWidth={isSel ? 3 : 2}
            strokeDasharray={d.type === 'forecast' ? '7 5' : d.type === 'measure' ? '4 3' : null} strokeLinecap="round" />
          <line x1={sx} y1={sy} x2={ex} y2={ey} stroke="transparent" strokeWidth="14" />
          {d.type === 'arrow' && (() => {
            const a = Math.atan2(ey - sy, ex - sx), L = 11;
            return <path d={'M' + ex + ' ' + ey + 'L' + (ex - L * Math.cos(a - 0.42)) + ' ' + (ey - L * Math.sin(a - 0.42)) + 'M' + ex + ' ' + ey + 'L' + (ex - L * Math.cos(a + 0.42)) + ' ' + (ey - L * Math.sin(a + 0.42))} stroke={c} strokeWidth={isSel ? 3 : 2} strokeLinecap="round" fill="none" />;
          })()}
          {canHandle
            ? <CaHandle x={x1} y={y1} isSel={isSel} color={c} active={isEditing && dragMeta.current.key === 'a'} onPointerDown={ev => startAnchorDrag(ev, d, 'a')} />
            : <circle cx={x1} cy={y1} r={isSel ? 4.6 : 3.2} fill="#fff" stroke={c} strokeWidth="2.2" />}
          {B && (canHandle
            ? <CaHandle x={x2} y={y2} isSel={isSel} color={c} active={isEditing && dragMeta.current.key === 'b'} onPointerDown={ev => startAnchorDrag(ev, d, 'b')} />
            : <circle cx={x2} cy={y2} r={isSel ? 4.6 : 3.2} fill="#fff" stroke={c} strokeWidth="2.2" />)}
        </g>);
        if (wantPanel) panels.push(<CaPanel key={'p' + key} st={st} g={g} color={c} ax={Math.max(x1, x2)} ay={Math.min(y1, y2) - 8} />);

      } else if (d.type === 'channel' || d.type === 'regression') {
        const geo = st && st.geo;
        const isReg = d.type === 'regression';
        const P = i => g.Y(geo ? geo.intercept + geo.slope * i : A.val);
        const lo = geo ? geo.lo : Math.min(A.ix, B ? B.ix : A.ix), hi = geo ? geo.hi : Math.max(A.ix, B ? B.ix : A.ix);
        const xl = g.X(lo), xh = g.X(hi);
        const hwPx = geo ? Math.abs(g.Y(0) - g.Y(geo.hw)) : 12;
        /* Handles sit on the anchors the user actually clicked — never on the
           fitted line, which necessarily moves as the least-squares fit is
           recomputed. This is what made the first regression anchor appear to
           drift while the second point was being previewed: the anchor was
           locked all along, but the handle drawn for it was not the anchor. */
        const h1x = isReg ? x1 : xl, h1y = isReg ? y1 : P(lo);
        const h2x = isReg ? x2 : xh, h2y = isReg ? y2 : P(hi);
        nodes.push(<g key={key} {...common}>
          <path d={'M' + xl + ' ' + (P(lo) - hwPx) + 'L' + xh + ' ' + (P(hi) - hwPx) + 'L' + xh + ' ' + (P(hi) + hwPx) + 'L' + xl + ' ' + (P(lo) + hwPx) + 'Z'}
            fill={c} fillOpacity=".08" stroke={c} strokeOpacity={isSel ? '.7' : '.4'} strokeWidth="1.2" strokeDasharray="4 4" />
          <line x1={xl} y1={P(lo)} x2={xh} y2={P(hi)} stroke={c} strokeWidth={isSel ? 2.6 : 2} strokeLinecap="round" />
          <line x1={xl} y1={P(lo)} x2={xh} y2={P(hi)} stroke="transparent" strokeWidth="14" />
          {isReg && <g pointerEvents="none">
            {/* the two anchors are RANGE boundaries — mark them the way a
                regression trend does, so it reads as a window over the data */}
            <line x1={h1x} y1={g.padT} x2={h1x} y2={g.padT + g.ih} stroke={c} strokeWidth="1" strokeDasharray="3 4" opacity=".28" />
            {B && <line x1={h2x} y1={g.padT} x2={h2x} y2={g.padT + g.ih} stroke={c} strokeWidth="1" strokeDasharray="3 4" opacity=".28" />}
            <line x1={h1x} y1={h1y} x2={h1x} y2={P(lo)} stroke={c} strokeWidth="1.4" opacity=".5" />
            {B && <line x1={h2x} y1={h2y} x2={h2x} y2={P(hi)} stroke={c} strokeWidth="1.4" opacity=".5" />}
          </g>}
          {canHandle
            ? <CaHandle x={h1x} y={h1y} isSel={isSel} color={c} active={isEditing && dragMeta.current.key === 'a'} onPointerDown={ev => startAnchorDrag(ev, d, 'a')} />
            : <circle cx={h1x} cy={h1y} r={isSel ? 4.6 : 3.2} fill="#fff" stroke={c} strokeWidth="2.2" />}
          {canHandle
            ? <CaHandle x={h2x} y={h2y} isSel={isSel} color={c} active={isEditing && dragMeta.current.key === 'b'} onPointerDown={ev => startAnchorDrag(ev, d, 'b')} />
            : <circle cx={h2x} cy={h2y} r={isSel ? 4.6 : 3.2} fill="#fff" stroke={c} strokeWidth="2.2" />}
          {isReg && d.c && (() => {
            const C2 = caResolve(d.c, labels);
            const cx = g.X(C2.ix), cy = g.Y(C2.val);
            return canHandle
              ? <CaHandle x={cx} y={cy} isSel={isSel} color={c} active={isEditing && dragMeta.current.key === 'c'} onPointerDown={ev => startAnchorDrag(ev, d, 'c')} />
              : <circle cx={cx} cy={cy} r="3.2" fill="#fff" stroke={c} strokeWidth="2.2" opacity=".85" />;
          })()}
        </g>);
        if (wantPanel) panels.push(<CaPanel key={'p' + key} st={st} g={g} color={c} ax={Math.max(xh, h2x)} ay={Math.min(P(hi) - hwPx - 10, h2y)}
          avoid={{ x0: Math.min(xl, h1x) - 6, x1: Math.max(xh, h2x) + 6, y0: Math.min(P(lo), P(hi)) - hwPx - 6, y1: Math.max(P(lo), P(hi)) + hwPx + 6 }} />);

      } else if (d.type === 'pitchfork') {
        const geo = st && st.geo;
        if (geo) {
          const endIx = labels.length - 1;
          const yAt = i => g.Y(geo.va + geo.slope * (i - geo.ia));
          const xa = g.X(geo.ia), xe = g.X(endIx);
          const upPx = Math.abs(g.Y(0) - g.Y(Math.max(geo.up, geo.dn))) * (Math.max(geo.up, geo.dn) < 0 ? -1 : 1);
          const dnPx = Math.abs(g.Y(0) - g.Y(Math.min(geo.up, geo.dn))) * (Math.min(geo.up, geo.dn) < 0 ? -1 : 1);
          nodes.push(<g key={key} {...common}>
            <path d={'M' + xa + ' ' + yAt(geo.ia) + 'L' + xe + ' ' + (yAt(endIx) - upPx) + 'L' + xe + ' ' + (yAt(endIx) - dnPx) + 'Z'} fill={c} fillOpacity=".07" stroke="none" />
            <line x1={xa} y1={yAt(geo.ia)} x2={xe} y2={yAt(endIx)} stroke={c} strokeWidth={isSel ? 2.6 : 2} />
            <line x1={xa} y1={yAt(geo.ia)} x2={xe} y2={yAt(endIx) - upPx} stroke={c} strokeWidth="1.4" strokeDasharray="5 4" opacity=".8" />
            <line x1={xa} y1={yAt(geo.ia)} x2={xe} y2={yAt(endIx) - dnPx} stroke={c} strokeWidth="1.4" strokeDasharray="5 4" opacity=".8" />
            <line x1={xa} y1={yAt(geo.ia)} x2={xe} y2={yAt(endIx)} stroke="transparent" strokeWidth="14" />
            {canHandle
              ? <CaHandle x={xa} y={yAt(geo.ia)} isSel={isSel} color={c} active={isEditing && dragMeta.current.key === 'a'} onPointerDown={ev => startAnchorDrag(ev, d, 'a')} />
              : <circle cx={xa} cy={yAt(geo.ia)} r={isSel ? 4.6 : 3.2} fill="#fff" stroke={c} strokeWidth="2.2" />}
          </g>);
          if (wantPanel) panels.push(<CaPanel key={'p' + key} st={st} g={g} color={c} ax={xa} ay={yAt(geo.ia) + 10} />);
        }

      } else if (d.type === 'hline') {
        nodes.push(<g key={key} {...common}>
          <line x1={g.padL} y1={y1} x2={g.padL + g.iw} y2={y1} stroke={c} strokeWidth={isSel ? 2.6 : 1.8} strokeDasharray="6 4" />
          <line x1={g.padL} y1={y1} x2={g.padL + g.iw} y2={y1} stroke="transparent" strokeWidth="14" />
          <rect x={g.padL + g.iw - 66} y={y1 - 9} width="62" height="17" rx="5" fill={c} />
          <text x={g.padL + g.iw - 35} y={y1 + 3.5} textAnchor="middle" fontSize="9.5" fontWeight="800" fill="#fff">{window.IC.short(A.val)}</text>
          {canHandle && <CaHandle x={g.padL + g.iw / 2} y={y1} isSel={isSel} color={c} active={isEditing} onPointerDown={ev => startAnchorDrag(ev, d, 'a')} />}
        </g>);
        if (wantPanel) panels.push(<CaPanel key={'p' + key} st={st} g={g} color={c} ax={g.padL - 2} ay={y1 + 10} />);

      } else if (d.type === 'vline') {
        const li = Math.round(A.ix);
        nodes.push(<g key={key} {...common}>
          <line x1={x1} y1={g.padT} x2={x1} y2={g.padT + g.ih} stroke={c} strokeWidth={isSel ? 2.6 : 1.8} strokeDasharray="6 4" />
          <line x1={x1} y1={g.padT} x2={x1} y2={g.padT + g.ih} stroke="transparent" strokeWidth="14" />
          <rect x={x1 - 26} y={g.padT - 2} width="52" height="17" rx="5" fill={c} />
          <text x={x1} y={g.padT + 10.5} textAnchor="middle" fontSize="9" fontWeight="800" fill="#fff">{labels[li] || ''}</text>
          {canHandle && <CaHandle x={x1} y={g.padT + g.ih / 2} isSel={isSel} color={c} active={isEditing} onPointerDown={ev => startAnchorDrag(ev, d, 'a')} />}
        </g>);
        if (wantPanel) panels.push(<CaPanel key={'p' + key} st={st} g={g} color={c} ax={x1 - 2} ay={g.padT + 22} />);

      } else if (d.type === 'rect' || d.type === 'zoneRisk' || d.type === 'zoneOpp') {
        const zc = d.type === 'zoneRisk' ? '#DC2626' : d.type === 'zoneOpp' ? '#16A34A' : c;
        const rx = Math.min(x1, x2), rw = Math.abs(x2 - x1);
        const ry = d.type === 'rect' ? Math.min(y1, y2) : g.padT;
        const rh = d.type === 'rect' ? Math.abs(y2 - y1) : g.ih;
        nodes.push(<g key={key} {...common}>
          <rect x={rx} y={ry} width={rw} height={rh} rx="4" fill={zc} fillOpacity={d.type === 'rect' ? '.09' : '.10'} stroke={zc} strokeOpacity={isSel ? '.9' : '.5'} strokeWidth={isSel ? 2 : 1.3} strokeDasharray={d.type === 'rect' ? null : '5 4'} />
          {d.type !== 'rect' && (() => {
            const zt = d.type === 'zoneRisk' ? 'RISK ZONE' : 'OPPORTUNITY ZONE';
            const zw = zt.length * 6.1 + 12;
            return <g><rect x={rx + 4} y={ry + 4} width={zw} height="16" rx="4" fill={zc} />
              <text x={rx + 4 + zw / 2} y={ry + 15.5} textAnchor="middle" fontSize="9" fontWeight="800" fill="#fff" letterSpacing=".06em">{zt}</text></g>;
          })()}
          {canHandle
            ? <CaHandle x={x1} y={y1} isSel={isSel} color={zc} active={isEditing && dragMeta.current.key === 'a'} onPointerDown={ev => startAnchorDrag(ev, d, 'a')} />
            : null}
          {canHandle
            ? <CaHandle x={x2} y={y2} isSel={isSel} color={zc} active={isEditing && dragMeta.current.key === 'b'} onPointerDown={ev => startAnchorDrag(ev, d, 'b')} />
            : null}
        </g>);
        if (wantPanel) panels.push(<CaPanel key={'p' + key} st={st} g={g} color={zc} ax={rx + rw - 2} ay={ry + 6} />);

      } else if (d.type === 'ellipse') {
        nodes.push(<g key={key} {...common}>
          <ellipse cx={(x1 + x2) / 2} cy={(y1 + y2) / 2} rx={Math.abs(x2 - x1) / 2} ry={Math.abs(y2 - y1) / 2} fill={c} fillOpacity=".08" stroke={c} strokeOpacity={isSel ? '.95' : '.6'} strokeWidth={isSel ? 2.4 : 1.8} />
          {canHandle && <CaHandle x={x1} y={y1} isSel={isSel} color={c} active={isEditing && dragMeta.current.key === 'a'} onPointerDown={ev => startAnchorDrag(ev, d, 'a')} />}
          {canHandle && <CaHandle x={x2} y={y2} isSel={isSel} color={c} active={isEditing && dragMeta.current.key === 'b'} onPointerDown={ev => startAnchorDrag(ev, d, 'b')} />}
        </g>);
        if (wantPanel) panels.push(<CaPanel key={'p' + key} st={st} g={g} color={c} ax={Math.max(x1, x2) - 2} ay={Math.min(y1, y2)} />);

      } else if (d.type === 'note') {
        const words = String(d.text || 'Note').split(/\s+/);
        const lns = []; let cur = '';
        words.forEach(w => { if ((cur + ' ' + w).trim().length > 26) { lns.push(cur.trim()); cur = w; } else cur = (cur + ' ' + w).trim(); });
        if (cur) lns.push(cur);
        const bw = Math.min(190, Math.max.apply(null, lns.map(l => l.length * 5.6)) + 20);
        const bh = lns.length * 13 + 16;
        const bx = stClamp(x1, g.padL, g.padL + g.iw - bw), by = stClamp(y1 - bh - 8, g.padT, g.padT + g.ih - bh);
        nodes.push(<g key={key} {...common}>
          <line x1={x1} y1={y1} x2={bx + bw / 2} y2={by + bh} stroke={c} strokeWidth="1.2" strokeDasharray="3 3" opacity=".6" />
          {canHandle
            ? <CaHandle x={x1} y={y1} isSel={isSel} color={c} active={isEditing} onPointerDown={ev => startAnchorDrag(ev, d, 'a')} />
            : <circle cx={x1} cy={y1} r="3.4" fill={c} />}
          <rect x={bx} y={by} width={bw} height={bh} rx="8" fill="#fff" stroke={isSel ? c : '#EDEBE8'} strokeWidth={isSel ? 2 : 1} style={{ filter: 'drop-shadow(0 6px 16px rgba(20,18,15,.12))' }} />
          {lns.map((l, i) => <text key={i} x={bx + 10} y={by + 18 + i * 13} fontSize="10.5" fill="#1A1917" fontWeight="600">{l}</text>)}
        </g>);
      }
    });
    if (snapVis && (draft || dragMeta.current)) {
      const sx = g.X(snapVis.ix), sy = g.Y(snapVis.val);
      const lbl = CA_SNAP_LBL[snapVis.kind] || 'snap';
      nodes.push(<g key="ca-snap" pointerEvents="none" style={{ animation: 'caSnapIn .15s cubic-bezier(.2,.8,.2,1)' }}>
        <circle cx={sx} cy={sy} r="9" fill="none" stroke="#F97316" strokeWidth="1.6" opacity=".9" />
        <circle cx={sx} cy={sy} r="2.2" fill="#F97316" />
        <text x={sx + 12} y={sy - 9} fontSize="8.6" fontWeight="800" fill="#F97316" letterSpacing=".06em">{lbl.toUpperCase()}</text>
      </g>);
    }
    return nodes.concat(panels);
  }

  const selDraw = draws.find(d => d.id === sel);
  const selStats = selDraw && SM ? SM.stats(selDraw, env, null) : null;

  const plotH = fs ? Math.max(300, vh - 152) : 420;
  const hintMsg = tool !== 'select'
    ? (draft && draft.stage === 2 ? (tool === 'regression' ? 'Both anchors locked — move to set the channel width, click to finish' : 'First two points locked — click to set the third point of the pitchfork')
      : toolDef.pts === 1 ? 'Click to place the ' + toolDef.label.toLowerCase()
        : draft ? 'First anchor locked — click (or release) to confirm the second point'
          : 'Click to lock the first anchor, then move and click again')
    : (sel ? 'Selected · ' + caName(draws.find(d => d.id === sel) || {}) : (draws.length ? 'Select an object to read its measurement' : 'Pick a tool and click the chart to begin'));
  const toolBtn = t => (
    <button key={t.id} className={'st-tool' + (tool === t.id ? ' on' : '')} title={t.label + '  ·  ' + t.k + (t.d ? '\n' + t.d : '')}
      onClick={() => { setTool(t.id); setDraftBoth(null); setSnapVis(null); }} aria-label={t.label} aria-pressed={tool === t.id}>
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d={t.icon} /></svg>
    </button>
  );
  const toggleFs = () => { const n = !fs; setFs(n); if (n) setLayers(true); };

  return (
    <div>
      <style>{'@keyframes caSnapIn{from{opacity:0;transform:scale(1.6)}to{opacity:1;transform:scale(1)}}@keyframes caFsIn{from{opacity:0;transform:scale(.985)}to{opacity:1;transform:scale(1)}}@keyframes caPop{from{opacity:0;transform:translateY(-4px) scale(.97)}to{opacity:1;transform:none}}'}</style>
      <div className="ca-head">
        <span className="ca-head-k">Canvas</span>
        <span className="ca-head-s">Every object is an instrument — anchored to a bucket and a value, measured against live records, never written back.</span>
        <button className={'ca-help' + (guide ? ' on' : '')} onClick={() => { if (guide) closeGuide(); else setGuide(true); }}
          aria-expanded={guide} aria-controls="ca-guide-card" title="Canvas guide  ·  ?">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><path d="M12 16.4v.01M9.6 9.4a2.5 2.5 0 114 2.2c-.9.6-1.6 1-1.6 2" /></svg>
          Canvas guide
        </button>
      </div>

      <CaPortal on={fs} mode={ctx.mode}>
      <div className={'st-canvas-wrap' + (fs ? ' ca-fs' : '')}>
        {fs && (
          <div className="ca-fs-bar">
            <span className="ca-fs-t"><i></i>Analysis Studio</span>
            <div className="ca-fs-metrics">
              {opts.map(o => <button key={o} className={'i-ctl sm' + (metric === o ? ' on' : '')} onClick={() => setMetric(o)}>{IEc2.METRICS[o].label}</button>)}
            </div>
            <span className="ca-fs-s">{ctx.ser.grain} · {ctx.period.label}</span>
            <input className="st-tool-input ca-fs-name" value={wsName} onChange={e => setWsName(e.target.value)} placeholder="Name this analysis…"
              onKeyDown={e => { if (e.key === 'Enter') saveWorkspace(); }} />
            <button className="i-ctl" onClick={saveWorkspace} disabled={!draws.length}>Save</button>
            <button className="i-ctl" onClick={() => setFs(false)}>Exit · Esc</button>
          </div>
        )}
        <div className="st-tools" role="toolbar" aria-label="Drawing tools">
          {CA_GRPS.map((grp, gi) => {
            const list = CA_TOOLS.filter(t => t.grp === grp);
            if (!list.length) return null;
            return (
              <React.Fragment key={grp}>
                {gi > 0 && <span className="st-tool-sep"></span>}
                <span className="ca-grp-k">{grp}</span>
                {list.map(toolBtn)}
              </React.Fragment>
            );
          })}
          <span className="st-tool-sep"></span>
          <span className="ca-grp-k">Colour</span>
          {CA_COLORS.map(c => (
            <button key={c} className={'st-tool st-swatch' + (color === c ? ' on' : '')} onClick={() => setColor(c)} aria-label={'Colour ' + c} title={'Colour ' + c}
              style={{ '--sw': c }}><span></span></button>
          ))}
          <span className="st-tool-sep"></span>
          <span className="ca-grp-k">Zoom</span>
          <button className="st-tool ca-zoom-btn" title="Zoom out  ·  ⌘−" onClick={zoomOut} aria-label="Zoom out" disabled={zoomShown <= zoomMin * 1.02}>
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><circle cx="10" cy="10" r="7" /><path d="M7 10h6M15.2 15.2L20 20" /></svg>
          </button>
          <span className="ca-zoom-pct" title="Visible scale · the container never changes size">{zoomLbl}</span>
          <button className="st-tool ca-zoom-btn" title="Zoom in  ·  ⌘+" onClick={zoomIn} aria-label="Zoom in" disabled={zoomShown >= zoomMax - 0.01}>
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><circle cx="10" cy="10" r="7" /><path d="M10 7v6M7 10h6M15.2 15.2L20 20" /></svg>
          </button>
          <button className="st-tool ca-zoom-btn" title="Fit to screen  ·  ⌘0 · double-click the chart" onClick={zoomReset} aria-label="Fit to screen" disabled={!view}>
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="7.5" /><path d="M12 3.2v3M12 17.8v3M3.2 12h3M17.8 12h3" /></svg>
          </button>
          <span className="st-tool-sep"></span>
          <span className="ca-grp-k">History</span>
          <button className="st-tool" title="Undo  ·  ⌘Z" onClick={undo} aria-label="Undo" disabled={!hist.current.past.length}>
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round"><path d="M9 14L4 9l5-5" /><path d="M4 9h10a6 6 0 010 12H9" /></svg>
          </button>
          <button className="st-tool" title="Redo  ·  ⇧⌘Z" onClick={redo} aria-label="Redo" disabled={!hist.current.future.length}>
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round"><path d="M15 14l5-5-5-5" /><path d="M20 9H10a6 6 0 000 12h5" /></svg>
          </button>
          <button className="st-tool" title="Delete selected  ·  ⌫" onClick={() => sel && remove(sel)} aria-label="Delete selected" disabled={!sel}>
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round"><path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" /></svg>
          </button>
          <button className="st-tool" title="Clear all" onClick={clear} aria-label="Clear all" disabled={!draws.length}>
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round"><path d="M6 6l12 12M18 6L6 18" /></svg>
          </button>
          <span className="st-tool-sep"></span>
          <span className="ca-grp-k">View</span>
          <button className={'st-tool' + (showAll ? ' on' : '')} title={showAll ? 'Showing every readout' : 'Show every readout at once'} onClick={() => setShowAll(s => !s)} aria-pressed={showAll} aria-label="Toggle all readouts">
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M3 5h8v6H3zM13 5h8v3h-8zM13 10h8v3h-8zM3 13h8v6H3zM13 15h8v4h-8z" /></svg>
          </button>
          <button className={'st-tool' + (layers ? ' on' : '')} title="Objects  ·  L" onClick={() => setLayers(v => !v)} aria-pressed={layers} aria-label="Object layers">
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3l9 5-9 5-9-5 9-5M3 14l9 5 9-5" /></svg>
          </button>
          <span className="ca-tool-gap"></span>
          <button className={'st-tool' + (guide ? ' on' : '')} title="Canvas guide  ·  ?" onClick={() => { if (guide) closeGuide(); else setGuide(true); }} aria-pressed={guide} aria-label="Canvas guide">
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><path d="M12 16.4v.01M9.6 9.4a2.5 2.5 0 114 2.2c-.9.6-1.6 1-1.6 2" /></svg>
          </button>
          <button className={'st-tool ca-expand' + (fs ? ' on' : '')} title={(fs ? 'Exit full screen' : 'Expand analysis') + '  ·  F'} onClick={toggleFs} aria-pressed={fs} aria-label="Expand analysis">
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
              <path d={fs ? 'M9 4v5H4M15 20v-5h5M9 20v-5H4M15 4v5h5' : 'M4 9V4h5M20 15v5h-5M4 15v5h5M20 9V4h-5'} />
            </svg>
          </button>
        </div>
        <div className="st-tools2">
          <span className="st-tools-k">Metric</span>
          <div className="st-tool-metrics">
            {opts.map(o => <button key={o} className={'i-ctl sm' + (metric === o ? ' on' : '')} onClick={() => setMetric(o)}>{IEc2.METRICS[o].label}</button>)}
          </div>
          {tool === 'note'
            ? <input className="st-tool-input" value={noteText} onChange={e => setNoteText(e.target.value)} placeholder="Note text, then click the chart…" autoFocus />
            : <span className="st-tools-hint">{draws.length ? draws.length + ' object' + (draws.length === 1 ? '' : 's') + ' · select one to read its measurement' : 'Pick a tool and drag across the chart'}</span>}
        </div>
        <div className={'ca-plot' + (fs ? ' fs' : '')} style={{ position: 'relative', padding: '10px 14px 4px' }} onContextMenu={e => { if (!e.target.closest('.ca-obj')) { e.preventDefault(); setMenu(null); } }}>
          <StPlot height={plotH} labels={labels} yKind={meta.fmt}
            series={[{ key: 'v', label: meta.label, color: window.IC.MODE_COLOR[ctx.mode], values, area: true, width: 2.6 }]}
            overlay={overlay} pointer={pointer} cursor={tool === 'select' ? 'default' : CA_CURSORS[tool]} hideTip={tool !== 'select'}
            view={view} onView={applyView} wheelMode={fs ? 'always' : 'modifier'} />          {layers && (
            <div className="ca-layers">
              <div className="ca-lay-h">
                <b>Objects</b><span>{draws.length}</span>
                <button className="ca-lay-x" onClick={() => setLayers(false)} aria-label="Close objects panel" title="Close  ·  L">
                  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round"><path d="M6 6l12 12M18 6L6 18" /></svg>
                </button>
              </div>
              <div className="ca-lay-list">
                {!draws.length && <div className="ca-lay-empty">Nothing drawn yet. Pick a tool and click the chart — every object lands here.</div>}
                {draws.slice().reverse().map(d => (
                  <div key={d.id} className={'ca-lay' + (sel === d.id ? ' on' : '') + (d.hidden ? ' off' : '')}
                    onPointerDown={() => setSel(d.id)}
                    onContextMenu={e => { e.preventDefault(); setSel(d.id); setMenu({ x: e.clientX, y: e.clientY, id: d.id }); }}>
                    <span className="ca-lay-c" style={{ background: d.color }}></span>
                    <input className="ca-lay-n" value={caName(d)} onChange={e => patchQ(d.id, { name: e.target.value })} aria-label="Object name" />
                    <button title={d.hidden ? 'Show' : 'Hide'} aria-label={d.hidden ? 'Show' : 'Hide'} onClick={() => patch(d.id, { hidden: !d.hidden })}>
                      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round"><path d={d.hidden ? 'M4 4l16 16M10.6 6.3A9 9 0 0121 12a12 12 0 01-2.6 3.3M6.2 8.3A12.6 12.6 0 003 12a9.6 9.6 0 0013.2 3.9' : 'M3 12s3.6-6 9-6 9 6 9 6-3.6 6-9 6-9-6-9-6z'} /><circle cx="12" cy="12" r="2.4" /></svg>
                    </button>
                    <button title={d.locked ? 'Unlock' : 'Lock'} aria-label={d.locked ? 'Unlock' : 'Lock'} className={d.locked ? 'on' : ''} onClick={() => patch(d.id, { locked: !d.locked })}>
                      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round"><rect x="5" y="10" width="14" height="10" rx="2.4" /><path d={d.locked ? 'M8 10V7a4 4 0 018 0v3' : 'M8 10V7a4 4 0 017-2.6'} /></svg>
                    </button>
                    <button title="Duplicate" aria-label="Duplicate" onClick={() => duplicate(d.id)}>
                      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="11" height="11" rx="2.4" /><path d="M5 15V6a2 2 0 012-2h9" /></svg>
                    </button>
                    <button title="Remove" aria-label="Remove" onClick={() => remove(d.id)}>
                      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round"><path d="M6 6l12 12M18 6L6 18" /></svg>
                    </button>
                  </div>
                ))}
              </div>
            </div>
          )}
          {guide && (
            <div className="ca-guide" id="ca-guide-card" role="dialog" aria-label="Canvas guide">
              <div className="ca-guide-h">
                <b>Canvas guide</b>
                <button onClick={closeGuide} aria-label="Close guide">×</button>
              </div>
              <div className="ca-guide-b">
                <section>
                  <h6>Drawing</h6>
                  <p>Click once to <b>lock</b> the first anchor — it never drifts. Move to preview, click again to confirm. Press-and-drag works the same way. The regression channel takes three: start, end, then the band width.</p>
                </section>
                <section>
                  <h6>Navigating</h6>
                  <p>Wheel or pinch <b>zooms about the cursor</b> — bare in full screen, ⌘/Ctrl+wheel inside the page. Two-finger scroll and <kbd>⇧</kbd>+wheel pan the timeline; middle-drag or <kbd>Space</kbd>+drag pans; double-click fits back to the data. The chart scales inside its frame — the workspace never moves.</p>
                </section>
                <section>
                  <h6>Snapping</h6>
                  <p>Magnetism pulls onto peaks, lows, data points and existing anchors, ranked in that order. <kbd>Alt</kbd> draws free, <kbd>Shift</kbd> locks the angle to 0°/45°/90°.</p>
                </section>
                <section>
                  <h6>Editing</h6>
                  <p>Select an object for its handles — drag a handle to move one anchor, the body to move all of it. Right-click for colour, weight, opacity, lock, hide and order.</p>
                </section>
                <section>
                  <h6>Measurement</h6>
                  <p>Every readout is recomputed from the current ERP series on each render. Change a transaction, a filter or the period and the figures move with it.</p>
                </section>
                <section>
                  <h6>Shortcuts</h6>
                  <div className="ca-guide-keys">
                    <span><kbd>V</kbd>Select</span><span><kbd>T</kbd>Trend</span><span><kbd>R</kbd>Regression</span><span><kbd>M</kbd>Measure</span>
                    <span><kbd>P</kbd>Forecast</span><span><kbd>N</kbd>Note</span><span><kbd>L</kbd>Objects</span><span><kbd>F</kbd>Full screen</span>
                    <span><kbd>⌘Z</kbd>Undo</span><span><kbd>⇧⌘Z</kbd>Redo</span><span><kbd>⌘D</kbd>Duplicate</span><span><kbd>⌫</kbd>Delete</span>
                    <span><kbd>←→↑↓</kbd>Nudge</span><span><kbd>Esc</kbd>Cancel</span><span><kbd>?</kbd>This guide</span>
                    <span><kbd>⌘+</kbd>Zoom in</span><span><kbd>⌘−</kbd>Zoom out</span><span><kbd>⌘0</kbd>Fit to screen</span>
                    <span><kbd>Space</kbd>Pan</span><span><kbd>⇧</kbd>+wheel Pan X</span><span><kbd>⇧⇧</kbd>Double-click fits</span>
                  </div>
                </section>
              </div>
            </div>
          )}
          {tool !== 'select' && !fs && (
            <div className="st-hint">
              {hintMsg} · Shift locks the angle · Alt draws free · Esc cancels
            </div>
          )}
        </div>
        {fs && (
          <div className="ca-status">
            <div className="ca-status-l">
              <span><b>Tool</b>{toolDef.label}</span>
              <span><b>Snap</b>{snapVis ? (CA_SNAP_LBL[snapVis.kind] || 'magnetic') : 'magnetic'}</span>
              <span><b>Objects</b>{draws.length}{sel ? ' · 1 selected' : ''}</span>
            </div>
            <div className="ca-status-c">{hintMsg}</div>
            <div className="ca-status-r">
              <span><b>Bucket</b>{cur ? (labels[Math.round(stClamp(cur.ix, 0, labels.length - 1))] || '—') : '—'}</span>
              <span><b>{meta.label}</b>{cur ? window.IC.fmt(cur.val, meta.fmt) : '—'}</span>
              <span><b>Zoom</b>{zoomLbl}</span>
              <span className="ca-status-hint">Wheel zooms · Space-drag pans · double-click fits · ⌘Z undo</span>
            </div>
          </div>
        )}
        <div className="st-canvas-foot">
          <span className="st-canvas-foot-k">Saved analyses</span>
          <div className="st-ws-list">
            {ws.length === 0 && <span style={{ fontSize: 10.5, color: 'var(--iInk3)' }}>None yet — draw something and name it below.</span>}
            {ws.map(w => (
              <span key={w.name} className={'st-ws-item' + (active === w.name ? ' on' : '')} onClick={() => loadWorkspace(w)} title={(w.draws || []).length + ' annotations · saved on ' + w.period.label}>
                {w.name}<button onClick={e => { e.stopPropagation(); delWorkspace(w.name); }} aria-label="Delete">×</button>
              </span>
            ))}
          </div>
          <div style={{ display: 'flex', gap: 6, marginLeft: 'auto' }}>
            <input className="st-tool-input" style={{ width: 200 }} value={wsName} onChange={e => setWsName(e.target.value)} placeholder="July Analysis, Board Review…" onKeyDown={e => { if (e.key === 'Enter') saveWorkspace(); }} />
            <button className="i-ctl" onClick={saveWorkspace} disabled={!draws.length}>Save analysis</button>
          </div>
        </div>
      </div>
      </CaPortal>

      {menu && (() => {
        const d = draws.find(x => x.id === menu.id);
        if (!d) return null;
        const left = Math.min(menu.x, window.innerWidth - 226), top = Math.min(menu.y, window.innerHeight - 320);
        const op = d.opacity == null ? 1 : d.opacity;
        return (
          <CaPortal on={true} mode={ctx.mode}>
          <div className="ca-menu" style={{ left: Math.max(8, left), top: Math.max(8, top) }} onContextMenu={e => e.preventDefault()}>
            <div className="ca-menu-h"><span style={{ background: d.color }}></span>{caName(d)}</div>
            <button onClick={() => duplicate(d.id)}>Duplicate<i>⌘D</i></button>
            <button onClick={() => { patch(d.id, { locked: !d.locked }); setMenu(null); }}>{d.locked ? 'Unlock' : 'Lock'}</button>
            <button onClick={() => { patch(d.id, { hidden: !d.hidden }); setMenu(null); }}>{d.hidden ? 'Show' : 'Hide'}</button>
            <button onClick={() => { setSel(d.id); setShowAll(true); setMenu(null); }}>Show statistics</button>
            <button onClick={() => reorder(d.id, 'front')}>Bring to front</button>
            <button onClick={() => reorder(d.id, 'back')}>Send to back</button>
            <div className="ca-menu-sep"></div>
            <div className="ca-menu-row">
              <span>Colour</span>
              <div className="ca-menu-sws">
                {CA_COLORS.map(c => <button key={c} className={'ca-menu-sw' + (d.color === c ? ' on' : '')} style={{ '--sw': c }} onClick={() => patch(d.id, { color: c })} aria-label={'Colour ' + c}></button>)}
              </div>
            </div>
            <div className="ca-menu-row">
              <span>Weight</span>
              <div className="ca-menu-sws">
                {CA_WEIGHTS.map((w, i) => <button key={w} className={'ca-menu-w' + ((d.w || 1) === w ? ' on' : '')} onClick={() => patch(d.id, { w })} aria-label={'Weight ' + (i + 1)}><b style={{ height: 1 + i }}></b></button>)}
              </div>
            </div>
            <div className="ca-menu-row">
              <span>Opacity</span>
              <input type="range" min="20" max="100" value={Math.round(op * 100)} onChange={e => patchQ(d.id, { opacity: Number(e.target.value) / 100 })} aria-label="Opacity" />
              <em>{Math.round(op * 100)}%</em>
            </div>
            <div className="ca-menu-sep"></div>
            <button className="dgr" onClick={() => remove(d.id)}>Delete<i>⌫</i></button>
          </div>
          </CaPortal>
        );
      })()}

      {selStats && (
        <StSec title={'Measurement · ' + selStats.title} icon="ruler"
          sub="The full readout for the selected object, recomputed against live records. Change a transaction, a filter or the period and every figure below moves with it.">
          <div className="i-card">
            <div className="ca-readout">
              {selStats.rows.map((r, i) => (
                <div className={'ca-ro' + (r.em ? ' em' : '')} key={i}><span>{r.k}</span><b>{r.v}</b></div>
              ))}
            </div>
            {selStats.badge && selStats.badge.text && (
              <div className={'ca-verdict ' + (selStats.badge.tone || 'n')}>{selStats.badge.text}</div>
            )}
          </div>
        </StSec>
      )}

      {draws.length > 0 && (
        <StSec title="Annotations on this chart" icon="pen" sub="Each row lists the bucket and value it is anchored to, plus its headline measurement — both re-derived from live data, not stored with the drawing.">
          <div className="i-card">
            <div className="i-dl">
              {draws.map(d => {
                const t = CA_TOOLS.find(z => z.id === d.type);
                const sum = SM ? SM.summary(d, env) : '';
                return (
                  <div className="i-dl-row" key={d.id} onClick={() => setSel(d.id)} style={sel === d.id ? { background: '#FAF9F7', borderColor: 'var(--iLine)' } : null}>
                    <span style={{ width: 10, height: 10, borderRadius: 3, background: d.color, flexShrink: 0 }}></span>
                    <b>{t ? t.label : d.type}{d.text ? ' — ' + d.text : ''}</b>
                    <em>{d.a.label}{d.b ? ' → ' + d.b.label : ''}</em>
                    <i>{sum || window.IC.fmt(d.a.val, meta.fmt)}</i>
                    <button className="i-crumb" style={{ padding: '3px 8px', fontSize: 9.5 }} onClick={e => { e.stopPropagation(); remove(d.id); }}>Remove</button>
                  </div>
                );
              })}
            </div>
          </div>
        </StSec>
      )}
    </div>
  );
}

Object.assign(window, { StudioCanvas, CA_TOOLS, CaPanel, CA_CURSORS });
