// SPDX-License-Identifier: Apache-2.0 // Copyright 2026 The Sargassum Training Kit Authors /* ── Presentational components for the Drifter Field Console ── Ported from the Hover design project (Sargassum Drifter Console, prompt-10/11/12). sim.jsx's throwaway simulation is gone -- these components are now driven entirely by real API data from app.jsx. The live features panel lives in its own features.jsx (matching the design's zone/file split). */ const { useState, useEffect, useRef } = React; // One canonical class table drives EVERY 3-way surface (label buttons, board verdict, timeline model-call // track, dataset balance bar, span chips, registry counts). label ints match the trainer/board // (open-water=0, in-mat=1, out-of-water=2); adding a class is one row here. `key` is the short string the // label UI round-trips through onSet; `cvar` is the CSS color var; tintA/tintB are the fill/border tints. const SARG_CLASSES = [ { label: 0, key: 'OUT', name: 'OPEN WATER', short: 'OPEN', cvar: '--out', tintA: 'rgba(90,138,156,0.14)', tintB: 'rgba(90,138,156,0.30)' }, { label: 1, key: 'IN', name: 'IN-MAT', short: 'IN-MAT', cvar: '--in', tintA: 'rgba(184,176,106,0.14)', tintB: 'rgba(184,176,106,0.28)' }, { label: 2, key: 'DRY', name: 'OUT OF WATER', short: 'DRY', cvar: '--dry', tintA: 'rgba(238,240,244,0.12)', tintB: 'rgba(238,240,244,0.34)' }, ]; const SARG = { classes: SARG_CLASSES, byKey: (k) => SARG_CLASSES.find(c => c.key === k) || null, // legacy spans stored a string label ('IN'/'OUT'); tolerate both int and string here. byLabel: (l) => SARG_CLASSES.find(c => c.label === l || c.key === l) || null, _resolve: (ref) => (typeof ref === 'number' ? SARG.byLabel(ref) : (SARG.byKey(ref) || SARG.byLabel(ref))), color: (ref) => { const c = SARG._resolve(ref); return c ? `var(${c.cvar})` : 'var(--t-3)'; }, name: (ref) => { const c = SARG._resolve(ref); return c ? c.name : String(ref); }, // Sensor swatch color: ISL29125 raw values are 16-bit (10k-lux range, 0..65535 full scale). CSS rgb() // clamps >255 to white, so real board data (thousands) renders every swatch white. Normalize to 0..255 // for the COLOR only -- numeric readouts keep the raw values. A linear /65535 is physically correct but // maps real underwater readings (~1000-6000, well under the sun-clip ceiling) near-black, defeating the // see-the-color glance. A sqrt (gamma ~2) perceptual curve lifts those dim readings into a visible, // hue-distinguishable range while bright out-of-water readings still approach white. rgb255: (rgb) => rgb.map(v => Math.max(0, Math.min(255, Math.round(255 * Math.sqrt(Math.max(0, v) / 65535))))), cssRgb: (rgb) => { const [r, g, b] = SARG.rgb255(rgb); return `rgb(${r}, ${g}, ${b})`; }, }; // ───────────────────────────────────────────────────────────── // ZONE 1 — LIVE NOW // ───────────────────────────────────────────────────────────── function SensorSwatch({ idx, sensor, present }) { if (!present) { return (
no signal
S{idx + 1} —/—/—
); } const [r, g, b] = sensor; return (
0..255 for the color (readout stays raw) boxShadow: 'inset 0 0 0 1px rgba(0,0,0,0.25)', }} />
S{idx + 1} {r | 0}/{g | 0}/{b | 0}
); } function LiveNow({ sensors, present, verdict, modelVersion }) { const nPresent = present.filter(Boolean).length; const healthy = nPresent === 4; return (
{/* sensors */}
4× RGB sensor · live {healthy ? `${nPresent}/4 sensors present` : `${nPresent}/4 — ${4 - nPresent} offline`}
{sensors.map((s, i) => ( ))}
{!healthy && (
A dead sensor reads as a bad label — pause labeling until it recovers.
)}
{/* board verdict */}
); } function VerdictCard({ verdict, modelVersion }) { const hasModel = modelVersion != null; const cls = verdict ? SARG.byKey(verdict.label) : null; // null while waiting for the first detection const color = !hasModel || !cls ? 'var(--t-3)' : `var(${cls.cvar})`; const bg = !hasModel || !cls ? 'var(--bg-1)' : cls.tintA; return (
Board verdict {hasModel ? 'model v' + modelVersion : 'no model'}
{hasModel ? (
{cls ? cls.name : 'waiting…'}
confidence {verdict && verdict.conf != null ? verdict.conf.toFixed(2) : '—'}
) : (
Board is not classifying
Train a model and push it to the board to see a live IN-MAT / OPEN-WATER verdict here.
)}
); } // ───────────────────────────────────────────────────────────── // ZONE 3 — LABEL // ───────────────────────────────────────────────────────────── // UI button order (label ints are OUT=0/IN=1/DRY=2, but in-mat reads first, then open water, then dry). const LABEL_ORDER = ['IN', 'OUT', 'DRY']; function LabelToggle({ active, recCount, onSet }) { const activeCls = active ? SARG.byKey(active) : null; return (
{LABEL_ORDER.map(key => { const c = SARG.byKey(key); const on = active === key; return ( ); })}
{activeCls ? ( ● recording {' '}— tagging live readings as {activeCls.name}. {recCount} samples captured. Tap again to append the span. ) : ( Hold the drifter in position, tap a label to start recording, tap again to append it to the dataset. )}
); } // Rolling timeline with drag-to-select spans of past readings. `buffer` entries are {sensors, ts} where ts // is the BOARD's clock (seconds-since-boot) -- the same clock every span/label is matched against. function Timeline({ buffer, sel, onSelChange, onLabel }) { const ref = useRef(null); const dragging = useRef(false); const [hoverIdx, setHoverIdx] = useState(null); const [hoverPos, setHoverPos] = useState({ x: 0, y: 0 }); const idxFromEvent = (e) => { const el = ref.current; if (!el) return null; const rect = el.getBoundingClientRect(); const x = (e.touches ? e.touches[0].clientX : e.clientX) - rect.left; const i = Math.floor((x / rect.width) * buffer.length); return Math.max(0, Math.min(buffer.length - 1, i)); }; const down = (e) => { const i = idxFromEvent(e); if (i == null) return; dragging.current = true; onSelChange({ a: i, b: i }); }; const move = (e) => { const i = idxFromEvent(e); if (i != null) { setHoverIdx(i); setHoverPos({ x: e.clientX, y: e.clientY }); } if (dragging.current && i != null) onSelChange(s => ({ a: s.a, b: i })); }; const leave = () => setHoverIdx(null); const up = () => { dragging.current = false; }; useEffect(() => { window.addEventListener('mouseup', up); window.addEventListener('touchend', up); return () => { window.removeEventListener('mouseup', up); window.removeEventListener('touchend', up); }; }, []); const lo = sel ? Math.min(sel.a, sel.b) : -1; const hi = sel ? Math.max(sel.a, sel.b) : -1; const hasSel = sel && hi > lo; const selCount = hasSel ? hi - lo + 1 : 0; const rgbMean = (sensors) => { const m = sensors.reduce((a, s) => [a[0] + s[0], a[1] + s[1], a[2] + s[2]], [0, 0, 0]); return [(m[0] / sensors.length) | 0, (m[1] / sensors.length) | 0, (m[2] / sensors.length) | 0]; }; const rgbCss = (sensors) => SARG.cssRgb(rgbMean(sensors)); // normalized for the tick color; tooltip keeps raw const modelCallColor = (call) => { const c = SARG.byKey(call); return c ? `var(${c.cvar})` : 'var(--bg-3)'; }; const hhmmss = (wallSec) => { if (!wallSec) return null; const d = new Date(wallSec * 1000); return [d.getHours(), d.getMinutes(), d.getSeconds()].map(n => String(n).padStart(2, '0')).join(':'); }; // sparse x-axis labels: roughly every 8-10th tick, always including the last (rightmost/most-recent) one const AXIS_STRIDE = 9; const hovered = hoverIdx != null ? buffer[hoverIdx] : null; return (
Recent readings · drag to select a range {buffer.length} readings buffered
{/* row 1: reading color (per-sensor mean RGB) */}
reading
{buffer.map((f, i) => { const inSel = i >= lo && i <= hi; return (
); })} {buffer.length === 0 && (
waiting for readings…
)}
{/* row 2: the board's OWN model call at each reading's instant (timestamp-aligned, not reclassified) */}
model call
{buffer.map((f, i) => { const inSel = i >= lo && i <= hi; return (
); })}
{/* sparse x-axis: capture time (wall-clock), every ~AXIS_STRIDE-th tick */} {buffer.length > 0 && (
{buffer.map((f, i) => { const showLabel = i === buffer.length - 1 || i % AXIS_STRIDE === 0; return (
{showLabel && ( {hhmmss(f.wall) || ''} )}
); })}
)}
{/* hover tooltip: precise capture time + reading value + the board's own model call, all at once */} {hovered && (
{hhmmss(hovered.wall) || 'time unknown'}
rgb {rgbMean(hovered.sensors).join(', ')}
model: {(SARG.byKey(hovered.modelCall) || {}).name || 'no model yet'}
)}
{hasSel ? ( <> {selCount} readings selected — label as {LABEL_ORDER.map(key => { const c = SARG.byKey(key); return ( ); })} ) : ( Clean up a transition after the fact — drag across the ticks to pick a past range. )}
); } // Battery telemetry pill (header). `pct` is the board's AXP2101 fuel-gauge percent (null/-1 = no reading // yet, e.g. before the board reports); `mv` is the raw pack voltage. Colored by charge, and falls back to // voltage, then a dash, when the percent is unavailable so it degrades gracefully pre-firmware-update. function BatteryPill({ battery }) { const pct = battery && battery.pct != null && battery.pct >= 0 ? battery.pct : null; const mv = battery && battery.mv != null && battery.mv > 0 ? battery.mv : null; const color = pct == null ? 'var(--t-4)' : pct > 50 ? 'var(--green)' : pct > 20 ? 'var(--amber)' : 'var(--wine)'; const label = pct != null ? `${pct}%` : (mv ? `${(mv / 1000).toFixed(2)}V` : '—'); const title = pct != null ? `battery ${pct}%${mv ? ` · ${(mv / 1000).toFixed(2)} V` : ''}` : (mv ? `battery ${(mv / 1000).toFixed(2)} V (fuel-gauge % unavailable)` : 'battery: no telemetry yet'); return ( {label} ); } // "Last data" pill (header): how long since the drifter last sent data. `wall` is the server-receipt time // (seconds) of the freshest reading; `nowMs` is a 1s-ticking clock so it counts up live. Green < 30s (live), // amber < 2m (lagging), wine older (likely offline), grey until the first data arrives. function relAgo(secs) { if (secs == null) return '—'; if (secs < 0) secs = 0; if (secs < 60) return `${Math.round(secs)}s ago`; if (secs < 3600) return `${Math.round(secs / 60)}m ago`; if (secs < 86400) return `${Math.round(secs / 3600)}h ago`; return `${Math.round(secs / 86400)}d ago`; } function LastSeen({ wall, nowMs }) { const secs = wall ? (nowMs / 1000 - wall) : null; const color = secs == null ? 'var(--t-4)' : secs < 30 ? 'var(--green)' : secs < 120 ? 'var(--amber)' : 'var(--wine)'; return ( last data {wall ? relAgo(secs) : 'waiting'} ); } Object.assign(window, { SARG, SensorSwatch, LiveNow, VerdictCard, LabelToggle, Timeline, BatteryPill, LastSeen });