/* ─────────────────────────────────────────────────────────────
   PKChart — responsive SVG pharmacokinetic chart (1:1 viewBox).
   ───────────────────────────────────────────────────────────── */
(function () {
  const { useState, useRef, useMemo, useEffect } = React;
  const fmt = window.PK.fmt;
  const DAY = window.PK.DAY;
  const COMPOUNDS = window.COMPOUNDS || {};
  // Category of a curve = its compound type (trt / glp1 / peptide); falls back to unit.
  // The dual-axis rule keys off this: one category → one axis, two+ → a second axis.
  function catOf(c) { const cc = COMPOUNDS[c.compoundId]; return cc ? cc.type : (c.unit || 'x'); }

  const PAD = { l: 14, r: 16, t: 22, b: 6 };  // small left/bottom pad — x & y labels sit inline on the plot
  const MON = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
  const MON_FULL = ['January','February','March','April','May','June','July','August','September','October','November','December'];
  const DOW_FULL = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
  function ordinal(n) { const s = ['th','st','nd','rd'], v = n % 100; return n + (s[(v - 20) % 10] || s[v] || s[0]); }
  // "Wednesday 24th May" — for the floating date above a grab handle while dragging
  function fmtLongDate(ms) { const dt = new Date(ms); return DOW_FULL[dt.getDay()] + ' ' + ordinal(dt.getDate()) + ' ' + MON_FULL[dt.getMonth()]; }

  function niceStepDays(span) {
    const opts = [1, 2, 3.5, 7, 14, 28, 56, 84];
    const target = span / 6.5;
    for (const o of opts) if (o >= target) return o;
    return 112;
  }
  // Axis top: the peak should fill ~90% of the plot height (not float at ~60%). Target
  // = peak / 0.9, then round UP to a small tidy increment (~5% of its magnitude — e.g. 5
  // in the 100s, 0.5 in the 10s) so the gridline labels stay readable while the curve
  // still fills the chart. e.g. peak 99 → target 110 → 110 (was 150 with nice-rounding).
  function niceMax(v) {
    if (!(v > 0)) return 1;
    const target = v / 0.9;
    const pow = Math.pow(10, Math.floor(Math.log10(target)));
    const step = pow / 20;
    return Math.ceil(target / step) * step;
  }
  function fmtDate(ms) { const d = new Date(ms); return d.getDate() + ' ' + MON[d.getMonth()]; }

  function PKChart({ curves, fromMs, toMs, showInj, showStats, bars, onTlStart, onTlFinish, onTlToggle, onReorderTo, onTlEdit, onDragActive, dimmed, noDrag, ghost, calculated, showHint }) {
    // noDrag (signed-out preview): the timeline bars render but can't be dragged,
    // resized, toggled or reordered — a hover tooltip points to the login features.
    var NODRAG_TIP = 'Sign in to drag dates, resize and reorder your cycle — free, connect your Google account in seconds.';
    const boxRef = useRef(null);
    const scrollRef = useRef(null);
    const centredKeyRef = useRef(null);   // enabled-set key the view was last centred for
    const snapRef = useRef(null);
    const snapFnRef = useRef(null);   // latest snapBack, called by the scrollend listener
    const snapAnimRef = useRef(null); // rAF id for the custom eased snap-back animation
    const pinchRef = useRef(null);    // active 2-finger pinch { dist0, zoom0 }
    const zoomRef = useRef(4.2);      // latest px/day (so the once-bound pinch listener reads it)
    const plotWRef = useRef(0);       // latest PLOT_W (same reason)
    const pendFracRef = useRef(null); // centre fraction to restore after a zoom re-render
    const centreTimeRef = useRef(null); // time (ms) currently under the viewport centre
    const prevCalcRef = useRef(false);  // tracks calculated, to detect the teaser→calculated press
    const recenterPrevRef = useRef([]); // enabled ids the re-centre effect last saw (to detect grow vs shrink)
    const scrollingRef = useRef(false); // true mid horizontal-scroll, so off-card placeholders follow the
    const scrollStopRef = useRef(null); // scroll instantly (no transition lag) but still animate on toggle
    const [hoverIdx, setHoverIdx] = useState(null);
    const [vpW, setVpW] = useState(360);   // scroll-viewport width
    const [vbH, setVbH] = useState(340);   // chart-box height
    const [zoom, setZoom] = useState(4.2);   // px per day on mobile (pinch-adjustable); ~4.2 ⇒ compact axis, less horizontal scroll
    const [, setScrollTick] = useState(0); // re-render the floating bars on scroll

    useEffect(() => {
      const ro = new ResizeObserver(() => {
        if (scrollRef.current && scrollRef.current.clientWidth) setVpW(scrollRef.current.clientWidth);
        if (boxRef.current) { const r = boxRef.current.getBoundingClientRect(); if (r.height) setVbH(r.height); }
      });
      if (scrollRef.current) ro.observe(scrollRef.current);
      if (boxRef.current) ro.observe(boxRef.current);
      return () => ro.disconnect();
    }, []);

    // Mobile (narrow): the chart width scales with the time span at a constant px/day,
    // so a longer range is a longer scroll rather than a more-compressed chart — and the
    // generous range (set in app.jsx) makes the lines continue, feeling like endless
    // scroll. Desktop fits the chart to the viewport.
    const span = (toMs - fromMs) / DAY;
    // matches the CSS @media(max-width:940) that turns on the scroll layout, so the
    // width-scaling and the spacer/scroll CSS agree (re-evaluated each render; vpW
    // changes on resize trigger the re-render).
    const isNarrow = typeof window !== 'undefined' && !!window.matchMedia && window.matchMedia('(max-width: 940px)').matches;
    const PX_PER_DAY = zoom;   // pinch-to-zoom adjusts this on mobile
    zoomRef.current = zoom;
    const VB_H = vbH;
    // Mobile: the chart always spills past the screen (≥ 2× the viewport) so the graph
    // runs off both edges and scrolls inside its container — full-bleed, never stretched
    // (viewBox === display width). Desktop fits the viewport.
    const VB_W = isNarrow ? Math.max(2 * vpW, Math.round(span * PX_PER_DAY)) : vpW;
    const scrollable = isNarrow && VB_W > vpW + 4;
    const PLOT_H = VB_H - PAD.t - PAD.b;

    // ── axes (category rule) ──────────────────────────────────────────────────────
    // ONE category of compound (e.g. all testosterone/steroid, same unit) → a single
    // left axis, ALWAYS — compounds in the same family share a scale even if their peaks
    // differ (a short-ester spike vs a long-ester plateau is real, not a reason to split).
    // TWO+ categories (e.g. hormone + peptide, or test + GLP-1) → a second right-hand
    // axis so the smaller-scale group isn't flattened; the minority category (smaller
    // peak) goes on the secondary axis. The Σ combined curve always stays on the primary.
    const axis = useMemo(() => {
      const mags = curves.map(c => { let m = 0; c.points.forEach(p => { if (p.v > m) m = p.v; }); return m; });
      const cats = [];
      curves.forEach(c => { if (c.combined) return; const k = catOf(c); if (cats.indexOf(k) < 0) cats.push(k); });
      let isSec, split;
      if (cats.length >= 2) {
        // peak magnitude per category → the smallest-peak category is the secondary one
        const peak = {};
        curves.forEach((c, i) => { if (c.combined) return; const k = catOf(c); peak[k] = Math.max(peak[k] || 0, mags[i]); });
        const secCat = cats.slice().sort((a, b) => (peak[a] || 0) - (peak[b] || 0))[0];
        isSec = curves.map(c => !c.combined && catOf(c) === secCat);
        split = true;
      } else {
        isSec = curves.map(() => false);   // single category → one shared axis
        split = false;
      }
      let priMax = 0, secMax = 0;
      curves.forEach((c, i) => {
        const m = mags[i];
        if (split && isSec[i]) { if (m > secMax) secMax = m; } else if (m > priMax) priMax = m;
      });
      return { split, isSec, yMaxP: niceMax(priMax) || 1, yMaxS: niceMax(secMax) || 1 };
    }, [curves]);

    const PADR = axis.split ? 50 : PAD.r;
    const PLOT_W = VB_W - PAD.l - PADR;
    plotWRef.current = PLOT_W;
    // each curve tagged with the axis it belongs to (_sec = right-hand axis)
    const acurves = curves.map((c, i) => Object.assign({}, c, { _sec: axis.split && axis.isSec[i] }));

    const x = (t) => PAD.l + ((t - fromMs) / (toMs - fromMs)) * PLOT_W;
    const yP = (v) => PAD.t + PLOT_H - (v / axis.yMaxP) * PLOT_H;
    const yS = (v) => PAD.t + PLOT_H - (v / axis.yMaxS) * PLOT_H;
    const yOf = (c) => (c && c._sec) ? yS : yP;

    // ── snap-back bounds. Start = 1 week before the earliest enabled card's start.
    // End = 6 weeks after the latest enabled card's finish. Both bracket the slider
    // cards directly and recompute from the live bars each render, so re-dating a card
    // moves the snap-back lines with it.
    const snapEb = (bars || []).filter(b => b.enabled);
    // Week numbering reference: Week 1 starts at the first dose (earliest enabled
    // protocol's start). Used to label the x-axis with the cycle week under each date.
    const firstDoseMs = snapEb.length ? Math.min.apply(null, snapEb.map(b => b.startMs)) : null;
    // Before Calculate (cards enabled but the teaser is still showing) the cards aren't
    // placed yet, so the amber boundaries sit at the chart's left/right edges. Once
    // calculated they bracket the cards: 1 week before the earliest start, 6 weeks after
    // the latest finish.
    const snapLo = snapEb.length ? (calculated ? x(Math.min.apply(null, snapEb.map(b => b.startMs)) - 7 * DAY) : PAD.l) : null;
    const snapHi = snapEb.length ? (calculated ? x(Math.max.apply(null, snapEb.map(b => b.finishMs)) + 42 * DAY) : (VB_W - PADR)) : null;

    // ── centred scroll readout (mobile) ──────────────────────────────────────────
    // When the chart overflows (mobile 2× width), the readout is pinned to the centre
    // of the scroll viewport and follows scroll instead of pointer hover. 50%-viewport
    // spacers each side let the start/end reach the centre; native overscroll snaps back.
    function centreIdx() {
      const sc = scrollRef.current; if (!sc) return null;
      const c0 = curves[0]; if (!c0 || !c0.points.length) return null;
      const frac = (sc.scrollLeft - PAD.l) / PLOT_W;   // scrollLeft == chart-x at centre
      return Math.max(0, Math.min(c0.points.length - 1, Math.round(frac * (c0.points.length - 1))));
    }
    // Soft snap-back: the range is padded generously so the lines keep going (endless
    // feel), but when the user stops scrolling past a snap bound, spring back to it.
    const SNAP_MARGIN = 56;   // a little rubber-band past the line, then snap
    // Custom eased scroll for the snap-back: stronger than the native smooth scroll (it
    // overrides leftover momentum by setting scrollLeft each frame) and smoother (a fixed
    // ease-out cubic instead of the browser's variable smooth-scroll curve).
    function animateScrollLeft(sc, to, dur) {
      if (snapAnimRef.current) cancelAnimationFrame(snapAnimRef.current);
      const from = sc.scrollLeft, dist = to - from;
      if (Math.abs(dist) < 1) { sc.scrollLeft = to; return; }
      const start = performance.now();
      function step(now) {
        const p = Math.min(1, (now - start) / dur);
        const e = 1 - Math.pow(1 - p, 3);   // ease-out cubic: firm pull, smooth settle
        sc.scrollLeft = from + dist * e;
        if (p < 1) snapAnimRef.current = requestAnimationFrame(step); else snapAnimRef.current = null;
      }
      snapAnimRef.current = requestAnimationFrame(step);
    }
    function snapBack() {
      const sc = scrollRef.current; if (!sc || !scrollable || snapLo == null || pinchRef.current) return;
      const c = sc.scrollLeft;
      if (c < snapLo - 1) animateScrollLeft(sc, Math.max(0, snapLo), 380);
      else if (c > snapHi + 1) animateScrollLeft(sc, snapHi, 380);
    }
    snapFnRef.current = snapBack;   // keep the scrollend listener pointed at the latest
    function onScroll() {
      // mid-scroll: off-card placeholders must track the moving centre line instantly (no
      // transition lag / rubber-banding). A short timeout re-enables their transition once
      // scrolling stops, so a card toggled off still animates to its placeholder spot.
      scrollingRef.current = true;
      if (scrollStopRef.current) clearTimeout(scrollStopRef.current);
      scrollStopRef.current = setTimeout(function () { scrollingRef.current = false; setTlTick(t => t + 1); }, 160);
      // remember the time under the viewport centre, so pressing Calculate can keep that
      // same time centred across the range change (instead of jerking to today).
      if (scrollable && PLOT_W) centreTimeRef.current = fromMs + ((scrollRef.current.scrollLeft - PAD.l) / PLOT_W) * (toMs - fromMs);
      // No per-frame clamp (that felt jerky) — let the user overscroll freely into the
      // continuing lines; the smooth snapBack on scroll-stop eases them back to the line.
      if (scrollable && ghost) {
        setScrollTick(t => t + 1);   // teaser: re-render to keep the pinned y-axis in place, but no crosshair readout
      } else if (scrollable) {
        const i = centreIdx();
        if (i != null) setHoverIdx(i); else setScrollTick(t => t + 1);
      }
      if (snapRef.current) clearTimeout(snapRef.current);
      snapRef.current = setTimeout(snapBack, 130);   // fallback for browsers w/o scrollend
    }

    // Reliable snap trigger: fire snapBack when scrolling fully stops (incl. iOS
    // momentum). scrollend covers modern browsers; touchend + a delay covers the rest.
    useEffect(() => {
      const sc = scrollRef.current; if (!sc) return;
      const run = function () { if (snapFnRef.current) snapFnRef.current(); };
      const onTouchEnd = function () { setTimeout(run, 220); };
      // cancel an in-flight snap if the user grabs the chart again (don't fight them)
      const onTouchStart = function () { if (snapAnimRef.current) { cancelAnimationFrame(snapAnimRef.current); snapAnimRef.current = null; } };
      sc.addEventListener('scrollend', run);
      sc.addEventListener('touchend', onTouchEnd, { passive: true });
      sc.addEventListener('touchstart', onTouchStart, { passive: true });
      return function () { sc.removeEventListener('scrollend', run); sc.removeEventListener('touchend', onTouchEnd); sc.removeEventListener('touchstart', onTouchStart); };
    }, []);

    // Pinch-to-zoom the time axis (mobile). Two fingers apart → more px/day (stretch,
    // longer scroll); together → fewer (shorten). The viewport-centre time is kept put
    // across the zoom (pendFracRef restored after the width re-render below).
    useEffect(() => {
      const sc = scrollRef.current; if (!sc) return;
      function dist(t) { return Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY); }
      function onStart(e) { if (e.touches.length === 2) pinchRef.current = { dist0: dist(e.touches) || 1, zoom0: zoomRef.current }; }
      function onMove(e) {
        if (e.touches.length !== 2 || !pinchRef.current) return;
        e.preventDefault();
        var ratio = dist(e.touches) / pinchRef.current.dist0;
        var nz = Math.max(3, Math.min(48, pinchRef.current.zoom0 * ratio));
        // remember the time currently at the viewport centre, to restore after re-render
        if (plotWRef.current) pendFracRef.current = (sc.scrollLeft - PAD.l) / plotWRef.current;
        setZoom(Math.round(nz * 100) / 100);
      }
      function onEnd(e) { if (!e.touches || e.touches.length < 2) pinchRef.current = null; }
      sc.addEventListener('touchstart', onStart, { passive: false });
      sc.addEventListener('touchmove', onMove, { passive: false });
      sc.addEventListener('touchend', onEnd);
      sc.addEventListener('touchcancel', onEnd);
      return function () {
        sc.removeEventListener('touchstart', onStart); sc.removeEventListener('touchmove', onMove);
        sc.removeEventListener('touchend', onEnd); sc.removeEventListener('touchcancel', onEnd);
      };
    }, []);

    // After a zoom re-render (VB_W changed), keep the same time under the viewport centre.
    useEffect(() => {
      if (pendFracRef.current == null) return;
      const sc = scrollRef.current; if (sc && PLOT_W) sc.scrollLeft = Math.max(0, PAD.l + pendFracRef.current * PLOT_W);
      pendFracRef.current = null;
      // eslint-disable-next-line
    }, [VB_W]);

    // Centre on today when the chart becomes scrollable AND whenever the SET of plotted
    // protocols changes (e.g. you tick a card on). Keying on the enabled-set — not on
    // fromMs/toMs — means activating a card re-centres cleanly on today, while dragging a
    // card's dates does NOT yank the view. Without this the scroll was left stale after a
    // range change and the snap-back animated it across the chart (the "spasm").
    // Plain useEffect (after paint): setting scrollLeft on the touch-scroll container
    // BEFORE paint (useLayoutEffect) raced iOS Safari's layout and spilled the page wide /
    // blocked the Calculate button. After-paint is iOS-safe (the new width has settled).
    useEffect(() => {
      if (!scrollable) return;
      const sc = scrollRef.current; if (!sc || !PLOT_W) return;
      // Re-centre when the plotted SET changes, AND on the teaser→calculated transition
      // (Calculate swaps the default window for the full date range — without re-centring,
      // the stale scroll position lands out of bounds and snap-back yanks it: the "spasm").
      const curEnabled = (bars || []).filter(b => b.enabled).map(b => b.id);
      // vpW is in the key so the view RE-CENTRES once the real viewport width arrives: on
      // first paint vpW is the 360 default and VB_W (=2·vpW) — and therefore x(today) — is
      // wrong, so the initial centre lands left-of-true. When the ResizeObserver corrects
      // vpW, PLOT_W changes, this effect re-runs and (new key) re-centres at the right spot.
      const setKey = (calculated ? 'c|' : 't|') + Math.round(vpW) + '|' + curEnabled.join(',');
      const justCalculated = calculated && !prevCalcRef.current;
      prevCalcRef.current = calculated;
      if (centredKeyRef.current === setKey) return;
      const prevEnabled = recenterPrevRef.current || [];
      const grew = curEnabled.some(id => prevEnabled.indexOf(id) < 0);
      const shrank = prevEnabled.some(id => curEnabled.indexOf(id) < 0);
      recenterPrevRef.current = curEnabled;
      centredKeyRef.current = setKey;
      // DEactivating a card must NOT re-centre — that scroll jump cancels the card's
      // slide-down animation and yanks the view. But the INITIAL load, activating a card
      // (grow) and the Calculate press all SHOULD centre. So only skip on a pure shrink
      // (a card removed with none added) — not on load (empty→empty) or any growth.
      if (shrank && !grew && !justCalculated) return;
      const tMs = (function () { const d = new Date(); d.setHours(0, 0, 0, 0); return d.getTime(); })();
      // On the Calculate press, KEEP the time the user was looking at under the centre (the
      // screen stays put while the lines draw) instead of jerking back to today. If the user
      // hasn't scrolled yet (no remembered centre), centre on the enabled cards' OWN span
      // midpoint — so a freshly-spanned card sits centred in the viewport instead of starting
      // at today (the viewport centre) and hanging half off the right edge. Other re-centres
      // (ticking a card on/off) still snap to today.
      let keepMs = tMs;
      if (justCalculated) {
        if (centreTimeRef.current != null) keepMs = centreTimeRef.current;
        else {
          const eb = (bars || []).filter(b => b.enabled);
          if (eb.length) keepMs = (Math.min.apply(null, eb.map(b => b.startMs)) + Math.max.apply(null, eb.map(b => b.finishMs))) / 2;
        }
      }
      const inRange = keepMs >= fromMs && keepMs <= toMs;
      // scrollLeft maps to the chart-x under the viewport CENTRE (the 50% spacers make that
      // true), so setting it centres keepMs — ~a month visible either side.
      sc.scrollLeft = Math.max(0, inRange ? (PAD.l + ((keepMs - fromMs) / (toMs - fromMs)) * PLOT_W) : (VB_W / 2));
      const c0 = curves[0];
      if (!ghost && c0 && c0.points.length) {
        const frac = (sc.scrollLeft - PAD.l) / PLOT_W;
        setHoverIdx(Math.max(0, Math.min(c0.points.length - 1, Math.round(frac * (c0.points.length - 1)))));
      }
      // eslint-disable-next-line
    }, [scrollable, PLOT_W, vpW, fromMs, toMs, bars, calculated]);

    const xTicks = useMemo(() => {
      const stepD = niceStepDays(span);
      const out = [];
      const start = new Date(fromMs); start.setHours(0, 0, 0, 0);
      let t = start.getTime();
      while (t < fromMs) t += stepD * DAY;
      let guard = 0;
      while (t <= toMs && guard < 40) {
        const d = new Date(t);
        out.push({ t, label: stepD >= 28 ? MON[d.getMonth()] : d.getDate() + ' ' + MON[d.getMonth()] });
        t += stepD * DAY; guard++;
      }
      return out;
    }, [fromMs, toMs, span]);

    const yTicks = useMemo(() => Array.from({ length: 5 }, (_, i) => (axis.yMaxP / 4) * i), [axis.yMaxP]);
    const yTicksS = useMemo(() => Array.from({ length: 5 }, (_, i) => (axis.yMaxS / 4) * i), [axis.yMaxS]);

    // vertical gridlines: faint weekly minor lines + brighter monthly major lines
    const vGrid = useMemo(() => {
      const weeks = [], months = [];
      const wk = new Date(fromMs); wk.setHours(0, 0, 0, 0);
      // anchor weekly lines to Mondays so they line up week-to-week
      wk.setDate(wk.getDate() - ((wk.getDay() + 6) % 7));
      let t = wk.getTime(), g = 0;
      while (t <= toMs && g < 400) { if (t >= fromMs) weeks.push(t); t += 7 * DAY; g++; }
      const mo = new Date(fromMs); mo.setDate(1); mo.setHours(0, 0, 0, 0);
      let mt = mo.getTime(); g = 0;
      while (mt <= toMs && g < 80) { if (mt >= fromMs) months.push(mt); const d = new Date(mt); d.setMonth(d.getMonth() + 1); mt = d.getTime(); g++; }
      return { weeks, months };
    }, [fromMs, toMs]);

    function buildLine(points, yf) {
      let d = '';
      points.forEach((p, i) => { d += (i ? 'L' : 'M') + x(p.t).toFixed(1) + ' ' + yf(p.v).toFixed(1) + ' '; });
      return d.trim();
    }
    function buildArea(points, yf) {
      if (!points.length) return '';
      let d = 'M' + x(points[0].t).toFixed(1) + ' ' + yf(0).toFixed(1) + ' ';
      points.forEach(p => { d += 'L' + x(p.t).toFixed(1) + ' ' + yf(p.v).toFixed(1) + ' '; });
      d += 'L' + x(points[points.length - 1].t).toFixed(1) + ' ' + yf(0).toFixed(1) + ' Z';
      return d;
    }

    function handleMove(e) {
      if (ghost) return;        // teaser chart: no crosshair / readout
      if (e.touches && e.touches.length > 1) return;   // ignore multi-touch (pinch)
      if (scrollable) return;   // mobile: the readout follows scroll, not pointer
      const svg = boxRef.current; if (!svg || !curves.length) return;
      const rect = svg.getBoundingClientRect();
      const cx = (e.touches ? e.touches[0].clientX : e.clientX) - rect.left;
      const frac = (cx - PAD.l) / PLOT_W;
      const nn = curves[0].points.length;
      let idx = Math.round(frac * (nn - 1));
      idx = Math.max(0, Math.min(nn - 1, idx));
      setHoverIdx(idx);
    }
    function handleLeave() { if (!scrollable) setHoverIdx(null); }

    // ── draggable cycle-timing bars (rendered below the chart, in the same scroll
    // container, sharing the chart's x() so they line up with the curves) ──
    const tlDragRef = useRef(null);
    const stripRef = useRef(null);    // the card-strip scroll container (for reorder auto-scroll)
    const autoScrollRef = useRef(null);
    const prevEnabledRef = useRef('');
    const lastTouchRef = useRef(0);   // de-dup the synthetic mouse events that follow a touch
    // When a card is made active it slides to the top (app.js re-sorts active-first); bring
    // the strip's scroll back to the top too, so the newly-active card stays in view even if
    // the user was scrolled to the bottom of the list.
    useEffect(function () {
      const ids = (bars || []).filter(function (b) { return b.enabled; }).map(function (b) { return b.id; });
      const prev = prevEnabledRef.current ? prevEnabledRef.current.split(',') : [];
      const grew = ids.some(function (id) { return prev.indexOf(id) < 0; });
      prevEnabledRef.current = ids.join(',');
      if (grew && stripRef.current) {
        try { stripRef.current.scrollTo({ top: 0, behavior: 'smooth' }); }
        catch (e) { stripRef.current.scrollTop = 0; }
      }
    }, [bars]);
    const [, setTlTick] = useState(0);
    const TL_ROW = isNarrow ? 32 : 32;   // mobile: bars (26+6, ~20% taller); desktop: 24+8

    // ── reorder geometry ───────────────────────────────────────────────────────
    // While reordering, the grabbed card follows the pointer in the strip's CONTENT
    // coordinates (so it keeps tracking even as the list auto-scrolls). Its target row is
    // that position rounded to the nearest slot; the other cards animate aside to open the
    // gap. Falls back to dy-from-origin if the strip ref isn't ready.
    function reorderRawY(d) {
      const el = stripRef.current;
      let y;
      if (el && d && d.clientY != null) {
        const r = el.getBoundingClientRect();
        y = (d.clientY - r.top) + el.scrollTop - TL_ROW / 2;
      } else {
        y = d ? (d.idx * TL_ROW + (d.dy || 0)) : 0;
      }
      const max = Math.max(0, ((d && d.total ? d.total : 1) - 1) * TL_ROW);
      return Math.max(0, Math.min(max, y));
    }
    function reorderTargetIdx(d) { return Math.round(reorderRawY(d) / TL_ROW); }
    // Auto-scroll the card list when the grabbed card is held near its top/bottom edge,
    // so rows that are off-screen (5+ cards) can still be reached. Runs as a rAF loop for
    // the life of the reorder so it keeps scrolling even when the pointer is still.
    function reorderAutoScroll() {
      const d = tlDragRef.current, el = stripRef.current;
      if (!d || !d.reorder || !el) { autoScrollRef.current = null; return; }
      let scrolled = false;
      if (el.scrollHeight > el.clientHeight + 1 && d.clientY != null) {
        const r = el.getBoundingClientRect(), EDGE = 26, SPEED = 10, before = el.scrollTop;
        if (d.clientY < r.top + EDGE) el.scrollTop = Math.max(0, el.scrollTop - SPEED);
        else if (d.clientY > r.bottom - EDGE) el.scrollTop = Math.min(el.scrollHeight - el.clientHeight, el.scrollTop + SPEED);
        scrolled = el.scrollTop !== before;
      }
      if (scrolled) setTlTick(t => t + 1);   // re-render only when the list actually moved
      autoScrollRef.current = requestAnimationFrame(reorderAutoScroll);
    }
    function tlLocalX(ev) { return (ev.touches ? ev.touches[0].clientX : ev.clientX) - boxRef.current.getBoundingClientRect().left; }
    function evXY(ev) { const t = ev.touches ? ev.touches[0] : ev; return { x: t.clientX, y: t.clientY }; }
    function tlMove(ev) {
      const d = tlDragRef.current; if (!d) return;
      const p = evXY(ev);
      const dxPx = p.x - d.ox, dyPx = p.y - d.oy;
      if (!d.moved && (Math.abs(dxPx) > 14 || Math.abs(dyPx) > 14)) {   // touch jitter tolerance (roomy so a still long-press still arms reorder + jiggle on mobile)
        d.moved = true;
        if (d.wasEnabled && onDragActive) onDragActive(true);   // a real drag started → hide the Calculate button until release
        // moved horizontally before the long-press fired → it's a move/resize, not reorder
        if (!d.reorder && Math.abs(dxPx) >= Math.abs(dyPx)) clearTimeout(d.timer);
      }
      // Inactive cards aren't positioned at their dates (they're centred ghost placeholders),
      // so they can't be moved/resized — a clean tap toggles them on; a bigger move just lets
      // the touch scroll (no preventDefault, no date change). This fixes "hard to click in /
      // the card thinks it's moving side to side".
      if (!d.wasEnabled) return;
      if (ev.cancelable) ev.preventDefault();
      if (d.reorder) { tlDragRef.current = Object.assign({}, d, { dy: dyPx, clientY: p.y, moved: true }); setTlTick(t => t + 1); return; }
      const dDays = Math.round(((dxPx / PLOT_W) * (toMs - fromMs)) / DAY);
      let s = d.origStart, f = d.origFinish;
      if (d.mode === 'move') { s = d.origStart + dDays * DAY; f = d.origFinish + dDays * DAY; }
      else if (d.mode === 'start') { s = Math.min(d.origStart + dDays * DAY, d.origFinish - DAY); }
      else { f = Math.max(d.origFinish + dDays * DAY, d.origStart + DAY); }
      tlDragRef.current = Object.assign({}, d, { startMs: s, finishMs: f, moved: true });
      setTlTick(t => t + 1);
    }
    function tlUp() {
      const d = tlDragRef.current; tlDragRef.current = null;
      if (d) clearTimeout(d.timer);
      if (d && d.moved && onDragActive) onDragActive(false);   // released → let the Calculate button return
      if (autoScrollRef.current) { cancelAnimationFrame(autoScrollRef.current); autoScrollRef.current = null; }
      window.removeEventListener('mousemove', tlMove); window.removeEventListener('mouseup', tlUp);
      window.removeEventListener('touchmove', tlMove); window.removeEventListener('touchend', tlUp);
      if (d) {
        if (!d.moved) {                              // a tap (no real movement) → ALWAYS toggle,
          // even if the long-press armed reorder mode — a tap-and-hold without dragging
          // should still toggle, not vanish.
          // A tap only activates the card — it does NOT set the dates (no onTlStart/
          // onTlFinish), so the bar keeps the compact placeholder size instead of jumping
          // to its full date-span width. It only takes its real width once the user
          // actually drags it (the drag-commit branch below sets the dates → dated → span).
          if (onTlToggle) onTlToggle(d.id);
        } else if (d.reorder) {                      // long-press + vertical drag → reorder
          const target = reorderTargetIdx(d);
          if (target !== d.idx && onReorderTo) onReorderTo(d.id, target);
        } else {                                     // drag → commit the new dates
          if (d.startMs !== d.origStart && onTlStart) onTlStart(d.id, d.startMs);
          if (d.finishMs !== d.origFinish && onTlFinish) onTlFinish(d.id, d.finishMs);
        }
      }
      setTlTick(t => t + 1);
    }
    function tlDown(ev, b, forceMode, idx, total) {
      if (ev.touches && ev.touches.length > 1) return;   // pinch, not a bar drag
      // A tap fires BOTH touchstart and (because React's touch listener is passive,
      // preventDefault can't stop it) a synthetic mousedown ~ms later. Without this
      // guard the bar toggled twice per tap → "can't click it in". Ignore the mouse
      // event that trails a recent touch.
      if (ev.touches) lastTouchRef.current = Date.now();
      else if (Date.now() - lastTouchRef.current < 700) return;
      if (ev.cancelable) ev.preventDefault();
      const px = tlLocalX(ev), p = evXY(ev);
      const EDGE = 16;
      let mode = forceMode || 'move';
      if (!forceMode) {
        if (Math.abs(px - x(b.startMs)) <= EDGE) mode = 'start';
        else if (Math.abs(px - x(b.finishMs)) <= EDGE) mode = 'finish';
      }
      const d = { id: b.id, mode, color: b.color, ox: p.x, oy: p.y, origStart: b.startMs, origFinish: b.finishMs, startMs: b.startMs, finishMs: b.finishMs, moved: false, reorder: false, dy: 0, clientY: p.y, idx: idx, total: total, wasEnabled: !!b.enabled };
      // long-press on the MIDDLE of an active bar → enter reorder mode (drag up/down)
      if (mode === 'move' && b.enabled && onReorderTo) {
        d.timer = setTimeout(function () {
          const cur = tlDragRef.current;
          if (cur && cur.id === b.id && !cur.moved) {
            cur.reorder = true; cur.clientY = cur.oy;
            if (!autoScrollRef.current) autoScrollRef.current = requestAnimationFrame(reorderAutoScroll);
            setTlTick(t => t + 1);
          }
        }, 320);
      }
      tlDragRef.current = d;
      setTlTick(t => t + 1);
      window.addEventListener('mousemove', tlMove); window.addEventListener('mouseup', tlUp);
      window.addEventListener('touchmove', tlMove, { passive: false }); window.addEventListener('touchend', tlUp);
    }
    function renderStrip() {
      if (!bars || !bars.length) return null;
      // Each card keeps its own row in the protocols' natural order. Toggling a card
      // on/off must NOT reshuffle the strip — the old active-first sort made tapped cards
      // jump to the top, which read as disorienting. Rows only move when the user
      // explicitly reorders (long-press drag), which reorders the underlying protocols.
      const sorted = bars.slice();
      // inactive cards track the centre line (the scroll position) so the user can
      // scroll to place them, then tap to drop them in. Active cards lock to their dates.
      const centreX = (scrollable && scrollRef.current) ? scrollRef.current.scrollLeft : null;
      // live reorder state: which card is grabbed (fromIdx), the row it's headed for
      // (tgtIdx) and its free-following y (draggedTopY). The other cards animate aside.
      const reorderDrag = (tlDragRef.current && tlDragRef.current.reorder) ? tlDragRef.current : null;
      const fromIdx = reorderDrag ? reorderDrag.idx : -1;
      const tgtIdx = reorderDrag ? reorderTargetIdx(reorderDrag) : -1;
      const draggedTopY = reorderDrag ? reorderRawY(reorderDrag) : 0;
      // more than 4 cards → cap the strip height and let it scroll vertically
      const MAX_VISIBLE_ROWS = 4;
      const stripStyle = sorted.length > MAX_VISIBLE_ROWS
        ? { height: (MAX_VISIBLE_ROWS * TL_ROW) + 'px', overflowY: 'auto', overflowX: 'hidden', touchAction: 'pan-y' }
        : { height: (sorted.length * TL_ROW) + 'px' };
      // The chart's vertical gridlines flow DOWN behind the card strip (mobile), so the
      // weekly/monthly lines continue past the plot and under the slider cards (which stay
      // on top). Drawn full content-height so they stay aligned even when the strip scrolls.
      const gridContentH = sorted.length * TL_ROW;
      const stripGrid = isNarrow && React.createElement('svg', {
        key: 'stripgrid', className: 'cp-strip-grid', width: VB_W, height: gridContentH,
        viewBox: '0 0 ' + VB_W + ' ' + gridContentH, preserveAspectRatio: 'none',
        style: { position: 'absolute', left: 0, top: 0, width: VB_W + 'px', height: gridContentH + 'px', pointerEvents: 'none' },
      },
        vGrid.weeks.map((t, i) => React.createElement('line', { key: 'swk' + i, x1: x(t), x2: x(t), y1: 0, y2: gridContentH, className: 'cp-gl-week' })),
        vGrid.months.map((t, i) => React.createElement('line', { key: 'smo' + i, x1: x(t), x2: x(t), y1: 0, y2: gridContentH, className: 'cp-gl-month' }))
      );
      return React.createElement('div', { className: 'cp-tl-strip', style: stripStyle, ref: stripRef, onScroll: () => setTlTick(t => t + 1) },
        stripGrid,
        sorted.map((b, idx) => {
          const d0 = tlDragRef.current;
          const dragging = d0 && d0.id === b.id;
          const reordering = dragging && d0.reorder;
          const live = dragging ? d0 : b;
          // off (unclicked) cards are placeholders: a full-screen-width (minus side padding)
          // ghost centred on the line (mobile), not their cycle-duration width. Active /
          // dragging cards keep their true date-spanning width.
          // OFF cards are uniform full-width placeholders (a clean, aligned selectable
          // list) — NOT date-spanning bars, which would each sit at their own start date
          // and look misaligned. On mobile they centre on the scroll line; on desktop they
          // span the plot. A card snaps to its real date span only once it's enabled.
          const isGhost = !b.enabled && !dragging;
          const fullW = Math.max(16, (VB_W - PADR) - PAD.l);
          // uniform placeholder width: inset ~5% each side → 90%, centred in the plot
          const ghostW = Math.max(16, (centreX != null ? (vpW - 24) : fullW) * 0.9);
          // A bar shows its real date SPAN only once the user has set its dates (dragged the
          // bar / edited) or is actively dragging it. Otherwise it stays the same compact,
          // centred width as the inactive cards — so enabling a card doesn't stretch it
          // off-screen across the whole (default) cycle.
          // Once Calculate has run (calculated) the chart shows real curves over the real
          // date range, so the bar takes its true date-span width — aligning with its curve
          // and the amber start/finish boundary lines. Before Calculate (the teaser) it
          // stays the compact centred placeholder so it doesn't stretch silly.
          const spanMode = b.enabled && (b.dated || calculated || (dragging && !reordering));
          const durPx = spanMode
            ? Math.max(16, x(live.finishMs) - x(live.startMs))
            : ghostW;
          const l = spanMode
            ? Math.max(0, Math.min(VB_W, x(live.startMs)))
            : (centreX != null ? Math.max(0, Math.min(VB_W - ghostW, centreX - ghostW / 2)) : (PAD.l + fullW * 0.05));   // mobile: centred on the line · desktop: 5% inset
          // reorder displacement: cards between the grabbed slot and the target shift one
          // row to open a gap; the grabbed card follows the pointer freely (draggedTopY).
          let slot = idx;
          if (reorderDrag && idx !== fromIdx) {
            if (fromIdx < tgtIdx && idx > fromIdx && idx <= tgtIdx) slot = idx - 1;
            else if (fromIdx > tgtIdx && idx >= tgtIdx && idx < fromIdx) slot = idx + 1;
          }
          const topPx = reordering ? draggedTopY : slot * TL_ROW;
          const displaced = !!reorderDrag && !reordering && slot !== idx;        // shuffled aside → fade
          const aboveGap = !!reorderDrag && !reordering && slot === tgtIdx - 1;   // card just above the drop point → lift
          // Active bars (mobile): the label + ✎ edit group tracks the centre line so you can
          // always read which bar you're over — clamped inside the bar so it stops at either
          // end, then resumes following once the line reaches the middle of the label again.
          // only wide (date-span) bars need the label to track the centre line; compact
          // uniform bars already sit centred so the label stays readable in place
          const following = spanMode && centreX != null;
          let groupStyle = { display: 'flex', alignItems: 'center', gap: 9, minWidth: 0 };
          if (following) {
            const groupW = Math.min(Math.max(0, durPx - 28), Math.max(40, (b.label || '').length * 6.6 + (onTlEdit ? 32 : 0)));
            const half = groupW / 2;
            const minC = 26 + half;            // clear the on/off tick at the left edge
            const maxC = durPx - 10 - half;    // stop before the right edge
            const cRel = (minC >= maxC) ? durPx / 2 : Math.max(minC, Math.min(maxC, centreX - l));
            groupStyle = Object.assign({ position: 'absolute', left: cRel + 'px', top: '50%', transform: 'translate(-50%, -50%)', maxWidth: Math.max(30, durPx - 22) + 'px' }, groupStyle);
          }
          const grip = (side) => React.createElement('span', {
            className: 'cp-tl-grip cp-tl-grip-' + (side === 'start' ? 'l' : 'r'),
            onMouseDown: (e) => { e.stopPropagation(); tlDown(e, b, side, idx, sorted.length); },
            onTouchStart: (e) => { e.stopPropagation(); tlDown(e, b, side, idx, sorted.length); },
          });
          // edit (✎) button — sits BESIDE the name (inside the label group). Opens the standard
          // popup to change dose/frequency; stops the press bubbling so it never drags/toggles.
          const editBtn = onTlEdit && React.createElement('button', {
            className: 'cp-tl-strip-edit', 'aria-label': 'Edit dose & frequency',
            onMouseDown: (e) => e.stopPropagation(), onTouchStart: (e) => e.stopPropagation(),
            onClick: (e) => { e.stopPropagation(); onTlEdit(b.id); },
            style: { flexShrink: 0, width: isNarrow ? 14 : 24, height: isNarrow ? 14 : 24, borderRadius: isNarrow ? 4 : 6, border: 'none', background: 'rgba(0,0,0,0.4)', color: '#fff', fontSize: isNarrow ? 9 : 14, lineHeight: 1, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0 },
          }, '✎');
          const labelGroup = React.createElement('span', { className: 'cp-tl-strip-labelwrap', style: groupStyle },
            React.createElement('span', { className: 'cp-tl-strip-name', style: { minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' } }, b.label),
            editBtn
          );
          return React.createElement('div', {
            key: b.id, className: 'cp-tl-strip-bar' + (b.enabled ? ' on' : ' off') + (reordering ? ' reorder' : ''),
            style: Object.assign(
              // inactive cards: faint colour gradient fill (~10%) + a soft outline, but the
              // card itself stays full-opacity so the label is readable; active = solid fill
              { '--c': b.color, top: topPx + 'px', left: l + 'px', width: durPx + 'px',
                // off cards (mobile only): animate left/width when toggled (so a deactivated
                // card slides down + into its placeholder spot instead of glitching across) —
                // but snap instantly while scrolling so they track the centre line without lag.
                transition: (b.enabled || !isNarrow) ? undefined : (scrollingRef.current ? 'none' : 'top .26s cubic-bezier(.2,.7,.2,1), left .26s cubic-bezier(.2,.7,.2,1), width .26s cubic-bezier(.2,.7,.2,1), opacity .18s'),
                background: b.enabled ? ('linear-gradient(180deg, rgba(255,255,255,0.4), rgba(255,255,255,0.06) 45%, rgba(0,0,0,0.3)), ' + b.color) : ('linear-gradient(180deg, ' + b.color + '26, ' + b.color + '0d)'), border: b.enabled ? undefined : ('1px solid ' + b.color + '80'), cursor: noDrag ? 'default' : undefined },
              reordering ? { zIndex: 20 } : null,
              displaced ? { opacity: 0.4 } : null,                                                       // card the grabbed one is passing over → see-through
              aboveGap ? { boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.3), 0 6px 18px rgba(0,0,0,0.5)' } : null   // card above the drop point → stronger shadow
            ),
            onMouseDown: noDrag ? undefined : (e) => tlDown(e, b, null, idx, sorted.length),
            onTouchStart: noDrag ? undefined : (e) => tlDown(e, b, null, idx, sorted.length),
            title: noDrag ? NODRAG_TIP : (b.enabled ? 'Tap to remove · drag ends to resize · long-press then drag up/down to reorder' : 'Tap to add'),
          },
            !noDrag && b.enabled && grip('start'),
            React.createElement('span', { className: 'cp-tl-strip-tick' }),
            labelGroup,
            !noDrag && b.enabled && grip('finish')
          );
        })
      );
    }

    const hoverT = hoverIdx != null && curves[0] ? curves[0].points[hoverIdx].t : null;
    const todayMs = useMemo(() => { const d = new Date(); d.setHours(0, 0, 0, 0); return d.getTime(); }, []);
    const showToday = todayMs >= fromMs && todayMs <= toMs;

    // ── live drag preview ────────────────────────────────────────────────────────
    // While a bar is being dragged (move OR start/finish resize, not reorder): grey the
    // whole chart and keep ONLY the dragged card's line bright. A move also slides that
    // line with the bar in real time (a pure x-shift); a resize leaves the line in place
    // (its shape recomputes on release, when the whole graph dulls pending recalculate).
    const mvDrag = (tlDragRef.current && tlDragRef.current.moved && !tlDragRef.current.reorder) ? tlDragRef.current : null;
    const movePreview = !!mvDrag;
    const moveId = mvDrag ? mvDrag.id : null;
    const moveDx = (mvDrag && mvDrag.mode === 'move') ? (x(mvDrag.startMs) - x(mvDrag.origStart)) : 0;

    const hoverX = hoverT != null ? x(hoverT) : 0;
    const boxLeft = hoverX > PAD.l + PLOT_W * 0.55;
    const rows = hoverIdx != null ? acurves.map(c => ({ c, v: c.points[hoverIdx] ? c.points[hoverIdx].v : 0 })) : [];

    // Value-chip layout. Each value floats beside its own line. Non-zero chips squeeze
    // in side by side (alternating L/R of the crosshair by height, nudged vertically so
    // none overlap). Chips at ~0 (washed out / not started) drop BELOW the plot and
    // stack, centred on the crosshair.
    const CHIP_GAP_PX = 13;
    const chipLayout = (function () {
      const MIN_GAP = 22;
      const baseY = PAD.t + PLOT_H;
      const zeroThr = (axis.yMaxP || 1) * 0.015;
      const info = rows.map(r => {
        const cy = yOf(r.c)(r.v);
        const vs = fmt(r.v);
        // show the compound's short name beside the value (e.g. "Test E  0.85") so each
        // floating pill says which line it belongs to. Combined curves use their label.
        const lbl = (r.c.short ? r.c.short + '  ' : '') + vs;
        return { r, cy, vs, lbl, chipW: 14 + lbl.length * 7, isZero: r.v <= zeroThr };
      });
      // non-zero: alternate sides up the chart, nudge down per side so none collide
      const normal = info.filter(o => !o.isZero).slice().sort((a, b) => a.cy - b.cy);
      const defSide = boxLeft ? -1 : 1;
      const lastY = {}; lastY['-1'] = -Infinity; lastY['1'] = -Infinity;
      normal.forEach((o, i) => {
        const side = (i % 2 === 0) ? defSide : -defSide;
        const py = Math.max(o.cy, lastY[String(side)] + MIN_GAP);
        lastY[String(side)] = py;
        o.side = side; o.px = side * (CHIP_GAP_PX + o.chipW / 2); o.py = py; o.centered = false;
      });
      // zero: stacked just below the plot, pill centred on the centre line
      info.filter(o => o.isZero).forEach((o, i) => {
        o.side = 0; o.px = 0; o.py = baseY + 18 + i * MIN_GAP; o.centered = true;
      });
      return info;
    })();

    if (!curves.length && !(bars && bars.length)) {
      return React.createElement('div', { className: 'cp-chart-box', ref: boxRef },
        React.createElement('div', { className: 'cp-empty' },
          React.createElement('div', { className: 'cp-empty-ico' }, '📈'),
          React.createElement('div', { className: 'cp-empty-t' }, 'No protocols on this chart'),
          React.createElement('div', { className: 'cp-empty-s' }, 'Toggle a saved protocol on, or assign one here with the 1 · 2 buttons.')
        )
      );
    }

    return React.createElement('div', { className: 'cp-chart-fixwrap', style: { position: 'relative' } },
      React.createElement('div', { className: 'cp-chart-scroll', ref: scrollRef, onScroll: onScroll },
      React.createElement('div', { className: 'cp-chart-spacer' }),
      React.createElement('div', { className: 'cp-chart-col', style: scrollable ? { width: VB_W + 'px', flex: '0 0 auto' } : undefined },
      React.createElement('div', { className: 'cp-chart-box', ref: boxRef, style: scrollable ? { width: VB_W + 'px' } : undefined },
      React.createElement('svg', {
        viewBox: `0 0 ${VB_W} ${VB_H}`, className: 'cp-svg',
        onMouseMove: handleMove, onMouseLeave: handleLeave,
        onTouchStart: handleMove, onTouchMove: handleMove, onTouchEnd: handleLeave,
      },
        React.createElement('defs', null,
          acurves.filter(c => !c.combined).map((c, i) => React.createElement('linearGradient', { key: i, id: 'cpg-' + c.id, x1: 0, y1: 0, x2: 0, y2: 1 },
            React.createElement('stop', { offset: '0%', stopColor: c.color, stopOpacity: 0.13 }),
            React.createElement('stop', { offset: '100%', stopColor: c.color, stopOpacity: 0.004 })
          ))
        ),

        yTicks.map((v, i) => React.createElement('g', { key: 'y' + i },
          React.createElement('line', { x1: PAD.l, y1: yP(v), x2: VB_W - PADR, y2: yP(v), className: 'cp-gl' }),
          // value label inline (desktop). On mobile (scrollable) the chart scrolls, so the
          // y-axis is drawn by a fixed overlay pinned to the device's left edge instead —
          // omitted here so it doesn't scroll away.
          !scrollable && React.createElement('text', { x: PAD.l + 3, y: yP(v) - 5, className: 'cp-axis-y-in' }, fmt(v))
        )),

        // right-hand (secondary) value axis — only when a series is out of scale
        axis.split && yTicksS.map((v, i) => React.createElement('text', {
          key: 'y2' + i, x: VB_W - PADR + 8, y: yS(v) + 3.5, className: 'cp-axis-y2',
        }, fmt(v))),

        // faint weekly minor gridlines
        vGrid.weeks.map((t, i) => React.createElement('line', { key: 'wk' + i, x1: x(t), x2: x(t), y1: PAD.t, y2: PAD.t + PLOT_H, className: 'cp-gl-week' })),
        // brighter monthly major gridlines
        vGrid.months.map((t, i) => React.createElement('line', { key: 'mo' + i, x1: x(t), x2: x(t), y1: PAD.t, y2: PAD.t + PLOT_H, className: 'cp-gl-month' })),
        // x-axis date labels
        xTicks.map((t, i) => {
          // cycle week of this tick (Week 1 = the week of the first dose); blank before it
          const wk = firstDoseMs != null ? Math.floor((t.t - firstDoseMs) / (7 * DAY)) + 1 : 0;
          return React.createElement('g', { key: 'x' + i },
            React.createElement('text', { x: x(t.t), y: VB_H - PAD.b - 18, className: 'cp-axis-x' }, t.label),
            wk >= 1 && React.createElement('text', { x: x(t.t), y: VB_H - PAD.b - 6, className: 'cp-axis-wk' }, 'Week ' + wk)
          );
        }),

        showToday && React.createElement('g', null,
          React.createElement('line', { x1: x(todayMs), y1: PAD.t, x2: x(todayMs), y2: PAD.t + PLOT_H, className: 'cp-today' }),
          React.createElement('text', { x: x(todayMs), y: PAD.t - 6, className: 'cp-today-lbl' }, 'TODAY')
        ),

        // dim the out-of-cycle regions (before the first / after the last snap line)
        scrollable && snapLo != null && React.createElement('g', null,
          snapLo > PAD.l && React.createElement('rect', { x: PAD.l, y: PAD.t, width: Math.max(0, snapLo - PAD.l), height: PLOT_H, className: 'cp-offcycle' }),
          snapHi < VB_W - PADR && React.createElement('rect', { x: snapHi, y: PAD.t, width: Math.max(0, (VB_W - PADR) - snapHi), height: PLOT_H, className: 'cp-offcycle' })
        ),
        // snap-back boundary lines (1 week before first / washout after last)
        scrollable && snapLo != null && React.createElement('g', null,
          React.createElement('line', { x1: snapLo, x2: snapLo, y1: PAD.t, y2: PAD.t + PLOT_H, className: 'cp-snapline' }),
          React.createElement('line', { x1: snapHi, x2: snapHi, y1: PAD.t, y2: PAD.t + PLOT_H, className: 'cp-snapline' })
        ),

        // persistent centre line — always shown on the scrollable (mobile) view, even
        // with no protocols ticked on, so the user has a reference to scroll against.
        scrollable && scrollRef.current && React.createElement('line', {
          x1: scrollRef.current.scrollLeft, x2: scrollRef.current.scrollLeft,
          y1: PAD.t, y2: PAD.t + PLOT_H, className: 'cp-cross',
        }),

        // Data layer (areas + stats + curves). While the chart is "dirty" (a change is
        // pending Calculate) the curves are FROZEN (drawn from the last-calculated snapshot)
        // and rendered GREYSCALE — so nothing auto-sums and the grey signals "press Update".
        // Guide / centre / today lines stay bright (they're outside this group).
        React.createElement('g', { style: { transition: 'opacity .25s, filter .25s', opacity: ghost ? 0.28 : (dimmed ? 0.78 : 1), filter: (!ghost && dimmed) ? 'grayscale(1)' : 'none' } },
          showStats && acurves.map((c) => React.createElement('g', { key: 's' + c.id, opacity: movePreview ? 0.12 : 1 },
            React.createElement('line', { x1: PAD.l, y1: yOf(c)(c.stats.steady), x2: VB_W - PADR, y2: yOf(c)(c.stats.steady), style: { stroke: c.color }, strokeWidth: 1, strokeDasharray: '5 5', opacity: 0.6 }),
            acurves.length === 1 && React.createElement('line', { x1: PAD.l, y1: yOf(c)(c.stats.peak), x2: VB_W - PADR, y2: yOf(c)(c.stats.peak), style: { stroke: c.color }, strokeWidth: 1, strokeDasharray: '2 4', opacity: 0.4 }),
            acurves.length === 1 && React.createElement('line', { x1: PAD.l, y1: yOf(c)(c.stats.trough), x2: VB_W - PADR, y2: yOf(c)(c.stats.trough), style: { stroke: c.color }, strokeWidth: 1, strokeDasharray: '2 4', opacity: 0.4 })
          )),

          (function () {
            const THRESH = 4;   // px: only near-TOUCHING points dot (keeps the dotting short,
            const CROSS_WIN = 1; // not running all the way back to the left peak along a shared tail)
            // every curve's y-in-pixels, so crossings/proximity can be detected per index
            const yPix = acurves.map(c => c.points.map(p => yOf(c)(p.v)));
            // overlap[ci][i] = curve ci is near-touching another curve at index i (within
            // THRESH), OR actually CROSSES one in the segment touching i (the vertical order
            // swaps between samples). A crossing dots only a SHORT window (±CROSS_WIN) around
            // the swap, so the dots sit at the intersection — not spread along the curve.
            const overlap = acurves.map((c, ci) => {
              const n = c.points.length;
              const ov = new Array(n).fill(false);
              for (let cj = 0; cj < acurves.length; cj++) {
                if (cj === ci) continue;
                const oc = acurves[cj];
                if (!oc.points || oc.points.length !== n) continue;
                for (let i = 0; i < n; i++) {
                  const d0 = yPix[ci][i] - yPix[cj][i];
                  if (Math.abs(d0) <= THRESH) ov[i] = true;
                  if (i < n - 1) {
                    const d1 = yPix[ci][i + 1] - yPix[cj][i + 1];
                    if (d0 === 0 || d1 === 0 || (d0 < 0) !== (d1 < 0)) {   // crossing in (i, i+1)
                      for (let k = i - CROSS_WIN; k <= i + 1 + CROSS_WIN; k++) { if (k >= 0 && k < n) ov[k] = true; }
                    }
                  }
                }
              }
              return ov;
            });
            // Split a curve into a SOLID sub-path (segments clear of other lines) and a
            // DOTTED sub-path (segments where it crosses/nears another line). The dots let
            // the other colour show through — applied to ALL lines (mobile + desktop),
            // including the combined Total (which used to go hollow there). A segment is
            // dotted if EITHER endpoint qualifies, so even a one-sample crossing shows dots.
            function splitPath(c, ci) {
              const yf = yOf(c), ov = overlap[ci], pts = c.points;
              let solid = '', dotted = '', sRun = false, dRun = false;
              for (let i = 0; i < pts.length - 1; i++) {
                const xa = x(pts[i].t).toFixed(1), ya = yf(pts[i].v).toFixed(1);
                const xb = x(pts[i + 1].t).toFixed(1), yb = yf(pts[i + 1].v).toFixed(1);
                if (ov[i] && ov[i + 1]) {            // both ends near/crossing → dotted segment (tight)
                  dotted += (dRun ? '' : ('M' + xa + ' ' + ya + ' ')) + 'L' + xb + ' ' + yb + ' ';
                  dRun = true; sRun = false;
                } else {                              // clear → solid segment
                  solid += (sRun ? '' : ('M' + xa + ' ' + ya + ' ')) + 'L' + xb + ' ' + yb + ' ';
                  sRun = true; dRun = false;
                }
              }
              return { solid: solid.trim(), dotted: dotted.trim() };
            }
            let rank = -1;
            return acurves.map((c, ci) => {
              if (!c.combined) rank++;
              // top of the stack (rank 0) is brightest; each one below is dimmer
              const op = c.combined ? 1 : Math.max(0.34, 1 - rank * 0.22);
              // during a sideways MOVE: the dragged line slides with the bar (a pure x
              // shift — the shape is unchanged) and stays bright; every other line dulls.
              const isMoving = movePreview && c.id === moveId;
              const eOp = movePreview ? (isMoving ? op : op * 0.16) : op;
              const sw = c.combined ? 1.8 : (rank === 0 ? 2.6 : 2.2);
              const tr = isMoving ? ('translate(' + moveDx.toFixed(1) + ' 0)') : undefined;
              // the combined Total keeps its glow; dot spacing scales with stroke width
              const glow = c.combined ? { filter: 'drop-shadow(0 0 3px ' + c.color + ') drop-shadow(0 0 5px ' + c.color + '66)' } : null;
              const dash = (sw * 0.55).toFixed(2) + ' ' + (sw * 2.6).toFixed(2);
              const parts = splitPath(c, ci);
              return React.createElement(React.Fragment, { key: 'l' + c.id },
                parts.solid && React.createElement('path', {
                  d: parts.solid, fill: 'none', style: Object.assign({ stroke: c.color }, glow),
                  opacity: eOp, transform: tr, strokeWidth: sw, strokeLinejoin: 'round', strokeLinecap: 'round',
                }),
                parts.dotted && React.createElement('path', {
                  d: parts.dotted, fill: 'none', style: Object.assign({ stroke: c.color }, glow),
                  opacity: eOp, transform: tr, strokeWidth: sw, strokeLinejoin: 'round', strokeLinecap: 'round',
                  strokeDasharray: dash,
                })
              );
            });
          })()
        ),

        // while adjusting a bar: bright guide lines at its start + finish, in its colour,
        // drawn over the (dimmed) data so the user can place the dates precisely
        tlDragRef.current && tlDragRef.current.moved && !tlDragRef.current.reorder && (function () {
          const dr = tlDragRef.current;
          const yb = PAD.t + PLOT_H;
          // floating date chip sitting at the bottom of the guide line — directly above the
          // grab handle on the card below (the strip shares the chart's x), updating live.
          const tag = (ms, key) => {
            const label = fmtLongDate(ms);
            const w = label.length * 6.1 + 18;
            const xx = Math.max(PAD.l + w / 2, Math.min((VB_W - PADR) - w / 2, x(ms)));
            return React.createElement('g', { key: key, transform: 'translate(' + xx.toFixed(1) + ', ' + (yb - 13) + ')' },
              React.createElement('rect', { x: -w / 2, y: -11, width: w, height: 19, rx: 9.5, className: 'cp-readout-bg', style: { stroke: dr.color } }),
              React.createElement('text', { x: 0, y: 3.5, textAnchor: 'middle', className: 'cp-tl-dragdate' }, label)
            );
          };
          return React.createElement('g', null,
            React.createElement('line', { x1: x(dr.startMs), x2: x(dr.startMs), y1: PAD.t, y2: yb, stroke: dr.color, strokeWidth: 2, strokeDasharray: '5 5', opacity: 0.95 }),
            React.createElement('line', { x1: x(dr.finishMs), x2: x(dr.finishMs), y1: PAD.t, y2: yb, stroke: dr.color, strokeWidth: 2, strokeDasharray: '5 5', opacity: 0.95 }),
            dr.mode !== 'finish' && tag(dr.startMs, 'sd'),
            dr.mode !== 'start' && tag(dr.finishMs, 'fd')
          );
        })(),

        hoverT != null && React.createElement('g', null,
          // bright vertical crosshair
          React.createElement('line', { x1: hoverX, y1: PAD.t, x2: hoverX, y2: PAD.t + PLOT_H, className: 'cp-cross' }),
          // date pill at the top of the crosshair (replaces the old combined box)
          React.createElement('g', { key: boxLeft ? 'L' : 'R', className: 'cp-readout-g ' + (boxLeft ? 'flip-l' : 'flip-r'), transform: `translate(${hoverX}, ${PAD.t})` },
            React.createElement('rect', { x: -36, y: -17, width: 72, height: 16, rx: 8, className: 'cp-readout-bg' }),
            React.createElement('text', { x: 0, y: -5, className: 'cp-cross-date', textAnchor: 'middle' }, fmtDate(hoverT))
          ),
          // per-line: a brighter dot + the value floating beside its OWN line (no shared
          // box). Chips squeeze in side by side; ~0 ones drop below the plot, centred.
          (function () {
            // MOBILE: pin the value pills in a centred horizontal row jammed to the TOP of the
            // chart (below the floating nav), instead of letting them float up/down beside each
            // line. The dot stays on the line as the read marker. The row centres on the
            // crosshair and shuffles sideways (clamped) to stay on-screen when there are several.
            // Desktop keeps the original float-beside-each-line layout.
            if (isNarrow) {
              const GAP = 6;
              const n = chipLayout.length;
              const totalW = chipLayout.reduce((s, o) => s + o.chipW, 0) + GAP * Math.max(0, n - 1);
              // Centre the row on the crosshair (hoverX). On mobile the crosshair is pinned to
              // the viewport centre (the readout follows the scroll centre), so centring on it
              // is inherently viewport-centred — no clamp needed (and a clamp using the possibly
              // stale vpW state was shoving the row off to one side).
              const startX = hoverX - totalW / 2;
              const rowCy = PAD.t + 13;                                     // hard up the top of the plot
              let cum = 0;
              return chipLayout.map((it, i) => {
                const slotX = startX + cum + it.chipW / 2;                  // absolute centre of this pill
                cum += it.chipW + GAP;
                // offset from the (crisp, untransitioned) crosshair anchor; stays constant during
                // mid-range scroll (so no transition lag), shifts only on clamp/re-balance → shuffle
                const dx = (slotX - hoverX).toFixed(1);
                return React.createElement('g', { key: i },
                  React.createElement('circle', { cx: hoverX, cy: it.cy, r: 8, fill: it.r.c.color, opacity: 0.22 }),
                  React.createElement('circle', { cx: hoverX, cy: it.cy, r: 4.8, style: { fill: it.r.c.color, stroke: '#fff' }, strokeWidth: 1.8 }),
                  React.createElement('g', { transform: `translate(${hoverX}, ${rowCy})` },
                    React.createElement('g', { className: 'cp-valchip-g', style: { transform: `translateX(${dx}px)` } },
                      React.createElement('rect', { x: -it.chipW / 2, y: -10, width: it.chipW, height: 20, rx: 10, className: 'cp-valchip', style: { stroke: it.r.c.color } }),
                      React.createElement('text', { x: 0, y: 4.5, className: 'cp-valchip-t', textAnchor: 'middle', style: { fill: it.r.c.color } }, it.lbl)
                    )
                  )
                );
              });
            }
            return chipLayout.map((it, i) => {
              return React.createElement('g', { key: i },
                React.createElement('circle', { cx: hoverX, cy: it.cy, r: 8, fill: it.r.c.color, opacity: 0.22 }),
                React.createElement('circle', { cx: hoverX, cy: it.cy, r: 4.8, style: { fill: it.r.c.color, stroke: '#fff' }, strokeWidth: 1.8 }),
                React.createElement('g', { transform: `translate(${hoverX}, ${it.py})` },
                  React.createElement('g', { className: 'cp-valchip-g', style: { transform: `translateX(${it.px}px)` } },
                    React.createElement('rect', { x: -it.chipW / 2, y: -10, width: it.chipW, height: 20, rx: 10, className: 'cp-valchip', style: { stroke: it.r.c.color } }),
                    React.createElement('text', { x: 0, y: 4.5, className: 'cp-valchip-t', textAnchor: 'middle', style: { fill: it.r.c.color } }, it.lbl)
                  )
                )
              );
            });
          })()
        )
      )
    ),
      renderStrip()
    ),
      React.createElement('div', { className: 'cp-chart-spacer' })
    ),
      // fixed y-axis pinned to the device's left edge (mobile) — outside the scroll, so it
      // never scrolls away; same tick values/positions as the plot. The TOP value is skipped
      // here (the burger floats over that corner) and drawn inside the plot instead, below.
      scrollable && React.createElement('svg', { className: 'cp-yaxis-fix', viewBox: '0 0 46 ' + VB_H, width: 46, height: VB_H, style: { height: VB_H + 'px' } },
        yTicks.map((v, i) => (i === yTicks.length - 1) ? null : React.createElement('text', { key: 'fy' + i, x: 3, y: yP(v) - 5, className: 'cp-axis-y-in' }, fmt(v)))
      ),
      // top y-axis value, moved INSIDE the chart (right of the burger) so the floating burger
      // can't block it. Pinned to the fixwrap (doesn't scroll), aligned to the top gridline.
      scrollable && React.createElement('div', { className: 'cp-axis-top-in', style: { position: 'absolute', left: '52px', top: Math.max(2, PAD.t - 13) + 'px', zIndex: 6, pointerEvents: 'none' } }, fmt(axis.yMaxP)),
      // first-load watermark (mobile only): a faint, angled "PICK YOUR PROTOCOL" over the
      // empty teaser chart. Cleared the moment the user picks/calculates (showHint goes false).
      showHint && isNarrow && React.createElement('div', { className: 'cp-pick-hint' },
        React.createElement('span', null, 'Pick your protocol')
      ),
      // custom vertical scrollbar for the card strip — pinned to the device's right edge
      // (the native one is off-screen because the strip is the full chart width inside the
      // horizontal scroller). Shown only when the strip overflows (>4 cards).
      (function () {
        const el = stripRef.current;
        if (!el || el.scrollHeight <= el.clientHeight + 1) return null;
        const trackH = el.clientHeight - 6;                 // visible rows (minus the strip's own padding)
        const thumbH = Math.max(20, trackH * (el.clientHeight / el.scrollHeight));
        const maxScroll = el.scrollHeight - el.clientHeight;
        const thumbTop = maxScroll > 0 ? (el.scrollTop / maxScroll) * (trackH - thumbH) : 0;
        return React.createElement('div', { key: 'stripsb', style: { position: 'absolute', right: '2px', bottom: '4px', width: '5px', height: trackH + 'px', borderRadius: '3px', background: 'rgba(127,127,140,0.18)', zIndex: 7, pointerEvents: 'none' } },
          React.createElement('div', { style: { position: 'absolute', top: thumbTop + 'px', height: thumbH + 'px', width: '100%', borderRadius: '3px', background: 'rgba(180,180,190,0.55)' } })
        );
      })()
    );
  }

  window.PKChart = PKChart;
})();
