// Detailed view for one axis. Reads live data from the parent // (an `axis` row produced by deriveAxisRows() in index.html) and from // the global CHVault for axis history + recent signal events. const AxisDetail = ({ axis, onBack, onLog }) => { const A = axis; if (!A) return null; const [history, setHistory] = React.useState(null); // [{date, score}] const [error, setError] = React.useState(null); React.useEffect(() => { let dead = false; (async () => { try { // /api/v1/health/axes returns history with combined CM/CH per day; // for per-axis trends we use the daily axis snapshot endpoint. const r = await fetch(window.CHVault.apiRoot + '/api/v1/health/axis/' + A.code + '/history?range=30D', { credentials: 'include', headers: { 'Accept': 'application/json' }, }); if (!r.ok) throw new Error('history-' + r.status); const j = await r.json(); if (dead) return; const arr = (j.data || j.history || j.points || []).map(p => ({ date: p.date || p.snapshot_date, score: typeof p.score === 'number' ? p.score : (typeof p.value === 'number' ? p.value : (p.axis_value || 0)), })); setHistory(arr); } catch (e) { if (dead) return; setError(e.message || String(e)); } })(); return () => { dead = true; }; }, [A.code]); /* --- Trend chart (live, falls back to flat if no history yet) Canonical 0..100 scale. Legacy /health/axis/.../history may return 0..10 values — detect via the global max and scale up so the chart and the score header agree. */ const W = 720, H = 200, P = 12; let rawSeries = (history && history.length) ? history.map(h => Number(h.score) || 0) : null; if (rawSeries && rawSeries.length) { const seriesMax = Math.max(...rawSeries); const isLegacy10 = seriesMax <= 10; // legacy endpoint returns 0..10 rawSeries = rawSeries.map(v => Math.max(0, Math.min(100, isLegacy10 ? v * 10 : v))); } let series = rawSeries && rawSeries.length ? rawSeries : [A.value]; const min = Math.min(...series); const max = Math.max(...series); const pts = series.map((v, i) => { const x = P + (series.length === 1 ? (W - P*2)/2 : (i / (series.length - 1)) * (W - P*2)); const y = P + (1 - v / 100) * (H - P*2); return [x, y]; }); const path = pts.map((p, i) => (i === 0 ? `M${p[0]},${p[1]}` : `L${p[0]},${p[1]}`)).join(' '); const area = pts.length > 1 ? `${path} L${pts[pts.length - 1][0]},${H - P} L${pts[0][0]},${H - P} Z` : ''; /* --- Sub-signal contributions (live, from /master-equation/me/axes signal_scores) --- */ const signalScores = A.signal_scores || {}; const signalRows = Object.keys(signalScores).map(sid => ({ id: sid, score: signalScores[sid], })).sort((a,b) => b.score - a.score); const decayColor = A.decay.status === 'decaying' ? 'var(--accent-coral)' : A.decay.status === 'grace' ? 'var(--bracket-poor)' : A.decay.status === 'unknown' ? 'var(--fg-3)' : 'var(--accent-clinical)'; const valueLabel = A.coverage > 0 ? A.value.toFixed(1) : '—'; return (