185 lines
7.4 KiB
TypeScript
185 lines
7.4 KiB
TypeScript
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 (
|
||
<div className="glass px-2.5 py-1.5 text-[11px]">
|
||
<div className="text-gray-300">{p.date}</div>
|
||
<div className="text-gray-400">
|
||
State <span className="text-blue-300">{Math.round(p.x)}</span> · Warning{' '}
|
||
<span className="text-orange-300">{Math.round(p.y)}</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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<QPoint[]>(() => {
|
||
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 (
|
||
<div className="glass p-5">
|
||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||
<div className="text-[11px] uppercase tracking-wider text-gray-500">
|
||
State × Warning quadrant — last {TRAIL} sessions
|
||
</div>
|
||
{latest && (
|
||
<div className="text-[11px] text-gray-500">
|
||
now: State <span className="text-blue-300">{Math.round(latest.x)}</span> · Warning{' '}
|
||
<span className="text-orange-300">{Math.round(latest.y)}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{history.isLoading ? (
|
||
<SkeletonCard className="mt-3 h-72" />
|
||
) : !points.length ? (
|
||
<Callout variant="empty">
|
||
Not enough coverage-qualified v2 history yet.
|
||
</Callout>
|
||
) : (
|
||
<>
|
||
<div className="mt-3 h-80">
|
||
<ResponsiveContainer width="100%" height="100%">
|
||
<ScatterChart margin={{ top: 10, right: 16, bottom: 22, left: 0 }}>
|
||
{/* Quadrant shading (drawn first, behind everything) */}
|
||
<ReferenceArea x1={0} x2={xDiv} y1={yDiv} y2={100} fill="#f59e0b" fillOpacity={0.07} stroke="none" />
|
||
<ReferenceArea x1={xDiv} x2={100} y1={yDiv} y2={100} fill="#f97316" fillOpacity={0.07} stroke="none" />
|
||
<ReferenceArea x1={0} x2={xDiv} y1={0} y2={yDiv} fill="#10b981" fillOpacity={0.07} stroke="none" />
|
||
<ReferenceArea x1={xDiv} x2={100} y1={0} y2={yDiv} fill="#ef4444" fillOpacity={0.08} stroke="none" />
|
||
<CartesianGrid stroke="rgba(255,255,255,0.04)" />
|
||
<ReferenceLine x={xDiv} stroke="rgba(255,255,255,0.12)" />
|
||
<ReferenceLine y={yDiv} stroke="rgba(255,255,255,0.12)" />
|
||
<XAxis
|
||
type="number"
|
||
dataKey="x"
|
||
domain={[0, 100]}
|
||
ticks={[0, 20, 40, 60, 80, 100]}
|
||
tick={{ fill: '#6b7280', fontSize: 10 }}
|
||
tickLine={false}
|
||
axisLine={{ stroke: 'rgba(255,255,255,0.08)' }}
|
||
label={{ value: 'State →', position: 'insideBottom', offset: -12, fill: '#6b7280', fontSize: 10 }}
|
||
/>
|
||
<YAxis
|
||
type="number"
|
||
dataKey="y"
|
||
domain={[0, 100]}
|
||
ticks={[0, 20, 40, 60, 80, 100]}
|
||
tick={{ fill: '#6b7280', fontSize: 10 }}
|
||
width={30}
|
||
tickLine={false}
|
||
axisLine={false}
|
||
label={{ value: 'Warning', angle: -90, position: 'insideLeft', fill: '#6b7280', fontSize: 10 }}
|
||
/>
|
||
<ZAxis range={[13, 13]} />
|
||
<Tooltip cursor={{ strokeDasharray: '3 3', stroke: 'rgba(255,255,255,0.2)' }} content={<QuadrantTip />} />
|
||
{/* Smoothed trail with a recency gradient (old → new) */}
|
||
<Scatter
|
||
data={trail}
|
||
line={{ stroke: 'rgba(96,165,250,0.18)', strokeWidth: 1.5 }}
|
||
isAnimationActive={false}
|
||
>
|
||
{trail.map((_, i) => (
|
||
<Cell key={i} fill={recencyColor(trail.length <= 1 ? 1 : i / (trail.length - 1))} />
|
||
))}
|
||
</Scatter>
|
||
{/* Today */}
|
||
{latest && (
|
||
<Scatter
|
||
data={[latest]}
|
||
isAnimationActive={false}
|
||
shape={(props: { cx?: number; cy?: number }) => (
|
||
<circle cx={props.cx} cy={props.cy} r={6} fill="#ffffff" stroke="#60a5fa" strokeWidth={2} />
|
||
)}
|
||
/>
|
||
)}
|
||
</ScatterChart>
|
||
</ResponsiveContainer>
|
||
</div>
|
||
|
||
<div className="mt-2 grid grid-cols-1 gap-x-4 gap-y-1 text-[11px] text-gray-500 sm:grid-cols-2">
|
||
<span><span className="text-amber-400">Early warning</span> — state calm, fragility rising</span>
|
||
<span><span className="text-orange-400">Active stress</span> — damaged and deteriorating</span>
|
||
<span><span className="text-emerald-400">Healthy</span> — calm and broadly supported</span>
|
||
<span><span className="text-red-400">Stressed / stabilizing</span> — damage remains, warning lower</span>
|
||
</div>
|
||
<p className="mt-2 text-[11px] leading-relaxed text-gray-600">
|
||
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.
|
||
</p>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|