/* ============================================================
   radar-core.jsx — analytics + the radar plot + sparkline
   POLARITY-AWARE classifier: BULL-QQQ / BEAR-QQQ / WATCH / stable.
   Each asset is tagged by its natural relationship to QQQ
   (pro / counter / haven / cyclical_commod / mixed) and the
   onset is mapped to a QQQ implication accordingly.
   The underlying mechanic (COUPLE+/FLIP-) is still computed via
   mechanicOf() and surfaced in tooltips + method footer.
   Exposes: classify, mechanicOf, polarityOf, fmtB, fmtPct,
            signalMeta, polarityMeta, RadarPlot, Sparkline,
            SIG, POL, CAT_ORDER
   ============================================================ */
const { useState: useStateC, useMemo: useMemoC, useEffect: useEffectC, useRef: useRefC } = React;

/* -- signal vocabulary (QQQ-implication, not mechanic) ------ */
const SIG = {
  bull:   { key: 'bull',   label: 'BULL-QQQ', color: '#69B083', word: 'bullish for QQQ' },
  bear:   { key: 'bear',   label: 'BEAR-QQQ', color: '#CC7A6C', word: 'bearish for QQQ' },
  watch:  { key: 'watch',  label: 'watch',    color: '#8197CE', word: 'material move, evidence mixed' },
  stable: { key: 'stable', label: 'stable',   color: '#9CA3AE', word: 'no actionable shift' },
};
const signalMeta = (k) => SIG[k] || SIG.stable;

/* -- polarity -- natural relationship to QQQ ---------------- */
const POL = {
  pro:             { key: 'pro',             label: 'pro-cyclical',     word: 'should move WITH QQQ',         weight: 1.0 },
  counter:         { key: 'counter',         label: 'counter-cyclical', word: 'should move AGAINST QQQ',      weight: 1.2 },
  haven:           { key: 'haven',           label: 'defensive haven',  word: 'safety bid when QQQ wobbles',  weight: 0.8 },
  cyclical_commod: { key: 'cyclical_commod', label: 'growth commodity', word: 'moves with global growth',     weight: 1.0 },
  mixed:           { key: 'mixed',           label: 'mixed / spread',   word: 'context-dependent',            weight: 0.5 },
};
const polarityMeta = (k) => POL[k] || POL.mixed;

/* default polarity by category */
const POLARITY_BY_CAT = {
  'Tech/Leadership':   'pro',
  'Mega-cap':          'pro',
  'Mega-Cap Stock':    'pro',
  'Breadth/Factor':    'pro',
  'Index/Confirm':     'pro',
  'Futures':           'pro',
  'Crypto':            'pro',
  'Intl/Region':       'pro',
  'Sector':            'pro',
  'Credit/Financials': 'pro',
  'Income/REIT':       'haven',
  'Rates':             'counter',
  'Vol/Index':         'counter',
  'Volatility':        'counter',
  'FX':                'counter',
  'Commodity':         'cyclical_commod',
  'Spread':            'mixed',
};

/* per-symbol overrides -- highest-impact corrections to the
   category default. Edit this map as the universe evolves. */
const POLARITY_OVERRIDES = {
  /* commodity -> haven (safe-haven metals) */
  'GLD':'haven','SLV':'haven','GDX':'haven','SIL':'haven','IAU':'haven',
  /* commodity -> cyclical_commod (growth-sensitive) */
  'USO':'cyclical_commod','UNG':'cyclical_commod','OIH':'cyclical_commod','XOP':'cyclical_commod',
  'FCG':'cyclical_commod','DBC':'cyclical_commod','PDBC':'cyclical_commod','DBB':'cyclical_commod',
  'XME':'cyclical_commod','COPX':'cyclical_commod','CPER':'cyclical_commod','MOO':'cyclical_commod',
  'GUNR':'cyclical_commod','URA':'cyclical_commod','NLR':'cyclical_commod','LIT':'cyclical_commod',
  'REMX':'cyclical_commod','DBA':'cyclical_commod','AMLP':'cyclical_commod',
  /* credit -> pro (credit risk is risk-on) */
  'HYG':'pro','JBBB':'pro','EMHY':'pro','BKLN':'pro','EMB':'pro',
  /* credit -> counter (high-quality fixed income behaves like rates) */
  'LQD':'counter','JAAA':'counter','MBB':'counter','AGG':'counter','BND':'counter','BNDX':'counter',
  'BINC':'counter','TIP':'counter','VTIP':'counter',
  /* income / REIT / dividend -> haven */
  'XLU':'haven','VNQ':'haven','VNQI':'haven','REET':'haven','REM':'haven','XLRE':'haven',
  'SCHD':'haven','DVY':'haven','DIVO':'haven','JEPI':'haven','JEPQ':'haven','PFF':'haven',
  'QYLD':'haven','XYLD':'haven','QDVO':'haven','QQQI':'haven','SPHD':'haven',
  /* defensive sectors */
  'XLP':'haven','XLV':'haven',
  /* FX / dollar -> counter to QQQ */
  'UUP':'counter','DX-Y.NYB':'counter',
  /* rates / yields (Yahoo "^" symbols) */
  '^TNX':'counter','^FVX':'counter','^TYX':'counter','^IRX':'counter',
  /* vol indices */
  '^VIX':'counter','^VIX9D':'counter','^VIX3M':'counter','^VVIX':'counter','^MOVE':'counter','^SKEW':'counter',
  /* spreads */
  'HY_IG':'mixed','SMH_QQQ':'pro','VIX_TS':'counter','YC_10_5':'mixed',
};

function polarityOf(sym, cat) {
  if (POLARITY_OVERRIDES[sym]) return POLARITY_OVERRIDES[sym];
  return POLARITY_BY_CAT[cat] || 'mixed';
}

/* -- IMPACT weighting: how much a confirmed signal on this instrument should
   move the aggregate QQQ pressure read (0-100). Mega-cap constituents are
   anchored to ~ their QQQ index weight; everything else is a curated barometer
   tier. Website-curated for now -- graduates to a planner `impact` field. ----- */
const IMPACT_OVERRIDES = {
  /* index proxies / futures -- the cleanest QQQ read */
  'NQ=F': 95, 'ES=F': 90,
  /* mega-cap QQQ constituents (~ index weight, scaled) */
  'NVDA': 90, 'MSFT': 80, 'AAPL': 78, 'AMZN': 58, 'AVGO': 58, 'GOOGL': 55, 'META': 52,
  'TSLA': 38, 'NFLX': 35, 'COST': 33, 'AMD': 32, 'PLTR': 30, 'ADBE': 28,
  'ORCL': 30, 'CRM': 28, /* NYSE -> not in QQQ; scored as tech-correlated barometers */
  /* tech aggregates (heavy QQQ overlap) */
  'XLK': 65, 'VGT': 62, 'SMH': 62, 'SOXX': 60, 'IXN': 45, 'MAGS': 48, 'QTEC': 42, 'IGV': 42,
  'SMH_QQQ': 40, 'DRAM': 38, 'FDN': 38, 'CHAT': 35, 'CIBR': 35, 'SKYY': 35, 'AIQ': 35, 'HACK': 30,
  'QTUM': 30, 'DTCR': 30, 'FINX': 28, 'PAVE': 28, 'XT': 28, 'DRIV': 25, 'GRID': 25, 'BOTZ': 25,
  'IBUY': 25, 'IPAY': 25, 'ROKT': 20, 'SNSR': 20, 'METV': 20, 'ONLN': 20, 'HERO': 18, 'UFO': 12,
  /* large-cap / index confirms (QQQ overlap) */
  'SPY': 50, 'IWF': 48, 'VTI': 45, 'XLG': 45, 'IWY': 38, 'IVW': 38, 'OEF': 38, 'IWB': 35,
  'VT': 30, 'ACWI': 28, 'MDY': 25, 'IWM': 25, 'IJR': 20, 'IWO': 18, 'IWC': 12,
  /* volatility gauges */
  '^VIX': 70, 'VIX_TS': 35, '^VVIX': 35, '^MOVE': 35, '^SKEW': 32, '^VIX9D': 30, '^VIX3M': 30,
  /* rates -- duration is the tech driver */
  'TLT': 60, '^TNX': 60, 'IEF': 45, 'TLH': 45, '^TYX': 45, '^FVX': 40, '^IRX': 35, 'IEI': 35,
  'MBB': 18, 'TIP': 25, 'VTIP': 15, 'YC_10_5': 35, 'SHY': 8, 'SGOV': 3, 'BIL': 3, 'BILS': 3,
  /* fx / dollar */
  'DX-Y.NYB': 35, 'UUP': 35,
  /* credit */
  'HYG': 40, 'HY_IG': 35, 'JBBB': 28, 'BKLN': 25, 'EMHY': 22, 'EMB': 20, 'LQD': 18, 'IAI': 18,
  'AGG': 15, 'BND': 15, 'JAAA': 15, 'PFF': 15, 'BINC': 12, 'BNDX': 12,
  'KRE': 22, 'VFH': 22, 'KBE': 20, 'KIE': 15,
  /* commodity -- gold + growth metals carry, rest low */
  'GLD': 30, 'GDX': 12, 'COPX': 12, 'CPER': 10, 'SLV': 10, 'USO': 10,
  /* crypto -- risk-appetite proxy */
  'IBIT': 18, 'MSTR': 18, 'COIN': 18, 'BLOK': 15, 'DXYZ': 12,
  /* breadth / factor */
  'RSP': 30, 'RSPT': 25, 'SPHB': 22, 'ARKK': 22, 'ARKW': 22, 'ARKF': 22, 'ARKQ': 22, 'ARKX': 22,
  'SPMO': 20, 'SPLV': 15, 'MOAT': 15,
  /* intl -- china-tech beta a touch higher */
  'KWEB': 12, 'FXI': 12, 'EEM': 12, 'MCHI': 10,
  /* sector -- comms (META/GOOGL) + financials/disc higher */
  'XLC': 30, 'XLF': 22, 'XLY': 22,
  /* income / other -- mostly defaults */
  'QQQI': 15, 'JEPQ': 15, 'VNQ': 14, 'SOCL': 12 };

const IMPACT_BY_CAT = {
  'Mega-Cap Stock': 35, 'Tech/Leadership': 30, 'Index/Confirm': 30, 'Volatility': 30,
  'Rates': 18, 'Breadth/Factor': 18, 'Sector': 18, 'Credit/Financials': 15, 'FX': 35,
  'Crypto': 15, 'Income/REIT': 12, 'Commodity': 6, 'Intl/Region': 8, 'Other': 6 };

function impactOf(sym, cat) {
  if (IMPACT_OVERRIDES[sym] != null) return IMPACT_OVERRIDES[sym];
  return IMPACT_BY_CAT[cat] != null ? IMPACT_BY_CAT[cat] : 10;
}

/* proxy/aggregate names whose contribution is halved in the AGGREGATE only
   (full impact still shown on their own card) -- avoids double-counting the
   same tech move already carried by the individual mega-cap constituents. */
const IMPACT_DAMPEN = new Set(['NQ=F', 'ES=F', 'SPY', 'IWF', 'VTI', 'XLG', 'IWY', 'IVW', 'OEF', 'IWB', 'VT', 'ACWI', 'RSP', 'RSPT', 'XLK', 'VGT', 'SMH', 'SOXX', 'IXN', 'MAGS', 'QTEC']);
const impactDamp = (sym) => IMPACT_DAMPEN.has(sym) ? 0.5 : 1;

/* conviction: how far past threshold the onset sits -- 0.5x at the threshold,
   1.0x at 2x threshold, capped 1.5x. Floors at 0.4 so a fired signal always counts. */
function convictionOf(row, hl, thr) {
  const h = hlAt(row, hl);
  const r = thr > 0 ? Math.abs(h.onset) / (2 * thr) : 1;
  return Math.max(0.4, Math.min(1.5, r));
}

/* signed impact contribution of a row given its signal (bull +, bear -) */
function contributionOf(row, sig, hl, thr) {
  if (sig !== 'bull' && sig !== 'bear') return 0;
  const imp = impactOf(row.sym, row.cat) * impactDamp(row.sym);
  return (sig === 'bull' ? 1 : -1) * imp * convictionOf(row, hl, thr);
}

const impactTier = (v) => v >= 70 ? 'critical' : v >= 45 ? 'high' : v >= 25 ? 'med' : v >= 12 ? 'low' : 'minimal';

/* -- safe half-life lookup: fall back to a present block so a missing
   half-life (e.g. switching to one the data doesn't carry) never crashes.
   Preference order: requested → '5' → '3' → first available. ----- */
function hlAt(row, hl) {
  const H = row && row.hl || {};
  return H[hl] || H['5'] || H['3'] || H[Object.keys(H)[0]] || { now: 0, ago5: 0, onset: 0, series: [] };
}

/* -- half-life-aware price-move lookup. The planner may publish a `moves`
   object keyed by half-life ({ "1": { a, q }, ... }); when present we show
   that window's move and label it `${win}d`. Falls back to the single 5-day
   amove/qmove pair (labelled 5d) until per-window moves are published. ----- */
function moveAt(row, hl) {
  const mv = row && row.moves && row.moves[hl];
  if (mv && typeof mv.a === 'number' && typeof mv.q === 'number') {
    return { a: mv.a, q: mv.q, win: String(hl) };
  }
  return { a: row ? row.amove : 0, q: row ? row.qmove : 0, win: '5' };
}

/* -- half-life-aware trend agreement. With per-window `moves` published
   (moves[hl] = {a,q}) the agree/diverge gate is read from the SAME window as
   the selected half-life: same sign of asset vs QQQ move = agree. Preserves a
   null agree (e.g. the YC spread) and falls back to the row's 5-day flag when
   per-window moves are absent. ----- */
function agreeAt(row, hl) {
  if (!row || row.agree == null) return row ? row.agree : null;
  const mv = row.moves && row.moves[hl];
  if (mv && typeof mv.a === 'number' && typeof mv.q === 'number' && mv.a !== 0 && mv.q !== 0) {
    return (mv.a > 0) === (mv.q > 0);
  }
  return row.agree;
}

/* -- direction of the coupling in the selected window: is QQQ's move UP?
   Used to split a tightening coupling into BULL-QQQ (engaging up) vs
   BEAR-QQQ (engaging down) — coupling while both fall is risk-off, not bull.
   Reads moves[hl].q, falls back to the 5-day qmove. ----- */
function moveUpAt(row, hl) {
  const mv = row && row.moves && row.moves[hl];
  const q = (mv && typeof mv.q === 'number') ? mv.q : (row ? row.qmove : null);
  return q == null ? null : q > 0;
}

/* -- direction of the INSTRUMENT's own move in the window (is it up?).
   For counter / haven names the QQQ implication keys off this: a fear/vol
   gauge (or haven) RISING is risk-off; FALLING is risk-on. ----- */
function assetUpAt(row, hl) {
  const mv = row && row.moves && row.moves[hl];
  const a = (mv && typeof mv.a === 'number') ? mv.a : (row ? row.amove : null);
  return a == null ? null : a > 0;
}

/* -- underlying mechanic (preserved for tooltips/method) ----- */
function mechanicOf(row, hl, thr) {
  const h = hlAt(row, hl);
  if (!h) return 'stable';
  const { now, ago5, onset } = h;
  const agree = agreeAt(row, hl);
  if (onset >= thr && now > 0.2 && now > ago5 && agree) return 'couple';
  if (onset <= -thr && now < -0.2 && !agree) return 'flip';
  if (Math.abs(onset) >= thr) return 'watch';
  return 'stable';
}
const MECH_LABEL = { couple: 'COUPLE+', flip: 'FLIP-', watch: 'watch', stable: 'stable' };

/* -- classification -- polarity-aware QQQ implication -------- */
function classify(row, hl, thr) {
  const h = hlAt(row, hl);
  if (!h) return 'stable';
  const { now, ago5, onset } = h;
  const agree = agreeAt(row, hl);
  const pol = polarityOf(row.sym, row.cat);

  if (pol === 'pro' || pol === 'cyclical_commod') {
    /* pro: tightening coupling (beta rising, positive, agreeing) is BULL-QQQ only
       when it's engaging UP; coupling while both fall is risk-off = BEAR-QQQ. */
    if (onset >= thr && now > 0.2 && now > ago5 && agree) return moveUpAt(row, hl) === false ? 'bear' : 'bull';
    if (onset <= -thr && now < ago5 && !agree) return 'bear';
    if (Math.abs(onset) >= thr) return 'watch';
    return 'stable';
  }

  if (pol === 'counter') {
    /* counter (vol / fear / rates / dollar): inverse to QQQ is the default.
       The QQQ implication keys off the instrument's OWN direction in the window
       — a fear/vol gauge RISING is risk-off (BEAR-QQQ); FALLING is risk-on
       (BULL-QQQ). Fire only on a real coupling move (|onset| >= thr); when the
       move is flat/unknown, fall back to the beta trajectory. */
    if (Math.abs(onset) >= thr) {
      const up = assetUpAt(row, hl);
      if (up === true) return 'bear';
      if (up === false) return 'bull';
      return now <= -0.15 ? 'bull' : 'bear';
    }
    return 'stable';
  }

  if (pol === 'haven') {
    /* haven (gold, defensives, REITs, income): a haven is only a RISK signal
       when it DIVERGES from QQQ. Haven catching a bid while QQQ falls = flight
       to safety = BEAR-QQQ; haven sold while QQQ rises = rotation into risk =
       BULL-QQQ. Moving together is just beta, not a safety signal = watch.
       Gated on a real coupling onset (|onset| >= thr). */
    if (Math.abs(onset) >= thr) {
      const ag = agreeAt(row, hl);
      if (ag === false) return assetUpAt(row, hl) ? 'bear' : 'bull';
      return 'watch';
    }
    return 'stable';
  }

  /* mixed / spread / unknown polarity -- never bull/bear, only watch */
  if (Math.abs(onset) >= thr) return 'watch';
  return 'stable';
}

/* -- formatters --------------------------------------------- */
const fmtB = (v) => (v >= 0 ? '+' : '−') + Math.abs(v).toFixed(2);
const fmtPct = (v) => (v >= 0 ? '+' : '−') + Math.abs(v).toFixed(2) + '%';
const fmtOnset = (v) => (v >= 0 ? '+' : '−') + Math.abs(v).toFixed(2);

/* category display order for the radar arcs (others fall after) */
const CAT_ORDER = [
  'Tech/Leadership', 'Breadth/Factor', 'Index/Confirm', 'Intl/Region',
  'Commodity', 'Crypto', 'Credit/Financials', 'Income/REIT', 'Rates',
  'Mega-cap', 'Vol/Index', 'Futures', 'Spread', 'Sector',
];
const catRank = (c) => { const i = CAT_ORDER.indexOf(c); return i < 0 ? 99 : i; };

/* ============================================================
   SPARKLINE -- 25-pt beta trend (the signal to trust)
   ============================================================ */
// monotone cubic Hermite (Fritsch-Carlson) -- smooth, no overshoot past data points
function monotonePath(xs, ys) {
  const n = xs.length;
  if (n < 2) return '';
  if (n === 2) return `M ${xs[0].toFixed(2)} ${ys[0].toFixed(2)} L ${xs[1].toFixed(2)} ${ys[1].toFixed(2)}`;
  const dx = [], dy = [], m = [];
  for (let i = 0; i < n - 1; i++) { dx[i] = xs[i + 1] - xs[i]; dy[i] = ys[i + 1] - ys[i]; m[i] = dy[i] / dx[i]; }
  const t = [m[0]];
  for (let i = 1; i < n - 1; i++) t[i] = m[i - 1] * m[i] <= 0 ? 0 : (m[i - 1] + m[i]) / 2;
  t[n - 1] = m[n - 2];
  for (let i = 0; i < n - 1; i++) {
    if (m[i] === 0) { t[i] = 0; t[i + 1] = 0; }
    else {
      const a = t[i] / m[i], b = t[i + 1] / m[i], s = a * a + b * b;
      if (s > 9) { const tau = 3 / Math.sqrt(s); t[i] = tau * a * m[i]; t[i + 1] = tau * b * m[i]; }
    }
  }
  let d = `M ${xs[0].toFixed(2)} ${ys[0].toFixed(2)}`;
  for (let i = 0; i < n - 1; i++) {
    const h = dx[i];
    d += ` C ${(xs[i] + h / 3).toFixed(2)} ${(ys[i] + t[i] * h / 3).toFixed(2)}, ${(xs[i + 1] - h / 3).toFixed(2)} ${(ys[i + 1] - t[i + 1] * h / 3).toFixed(2)}, ${xs[i + 1].toFixed(2)} ${ys[i + 1].toFixed(2)}`;
  }
  return d;
}

let __sparkSeq = 0;
function Sparkline({ series, color, height = 38, showZero = true, showDot = true, strokeW = 2 }) {
  if (!series || !series.length) return null;
  const uid = useMemoC(() => 'spk' + (++__sparkSeq), []);
  const W = 100, H = height, pad = 3;
  const lo = Math.min(...series, 0), hi = Math.max(...series, 0);
  const span = (hi - lo) || 1;
  const x = (i) => pad + (i / (series.length - 1)) * (W - 2 * pad);
  const y = (v) => pad + (1 - (v - lo) / span) * (H - 2 * pad);
  const xs = series.map((_, i) => x(i)), ys = series.map((v) => y(v));
  const line = monotonePath(xs, ys);
  const baseY = H - pad;
  const area = `${line} L ${xs[xs.length - 1].toFixed(2)} ${baseY.toFixed(2)} L ${xs[0].toFixed(2)} ${baseY.toFixed(2)} Z`;
  const zeroY = y(0);
  const last = series[series.length - 1];
  return (
    <svg className="spark-svg" viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" style={{ height }}>
      <defs>
        <linearGradient id={uid} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stopColor={color} stopOpacity="0.20" />
          <stop offset="100%" stopColor={color} stopOpacity="0" />
        </linearGradient>
      </defs>
      <path className="spark-area" d={area} fill={`url(#${uid})`} />
      {showZero && lo < 0 && hi > 0 && (
        <line className="spark-zero" x1={pad} y1={zeroY} x2={W - pad} y2={zeroY} vectorEffect="non-scaling-stroke" />
      )}
      <path className="spark-path" d={line} stroke={color} strokeWidth={strokeW} vectorEffect="non-scaling-stroke" />
      {showDot && <circle className="spark-dot" cx={x(series.length - 1)} cy={y(last)} r={2.4} fill={color} vectorEffect="non-scaling-stroke" />}
    </svg>
  );
}

/* ============================================================
   RADAR PLOT -- active instruments on category-grouped spokes.
   radius = |beta to QQQ|, faint dot = beta 5d ago -> bold dot = beta now
   (the drift = the coupling onset). color = signal. halo = alert.
   ============================================================ */
const VB = 1000, CX = 500, CY = 500, MAXR = 320;
const LABELR = MAXR + 18, ARCR = MAXR + 44, SECR = MAXR + 88;
const CORER = 62, INNERR = 74;

function RadarPlot({ rows, hl, thr, betaCap }) {
  const [hover, setHover] = useStateC(null);
  const [animKey, setAnimKey] = useStateC(0);
  const [entered, setEntered] = useStateC(true);
  useEffectC(() => {
    if (animKey === 0) return;
    setEntered(false);
    const t = setTimeout(() => setEntered(true), 60);
    return () => clearTimeout(t);
  }, [animKey]);

  const cap = betaCap || 2.5;
  const rOf = (b) => INNERR + Math.min(1, Math.abs(b) / cap) * (MAXR - INNERR);
  const angOf = (i, n) => (-90 + (i * 360) / n) * Math.PI / 180;

  const nodes = useMemoC(() => {
    const enriched = rows.map((r) => {
      const h = window.hlAt(r, hl);
      const m2 = window.moveAt(r, hl);
      return { ...r, _now: h.now, _ago: h.ago5, _onset: h.onset, _series: h.series, _amv: m2.a, _qmv: m2.q, _mvwin: m2.win,
               _sig: classify(r, hl, thr), _mech: mechanicOf(r, hl, thr), _pol: polarityOf(r.sym, r.cat) };
    });
    enriched.sort((a, b) => (catRank(a.cat) - catRank(b.cat)) || (Math.abs(b._onset) - Math.abs(a._onset)) || a.sym.localeCompare(b.sym));
    return enriched.map((d, i) => ({ ...d, i }));
  }, [rows, hl, thr]);

  const n = nodes.length;
  const secRanges = useMemoC(() => {
    const cats = [];
    nodes.forEach((d) => { if (!cats.find((c) => c.key === d.cat)) cats.push({ key: d.cat, idxs: [] }); cats.find((c) => c.key === d.cat).idxs.push(d.i); });
    const pad = n > 1 ? (360 / n) * Math.PI / 180 * 0.4 : 0;
    return cats.map((c) => {
      const first = Math.min(...c.idxs), last = Math.max(...c.idxs);
      return { ...c, a0: angOf(first, n) - pad, a1: angOf(last, n) + pad, mid: angOf((first + last) / 2, n) };
    });
  }, [nodes, n]);

  if (!n) return null;
  const rings = [0.25, 0.5, 0.75, 1.0];
  const arc = (r, a0, a1) => {
    const x0 = CX + r * Math.cos(a0), y0 = CY + r * Math.sin(a0);
    const x1 = CX + r * Math.cos(a1), y1 = CY + r * Math.sin(a1);
    const large = (a1 - a0) > Math.PI ? 1 : 0;
    return `M ${x0.toFixed(1)} ${y0.toFixed(1)} A ${r} ${r} 0 ${large} 1 ${x1.toFixed(1)} ${y1.toFixed(1)}`;
  };
  const ptAt = (i, b) => { const a = angOf(i, n), r = rOf(b); return [CX + r * Math.cos(a), CY + r * Math.sin(a)]; };

  return (
    <div className="radar-stage">
      <svg className="radar-svg" viewBox={`0 0 ${VB} ${VB}`} role="img" aria-label="QQQ-implication radar of active instruments">
        <circle className="rr-ring zero" cx={CX} cy={CY} r={INNERR} />
        {rings.map((rv) => <circle key={rv} className="rr-ring" cx={CX} cy={CY} r={rOf(rv * cap)} />)}
        <text className="rr-ring-lab" x={CX + 5} y={CY - INNERR - 3}>{'β'} 0</text>
        {rings.map((rv) => <text key={'l' + rv} className="rr-ring-lab" x={CX + 5} y={CY - rOf(rv * cap) - 3}>{(rv * cap).toFixed(1)}</text>)}

        {nodes.map((d) => {
          const a = angOf(d.i, n);
          const sx = CX + INNERR * Math.cos(a), sy = CY + INNERR * Math.sin(a);
          const ex = CX + MAXR * Math.cos(a), ey = CY + MAXR * Math.sin(a);
          return <line key={'sp' + d.sym} className="rr-spoke" x1={sx} y1={sy} x2={ex} y2={ey} />;
        })}

        {secRanges.map((s) => {
          const [sx, sy] = [CX + SECR * Math.cos(s.mid), CY + SECR * Math.sin(s.mid)];
          const anchor = Math.cos(s.mid) > 0.1 ? 'start' : Math.cos(s.mid) < -0.1 ? 'end' : 'middle';
          const short = s.key.split('/')[0];
          return (
            <g key={s.key}>
              <path className="rr-arc" d={arc(ARCR, s.a0, s.a1)} stroke="#9CA3AE" />
              <text className="rr-sec" x={sx} y={sy} textAnchor={anchor} dominantBaseline="middle">{short}</text>
            </g>
          );
        })}

        {nodes.map((d) => {
          const a = angOf(d.i, n);
          const lx = CX + LABELR * Math.cos(a), ly = CY + LABELR * Math.sin(a);
          const anchor = Math.cos(a) > 0.1 ? 'start' : Math.cos(a) < -0.1 ? 'end' : 'middle';
          const dim = hover != null && hover !== d.i;
          return <text key={'tk' + d.sym} className={`rr-tk${dim ? ' dim' : ''}`} x={lx} y={ly} textAnchor={anchor} dominantBaseline="middle"
            onMouseEnter={() => setHover(d.i)} onMouseLeave={() => setHover(null)}>{d.sym}</text>;
        })}

        <g key={animKey}>
          {nodes.map((d) => {
            const [ax, ay] = ptAt(d.i, d._ago);
            const [nx, ny] = ptAt(d.i, d._now);
            const col = signalMeta(d._sig).color;
            return <line key={'dr' + d.sym} className="rr-drift" x1={ax} y1={ay} x2={nx} y2={ny}
              stroke={col} strokeWidth={d._sig === 'stable' ? 1.4 : 2.4}
              style={{ opacity: entered ? (d._sig === 'stable' ? 0.4 : 0.85) : 0, transitionDelay: `${0.2 + d.i * 0.01}s` }} />;
          })}
          {nodes.map((d) => {
            const [ax, ay] = ptAt(d.i, d._ago);
            return <circle key={'ag' + d.sym} className="rr-ago" cx={ax} cy={ay} r={3} fill="none"
              stroke={signalMeta(d._sig).color} strokeWidth={1.4}
              style={{ opacity: entered ? 0.5 : 0, transitionDelay: `${0.3 + d.i * 0.01}s` }} />;
          })}
          {nodes.map((d) => {
            const [nx, ny] = ptAt(d.i, d._now);
            const col = signalMeta(d._sig).color;
            const alert = d._sig === 'bull' || d._sig === 'bear';
            return (
              <g key={'nw' + d.sym}>
                {alert && entered && <circle className="rr-halo" cx={nx} cy={ny} r={13} stroke={col} />}
                <circle className="rr-now" cx={nx} cy={ny} r={alert ? 7 : 5} fill={col} stroke="#fff" strokeWidth={1.8}
                  style={{ opacity: entered ? 1 : 0, transform: entered ? 'scale(1)' : 'scale(0)', transitionDelay: `${0.55 + d.i * 0.012}s` }}
                  onMouseEnter={() => setHover(d.i)} onMouseLeave={() => setHover(null)} />
              </g>
            );
          })}
          <circle className="rr-core" cx={CX} cy={CY} r={CORER} />
          <text className="rr-core-t" x={CX} y={CY + 1} textAnchor="middle" dominantBaseline="middle">QQQ</text>
          <text className="rr-core-s" x={CX} y={CY + 24} textAnchor="middle">BENCHMARK</text>
        </g>
      </svg>

      {hover != null && nodes[hover] && (() => {
        const d = nodes[hover];
        const [hx, hy] = ptAt(d.i, d._now);
        const m = signalMeta(d._sig);
        const pm = polarityMeta(d._pol);
        return (
          <div className="radar-tip" style={{ left: (hx / VB) * 100 + '%', top: (hy / VB) * 100 + '%' }}>
            <div className="rt-tk">{d.sym}</div>
            <div className="rt-nm">{window.tickerName(d.sym, d.name)} {'·'} {d.cat}</div>
            <div className="rt-row"><span className="lab">polarity</span><span className="val" style={{ opacity: 0.85 }}>{pm.label}</span></div>
            <div className="rt-row"><span className="lab">{'β'} now</span><span className="val">{fmtB(d._now)}</span></div>
            <div className="rt-row"><span className="lab">{'β'} 5d ago</span><span className="val">{fmtB(d._ago)}</span></div>
            <div className="rt-row"><span className="lab">onset {'Δ'}</span><span className="val" style={{ color: d._onset >= 0 ? '#B8CDA0' : '#E7B8A8' }}>{fmtOnset(d._onset)}</span></div>
            <div className="rt-row"><span className="lab">{d._mvwin}d move</span><span className="val">{fmtPct(d._amv)} <span style={{ opacity: 0.5 }}>vs {fmtPct(d._qmv)}</span></span></div>
            <div className="rt-row"><span className="lab">mechanic</span><span className="val" style={{ opacity: 0.7, fontSize: 10 }}>{MECH_LABEL[d._mech]}</span></div>
            <div className={`rt-tag ${m.key}`}>{'◎'} {m.label} {'·'} {m.word}</div>
          </div>
        );
      })()}

      <div className="radar-legend">
        <div className="rleg-g">
          <div className="rleg-t">Radius = |{'β'}| to QQQ</div>
          <div style={{ width: 130 }}>
            <span className="rleg-bar" />
            <div className="rleg-scale-labs"><span>0 {'·'} at rim</span><span>{cap.toFixed(1)}+</span></div>
          </div>
        </div>
        <div className="rleg-g">
          <div className="rleg-t">Color = QQQ implication</div>
          <div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
            <div className="rleg-row"><span className="sw" style={{ background: SIG.bull.color }} /> BULL-QQQ {'·'} bullish for QQQ</div>
            <div className="rleg-row"><span className="sw" style={{ background: SIG.bear.color }} /> BEAR-QQQ {'·'} bearish for QQQ</div>
            <div className="rleg-row"><span className="sw" style={{ background: SIG.watch.color }} /> watch {'·'} evidence mixed</div>
          </div>
        </div>
        <div className="rleg-g">
          <div className="rleg-t">Drift = the onset</div>
          <div className="rleg-row"><span className="rleg-drift"><span style={{ width: 7, height: 7, border: '1.4px solid #9CA3AE', borderRadius: '50%', display: 'inline-block' }} /><span style={{ color: '#9CA3AE' }}>{'→'}</span><span style={{ width: 9, height: 9, background: '#69B083', borderRadius: '50%', display: 'inline-block' }} /></span> {'β'} 5d ago {'→'} {'β'} now</div>
          <div className="rleg-row" style={{ fontSize: 10.5, color: 'var(--stone-1)' }}>read against polarity: pro outward = bullish, counter outward = bearish</div>
        </div>
        <button className="rad-replay" onClick={() => { setEntered(false); setAnimKey((k) => k + 1); }} style={{ alignSelf: 'center' }}>{'↻'} Replay</button>
      </div>
    </div>
  );
}

Object.assign(window, { classify, mechanicOf, hlAt, moveAt, agreeAt, moveUpAt, assetUpAt, polarityOf, impactOf, convictionOf, contributionOf, impactTier, fmtB, fmtPct, fmtOnset, signalMeta, polarityMeta, SIG, POL, CAT_ORDER, catRank, Sparkline, RadarPlot });
