import { useMemo, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { CartesianGrid, Cell, Line, LineChart, ReferenceArea, ReferenceLine, ResponsiveContainer, Scatter, ScatterChart, Tooltip, XAxis, YAxis, ZAxis, } from 'recharts'; import { getRegimeHistory, getRegimeMonitor } from '../../api/regime'; import { Callout } from '../ui/Callout'; import { SkeletonCard } from '../ui/Skeleton'; import { formatDate } from '../../lib/format'; // Lazy-loaded (see RegimePage) so recharts stays in the regime-tab chunk. // Time and Path are two projections of one series, so they share a card and a // query rather than sitting in two panels that look like different data. const VIEWS = ['Time', 'Path'] as const; type View = (typeof VIEWS)[number]; const RANGES = [ { key: '1M', days: 30 }, { key: '3M', days: 90 }, { key: '6M', days: 182 }, { key: 'All', days: Number.POSITIVE_INFINITY }, ] as const; type RangeKey = (typeof RANGES)[number]['key']; /** Sessions drawn in Path view. The full series is unreadable as a path. */ const PATH_TRAIL = 60; const STATE_COLOR = '#60a5fa'; const WARNING_COLOR = '#fb923c'; // Fall back to the shipped constants, not v2's shared 60/60, so a missing // quadrant_config cannot draw dividers that disagree with the alert path. const DEFAULT_STATE_DIVIDER = 50; const DEFAULT_WARNING_DIVIDER = 40; interface PathPoint { x: number; y: number; date: string; } /** Centered moving average to de-noise the path; today (last) kept exact. */ function smoothTrail(points: PathPoint[], half = 2): PathPoint[] { const n = points.length; return points.map((p, i) => { if (i === n - 1) return { ...p }; let sx = 0; let sy = 0; let c = 0; for (let j = Math.max(0, i - half); j <= Math.min(n - 1, i + half); j++) { sx += points[j].x; sy += points[j].y; c += 1; } return { x: sx / c, y: sy / c, date: p.date }; }); } /** Recency gradient: 0 = oldest (muted slate), 1 = newest (bright blue). */ function recencyColor(t: number): string { const lerp = (a: number, b: number) => Math.round(a + (b - a) * t); return `rgba(${lerp(71, 96)}, ${lerp(85, 165)}, ${lerp(105, 250)}, ${(0.3 + 0.7 * t).toFixed(2)})`; } function SegmentedControl({ options, value, onChange, label, }: { options: readonly T[]; value: T; onChange: (next: T) => void; label: string; }) { return (
{options.map((option) => ( ))}
); } function PathTip({ active, payload }: { active?: boolean; payload?: { payload: PathPoint }[] }) { if (!active || !payload?.length) return null; const p = payload[0].payload; return (
{formatDate(p.date)}
State {Math.round(p.x)} · Warning{' '} {Math.round(p.y)}
); } export default function RegimeChart() { const [view, setView] = useState('Time'); const [range, setRange] = useState('3M'); const history = useQuery({ queryKey: ['regime', 'history'], queryFn: () => getRegimeHistory(800) }); const monitor = useQuery({ queryKey: ['regime', 'monitor'], queryFn: getRegimeMonitor }); const xDiv = monitor.data?.quadrant_config?.state_divider ?? DEFAULT_STATE_DIVIDER; const yDiv = monitor.data?.quadrant_config?.warning_divider ?? DEFAULT_WARNING_DIVIDER; const basketAsOf = monitor.data?.basket?.basket_asof; const series = useMemo(() => { const data = history.data ?? []; if (view === 'Path') { return data .filter((p) => p.state != null && p.warning != null) .slice(-PATH_TRAIL); } const days = RANGES.find((r) => r.key === range)!.days; if (!Number.isFinite(days)) return data; const cutoff = new Date(); cutoff.setDate(cutoff.getDate() - days); return data.filter((p) => new Date(p.date) >= cutoff); }, [history.data, view, range]); const pathPoints = useMemo( () => series.map((p) => ({ x: p.state as number, y: p.warning as number, date: p.date })), [series], ); const trail = useMemo(() => (view === 'Path' ? smoothTrail(pathPoints) : []), [pathPoints, view]); const latest = view === 'Path' && pathPoints.length ? pathPoints[pathPoints.length - 1] : null; // Only warn about pre-freeze history when the drawn window actually reaches // back past the freeze date. const crossesFreeze = Boolean(basketAsOf && series.length && series[0].date < basketAsOf); const enoughData = view === 'Path' ? pathPoints.length > 0 : series.length >= 2; return (
{view === 'Time' ? 'State & Warning over time' : `State × Warning path · last ${PATH_TRAIL} sessions`}
{view === 'Time' ? ( r.key)} value={range} onChange={setRange} label="Time range" /> ) : ( latest && ( now: State {Math.round(latest.x)} · Warning{' '} {Math.round(latest.y)} ) )}
{history.isLoading ? ( ) : !enoughData ? ( Not enough coverage-qualified history yet — it accumulates as the daily job runs. ) : ( <>
{view === 'Time' ? ( formatDate(String(d))} minTickGap={28} tickLine={false} axisLine={{ stroke: 'rgba(255,255,255,0.08)' }} /> {/* width must clear a 3-digit label: the old chart paired width 28 with margin.left -18 and clipped every tick. */} {/* The two axes have different thresholds, so each divider is drawn in its series' colour rather than as shared gridlines. */} formatDate(String(l))} formatter={(value) => (value == null ? '—' : Math.round(Number(value)))} /> ) : ( } /> {trail.map((_, i) => ( ))} {latest && ( ( )} /> )} )}
{view === 'Time' ? (
State Warning dashed = each axis's elevated threshold ({xDiv} / {yDiv})
) : (
Early warning — calm, fragility rising Active stress — damaged and deteriorating Healthy — calm, broadly supported Stabilizing — damage remains, warning lower White dot = today; trail brightens toward the present, smoothed.
)} {crossesFreeze && (

History before {basketAsOf} is reconstructed against today's basket — retrospective, not a live record.

)} )}
); }