wifi-densepose/v2/crates/wifi-densepose-sensing-server/static/mobile.html

312 lines
13 KiB
HTML

<!doctype html>
<!--
static/mobile.html — phone-first RuView UI.
Optimised for a 360-420 px wide screen held one-handed:
- sticky global status header (badge stays visible while scrolling)
- big readable per-node cards (28-34 px badges, 16 px stats)
- one trace per card (broadband mean amplitude over last 30 s) — bars
omitted because they're unreadable below 600 px wide
- touch targets >= 44 px
- no controls (reset is one-tap in the header)
- high-contrast palette tuned for outdoor light
Talks to the same /ws/sensing WebSocket as raw.html, so any improvement
to that contract lights up here automatically.
-->
<html lang="en"><head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no"/>
<meta name="theme-color" content="#0a0e13"/>
<meta name="apple-mobile-web-app-capable" content="yes"/>
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"/>
<title>RuView — Mobile</title>
<style>
:root { color-scheme: dark; --bg:#0a0e13; --card:#161b22; --line:#30363d;
--fg:#e6edf3; --mute:#7d8590; --accent:#7cb6ff;
--absent:#7d8590; --still:#58a6ff; --moving:#56d364; --active:#f85149; }
* { box-sizing:border-box; -webkit-tap-highlight-color:transparent; }
html, body { margin:0; padding:0; background:var(--bg); color:var(--fg);
font-family:-apple-system,BlinkMacSystemFont,Inter,system-ui,sans-serif;
-webkit-font-smoothing:antialiased; overscroll-behavior:none; }
body { padding: env(safe-area-inset-top) env(safe-area-inset-right)
env(safe-area-inset-bottom) env(safe-area-inset-left); }
header {
position: sticky; top: 0; z-index: 10; background: var(--bg);
padding: 10px 14px 8px; border-bottom: 1px solid var(--line);
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
}
.titleRow { display:flex; align-items:center; justify-content:space-between; gap:8px; }
.title { font-size:13px; font-weight:600; color:var(--mute); letter-spacing:.04em; text-transform:uppercase; }
.conn { font-size:11px; font-family:ui-monospace,Menlo,Consolas,monospace;
padding:3px 8px; border-radius:999px; background:#1c2128; color:var(--mute); }
.conn.ok { background:#0e2a1a; color:#7ce38b; }
.conn.dis { background:#3a1418; color:#ff6a6a; }
.globalRow { display:flex; align-items:center; gap:10px; margin-top:8px; }
#globalBadge {
flex: 1;
text-align: center;
font-size: 30px;
font-weight: 700;
padding: 14px 12px;
border-radius: 12px;
letter-spacing: .02em;
text-transform: lowercase;
background: #1c2128; color: var(--absent);
transition: background .15s ease;
}
#globalBadge.absent { background:#1c2128; color:var(--absent); }
#globalBadge.present_still { background:#0c2748; color:#9cccff; }
#globalBadge.present_moving{ background:#1d3a0d; color:#a0e783; }
#globalBadge.active { background:#430b0b; color:#ffa1a1; }
button.reset {
flex: 0 0 auto;
min-width: 60px;
min-height: 44px;
background: #21262d; color: var(--fg);
border: 1px solid var(--line); border-radius: 10px;
font-size: 13px; font-weight: 500;
}
button.reset:active { background:#30363d; }
main { padding: 12px 14px 24px; display:flex; flex-direction:column; gap:12px; }
.card {
background: var(--card); border: 1px solid var(--line); border-radius: 12px;
padding: 12px 14px;
}
.cardHead {
display:flex; align-items:baseline; justify-content:space-between; gap:8px;
margin-bottom: 8px;
}
.cardHead .nid { font-size:13px; font-weight:600; color:var(--accent);
font-family:ui-monospace,Menlo,Consolas,monospace; }
.cardHead .stale { font-size:10px; color:#ff6a6a; }
.nodeBadge {
display:block; width:100%; text-align:center;
font-size: 22px; font-weight: 700;
padding: 10px 8px; border-radius: 10px; margin-bottom: 10px;
background: #1c2128; color: var(--absent);
}
.nodeBadge.absent { background:#1c2128; color:var(--absent); }
.nodeBadge.present_still { background:#0c2748; color:#9cccff; }
.nodeBadge.present_moving{ background:#1d3a0d; color:#a0e783; }
.nodeBadge.active { background:#430b0b; color:#ffa1a1; }
.stats { display:grid; grid-template-columns: repeat(3, 1fr); gap:8px; margin-bottom: 10px; }
.stat { background:#0d1117; border-radius:8px; padding:8px 10px; text-align:center; }
.stat .v { font-size:18px; font-weight:600; color:var(--fg);
font-family:ui-monospace,Menlo,Consolas,monospace; }
.stat .l { font-size:10px; color:var(--mute); margin-top:2px;
font-family:ui-monospace,Menlo,Consolas,monospace;
text-transform: uppercase; letter-spacing:.05em; }
canvas.trace { display:block; width:100%; height:90px; background:#0d1117; border-radius:8px; }
.traceCaption { font-size:10px; color:var(--mute); margin-top:4px;
font-family:ui-monospace,Menlo,Consolas,monospace; }
.empty { color:var(--mute); text-align:center; padding:40px 16px; font-size:13px; }
</style>
</head>
<body>
<header>
<div class="titleRow">
<span class="title">RuView · mobile</span>
<span id="conn" class="conn dis">disconnected</span>
</div>
<div class="globalRow">
<div id="globalBadge" class="absent">absent</div>
<button class="reset" onclick="resetState()" aria-label="Reset state">reset</button>
</div>
</header>
<main id="nodes">
<div class="empty" id="emptyMsg">waiting for first frame…</div>
</main>
<script>
// ── Constants ──────────────────────────────────────────────────────
const TRACE_SEC = 30;
const TRACE_MAX_PTS = 600;
const STALE_MS = 3500; // ⇒ "stale" tag on the card
// ── State ──────────────────────────────────────────────────────────
const state = new Map(); // node_id → { meanAmpHist, lastFrameWall, fps }
function resetState() {
state.clear();
document.getElementById('nodes').innerHTML =
'<div class="empty" id="emptyMsg">waiting for first frame…</div>';
}
// ── Per-node card factory ─────────────────────────────────────────
function ensureCard(nodeId) {
if (state.has(nodeId)) return state.get(nodeId);
const empty = document.getElementById('emptyMsg');
if (empty) empty.remove();
const ent = {
meanAmpHist: [], // { t, v }
rssiHist: [],
lastFrameWall: performance.now(),
fps: 0,
lastTs: 0,
};
state.set(nodeId, ent);
const card = document.createElement('section');
card.className = 'card'; card.id = 'card-' + nodeId;
card.innerHTML = `
<div class="cardHead">
<span class="nid">node ${nodeId}</span>
<span class="stale" id="n${nodeId}-stale"></span>
</div>
<div class="nodeBadge absent" id="n${nodeId}-badge">absent</div>
<div class="stats">
<div class="stat"><div class="v" id="n${nodeId}-rssi">--</div><div class="l">rssi dBm</div></div>
<div class="stat"><div class="v" id="n${nodeId}-cv">0%</div><div class="l">conf</div></div>
<div class="stat"><div class="v" id="n${nodeId}-fps">0</div><div class="l">fps</div></div>
</div>
<canvas class="trace" id="n${nodeId}-trace"></canvas>
<p class="traceCaption">
<span style="color:#3fb950">●</span> broadband amplitude &nbsp;
<span style="color:#7d8590">●</span> rssi &nbsp; <span style="float:right">last ${TRACE_SEC}s</span>
</p>`;
document.getElementById('nodes').appendChild(card);
return ent;
}
// ── Drawing ────────────────────────────────────────────────────────
function drawTrace(canvas, rssi, amp) {
const dpr = window.devicePixelRatio || 1;
const w = canvas.clientWidth, h = canvas.clientHeight;
if (canvas.width !== w * dpr || canvas.height !== h * dpr) {
canvas.width = w * dpr; canvas.height = h * dpr;
}
const ctx = canvas.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = '#0d1117'; ctx.fillRect(0, 0, w, h);
const now = performance.now() / 1000;
const t0 = now - TRACE_SEC;
function plot(arr, color, fixedMin, fixedMax) {
const v = arr.filter(p => p.t >= t0);
if (v.length < 2) return null;
let min, max;
if (fixedMin != null) { min = fixedMin; max = fixedMax; }
else { const vs = v.map(p => p.v); min = Math.min(...vs); max = Math.max(...vs); }
const span = (max - min) || 1;
ctx.strokeStyle = color; ctx.lineWidth = 1.8; ctx.beginPath();
for (let i = 0; i < v.length; i++) {
const p = v[i];
const x = ((p.t - t0) / TRACE_SEC) * w;
const y = h - ((p.v - min) / span) * (h - 12) - 6;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
return { min, max };
}
const ampR = plot(amp, '#3fb950', 0, null);
const rssiR = plot(rssi, '#7d8590', null, null);
// Tiny corner labels
ctx.font = '10px ui-monospace,Menlo,Consolas,monospace';
if (ampR) { ctx.fillStyle = '#3fb950';
ctx.fillText('A ' + ampR.max.toFixed(0), 4, 11); }
if (rssiR) { ctx.fillStyle = '#7d8590';
ctx.fillText('rssi ' + rssiR.min.toFixed(0) + '…' + rssiR.max.toFixed(0), 4, 23); }
}
// ── Frame ingestion ───────────────────────────────────────────────
function handleSensingUpdate(d) {
const nodes = d.nodes || [];
const now = performance.now() / 1000;
for (const n of nodes) {
const id = n.node_id;
const ent = ensureCard(id);
const amps = n.amplitude || [];
const meanA = amps.length ? amps.reduce((s, x) => s + x, 0) / amps.length : 0;
ent.rssiHist.push({ t: now, v: n.rssi_dbm });
if (meanA > 0) 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);
const dt = now - (ent.lastFrameWall / 1000);
if (dt > 0 && dt < 5) {
ent.fps = ent.fps ? ent.fps * 0.85 + (1/dt) * 0.15 : 1/dt;
}
ent.lastFrameWall = performance.now();
ent.lastTs = (d.timestamp || Date.now() / 1000);
document.getElementById(`n${id}-rssi`).textContent = n.rssi_dbm.toFixed(0);
document.getElementById(`n${id}-fps`).textContent = ent.fps.toFixed(0);
}
// Per-node classification + global classification — same shape as raw.html.
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 = 'nodeBadge ' + lvl; }
const cv = document.getElementById(`n${id}-cv`);
if (cv) cv.textContent = Math.round((cls.confidence || 0) * 100) + '%';
}
const gcl = d.classification || {};
const glvl = gcl.motion_level || 'absent';
const gb = document.getElementById('globalBadge');
if (gb) { gb.textContent = glvl; gb.className = glvl; }
}
function renderTick() {
const now = performance.now();
for (const [id, ent] of state) {
const trace = document.getElementById('n' + id + '-trace');
if (trace) drawTrace(trace, ent.rssiHist, ent.meanAmpHist);
// Stale marker
const ageMs = now - ent.lastFrameWall;
const tag = document.getElementById('n' + id + '-stale');
if (tag) tag.textContent = ageMs > STALE_MS
? `stale ${(ageMs/1000).toFixed(1)}s`
: '';
}
requestAnimationFrame(renderTick);
}
requestAnimationFrame(renderTick);
// ── WS ────────────────────────────────────────────────────────────
function connect() {
const ws = new WebSocket('ws://' + location.hostname + ':8765/ws/sensing');
ws.onopen = () => {
const c = document.getElementById('conn');
c.textContent = 'connected'; c.className = 'conn ok';
};
ws.onclose = () => {
const c = document.getElementById('conn');
c.textContent = 'reconnecting'; c.className = 'conn dis';
setTimeout(connect, 1500);
};
ws.onerror = () => ws.close();
ws.onmessage = e => {
try {
const d = JSON.parse(e.data);
if (d.type === 'sensing_update') handleSensingUpdate(d);
} catch (_) {}
};
}
connect();
</script>
</body></html>