660 lines
30 KiB
HTML
660 lines
30 KiB
HTML
<!doctype html>
|
||
<html lang="en"><head>
|
||
<meta charset="utf-8"/>
|
||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||
<title>RuView โ Raw Signals</title>
|
||
<style>
|
||
:root { color-scheme: dark; }
|
||
body { margin:0; padding:14px; font-family:-apple-system,Inter,system-ui,sans-serif;
|
||
background:#0a0e13; color:#e6edf3; font-size:12px; }
|
||
h1 { font-size:15px; font-weight:600; margin:0 0 2px; }
|
||
.sub { font-size:11px; color:#888; margin:0 0 12px; }
|
||
.topbar { display:flex; gap:14px; align-items:center; margin-bottom:10px; flex-wrap:wrap; }
|
||
.pill { padding:4px 10px; border-radius:4px; font-family:JetBrains Mono,monospace; font-size:11px;
|
||
background:#1c2128; }
|
||
.pill.dis { background:#3a1418; color:#ff6a6a; }
|
||
.pill.ok { background:#0e2a1a; color:#7ce38b; }
|
||
button { background:#21262d; color:#e6edf3; border:1px solid #30363d; border-radius:4px;
|
||
padding:4px 10px; font-size:11px; cursor:pointer; }
|
||
.node { background:#161b22; border:1px solid #30363d; border-radius:6px;
|
||
padding:10px 12px; margin-bottom:10px; }
|
||
.node h2 { margin:0 0 6px; font-size:12px; font-weight:600; color:#7cb6ff;
|
||
font-family:JetBrains Mono,monospace; display:flex; gap:14px; align-items:baseline; }
|
||
.node h2 .stat { color:#888; font-weight:normal; font-size:11px; }
|
||
.node h2 .stat b { color:#e6edf3; font-weight:600; }
|
||
.badge { font-family:JetBrains Mono,monospace; font-size:11px; padding:2px 8px; border-radius:3px; }
|
||
.badge.absent { background:#21262d; color:#888; }
|
||
.badge.present_still { background:#1c3a55; color:#7cb6ff; }
|
||
.badge.present_moving{ background:#3a5520; color:#90d36b; }
|
||
.badge.active { background:#552020; color:#ff7a7a; }
|
||
.row { display:grid; grid-template-columns: 1fr 360px; gap:10px; }
|
||
@media (max-width: 900px) { .row { grid-template-columns: 1fr; } }
|
||
canvas { display:block; width:100%; background:#0a0e13; border-radius:3px; }
|
||
canvas.bars { height: 130px; }
|
||
canvas.trace { height: 130px; }
|
||
canvas.spark { height: 48px; margin-top: 6px; }
|
||
.lbl { color:#666; font-size:10px; font-family:JetBrains Mono,monospace; margin:2px 0 0; }
|
||
.controls { display:flex; gap:8px; margin-left:auto; }
|
||
.controls label { font-size:11px; color:#aaa; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<h1>RuView โ Raw CSI signals</h1>
|
||
<p class="sub">Per-node subcarrier amplitudes + RSSI/broadband traces. No DSP, no classification. Stream straight from the sensor.</p>
|
||
|
||
<div class="topbar">
|
||
<span id="status" class="pill dis">disconnected</span>
|
||
<span class="pill" id="rate">0 fps</span>
|
||
<span class="pill" id="lastTs">last: --</span>
|
||
<span class="badge absent" id="globalBadge" style="font-size:13px;padding:4px 12px;">absent</span>
|
||
<span class="pill" id="globalCV">CV 0%</span>
|
||
<!-- ADR-021 + ADR-121: dual-source vital signs.
|
||
Each pill shows WiFi-CSI (๐ถ) and mmWave (๐ก) side-by-side so
|
||
the operator can spot disagreement between modalities. Norms
|
||
are adult-at-rest ranges. -->
|
||
<span class="pill" id="brPill"
|
||
style="background:rgba(63,185,80,0.18); color:rgb(63,185,80); border:1px solid rgb(63,185,80);"
|
||
title="Breathing rate, two sources: ๐ถ = WiFi-CSI bandpass 0.1-0.5 Hz (ADR-021). ๐ก = HLK-LD2402 mmWave distance bandpass (ADR-121). Adult-at-rest norm: 12-20 BPM. Below 12 = bradypnea, above 20 = tachypnea.">
|
||
๐ซ <span style="opacity:0.6;font-size:11px">๐ถ</span><b id="brBpm">โ BPM</b><span id="brConf" style="opacity:0.7;font-size:11px">ยท</span>
|
||
<span style="opacity:0.4">|</span>
|
||
<span style="opacity:0.6;font-size:11px">๐ก</span><b id="brBpmMm">โ BPM</b><span id="brConfMm" style="opacity:0.7;font-size:11px">ยท</span>
|
||
<span style="opacity:0.55;font-size:11px;margin-left:6px">ะฝะพัะผะฐ 12โ20</span>
|
||
</span>
|
||
<span class="pill" id="hrPill"
|
||
style="background:rgba(248,81,73,0.18); color:rgb(248,81,73); border:1px solid rgb(248,81,73);"
|
||
title="Heart rate, two sources: ๐ถ = WiFi-CSI bandpass 0.8-2.0 Hz (ADR-021). ๐ก = HLK-LD2402 mmWave (cm precision and 6 Hz cadence put heartbeat below the noise floor โ value shown for transparency, expect low confidence). Adult-at-rest norm: 60-100 BPM.">
|
||
๐ <span style="opacity:0.6;font-size:11px">๐ถ</span><b id="hrBpm">โ BPM</b><span id="hrConf" style="opacity:0.7;font-size:11px">ยท</span>
|
||
<span style="opacity:0.4">|</span>
|
||
<span style="opacity:0.6;font-size:11px">๐ก</span><b id="hrBpmMm">โ BPM</b><span id="hrConfMm" style="opacity:0.7;font-size:11px">ยท</span>
|
||
<span style="opacity:0.55;font-size:11px;margin-left:6px">ะฝะพัะผะฐ 60โ100</span>
|
||
</span>
|
||
<!-- ADR-121: HLK-LD2402 24 GHz mmWave radar pill โ hidden until first reading. -->
|
||
<span class="pill" id="mmwavePill" style="display:none; background:rgba(33,150,243,0.18);
|
||
color:rgb(33,150,243); border:1px solid rgb(33,150,243);"
|
||
title="HLK-LD2402 24 GHz radar โ distance to closest target">
|
||
๐ก mmWave <b id="mmwaveDist">โ cm</b> <span id="mmwaveAge" style="opacity:0.7;font-size:11px">ยท</span>
|
||
</span>
|
||
<div class="controls">
|
||
<label>peak-hold <input type="checkbox" id="peakHold" checked></label>
|
||
<label>log-y <input type="checkbox" id="logY"></label>
|
||
<button onclick="resetState()">reset</button>
|
||
<button id="calibrateBtn" onclick="startCalibrate()" title="Step out of the room, click, wait 90 s">calibrate empty</button>
|
||
<span class="pill" id="calibStatus" style="display:none"></span>
|
||
<!-- ADR-107: visible progress bar shown while baseline capture runs. -->
|
||
<div id="calibProgress" style="display:none; position:relative; width:140px; height:14px;
|
||
border:1px solid #30363d; border-radius:7px; overflow:hidden;
|
||
background:#0a0e13;">
|
||
<div id="calibProgressFill" style="position:absolute; left:0; top:0; bottom:0; width:0%;
|
||
background:linear-gradient(90deg,#1f6feb,#3fb950);
|
||
transition: width 0.4s linear;"></div>
|
||
<span id="calibProgressLabel" style="position:absolute; inset:0; display:flex;
|
||
align-items:center; justify-content:center;
|
||
font-size:10px; font-family:JetBrains Mono,monospace;
|
||
color:#e6edf3; text-shadow:0 0 2px #000;"></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="nodes"></div>
|
||
|
||
<script>
|
||
// โโ State โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
||
const TRACE_SEC = 30; // seconds of history per node
|
||
const TRACE_MAX_PTS = 1200; // safety cap
|
||
const state = new Map(); // node_id -> { amp, peak, rssiHist[], meanAmpHist[], lastTs, frames }
|
||
let frameCount = 0;
|
||
let lastRateTs = performance.now();
|
||
let rateFps = 0;
|
||
let logY = false;
|
||
let peakHold = true;
|
||
|
||
function resetState() {
|
||
state.clear();
|
||
document.getElementById('nodes').innerHTML = '';
|
||
frameCount = 0;
|
||
}
|
||
|
||
document.getElementById('peakHold').addEventListener('change', e => { peakHold = e.target.checked; });
|
||
document.getElementById('logY').addEventListener('change', e => { logY = e.target.checked; });
|
||
|
||
// โโ Per-node block factory โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
||
function ensureNodeBlock(nodeId) {
|
||
if (state.has(nodeId)) return state.get(nodeId);
|
||
const ent = {
|
||
amp: [],
|
||
peak: [],
|
||
rssiHist: [], // { t, v }
|
||
meanAmpHist: [],
|
||
driftHist: [], // { t, v } โ ADR-104 per-sub drift score
|
||
lastTs: 0,
|
||
frames: 0,
|
||
lastFrameWall: performance.now(),
|
||
fps: 0,
|
||
};
|
||
state.set(nodeId, ent);
|
||
|
||
const wrap = document.createElement('div');
|
||
wrap.className = 'node';
|
||
wrap.id = 'node-' + nodeId;
|
||
wrap.innerHTML = `
|
||
<h2>
|
||
Node ${nodeId}
|
||
<span class="badge absent" id="n${nodeId}-badge">absent</span>
|
||
<span class="stat">CV <b id="n${nodeId}-cv">0%</b></span>
|
||
<span class="stat">subc <b id="n${nodeId}-sub">0</b></span>
|
||
<span class="stat">rssi <b id="n${nodeId}-rssi">--</b> dBm</span>
|
||
<span class="stat">mean A <b id="n${nodeId}-meanA">0</b></span>
|
||
<span class="stat">peak A <b id="n${nodeId}-peakA">0</b></span>
|
||
<span class="stat">drift <b id="n${nodeId}-drift">--</b></span>
|
||
<span class="stat">node fps <b id="n${nodeId}-fps">0</b></span>
|
||
</h2>
|
||
<div class="row">
|
||
<div>
|
||
<canvas class="bars" id="n${nodeId}-bars"></canvas>
|
||
<p class="lbl">subcarrier amplitude bars (left โ low freq, right โ high freq)</p>
|
||
</div>
|
||
<div>
|
||
<canvas class="trace" id="n${nodeId}-trace"></canvas>
|
||
<p class="lbl"><span style="color:#8b949e">RSSI</span> <span style="color:#3fb950">broadband mean amplitude</span> (last ${TRACE_SEC}s)</p>
|
||
<canvas class="spark" id="n${nodeId}-driftSpark"></canvas>
|
||
<p class="lbl"><span style="color:#d29922">per-sub drift</span> โ off-axis presence channel (ADR-104); dashed line = presence threshold 0.10</p>
|
||
</div>
|
||
</div>`;
|
||
document.getElementById('nodes').appendChild(wrap);
|
||
return ent;
|
||
}
|
||
|
||
// โโ Drawing โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
||
function drawBars(canvas, amps, peaks) {
|
||
const w = canvas.clientWidth, h = canvas.clientHeight;
|
||
if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; }
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.fillStyle = '#0a0e13'; ctx.fillRect(0, 0, w, h);
|
||
if (!amps.length) return;
|
||
|
||
// Determine scale
|
||
let maxV = peakHold && peaks.length
|
||
? Math.max(...peaks)
|
||
: Math.max(...amps);
|
||
if (!isFinite(maxV) || maxV <= 0) maxV = 1;
|
||
|
||
const n = amps.length;
|
||
const bw = w / n;
|
||
const margin = 4;
|
||
|
||
// Bars
|
||
for (let i = 0; i < n; i++) {
|
||
let v = amps[i];
|
||
let pv = peaks[i] || 0;
|
||
if (logY) {
|
||
v = v > 0 ? Math.log10(v + 1) : 0;
|
||
pv = pv > 0 ? Math.log10(pv + 1) : 0;
|
||
}
|
||
const scaleMax = logY ? Math.log10(maxV + 1) : maxV;
|
||
const bh = Math.max(1, (v / scaleMax) * (h - margin));
|
||
const ph = Math.max(1, (pv / scaleMax) * (h - margin));
|
||
const x = i * bw;
|
||
// peak (faint)
|
||
if (peakHold && pv > 0) {
|
||
ctx.fillStyle = '#1f3a5a';
|
||
ctx.fillRect(x, h - ph, Math.max(1, bw - 1), 1.5);
|
||
}
|
||
// bar (active)
|
||
const hue = 200 + (i / n) * 100;
|
||
ctx.fillStyle = `hsl(${hue}, 70%, 55%)`;
|
||
ctx.fillRect(x, h - bh, Math.max(1, bw - 1), bh);
|
||
}
|
||
|
||
// Y-axis label
|
||
ctx.fillStyle = '#555'; ctx.font = '9px monospace';
|
||
ctx.fillText('max=' + maxV.toFixed(0), 4, 10);
|
||
ctx.fillText('n=' + n, w - 40, 10);
|
||
}
|
||
|
||
function drawTrace(canvas, rssiHist, meanAmpHist) {
|
||
const w = canvas.clientWidth, h = canvas.clientHeight;
|
||
if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; }
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.fillStyle = '#0a0e13'; ctx.fillRect(0, 0, w, h);
|
||
|
||
const now = performance.now() / 1000;
|
||
const t0 = now - TRACE_SEC;
|
||
|
||
const drawSeries = (arr, color, getRange) => {
|
||
if (arr.length < 2) return;
|
||
const visible = arr.filter(p => p.t >= t0);
|
||
if (visible.length < 2) return;
|
||
const { min, max } = getRange(visible);
|
||
const span = (max - min) || 1;
|
||
ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.beginPath();
|
||
for (let i = 0; i < visible.length; i++) {
|
||
const p = visible[i];
|
||
const x = ((p.t - t0) / TRACE_SEC) * w;
|
||
const y = h - ((p.v - min) / span) * (h - 8) - 4;
|
||
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||
}
|
||
ctx.stroke();
|
||
// y-range text
|
||
ctx.fillStyle = color; ctx.font = '9px monospace';
|
||
return { min, max };
|
||
};
|
||
|
||
const rssiR = drawSeries(rssiHist, '#8b949e', arr => {
|
||
const vals = arr.map(p => p.v);
|
||
return { min: Math.min(...vals), max: Math.max(...vals) };
|
||
});
|
||
const ampR = drawSeries(meanAmpHist, '#3fb950', arr => {
|
||
const vals = arr.map(p => p.v);
|
||
return { min: 0, max: Math.max(...vals) };
|
||
});
|
||
|
||
// labels
|
||
ctx.font = '9px monospace';
|
||
if (rssiR) { ctx.fillStyle = '#8b949e'; ctx.fillText(`rssi ${rssiR.min.toFixed(0)}โฆ${rssiR.max.toFixed(0)} dBm`, 4, 10); }
|
||
if (ampR) { ctx.fillStyle = '#3fb950'; ctx.fillText(`A ${ampR.min.toFixed(0)}โฆ${ampR.max.toFixed(0)}`, 4, 22); }
|
||
|
||
// grid line at now
|
||
ctx.strokeStyle = '#1c2128'; ctx.beginPath();
|
||
ctx.moveTo(w - 1, 0); ctx.lineTo(w - 1, h); ctx.stroke();
|
||
}
|
||
|
||
// ADR-104: per-sub drift sparkline. Fixed Y range [0, 0.30] so the
|
||
// presence threshold (0.10, dashed) and warning threshold (0.15) are
|
||
// directly readable across nodes โ re-scaling per node would make it
|
||
// impossible to tell "Node 0 fired" from "Node 1 fired" at a glance.
|
||
const DRIFT_PRESENCE_THRESH = 0.10;
|
||
const DRIFT_WARN_THRESH = 0.15;
|
||
const DRIFT_MAX = 0.30;
|
||
|
||
function drawDriftSpark(canvas, hist) {
|
||
const w = canvas.clientWidth, h = canvas.clientHeight;
|
||
if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; }
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.fillStyle = '#0a0e13'; ctx.fillRect(0, 0, w, h);
|
||
|
||
const now = performance.now() / 1000;
|
||
const t0 = now - TRACE_SEC;
|
||
const yOf = v => h - (Math.min(v, DRIFT_MAX) / DRIFT_MAX) * (h - 4) - 2;
|
||
|
||
// Threshold lines.
|
||
ctx.setLineDash([3, 3]);
|
||
ctx.strokeStyle = '#5a4a1a'; ctx.lineWidth = 1; ctx.beginPath();
|
||
ctx.moveTo(0, yOf(DRIFT_PRESENCE_THRESH)); ctx.lineTo(w, yOf(DRIFT_PRESENCE_THRESH));
|
||
ctx.stroke();
|
||
ctx.strokeStyle = '#7a3030'; ctx.beginPath();
|
||
ctx.moveTo(0, yOf(DRIFT_WARN_THRESH)); ctx.lineTo(w, yOf(DRIFT_WARN_THRESH));
|
||
ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
|
||
const visible = hist.filter(p => p.t >= t0);
|
||
if (visible.length >= 2) {
|
||
ctx.strokeStyle = '#d29922'; ctx.lineWidth = 1.5; ctx.beginPath();
|
||
for (let i = 0; i < visible.length; i++) {
|
||
const p = visible[i];
|
||
const x = ((p.t - t0) / TRACE_SEC) * w;
|
||
const y = yOf(p.v);
|
||
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||
}
|
||
ctx.stroke();
|
||
}
|
||
|
||
// Axis text.
|
||
ctx.fillStyle = '#666'; ctx.font = '9px monospace';
|
||
ctx.fillText('0', 2, h - 2);
|
||
ctx.fillText(DRIFT_MAX.toFixed(2), 2, 10);
|
||
}
|
||
|
||
// โโ Frame ingestion โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
||
function handleSensingUpdate(d) {
|
||
const nodes = d.nodes || [];
|
||
const ts = d.timestamp || (Date.now() / 1000);
|
||
const now = performance.now() / 1000;
|
||
for (const n of nodes) {
|
||
const id = n.node_id;
|
||
const amps = n.amplitude || [];
|
||
// Skip empty-amp ticks (feature_state path doesn't carry raw CSI).
|
||
// Bars/traces only refresh on real raw-CSI frames so what you see
|
||
// is always a live snapshot, not a repeated stale vector.
|
||
if (!amps.length) continue;
|
||
const ent = ensureNodeBlock(id);
|
||
ent.amp = amps;
|
||
// peak-hold update
|
||
if (ent.peak.length !== amps.length) ent.peak = amps.slice();
|
||
else for (let i = 0; i < amps.length; i++) if (amps[i] > ent.peak[i]) ent.peak[i] = amps[i];
|
||
|
||
const meanA = amps.reduce((s, x) => s + x, 0) / amps.length;
|
||
// Only push valid (non-zero) RSSI samples so the trace doesn't
|
||
// jump between real dBm values and the "0 = no data" sentinel.
|
||
if (n.rssi_dbm && n.rssi_dbm !== 0) {
|
||
ent.rssiHist.push({ t: now, v: n.rssi_dbm });
|
||
}
|
||
ent.meanAmpHist.push({ t: now, v: meanA });
|
||
const cutoff = now - TRACE_SEC;
|
||
while (ent.rssiHist.length && ent.rssiHist[0].t < cutoff) ent.rssiHist.shift();
|
||
while (ent.meanAmpHist.length && ent.meanAmpHist[0].t < cutoff) ent.meanAmpHist.shift();
|
||
if (ent.rssiHist.length > TRACE_MAX_PTS) ent.rssiHist.splice(0, ent.rssiHist.length - TRACE_MAX_PTS);
|
||
if (ent.meanAmpHist.length > TRACE_MAX_PTS) ent.meanAmpHist.splice(0, ent.meanAmpHist.length - TRACE_MAX_PTS);
|
||
|
||
// per-node fps: count frames in the last second, refresh once a sec
|
||
// (instantaneous 1/dt was wildly noisy because multiple WS paths
|
||
// emit duplicate per-node updates back-to-back).
|
||
ent.fpsCounter = (ent.fpsCounter || 0) + 1;
|
||
const nowMs = performance.now();
|
||
if (!ent.fpsWindowStart) ent.fpsWindowStart = nowMs;
|
||
if (nowMs - ent.fpsWindowStart >= 1000) {
|
||
ent.fps = ent.fpsCounter * 1000 / (nowMs - ent.fpsWindowStart);
|
||
ent.fpsCounter = 0;
|
||
ent.fpsWindowStart = nowMs;
|
||
}
|
||
ent.lastFrameWall = nowMs;
|
||
ent.frames++;
|
||
ent.lastTs = ts;
|
||
|
||
document.getElementById(`n${id}-sub`).textContent = amps.length;
|
||
// n.rssi_dbm comes from sensing_update.nodes[]; it can be 0 on
|
||
// early ticks (history not yet populated). Coerce to "--" so the
|
||
// operator doesn't think the AP is dead.
|
||
const rssiVal = (n.rssi_dbm && Number.isFinite(n.rssi_dbm) && n.rssi_dbm !== 0)
|
||
? n.rssi_dbm.toFixed(1)
|
||
: '--';
|
||
document.getElementById(`n${id}-rssi`).textContent = rssiVal;
|
||
// Push to RSSI trace history if non-zero (so the chart shows the
|
||
// real ladder of dBm steps, not a fake "0 โ -54" jump on boot).
|
||
if (n.rssi_dbm && n.rssi_dbm !== 0) {
|
||
// (handled by ent.rssiHist push below)
|
||
}
|
||
document.getElementById(`n${id}-meanA`).textContent = meanA.toFixed(1);
|
||
document.getElementById(`n${id}-peakA`).textContent = Math.max(...ent.peak).toFixed(1);
|
||
document.getElementById(`n${id}-fps`).textContent = ent.fps.toFixed(1);
|
||
}
|
||
|
||
document.getElementById('lastTs').textContent = 'last: ' + new Date(ts * 1000).toLocaleTimeString();
|
||
|
||
// Global classification badge (ADR-101 fused).
|
||
const gcl = d.classification || {};
|
||
const glvl = gcl.motion_level || 'absent';
|
||
const gb = document.getElementById('globalBadge');
|
||
if (gb) { gb.textContent = glvl; gb.className = 'badge ' + glvl; gb.style.fontSize = '13px'; gb.style.padding = '4px 12px'; }
|
||
const gcv = document.getElementById('globalCV');
|
||
if (gcv) gcv.textContent = 'CV ' + ((gcl.confidence || 0) * 100).toFixed(1) + '%';
|
||
|
||
// ADR-021 โ WiFi-CSI vital signs (breathing + heart rate).
|
||
// `vital_signs` is embedded in SensingUpdate; values may be null
|
||
// when the detector hasn't accumulated enough history yet (~10s).
|
||
const vs = d.vital_signs || {};
|
||
const brBpm = document.getElementById('brBpm');
|
||
const brConf = document.getElementById('brConf');
|
||
const hrBpm = document.getElementById('hrBpm');
|
||
const hrConf = document.getElementById('hrConf');
|
||
const brPill = document.getElementById('brPill');
|
||
const hrPill = document.getElementById('hrPill');
|
||
// Adult-at-rest norms (resting). Out-of-norm values get a warning tint
|
||
// so the operator immediately sees brady/tachy without reading numbers.
|
||
// Tooltip on the pill carries the brady/tachy terminology.
|
||
const BR_MIN = 12, BR_MAX = 20; // breaths per minute
|
||
const HR_MIN = 60, HR_MAX = 100; // heartbeats per minute
|
||
if (vs && typeof vs.breathing_rate_bpm === 'number' && Number.isFinite(vs.breathing_rate_bpm) && vs.breathing_rate_bpm > 0) {
|
||
const v = vs.breathing_rate_bpm;
|
||
const out = v < BR_MIN || v > BR_MAX;
|
||
if (brBpm) {
|
||
brBpm.textContent = v.toFixed(1) + ' BPM';
|
||
brBpm.style.color = out ? '#f0a020' : '';
|
||
}
|
||
if (brConf) brConf.textContent = 'ยท ' + ((vs.breathing_confidence || 0) * 100).toFixed(0) + '%';
|
||
if (brPill) brPill.style.opacity = (vs.breathing_confidence || 0) < 0.2 ? '0.5' : '1.0';
|
||
} else {
|
||
if (brBpm) { brBpm.textContent = 'โ BPM'; brBpm.style.color = ''; }
|
||
if (brConf) brConf.textContent = 'ยท';
|
||
if (brPill) brPill.style.opacity = '0.5';
|
||
}
|
||
if (vs && typeof vs.heart_rate_bpm === 'number' && Number.isFinite(vs.heart_rate_bpm) && vs.heart_rate_bpm > 0) {
|
||
const v = vs.heart_rate_bpm;
|
||
const out = v < HR_MIN || v > HR_MAX;
|
||
if (hrBpm) {
|
||
hrBpm.textContent = v.toFixed(0) + ' BPM';
|
||
hrBpm.style.color = out ? '#f0a020' : '';
|
||
}
|
||
if (hrConf) hrConf.textContent = 'ยท ' + ((vs.heartbeat_confidence || 0) * 100).toFixed(0) + '%';
|
||
if (hrPill) hrPill.style.opacity = (vs.heartbeat_confidence || 0) < 0.2 ? '0.5' : '1.0';
|
||
} else {
|
||
if (hrBpm) { hrBpm.textContent = 'โ BPM'; hrBpm.style.color = ''; }
|
||
if (hrConf) hrConf.textContent = 'ยท';
|
||
if (hrPill) hrPill.style.opacity = '0.5';
|
||
}
|
||
|
||
// Per-node level badge from node_features[i].classification (ADR-101).
|
||
const nfNow = performance.now() / 1000;
|
||
const nf = d.node_features || [];
|
||
for (const f of nf) {
|
||
const id = f.node_id;
|
||
const cls = f.classification || {};
|
||
const lvl = cls.motion_level || 'absent';
|
||
const badge = document.getElementById(`n${id}-badge`);
|
||
if (badge) {
|
||
badge.textContent = lvl;
|
||
badge.className = 'badge ' + lvl;
|
||
}
|
||
const cvEl = document.getElementById(`n${id}-cv`);
|
||
if (cvEl) cvEl.textContent = ((cls.confidence || 0) * 100).toFixed(1) + '%';
|
||
|
||
// ADR-104 per-sub drift score (off-axis presence). May be absent
|
||
// when no per-sub baseline is loaded for this node โ show '--'
|
||
// instead of '0.000' so the operator can tell the channel is
|
||
// unknown vs. known and stable.
|
||
const driftEl = document.getElementById(`n${id}-drift`);
|
||
const driftLive = state.get(id);
|
||
if (typeof f.drift_score === 'number' && Number.isFinite(f.drift_score)) {
|
||
if (driftEl) driftEl.textContent = f.drift_score.toFixed(3);
|
||
if (driftLive) {
|
||
driftLive.driftHist.push({ t: nfNow, v: f.drift_score });
|
||
const cutoff = nfNow - TRACE_SEC;
|
||
while (driftLive.driftHist.length && driftLive.driftHist[0].t < cutoff) {
|
||
driftLive.driftHist.shift();
|
||
}
|
||
if (driftLive.driftHist.length > TRACE_MAX_PTS) {
|
||
driftLive.driftHist.splice(0, driftLive.driftHist.length - TRACE_MAX_PTS);
|
||
}
|
||
}
|
||
} else if (driftEl) {
|
||
driftEl.textContent = '--';
|
||
}
|
||
}
|
||
frameCount++;
|
||
}
|
||
|
||
function renderTick() {
|
||
for (const [id, ent] of state) {
|
||
const bars = document.getElementById('n' + id + '-bars');
|
||
const trace = document.getElementById('n' + id + '-trace');
|
||
const spark = document.getElementById('n' + id + '-driftSpark');
|
||
if (bars) drawBars(bars, ent.amp, ent.peak);
|
||
if (trace) drawTrace(trace, ent.rssiHist, ent.meanAmpHist);
|
||
if (spark) drawDriftSpark(spark, ent.driftHist);
|
||
}
|
||
// fps pill
|
||
const now = performance.now();
|
||
if (now - lastRateTs > 500) {
|
||
rateFps = (frameCount * 1000) / (now - lastRateTs);
|
||
document.getElementById('rate').textContent = rateFps.toFixed(1) + ' fps total';
|
||
frameCount = 0;
|
||
lastRateTs = now;
|
||
}
|
||
requestAnimationFrame(renderTick);
|
||
}
|
||
requestAnimationFrame(renderTick);
|
||
|
||
// โโ ADR-107: baseline calibrate button + progress bar โโโโโโโโโโโโโ
|
||
let calibPollTimer = null;
|
||
const CALIB_DURATION_SEC = 90;
|
||
|
||
function setCalibProgress(pct, label) {
|
||
const bar = document.getElementById('calibProgress');
|
||
const fill = document.getElementById('calibProgressFill');
|
||
const txt = document.getElementById('calibProgressLabel');
|
||
if (!bar || !fill || !txt) return;
|
||
bar.style.display = pct < 0 ? 'none' : 'inline-block';
|
||
fill.style.width = Math.max(0, Math.min(100, pct)) + '%';
|
||
txt.textContent = label || '';
|
||
}
|
||
|
||
async function startCalibrate() {
|
||
if (!confirm(`Step OUT of the room now. Calibration will record for ${CALIB_DURATION_SEC} s.\nClick OK when you are out.`)) return;
|
||
const btn = document.getElementById('calibrateBtn');
|
||
const stat = document.getElementById('calibStatus');
|
||
btn.disabled = true; btn.textContent = 'recordingโฆ';
|
||
// Hide the text-pill while the progress bar is the primary indicator;
|
||
// it reappears only on terminal status messages (error / complete).
|
||
stat.style.display = 'none';
|
||
setCalibProgress(0, 'startingโฆ');
|
||
try {
|
||
const res = await fetch('/api/v1/baseline/calibrate', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({ duration_sec: CALIB_DURATION_SEC, trim_sec: 15, clean_window_sec: 30 }),
|
||
});
|
||
const j = await res.json();
|
||
if (!j.started) {
|
||
setCalibProgress(-1, '');
|
||
stat.style.display = 'inline-block';
|
||
stat.textContent = j.reason || 'failed to start';
|
||
btn.disabled = false; btn.textContent = 'calibrate empty';
|
||
return;
|
||
}
|
||
} catch (e) {
|
||
setCalibProgress(-1, '');
|
||
stat.style.display = 'inline-block';
|
||
stat.textContent = 'network error';
|
||
btn.disabled = false; btn.textContent = 'calibrate empty';
|
||
return;
|
||
}
|
||
if (calibPollTimer) clearInterval(calibPollTimer);
|
||
let elapsed = 0;
|
||
calibPollTimer = setInterval(async () => {
|
||
elapsed += 2;
|
||
try {
|
||
const r = await fetch('/api/v1/baseline'); const j = await r.json();
|
||
const s = j.calibration_status || 'idle';
|
||
if (s.startsWith('running')) {
|
||
const pct = Math.min(99, (elapsed / CALIB_DURATION_SEC) * 100);
|
||
setCalibProgress(pct, `${elapsed}/${CALIB_DURATION_SEC} s`);
|
||
} else {
|
||
clearInterval(calibPollTimer); calibPollTimer = null;
|
||
btn.disabled = false; btn.textContent = 'calibrate empty';
|
||
if (s === 'complete') {
|
||
setCalibProgress(100, 'done');
|
||
stat.style.display = 'inline-block';
|
||
stat.textContent = 'baseline updated โ';
|
||
setTimeout(() => setCalibProgress(-1, ''), 3000);
|
||
} else {
|
||
setCalibProgress(-1, '');
|
||
stat.style.display = 'inline-block';
|
||
stat.textContent = s;
|
||
}
|
||
}
|
||
} catch (e) {}
|
||
}, 2000);
|
||
}
|
||
|
||
// โโ WS โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
||
function connect() {
|
||
const ws = new WebSocket('ws://' + location.hostname + ':8765/ws/sensing');
|
||
ws.onopen = () => {
|
||
const p = document.getElementById('status');
|
||
p.textContent = 'connected'; p.className = 'pill ok';
|
||
};
|
||
ws.onclose = () => {
|
||
const p = document.getElementById('status');
|
||
p.textContent = 'disconnected โ reconnecting'; p.className = 'pill dis';
|
||
setTimeout(connect, 1500);
|
||
};
|
||
ws.onmessage = (e) => {
|
||
try {
|
||
const d = JSON.parse(e.data);
|
||
if (d.type === 'sensing_update') handleSensingUpdate(d);
|
||
} catch (_) {}
|
||
};
|
||
}
|
||
connect();
|
||
|
||
// โโ ADR-121: poll HLK-LD2402 mmWave radar @ 5 Hz โโโโโโโโโโโโโโโโโโโโโ
|
||
const mmwavePill = document.getElementById('mmwavePill');
|
||
const mmwaveDist = document.getElementById('mmwaveDist');
|
||
const mmwaveAge = document.getElementById('mmwaveAge');
|
||
let mmwaveBusy = false;
|
||
async function pollMmwave() {
|
||
if (mmwaveBusy) return; mmwaveBusy = true;
|
||
try {
|
||
const r = await fetch('/api/v1/mmwave/latest', { cache: 'no-store' });
|
||
if (!r.ok) throw new Error('http ' + r.status);
|
||
const j = await r.json();
|
||
if (j && j.available) {
|
||
mmwavePill.style.display = '';
|
||
mmwaveDist.textContent = j.distance_cm + ' cm';
|
||
const age = Math.round(j.age_ms || 0);
|
||
mmwaveAge.textContent = 'ยท ' + age + ' ms';
|
||
// Fade pill if stale (>1.5 s) before server hides at 2 s.
|
||
mmwavePill.style.opacity = age > 1500 ? '0.5' : '1.0';
|
||
} else {
|
||
mmwavePill.style.display = 'none';
|
||
}
|
||
} catch (_) {
|
||
mmwavePill.style.display = 'none';
|
||
} finally { mmwaveBusy = false; }
|
||
}
|
||
pollMmwave();
|
||
setInterval(pollMmwave, 200);
|
||
|
||
// โโ ADR-121 follow-up: poll mmWave-derived vital signs @ 1 Hz โโโโโโโโ
|
||
// Slower than the distance poll (5 Hz) โ BPM only meaningfully updates
|
||
// once per breath cycle, and the FFT runs on a 30-s buffer so faster
|
||
// polling just wastes cycles.
|
||
const brBpmMm = document.getElementById('brBpmMm');
|
||
const brConfMm = document.getElementById('brConfMm');
|
||
const hrBpmMm = document.getElementById('hrBpmMm');
|
||
const hrConfMm = document.getElementById('hrConfMm');
|
||
let mmVitalsBusy = false;
|
||
async function pollMmwaveVitals() {
|
||
if (mmVitalsBusy) return; mmVitalsBusy = true;
|
||
try {
|
||
const r = await fetch('/api/v1/mmwave/vitals', { cache: 'no-store' });
|
||
if (!r.ok) throw new Error('http ' + r.status);
|
||
const j = await r.json();
|
||
const vs = (j && j.available) ? (j.vital_signs || {}) : {};
|
||
const BR_MIN = 12, BR_MAX = 20, HR_MIN = 60, HR_MAX = 100;
|
||
if (typeof vs.breathing_rate_bpm === 'number' && Number.isFinite(vs.breathing_rate_bpm) && vs.breathing_rate_bpm > 0) {
|
||
const v = vs.breathing_rate_bpm;
|
||
const out = v < BR_MIN || v > BR_MAX;
|
||
brBpmMm.textContent = ' ' + v.toFixed(1) + ' BPM ';
|
||
brBpmMm.style.color = out ? '#f0a020' : '';
|
||
brConfMm.textContent = 'ยท ' + ((vs.breathing_confidence || 0) * 100).toFixed(0) + '%';
|
||
} else {
|
||
brBpmMm.textContent = ' โ BPM ';
|
||
brBpmMm.style.color = '';
|
||
// Buffer-fill hint: at 6 Hz ร 30 s = 180 samples needed.
|
||
const fill = j && j.buffer_status ? j.buffer_status.breathing_samples : 0;
|
||
const cap = j && j.buffer_status ? j.buffer_status.breathing_capacity : 180;
|
||
brConfMm.textContent = cap > 0 ? 'ยท ' + fill + '/' + cap : 'ยท';
|
||
}
|
||
if (typeof vs.heart_rate_bpm === 'number' && Number.isFinite(vs.heart_rate_bpm) && vs.heart_rate_bpm > 0) {
|
||
const v = vs.heart_rate_bpm;
|
||
const out = v < HR_MIN || v > HR_MAX;
|
||
hrBpmMm.textContent = ' ' + v.toFixed(0) + ' BPM ';
|
||
hrBpmMm.style.color = out ? '#f0a020' : '';
|
||
hrConfMm.textContent = 'ยท ' + ((vs.heartbeat_confidence || 0) * 100).toFixed(0) + '%';
|
||
} else {
|
||
// mmWave at 6 Hz / cm precision can't resolve heartbeat โ
|
||
// expect this to stay "n/a" in practice.
|
||
hrBpmMm.textContent = ' n/a ';
|
||
hrBpmMm.style.color = '';
|
||
hrConfMm.textContent = 'ยท';
|
||
}
|
||
} catch (_) {
|
||
brBpmMm.textContent = ' โ BPM '; brConfMm.textContent = 'ยท';
|
||
hrBpmMm.textContent = ' n/a '; hrConfMm.textContent = 'ยท';
|
||
} finally { mmVitalsBusy = false; }
|
||
}
|
||
pollMmwaveVitals();
|
||
setInterval(pollMmwaveVitals, 1000);
|
||
</script>
|
||
</body></html>
|