// 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)',
}} />
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 });