From a12fc7645e2a03293383607945939beb3ce9cc13 Mon Sep 17 00:00:00 2001 From: nadaamd Date: Sat, 25 Jul 2026 14:29:49 +0100 Subject: [PATCH] feat(frontend): institutional terminal risk dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CircuitBreaker.ai frontend — "Institutional Terminal / high-density risk dashboard" (Next.js App Router + Tailwind + Recharts + Lucide). - CBRI Core gauge (0–100), risk evolution (CBRI ∥ USDC, stacked single-axis) - Pool Monitor (Uniswap v3): TVL, price divergence, order-flow imbalance - Backtest simulator: interactive τ threshold, funds-saved curve, slippage - Execution & status panel: SAFE/ARMED/TRIGGERED + 1inch evacuation log - Live replay of the real USDC/SVB depeg (Mar 2023) → evac @ $1.0000 vs $0.873 trough, +$126.9k saved on $1M at τ*=66 Reads bundled seed JSON generated from quant-backtest output; backend integration is a single file (lib/data.ts) — return types are the contract. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/README.md | 65 +++++++ frontend/app/page.tsx | 88 +++++++++ frontend/components/BacktestSimulator.tsx | 222 ++++++++++++++++++++++ frontend/components/CbriChart.tsx | 201 ++++++++++++++++++++ frontend/components/DrainChart.tsx | 107 +++++++++++ frontend/components/ExecutionPanel.tsx | 156 +++++++++++++++ frontend/components/Header.tsx | 77 ++++++++ frontend/components/KpiTape.tsx | 55 ++++++ frontend/components/PoolMonitor.tsx | 132 +++++++++++++ frontend/components/ReplayControls.tsx | 99 ++++++++++ frontend/components/RiskGauge.tsx | 155 +++++++++++++++ 11 files changed, 1357 insertions(+) create mode 100644 frontend/README.md create mode 100644 frontend/app/page.tsx create mode 100644 frontend/components/BacktestSimulator.tsx create mode 100644 frontend/components/CbriChart.tsx create mode 100644 frontend/components/DrainChart.tsx create mode 100644 frontend/components/ExecutionPanel.tsx create mode 100644 frontend/components/Header.tsx create mode 100644 frontend/components/KpiTape.tsx create mode 100644 frontend/components/PoolMonitor.tsx create mode 100644 frontend/components/ReplayControls.tsx create mode 100644 frontend/components/RiskGauge.tsx diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..3ca45cc --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,65 @@ +# CircuitBreaker.ai — Frontend + +> **Institutional Terminal** for the autonomous DeFi circuit breaker. +> High-density, data-centric risk dashboard — Bloomberg/Paradigm aesthetic, zero slop. + +Next.js (App Router) · TypeScript · Tailwind · Recharts · Lucide. + +## Run + +```bash +cd frontend +pnpm install +pnpm dev # http://localhost:3000 +# prod: pnpm build && pnpm start +``` + +> Note: another app already listens on :3000 in this repo — run the frontend on a +> free port with `pnpm exec next start -p 3939` if needed. + +## What it shows + +The whole terminal replays the **real USDC/SVB depeg (March 2023)** candle-by-candle +(data extracted by `quant-backtest`). The breaker walks **SAFE → ARMED → TRIGGERED** +live as CBRI climbs, evacuates at the optimal threshold **τ\* = 66**, exiting at +**\$1.0000** while the depeg bottomed at **\$0.873** → **+\$126.9k saved** on \$1M. + +Four screens (per the brief): +1. **CBRI Core** + **Risk Evolution** — global 0–100 risk gauge, CBRI ∥ USDC over time. +2. **Pool Monitor** — target Uniswap v3 pool, TVL, price divergence, order-flow imbalance. +3. **Backtest Simulator** — interactive τ slider, funds-saved curve, reliable vs false-positive zones, slippage. +4. **Execution & Status** — breaker state + 1inch best-exec USDC→USDT evacuation log. + +Replay transport (bottom bar): play/pause, speed, scrub, **jump-to-trip**. + +## 🔌 Backend integration — the ONE file to touch + +Everything reads through `lib/data.ts`. Today it returns **bundled seed JSON** +(`public/seed/*.json`, generated from `quant-backtest/output/*.csv`). To go live, +replace the three getters with calls to the quant backend / The Graph / 1inch — +**the return types are the contract** (`lib/types.ts`), nothing else changes: + +| Getter (`lib/data.ts`) | Returns | Live source | +|---|---|---| +| `getSeries()` | `CbriPoint[]` | quant CBRI stream (The Graph → `features.py`) | +| `getSweep()` | `SweepRow[]` | `backtest.py` τ-sweep | +| `getSummary()` | `Summary` | `backtest.py` τ\* selection | + +HTTP surface already wired (swap the handlers' data source, keep the shapes): + +- `GET /api/cbri/series` → `CbriPoint[]` +- `GET /api/backtest/sweep` → `{ sweep: SweepRow[], summary: Summary }` +- `GET /api/summary` → `Summary` + +For a **live feed**, point `lib/store.tsx` at a WebSocket/SSE that pushes new +`CbriPoint`s and flip the replay clock off — the status logic (`deriveStatus`) and +all panels already consume `current` reactively. + +## Design system + +Dark-committed "institutional terminal" (`tailwind.config.ts`): functional colors +only — `risk`/`safe`/`armed` reserved status hues (always icon + label, never +color-alone), a CVD-validated categorical trio, technical greys. Mono for all data +(JetBrains Mono), tabular numbers everywhere. No glassmorphism, no neon, no glow. +Charts follow single-axis discipline (CBRI and price are stacked panels sharing the +x-axis, never a dual-axis). diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx new file mode 100644 index 0000000..f56d8e4 --- /dev/null +++ b/frontend/app/page.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { BreakerProvider } from "@/lib/store"; +import { Panel } from "@/components/ui"; +import { Header } from "@/components/Header"; +import { KpiTape } from "@/components/KpiTape"; +import { RiskGauge } from "@/components/RiskGauge"; +import { CbriChart } from "@/components/CbriChart"; +import { PoolMonitor } from "@/components/PoolMonitor"; +import { DrainChart } from "@/components/DrainChart"; +import { BacktestSimulator } from "@/components/BacktestSimulator"; +import { ExecutionPanel } from "@/components/ExecutionPanel"; +import { ReplayControls } from "@/components/ReplayControls"; + +export default function Page() { + return ( + +
+
+ + +
+ {/* LEFT — the core + evolution + pool + drain */} +
+ + + + + + + + + + + + + + + +
+ + {/* RIGHT — backtest simulator + execution */} +
+ + + + + + + +
+
+ + +
+
+ ); +} diff --git a/frontend/components/BacktestSimulator.tsx b/frontend/components/BacktestSimulator.tsx new file mode 100644 index 0000000..a37295d --- /dev/null +++ b/frontend/components/BacktestSimulator.tsx @@ -0,0 +1,222 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { + LineChart, + Line, + XAxis, + YAxis, + ReferenceLine, + ReferenceDot, + ResponsiveContainer, + Tooltip, +} from "recharts"; +import { useBreaker } from "@/lib/store"; +import { sweepAt } from "@/lib/data"; +import { usdCompact, bps, price4, stampIso } from "@/lib/format"; +import { Target, AlertTriangle } from "lucide-react"; + +function SweepTip({ active, payload }: any) { + if (!active || !payload?.length) return null; + const p = payload[0].payload; + return ( +
+
τ = {p.tau}
+
+ {usdCompact(p.fundsSaved)} saved +
+
{bps(p.exitSlipBps)} slip
+
+ ); +} + +export function BacktestSimulator() { + const { sweep, summary } = useBreaker(); + const tauStar = summary?.tauStar ?? 66; + const [tau, setTau] = useState(tauStar); + + const data = useMemo( + () => + sweep.map((r) => ({ + ...r, + savedK: r.fundsSaved / 1e3, + reliable: r.fp === 0 ? r.fundsSaved / 1e3 : null, + risky: r.fp === 1 ? r.fundsSaved / 1e3 : null, + })), + [sweep] + ); + + const sel = sweepAt(sweep, tau); + const star = sweepAt(sweep, tauStar); + const delta = sel && star ? sel.fundsSaved - star.fundsSaved : 0; + const isOptimal = tau >= (summary?.plateauLo ?? tauStar) && tau <= (summary?.plateauHi ?? tauStar); + + return ( +
+ {/* chart */} +
+ + + + `${v}k`} + /> + + + + + {sel && ( + + )} + } cursor={{ stroke: "#3c424a" }} /> + + +
+ + {/* legend */} +
+ + reliable (0 false-positive) + + + τ too low → false alarm + +
+ + {/* slider */} +
+
+ Trigger threshold τ +
+ + {tau} + + +
+
+ setTau(Number(e.target.value))} + /> +
+ + {/* result readout */} +
+ {[ + { l: "Trigger", v: sel ? stampIso(sel.triggerDt).slice(3) : "—", s: "UTC" }, + { l: "Exit price", v: sel ? price4(sel.exitPrice) : "—", s: "USDC" }, + { l: "Slippage", v: sel ? bps(sel.exitSlipBps) : "—", s: "1inch-cal" }, + { + l: "Funds saved", + v: sel ? usdCompact(sel.fundsSaved) : "—", + s: "vs trough", + tone: sel && sel.fp ? "risk" : "safe", + }, + ].map((c) => ( +
+
{c.l}
+
+ {c.v} +
+
{c.s}
+
+ ))} +
+ + {/* verdict line */} +
+ {sel?.fp ? ( + <> + τ={tau} fires during calm market — false + evacuation risk. + + ) : isOptimal ? ( + <> + τ={tau} on the optimal plateau [{summary?.plateauLo}– + {summary?.plateauHi}] · max funds saved, 0 false-positive. + + ) : ( + <> + τ={tau} reliable but late — {delta < 0 ? usdCompact(delta) : "+" + usdCompact(delta)} vs τ*. + + )} +
+
+ ); +} diff --git a/frontend/components/CbriChart.tsx b/frontend/components/CbriChart.tsx new file mode 100644 index 0000000..009dc20 --- /dev/null +++ b/frontend/components/CbriChart.tsx @@ -0,0 +1,201 @@ +"use client"; + +import { + AreaChart, + Area, + LineChart, + Line, + XAxis, + YAxis, + ReferenceLine, + ReferenceDot, + ResponsiveContainer, + Tooltip, +} from "recharts"; +import { useBreaker } from "@/lib/store"; +import { armThreshold } from "@/lib/data"; +import { hhmm, stamp, price4 } from "@/lib/format"; + +const MARGIN = { top: 6, right: 16, bottom: 0, left: 34 }; + +function CbriTooltip({ active, payload }: any) { + if (!active || !payload?.length) return null; + const p = payload[0].payload; + return ( +
+
{stamp(p.t)} UTC
+
+ CBRI + {p.cbri.toFixed(1)} +
+
+ USDC + {price4(p.usdc)} +
+
+ drain + {p.drain.toFixed(1)}%/h +
+
+ ); +} + +export function CbriChart() { + const { visible, series, summary, triggerIdx, triggered } = useBreaker(); + const tau = summary?.tauStar ?? 66; + const arm = armThreshold(tau); + const start = summary?.startTs ?? series[0]?.t; + const end = summary?.endTs ?? series[series.length - 1]?.t; + const domain: [number, number] = [start ?? 0, end ?? 1]; + + const trig = triggerIdx >= 0 ? series[triggerIdx] : null; + const showTrig = triggered && trig; + + const xTicks = + start && end + ? Array.from({ length: 4 }, (_, k) => start + ((end - start) * (k + 1)) / 5) + : []; + + return ( +
+ {/* CBRI area */} +
+ + + + + + + + + + + + + + {showTrig && ( + + )} + } + cursor={{ stroke: "#3c424a", strokeWidth: 1 }} + /> + + +
+ +
+ USDC / USD · depeg signal + {showTrig && ( + + ⎇ EVAC @ {price4(trig!.usdc)} · {stamp(trig!.t)} + + )} +
+ + {/* Price strip */} +
+ + + + v.toFixed(2)} + tickLine={false} + axisLine={false} + width={30} + tick={{ fontSize: 10, fill: "#626871" }} + /> + + {summary && ( + + )} + + {showTrig && ( + + )} + + +
+
+ ); +} diff --git a/frontend/components/DrainChart.tsx b/frontend/components/DrainChart.tsx new file mode 100644 index 0000000..de78b86 --- /dev/null +++ b/frontend/components/DrainChart.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { + AreaChart, + Area, + XAxis, + YAxis, + ReferenceLine, + ResponsiveContainer, + Tooltip, +} from "recharts"; +import { useBreaker } from "@/lib/store"; +import { hhmm, stamp } from "@/lib/format"; + +const DRAIN_THRESHOLD = 6; // %/h (config.DRAIN_THRESHOLD * 100) + +function DrainTip({ active, payload }: any) { + if (!active || !payload?.length) return null; + const p = payload[0].payload; + return ( +
+ {stamp(p.t)}{" "} + {p.drain.toFixed(1)}%/h +
+ ); +} + +export function DrainChart() { + const { visible, series, summary, current } = useBreaker(); + const start = summary?.startTs ?? series[0]?.t; + const end = summary?.endTs ?? series[series.length - 1]?.t; + const cur = current?.drain ?? 0; + const overThreshold = cur >= DRAIN_THRESHOLD; + + return ( +
+
+ ΔL / Δt · liquidity flight rate + + {cur.toFixed(1)} + %/h + +
+
+ + + + + + + + + + `${v}`} + /> + + + } + cursor={{ stroke: "#3c424a", strokeWidth: 1 }} + /> + + +
+
+ ); +} diff --git a/frontend/components/ExecutionPanel.tsx b/frontend/components/ExecutionPanel.tsx new file mode 100644 index 0000000..090249e --- /dev/null +++ b/frontend/components/ExecutionPanel.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { useBreaker } from "@/lib/store"; +import { armThreshold } from "@/lib/data"; +import { StatusBadge } from "./StatusBadge"; +import { usdCompact, bps, hhmm, addr } from "@/lib/format"; +import { CheckCircle2, ArrowRight } from "lucide-react"; + +function LogLine({ + ts, + tone = "ink3", + children, +}: { + ts: number; + tone?: "ink3" | "ink2" | "armed" | "risk" | "safe"; + children: React.ReactNode; +}) { + const toneClass = { + ink3: "text-ink3", + ink2: "text-ink2", + armed: "text-armed", + risk: "text-risk", + safe: "text-safe", + }[tone]; + return ( +
+ {hhmm(ts)} + {children} +
+ ); +} + +export function ExecutionPanel() { + const { status, current, summary, execution, triggered } = useBreaker(); + const tau = summary?.tauStar ?? 66; + const arm = armThreshold(tau); + const cbri = current?.cbri ?? 0; + const ts = current?.t ?? 0; + + const totalOut = execution.reduce((s, l) => s + l.amountUsd, 0); + + return ( +
+ {/* status header */} +
+ +
+
Position
+
+ {usdCompact(summary?.position ?? 0)} {summary?.safeAsset ? "USDC" : ""} +
+
+
+ + {/* narrative line */} +
+ {status === "SAFE" && ( + <>Breaker armed & monitoring. CBRI {cbri.toFixed(0)} — well below trip τ* {tau}. + )} + {status === "ARMED" && ( + + ⚠ Risk building. CBRI {cbri.toFixed(0)} crossed arm {arm} — staging evacuation route. + + )} + {status === "TRIGGERED" && ( + + ⚡ TRIP. CBRI {cbri.toFixed(0)} ≥ τ* {tau} — emergency evacuation to {summary?.safeAsset} executed. + + )} +
+ + {/* body */} +
+ {!triggered ? ( + /* standby log */ +
+ subgraph sync · Uniswap v3 · {summary?.pool} + CBRI = {cbri.toFixed(1)} · arm={arm} · trip τ*={tau} + + drain {(current?.drain ?? 0).toFixed(1)}%/h · depeg {(Math.abs(1 - (current?.usdc ?? 1)) * 1e4).toFixed(0)}bps + + {status === "ARMED" && ( + <> + route staged: {summary?.safeAsset} best-exec via 1inch + awaiting τ* confirmation… + + )} + no action · funds retained in position +
+ ) : ( + /* execution log */ +
+ {/* summary strip */} +
+
+
Evacuated
+
+ {usdCompact(totalOut)} +
+
+
+
Funds saved
+
+ {usdCompact(summary?.fundsSaved ?? 0)} +
+
+
+
Success fee
+
+ {usdCompact(summary?.successFee ?? 0)} +
+
+
+ + {/* legs */} +
+ + ⚡ TRIP @ CBRI {tau} — 1inch best-execution split · $1M USDC → {summary?.safeAsset} + + {execution.map((l) => ( +
+
+ + + {l.venue} + + + {l.fromToken} {l.toToken} + +
+
+ + {usdCompact(l.amountUsd)} + + + {bps(l.priceImpactBps)} + + + {l.status} + +
+
+ ))} + + ✓ evacuation complete · exit @ {summary?.exitPrice.toFixed(4)} · avg slip {summary?.exitSlipBps.toFixed(0)}bps + +
+
+ )} +
+
+ ); +} diff --git a/frontend/components/Header.tsx b/frontend/components/Header.tsx new file mode 100644 index 0000000..1773fee --- /dev/null +++ b/frontend/components/Header.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useBreaker } from "@/lib/store"; +import { stamp } from "@/lib/format"; +import { StatusBadge } from "./StatusBadge"; + +function SponsorChip({ name, role }: { name: string; role: string }) { + return ( +
+ {name} + + {role} + +
+ ); +} + +export function Header() { + const { current, status, summary, ready } = useBreaker(); + return ( +
+ {/* Brand */} +
+
+ {/* circuit-breaker glyph */} + + + + +
+
+
+ CircuitBreaker.ai +
+
+ Autonomous DeFi Risk Terminal +
+
+
+ + {/* Live replay stamp */} +
+
+
Replay · {summary?.crash ?? "—"}
+
+ {current ? stamp(current.t) : "—— ——— ——:——"}{" "} + UTC +
+
+
+ +
+ + {/* Sponsors + system */} +
+
+ + + +
+
+ + + {ready ? "online" : "sync"} + +
+
+
+ ); +} diff --git a/frontend/components/KpiTape.tsx b/frontend/components/KpiTape.tsx new file mode 100644 index 0000000..7f63d51 --- /dev/null +++ b/frontend/components/KpiTape.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { useBreaker } from "@/lib/store"; +import { usdCompact, price4, pct } from "@/lib/format"; + +function Cell({ + label, + value, + tone = "ink", +}: { + label: string; + value: string; + tone?: "ink" | "risk" | "safe" | "armed"; +}) { + const t = { + ink: "text-ink", + risk: "text-risk", + safe: "text-safe", + armed: "text-armed", + }[tone]; + return ( +
+ {label} + {value} +
+ ); +} + +export function KpiTape() { + const { current, summary, status } = useBreaker(); + const depegBps = Math.abs(1 - (current?.usdc ?? 1)) * 1e4; + const savedPct = summary ? (summary.fundsSaved / summary.position) * 100 : 0; + + return ( +
+ + 120 ? "risk" : "ink"} /> + + +
+ + + + +
+ ); +} diff --git a/frontend/components/PoolMonitor.tsx b/frontend/components/PoolMonitor.tsx new file mode 100644 index 0000000..607706c --- /dev/null +++ b/frontend/components/PoolMonitor.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { useBreaker } from "@/lib/store"; +import { addr, usdCompact, price2, price4 } from "@/lib/format"; +import { Stat } from "./ui"; +import { ExternalLink, Droplets } from "lucide-react"; + +export function PoolMonitor() { + const { current, summary } = useBreaker(); + const c = current; + const usdc = c?.usdc ?? 1; + const depegBps = Math.abs(1 - usdc) * 1e4; + const ofi = c?.ofi ?? 0; + const draining = (c?.netLiq ?? 0) < 0; + + const depegTone = depegBps > 120 ? "risk" : depegBps > 40 ? "armed" : "safe"; + const depegColor = + depegBps > 120 ? "#e5484d" : depegBps > 40 ? "#f5a623" : "#30a46c"; + + return ( +
+ {/* pool identity */} +
+
+ +
+
+ {summary?.pool ?? "USDC / WETH 0.05%"} +
+
+ Uniswap v3 · Ethereum +
+
+
+ + {addr(summary?.poolAddr ?? "0x88e6a0c2…5640")} + + +
+ + {/* key stats */} +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + {/* price divergence bar */} +
+
+ Price Divergence · |1 − USDC| + + {depegBps.toFixed(0)} bps + +
+
+ {/* peg center */} +
+
+ {/* threshold tick at DEPEG_THRESHOLD 1.2% = 120bps */} +
+
+
+ peg + τ 120bps + 13% depeg +
+
+ + {/* order-flow imbalance */} +
+
+ Order-Flow Imbalance |I| + + {draining ? "NET LIQ OUTFLOW" : "NET LIQ INFLOW"} + +
+
+ bal +
+
+
+
+ + {ofi.toFixed(2)} + +
+
+
+ ); +} diff --git a/frontend/components/ReplayControls.tsx b/frontend/components/ReplayControls.tsx new file mode 100644 index 0000000..4b532b8 --- /dev/null +++ b/frontend/components/ReplayControls.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { useBreaker } from "@/lib/store"; +import { Chip } from "./ui"; +import { stamp } from "@/lib/format"; +import { Play, Pause, RotateCcw, Zap } from "lucide-react"; + +const SPEEDS: [string, number][] = [ + ["1×", 1], + ["2×", 3], + ["4×", 6], + ["8×", 12], +]; + +export function ReplayControls() { + const { + playing, + toggle, + reset, + speed, + setSpeed, + i, + series, + scrub, + current, + triggerIdx, + jumpToTrigger, + } = useBreaker(); + + const len = Math.max(1, series.length - 1); + const trigPct = triggerIdx >= 0 ? (triggerIdx / len) * 100 : -1; + + return ( +
+ {/* transport */} +
+ + + +
+ + {/* speed */} +
+ speed + {SPEEDS.map(([lbl, v]) => ( + setSpeed(v)}> + {lbl} + + ))} +
+ + {/* scrub */} +
+ {trigPct >= 0 && ( +
+
+
+ )} + scrub(Number(e.target.value))} + /> +
+ + {/* readout */} +
+ {current ? stamp(current.t) : "—"} + · {i}/{len} +
+
+ ); +} diff --git a/frontend/components/RiskGauge.tsx b/frontend/components/RiskGauge.tsx new file mode 100644 index 0000000..a1cdad5 --- /dev/null +++ b/frontend/components/RiskGauge.tsx @@ -0,0 +1,155 @@ +"use client"; + +import { useBreaker } from "@/lib/store"; +import { armThreshold } from "@/lib/data"; +import { ArrowUpRight, ArrowDownRight, Minus } from "lucide-react"; + +const SEGMENTS = 56; + +export function RiskGauge() { + const { current, visible, status, summary } = useBreaker(); + const cbri = current?.cbri ?? 0; + const tau = summary?.tauStar ?? 66; + const arm = armThreshold(tau); + + // trend over the last ~30 min (6 candles) + const prev = visible[visible.length - 7]?.cbri ?? cbri; + const delta = cbri - prev; + + const zoneColor = (v: number) => + v >= tau ? "#e5484d" : v >= arm ? "#f5a623" : "#30a46c"; + const statusInk = + status === "TRIGGERED" + ? "text-risk" + : status === "ARMED" + ? "text-armed" + : "text-safe"; + + return ( +
+
+
+
CBRI · Circuit Breaker Risk Index
+
+ Noisy-OR · drain ∨ imbalance ∨ depeg +
+
+
0.5 ? "text-risk" : delta < -0.5 ? "text-safe" : "text-ink3" + }`} + > + {delta > 0.5 ? ( + + ) : delta < -0.5 ? ( + + ) : ( + + )} + {delta >= 0 ? "+" : ""} + {delta.toFixed(1)} + /30m +
+
+ + {/* Hero number */} +
+
+ {cbri.toFixed(1)} +
+
/100
+
+ + {/* Segmented meter */} +
+ {/* threshold markers */} +
+ {[ + { v: arm, label: "ARM", c: "#f5a623" }, + { v: tau, label: "τ*", c: "#e5484d" }, + ].map((m) => ( +
+ + {m.label} {m.v} + +
+
+ ))} +
+ +
+ {Array.from({ length: SEGMENTS }).map((_, k) => { + const v = ((k + 0.5) / SEGMENTS) * 100; + const lit = v <= cbri; + const isHead = Math.abs(v - cbri) < 100 / SEGMENTS / 1.2; + const col = zoneColor(v); + return ( +
+ ); + })} +
+ + {/* zone legend */} +
+ 0 + SAFE ‹{arm} + ARMED {arm}–{tau} + TRIP ≥{tau} + 100 +
+
+ + {/* sub-signal contributions */} +
+ {[ + { k: "DRAIN ΔL/Δt", v: current?.sDrain ?? 0, w: 1.0 }, + { k: "IMBALANCE", v: current?.sOfi ?? 0, w: 0.0 }, + { k: "DEPEG", v: current?.sDepeg ?? 0, w: 1.0 }, + ].map((s) => ( +
+
+ {s.k} + {s.w === 0 && ( + + w=0 + + )} +
+
+ {(s.v * 100).toFixed(0)} + % +
+
+
+
+
+ ))} +
+
+ ); +}