import { useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; import { ScatterChart, Scatter, Cell, XAxis, YAxis, ZAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine, ReferenceArea, } from 'recharts'; import { getRegimeHistory, getRegimeMonitor } from '../../api/regime'; import { Callout } from '../ui/Callout'; import { SkeletonCard } from '../ui/Skeleton'; // Lazy-loaded (see RegimePage) so recharts stays in the regime-tab chunk. // Quadrant boundaries come from the backend v2 methodology response. const TRAIL = 60; // sessions shown interface QPoint { x: number; y: number; date: string; } /** Centered moving average to de-noise the path; today (last) kept exact. */ function smoothTrail(points: QPoint[], half = 2): QPoint[] { 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); const r = lerp(71, 96); const g = lerp(85, 165); const b = lerp(105, 250); const alpha = (0.3 + 0.7 * t).toFixed(2); return `rgba(${r}, ${g}, ${b}, ${alpha})`; } function QuadrantTip({ active, payload }: { active?: boolean; payload?: { payload: QPoint }[] }) { if (!active || !payload?.length) return null; const p = payload[0].payload; return (
{p.date}
State {Math.round(p.x)} · Warning{' '} {Math.round(p.y)}
); } export default function RegimeQuadrant() { 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 ?? 60; const yDiv = monitor.data?.quadrant_config?.warning_divider ?? 60; const points = useMemo(() => { const data = history.data ?? []; return data .filter((p) => p.state != null && p.warning != null) .slice(-TRAIL) .map((p) => ({ x: p.state as number, y: p.warning as number, date: p.date })); }, [history.data]); const trail = useMemo(() => smoothTrail(points), [points]); const latest = points.length ? points[points.length - 1] : null; return (
State × Warning quadrant — last {TRAIL} sessions
{latest && (
now: State {Math.round(latest.x)} · Warning{' '} {Math.round(latest.y)}
)}
{history.isLoading ? ( ) : !points.length ? ( Not enough coverage-qualified v2 history yet. ) : ( <>
{/* Quadrant shading (drawn first, behind everything) */} } /> {/* Smoothed trail with a recency gradient (old → new) */} {trail.map((_, i) => ( ))} {/* Today */} {latest && ( ( )} /> )}
Early warning — state calm, fragility rising Active stress — damaged and deteriorating Healthy — calm and broadly supported Stressed / stabilizing — damage remains, warning lower

White dot = today; the trail fades from muted (older) to bright blue (newer) over the last {TRAIL}{' '} sessions, smoothed. The path matters more than a single point. Risk thermometer — not an entry, exit, or sizing signal.

)}
); }