SLOPSHOPPER

ruview-live

RuView live sensing in Claude Code: /ruview opens a pane beside the transcript with three views: an overview of your CSI nodes (ESP32, Realtek) and 60 GHz…

newpanecommandstatusprocesstimer
★ 97,025v0.1.0MITupdated 2026-10-09ruvnet/RuView/harness/ruview/mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ruview-live
│ ┃ RuView ✕ › fix the failing auth test and add an audit log call │ ┃ RuView ● LIVE updated 1:53:20 AM (0s ago) · │ ┃ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ [ ▸ Overview (1) ] [ CSI waterfall (2) ] ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ! nodes: cli error ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ ╭────────────────────────────────────────╮ │ ┃ │ 60 GHz RADAR not configured │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ Set radarHost to an ESPHome radar kit: │ │ ┃ │ claude plugin configure ruview-live │ ✻ Worked for 42s · done 4:20 PM │ ┃ ╰────────────────────────────────────────╯ │ ┃ › /ruview │ ┃ ╭────────────────────────────────────────╮ ⎿ ruview-live: RuView pane open: nodes on UDP 5005; keys 1 overvie │ ┃ │ CSI NODES UDP 5005 · 0/0 decoded │ │ ┃ │ none streaming to this machine │ │ ┃ ╰────────────────────────────────────────╯ │ ┃ │ ┃ [ Refresh (r) ] [ Close (c) ] │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ ruview-live: RuView · 0 nodes · 1 alert

Draws

Pane · RuView
RuView ● LIVE updated 1:53:20 AM (0s ago) · every 15s ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ [ ▸ Overview (1) ] [ CSI waterfall (2) ] [ Radar (3) ] c ! nodes: cli error ╭────────────────────────────────────────────────────────╮ │ 60 GHz RADAR not configured │ │ Set radarHost to an ESPHome radar kit: │ │ claude plugin configure ruview-live │ ╰────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────╮ │ CSI NODES UDP 5005 · 0/0 decoded │ │ none streaming to this machine │ ╰────────────────────────────────────────────────────────╯ [ Refresh (r) ] [ Close (c) ]
README

π RuView

<a href="https://cognitum.one/seed"> <img src="assets/ruview-hero-h3-v3.gif" alt="RuView - WiFi DensePose — animated visualization of real-time pose estimation, breathing, and heart-rate sensing through WiFi" width="100%"> </a>

<a href="https://ruos.cognitum.one"> <img src="assets/ruos-animated.svg" alt="RuView — WiFi becomes spatial awareness, with Ruflo coordination and a ruOS sensing workspace" width="100%"> </a>

See through walls with WiFi

Turn ordinary WiFi into a spatial intelligence / sensing system. Detect people, measure breathing and heart rate, track movement, and monitor rooms — through walls, in the dark, with no cameras or wearables. Just physics.

Works natively with the four major smart-home ecosystems: Home Assistant via the HA-DISCO MQTT publisher, Apple Home & HomePod as a discoverable HAP-1.1 bridge, Google Home + Amazon Alexa via the same HA bridge or a Matter endpoint. Siri, Google Assistant, and Alexa can voice presence and vitals by room with zero custom skills.

Works with Home Assistant Works with Matter Works with Apple Home Works with Google Home Works with Alexa

Drop into any Home Assistant install with one --mqtt flag. Or pair into Apple Home / Google Home / Alexa / SmartThings as a Matter Bridge. Ships 21 entities per node (11 raw signals + 10 inferred semantic states: someone-sleeping, possible-distress, room-active, elderly-inactivity-anomaly, meeting-in-progress, bathroom-occupied, fall-risk-elevated, bed-exit, no-movement, multi-room-transition) plus 3 starter HA Blueprints. See docs/integrations/home-assistant.md · ADR-115.

π RuView is a WiFi sensing platform that turns radio signals into spatial intelligence.

Every WiFi router already fills your space with radio waves. When people move, breathe, or even sit still, they disturb those waves in measurable ways. RuView captures these disturbances using Channel State Information (CSI) from low-cost ESP32 sensors and turns them into actionable data: who's there, what they're doing, and whether they're okay.

What it senses:

  • Presence and occupancy — detect people through walls, count them, track entries and exits
  • Vital signs — breathing rate and heart rate, contactless, while sleeping or sitting
  • Activity recognition — walking, sitting, gestures, falls — from temporal CSI patterns
  • Environment mapping — RF fingerprinting identifies rooms, detects moved furniture, spots new objects
  • Sleep quality — overnight monitoring with sleep stage classification and apnea screening

Also included:

  • Camera-free pose — estimate 17 body keypoints from WiFi CSI
  • Built-in model workflow — record CSI, train models, load RVF files, and switch LoRA profiles
  • Local automation — HOMECORE provides state, history, automations, signed Wasm plugins, voice hooks, and HomeKit support
  • Unified RF world model — combine WiFi CSI, radar, UWB, and cellular sensing in one privacy-bounded scene model; accuracy is still synthetic until real-data validation
  • Governed evidence — attach privacy policy, uncertainty, provenance, and witness records to sensing events
  • RuView MetaHarness — use an AI operator to onboard, calibrate, train, verify, and check sensing claims

The RuView-specific metaharness we created is published as @ruvnet/ruview. It provides:

  • source-cited guidance;
  • device access for CSI nodes, radar and LiDAR;
  • firmware flashing with boot evidence;
  • one MCP server, usable over stdio or HTTP for ChatGPT, with a live console widget;
  • a Claude Code mod;
  • guarded Claude Code/Codex agents;
  • deterministic verification;
  • an honesty check for accuracy claims.

Companion packages: homecore (Homecore developer metaharness) and @ruvnet/ruview-kernel (the vitals pipeline as WASM).

# Check the local setup and get source-cited guidance
npx @ruvnet/ruview@0.9.1 doctor
npx @ruvnet/ruview@0.9.1 guidance --topic sensing --query "model loading"

# Hardware: what is plugged in, live CSI from your nodes, a 60 GHz radar kit
npx @ruvnet/ruview@0.9.1 devices
npx @ruvnet/ruview@0.9.1 esp32 --watch                       # ESP32 + Realtek RAC1 nodes, live view
npx @ruvnet/ruview@0.9.1 esp32 --seconds 45 --analyze        # live CSI through the vitals kernel
npx @ruvnet/ruview@0.9.1 mmwave --source esphome --host <kit-ip>

# Agents: MCP over stdio, or over HTTP for ChatGPT (token-protected, read tools only)
npx @ruvnet/ruview@0.9.1 mcp start
RUVIEW_MCP_GRANTS=device-access npx @ruvnet/ruview@0.9.1 mcp start --http

# Claude Code: a live sensing pane (/ruview), shipped in the package
npx @ruvnet/ruview@0.9.1 mod

# Run a read-only RuView agent through Codex
npx @ruvnet/ruview@0.9.1 agent run --host codex --repo . \
  --prompt "Find the nearest tests and cite the source files"

# Check claims, replay the deterministic proof, search the reviewed brain
npx @ruvnet/ruview@0.9.1 claim-check --file REPORT.md
npx @ruvnet/ruview@0.9.1 verify
npx @ruvnet/ruview@0.9.1 brain search --query "calibration"

Safety:

  • Agent runs are read-only by default. Workspace writes require both --allow-write and --confirm.
  • Hardware reads need the device-access grant.
  • Flashing and calibration never run over the HTTP transport.
  • Device-reported vitals are labelled as unvalidated.
  • Retrieved brain content is evidence, not authority.

A single npx ruview package bundling all of this is ready (ADR-376). It is waiting on npm to release the unscoped name.

Full walkthrough: user guide → RuView npm toolkit.

Built on RuVector and Cognitum Seed, RuView runs entirely on edge hardware — an ESP32 mesh (as low as $9 per node) paired with a Cognitum Seed for persistent memory, cryptographic attestation, and AI integration. No cloud, no cameras, no internet required.

The system learns each environment locally using spiking neural networks that adapt in under 30 seconds, with multi-frequency mesh scanning across 6 WiFi channels that uses your neighbors' routers as free radar illuminators. Every measurement is cryptographically attested via an Ed25519 witness chain.

RuView turns ordinary WiFi into a contactless sensor. A $9 ESP32 board reads the radio reflections off the people in a room, and a small pretrained model — published on Hugging Face at ruvnet/wifi-densepose-pretrained — tells you who's there, how they're breathing, and how their heart rate is trending. The model fits in 8 KB (4-bit quantized) and runs in microseconds on a Raspberry Pi. (The v2 encoder reports an honest, label-free held-out temporal-triplet accuracy of 82.3% — up from 66.4% raw; the older "100% presence" figure was measured on a single-class recording and has been retracted in favor of this.) No cameras, no wearables, no app on the user's phone.

Built for low-power edge applications

Edge modules are small programs that run directly on the ESP32 sensor — no internet needed, no cloud fees, instant response.

Rust 1.85+ License: MIT Tests: 1463 Docker: multi-arch Vital Signs ESP32 Ready crates.io Downloads

WhatHowSpeed / scale
🫁 Breathing rateBandpass 0.1–0.5 Hz on wrapped phase, circular variance, zero-crossing BPM (#593)6–30 BPM, real-time
💓 Heart rateBandpass 0.8–2.0 Hz, zero-crossing BPM40–120 BPM, real-time
👤 Presence detectionTrained head on Hugging Face (ruvnet/wifi-densepose-pretrained; v2 encoder = 82.3% held-out temporal-triplet acc, honestly re-benchmarked) + a phase-variance fallback that needs no model< 1 ms, ~30 s ambient calibration
🧬 CSI embeddings128-dim contrastive encoder shipped on Hugging Face, 4-bit quantised variant fits in 8 KB164,183 emb/s on M4 Pro
🦴 17-keypoint pose estimationcog-pose-estimation Cog v0.0.1 — signed aarch64 + x86_64 binaries on GCS, loads pose_v1.safetensors via Candle (the committed pose_v1 is a first-cut on-device model: PCK@20 = 3.0%, below the ADR-079 ≥35% target, and its runtime path is still a confidence=0 stub — see Model weights: what's real, what's not; the 82.69% figure below is the separate published MM-Fi benchmark, not this live cog). Train your own from paired data in 2.1 s on an RTX 5080 (ADR-101, benchmarks). SOTA on MM-Fi: ruvnet/wifi-densepose-mmfi-pose hits 82.69% torso-PCK@20 (ensemble 83.59%), beating MultiFormer (72.25%) and CSI2Pose (68.41%) on the matched MM-Fi random_split protocol — self-corrected and auditable on AetherArena8.4 ms cold-start on a Pi 5
🚶 Motion / activityMotion-band power + phase accelerationReal-time
🤸 Fall detectionPhase-acceleration threshold + 3-frame debounce + 5 s cooldown (#263)< 200 ms
🧮 Multi-person countAdaptive P95 normalisation + runtime-tunable dedup factor (/api/v1/config/dedup-factor, #491). Six specialised learned counters available as Cogs: occupancy-zones, elevator-count, queue-length, customer-flow, clean-room, person-matchingReal-time, self-calibrating
🌍 World model predictionOccWorld TransVQVAE — 15-frame future occupancy prediction, 209 ms inference, 3.4 GB VRAM on RTX 5080; fine-tune on your space with occworld_retrain.py (ADR-147)15 frames × 200×200×16 vox
🧱 Through-wall sensingFresnel-zone geometry + multipath modelingUp to ~5 m, signal-dependent
🧠 Edge intelligence105-cog catalog (ADR-102) live from app-registry.json — health, security, building, retail, industrial, research, AI, swarm, signal, network, and developer modules. Optional Cognitum Seed adds persistent vector store + kNN + witness chain$140 total BOM
🎯 Camera-free pre-trainingSelf-supervised contrastive encoder, 12.2M training steps on 60K frames, shipped on Hugging Face84 s/epoch retrain on M4 Pro
📷 Camera-supervised fine-tuneMediaPipe + ESP32 CSI paired training, end-to-end Candle pipeline on RTX 5080 (ADR-079)2.1 s for 400 epochs (~5 ms/epoch)
📡 Multi-frequency meshChannel hopping across 6 bands, TDM slot scheduling (ADR-029)3× sensing bandwidth
🌐 3D point cloud fusionCamera depth (MiDaS) + WiFi CSI + mmWave radar → unified spatial model22 ms pipeline · 19K+ points/frame

Browse the full 105-module catalog (with practical descriptions, sizes, and difficulty) below in 🧩 Edge Module Catalog, or visit seed.cognitum.one/store.

🤗 Pretrained weights: download from ruvnet/wifi-densepose-pretrained — see Loading the pretrained model below for one-command setup.

# Option 1: Docker (simulated data, no hardware needed)
docker pull ruvnet/wifi-densepose:latest
export RUVIEW_API_TOKEN=$(openssl rand -hex 32)   # required; the container exits 64 without it
docker run -p 127.0.0.1:3000:3000 -e RUVIEW_API_TOKEN ruvnet/wifi-densepose:latest
# Open http://localhost:3000
# ESP32 nodes in Docker also need a UDP source guard (RUVIEW_UDP_ALLOW);
# see docs/user-guide.md "Receiving ESP32 frames in Docker".

# Option 2a: Live sensing with ESP32-S3 hardware ($9)
# Flash firmware, provision WiFi, and start sensing:
python -m esptool --chip esp32s3 --port COM9 --baud 460800 \
  write_flash 0x0 bootloader.bin 0x8000 partition-table.bin \
  0xf000 ota_data_initial.bin 0x20000 esp32-csi-node.bin
python firmware/esp32-csi-node/provision.py --port COM9 \
  --ssid "YourWiFi" --password "secret" --target-ip 192.168.1.20

# Option 2b: WiFi 6 + 802.15.4 research sensing with ESP32-C6 ($6-10, ADR-110)
# Same csi-node firmware compiled for the C6 target — picks up the C6
# overlay (sdkconfig.defaults.esp32c6) automatically.
cd firmware/esp32-csi-node
idf.py set-target esp32c6 && idf.py build
idf.py -p COM6 flash
# C6 boot extras (vs S3): HE-LTF subcarrier tagging in ADR-018 bytes 18-19,
#   802.15.4 mesh time-sync on channel 15, TWT setup when the AP supports it,
#   opt-in LP-core wake-on-motion for ~5 µA battery seed nodes.
# v0.6.7 adds: real LP-core RISC-V motion-gate program (debounce + motion
#   counter) and a Wi-Fi 6 soft-AP with TWT Responder so two C6 boards can
#   benchmark real iTWT without buying an 11ax router. Both default off,
#   flip CONFIG_C6_{LP_CORE,SOFTAP_HE}_ENABLE to turn them on.

# Option 3: Full system with Cognitum Seed ($140)
# ESP32 streams CSI → bridge forwards to Seed for persistent storage + kNN + witness chain
node scripts/rf-scan.js --port 5006           # Live RF room scan
node scripts/snn-csi-processor.js --port 5006  # SNN real-time learning
node scripts/mincut-person-counter.js --port 5006  # Correct person counting

# Option 4: Python — live on PyPI (ADR-117)
pip install ruview                        # or: pip install wifi-densepose
# Both ship the same compiled PyO3 wheel (~250 KB, abi3-py310, Linux/macOS/Windows).
# Add [client] for the asyncio WebSocket + paho-mqtt clients:
pip install "ruview[client]"              # or: pip install "wifi-densepose[client]"

# from ruview import BreathingExtractor, HeartRateExtractor   # equivalent to:
# from wifi_densepose import BreathingExtractor, HeartRateExtractor
# from ruview.client import SensingClient, RuViewMqttClient

PyPI ruview PyPI wifi-densepose

[!NOTE] CSI-capable hardware recommended. Presence, vital signs, through-wall sensing, and all advanced capabilities require Channel State Information (CSI) from an ESP32-S3 ($9) or research NIC. The Docker image runs with simulated data for evaluation. Consumer WiFi laptops provide RSSI-only presence detection.

Hardware options for live CSI capture:

OptionHardwareCostFull CSICapabilities
ESP32 + Cognitum Seed (recommended)ESP32-S3 + Cognitum Seed~$140YesPresence, motion, breathing, heart rate, fall detection, multi-person counting, 17-keypoint pose (signed Cog binary — first-cut on-device model, see Model weights: what's real, what's not), 105-cog catalog, persistent vector store, kNN search, witness chain, MCP proxy
ESP32 Mesh3-6× ESP32-S3 + WiFi router~$54YesSame capabilities as above without the persistent-memory features
ESP32-C6 research node (ADR-110, witness, reviewer guide, firmware v0.7.0)ESP32-C6-DevKit ($6–10)~$10Yes (Wi-Fi 6 capable)Dual-target CSI with 99.56% measured ESP-NOW sync match and measured HE-LTF capture on IDF 5.5.2. TWT and ~5 µA operation still need hardware validation.
Research NICIntel 5300 / Atheros AR9580~$50-100YesFull CSI with 3x3 MIMO
Qualcomm CSI beta (ADR-268)QCA9300 now; QCN9074/QCN9274 experimental~$30-200Simulator now; hardware adapter gatedRust QCS1 codec, deterministic replay, UDP/API integration; modern ath11k/ath12k profiles do not claim public CSI export
Vendor provider beta (ADR-270)Origin, Plume, Mist, NETGEAR, Electric Imp, RF Solutions, Luma, Nest, Linksys, WifigardenVariesCapability-dependentBounded Rust adapters and deterministic fixtures; telemetry/network-only/unsupported states cannot masquerade as CSI
Any WiFiWindows, macOS, or Linux laptop$0NoRSSI-only: coarse presence and motion (see tutorial #36)

No hardware? Verify the signal processing pipeline with the deterministic reference signal: python archive/v1/data/proof/verify.py


<a href="https://ruvnet.github.io/RuView/"> <img src="assets/v2-screen.png" alt="WiFi DensePose — Live pose detection with setup guide" width="800"> </a> <em>Real-time pose skeleton from WiFi CSI signals — no cameras, no wearables (demo visualization; the live CSI-only single-ESP32 17-keypoint model is still first-cut — see <a href="#model-weights-whats-real-whats-not">Model weights: what's real, what's not</a>)</em> <a href="https://ruvnet.github.io/RuView/"><strong>▶ Live Observatory Demo</strong></a> &nbsp;|&nbsp; <a href="https://ruvnet.github.io/RuView/pose-fusion.html"><strong>▶ Dual-Modal Pose Fusion Demo</strong></a> &nbsp;|&nbsp; <a href="https://ruvnet.github.io/RuView/pointcloud/"><strong>▶ Live 3D Point Cloud</strong></a> &nbsp;|&nbsp; <a href="https://ruvnet.github.io/RuView/three.js/"><strong>▶ three.js Demos (5)</strong></a>

The server is optional for visualization and aggregation — the ESP32 runs independently for presence detection, vital signs, and fall alerts.

Live ESP32 pipeline: Connect an ESP32-S3 node → run the sensing server → open the pose fusion demo for real-time dual-modal pose estimation (webcam + WiFi CSI). See ADR-059. (The webcam supplies ground-truth pose in this dual-modal demo; the CSI-only on-device 17-keypoint model is still first-cut — see Model weights: what's real, what's not.)

three.js scene gallery at /three.js/ — five progressively richer ADR-097 demos: helpers, cinematic, GLTF skinned, FBX skinned, and a live MediaPipe→Mixamo retargeting feed driven by ESP32 CSI. Demos 04 and 05 require a local Mixamo X Bot.fbx (license boundary — not redistributed).

🤗 Pretrained model on Hugging Face

Pretrained CSI weights live at ruvnet/wifi-densepose-pretrained — 12.2M training steps on 60K frames / 610K contrastive triplets, 82.3% held-out temporal-triplet accuracy (up from 66.4% raw; the older "100% presence" figure was measured on a single-class recording and has been retracted), 4-bit quantized variant fits in 8 KB. The release includes a contrastive CSI encoder producing 128-dim embeddings (164,183 emb/s on M4 Pro) and a presence-detection head. Per-node LoRA adapters are included for environment-specific fine-tuning.

# Download the model bundle
pip install huggingface_hub
huggingface-cli download ruvnet/wifi-densepose-pretrained --local-dir models/wifi-densepose-pretrained

What works today vs. what's pending wiring:

ConsumerFormat usedStatus
Python training / evaluation / embedding extractionmodel.safetensors⚠️ The published file's header is NUL-padded, which the reference safetensors.torch.load_file rejects (issue #1522) — pending a corrected re-upload. csi-embed-v2.safetensors in the same repo is unaffected and loads normally.
Inspect / re-export the bundlemodel.rvf.jsonl (line-by-line JSON)✅ Works — plain JSONL
Sensing-server --model <PATH> flagnative RVF, model.safetensors, or model.rvf.jsonl✅ Native RVF loads directly; safetensors and JSONL auto-convert in memory

Loader scope: --model now accepts native RVF and auto-converts the published safetensors or JSONL files. The quantized model-q*.bin files still need a compatible reader, and loading weights does not supply the matching pose-decoder architecture or establish end-to-end pose accuracy.

Quantization choices (all in the HF repo): model-q2.bin (4 KB) · model-q4.bin ⭐ recommended (8 KB) · model-q8.bin (16 KB) · model.safetensors full (48 KB)

The separate 17-keypoint pose-estimation model is now published at ruvnet/wifi-densepose-mmfi-pose — 82.69% torso-PCK@20 on MM-Fi (single model) / 83.59% (3-model ensemble + TTA), beating the prior published SOTA MultiFormer (72.25%) and CSI2Pose (68.41%) on the matched random_split protocol. See Results & proof below.

Results & proof

See the measured benchmarks, witness records, and one-command reproducibility check.

WhatWhereNumbers

| MM-Fi pose model (SOTA) | [ruvnet/wifi-densepose-mmfi-pose](https://huggingface.co/ruvnet/wifi-d

Source 5 files
hooks/register.mjs 243 lines
1// SPDX-License-Identifier: MIT
2// ruview-live — a Claude Code mod (function hooks) shipped inside @ruvnet/ruview.
3//
4// `/ruview` opens a pane beside the transcript with three views: an overview of
5// the CSI nodes streaming to this machine and an optional ESPHome radar kit; a
6// live CSI amplitude waterfall; and a radar fan with vitals charts (ADR-377,
7// ADR-378). Every reading comes from the @ruvnet/ruview CLI this mod ships
8// beside (`--json`), so the pane shows exactly what the tested tools return.
9// Read-only: it never flashes, provisions or writes to a device.
10
11import { commandsOf, cliPathOf, historyWith, MODES, modelOf, resultOf, settingsOf, spectrumWith, statusOf } from './model.mjs';
12import { drain } from './anim.mjs';
13import { ANIMATED, picturesOf, sizesOf, viewOf } from './views.mjs';
14
15export * from './anim.mjs';
16export * from './model.mjs';
17export * from './raster.mjs';
18export { ANIMATED, picturesOf, sizesOf, viewOf } from './views.mjs';
19
20export const PANE_ID = 'ruview-live';
21export const COMMAND = 'ruview';
22const TICK_MS = 5000;
23/** Body rows each view asks for when the pane sits inline above the prompt. */
24export const VIEW_ROWS = Object.freeze({ overview: 16, waterfall: 30, radar: 26 });
25
26/**
27 * The open request: a dialog (focus + closeOnEscape + holdToasts) takes the
28 * keyboard, so the view keys work at once; `rows` sizes the inline pane to
29 * the view instead of a third of the screen (the dock ignores it).
30 */
31export const openArgsOf = (mode) => ({
32  id: PANE_ID, title: 'RuView', focus: true, closeOnEscape: true, holdToasts: true, rows: VIEW_ROWS[mode] ?? VIEW_ROWS.overview,
33});
34/** Animation frame period: ~12 fps, well inside blit's 60 shown a second. */
35export const FRAME_MS = 80;
36
37/** Spectrum request for the waterfall: one bin per picture column (at most 128). */
38export function spectrumOf(columns, rows) {
39  const { width, height } = sizesOf(columns, rows);
40  return { bins: Math.max(8, Math.min(128, width)), frames: Math.max(8, Math.min(256, height * 2)) };
41}
42
43/**
44 * The mod entry: `/ruview [off|refresh|overview|waterfall|radar]`, the pane,
45 * the timers, the status.
46 * @param on the engine's registrar
47 * @param options this plugin's userConfig values
48 */
49export function register(on, options = {}) {
50  const settings = settingsOf(options);
51  let host = null;
52  let isOpen = false;
53  let busy = false;
54  let model = null;
55  let history = null;
56  let spectrum = [];
57  let mode = 'overview';
58  let nodeIndex = 0;
59  let size = { columns: 100, rows: 30 };
60  let stopTimer = null;
61  let stopTick = null;
62  let stopAnim = null;
63  let animating = false;
64  let lastFrame = null;
65  let lag = {};
66  let cliPath = null;
67
68  const intervalOf = () => (mode === 'overview' ? settings.refreshMs : settings.liveRefreshMs);
69  const stop = () => {
70    if (stopTimer) { stopTimer(); stopTimer = null; }
71    if (stopTick) { stopTick(); stopTick = null; }
72    if (stopAnim) { stopAnim(); stopAnim = null; }
73  };
74  const cancelOf = (t) => (typeof t === 'function' ? t : () => t?.cancel?.());
75
76  async function runCli(args) {
77    if (!args) return null;
78    try {
79      return await host.run(['node', cliPath, ...args], { timeoutMs: (settings.captureSeconds + 30) * 1000 });
80    } catch (error) {
81      return { exitCode: -1, stdout: '', stderr: String(error?.message || error) };
82    }
83  }
84
85  async function refresh() {
86    if (!host || busy) return;
87    busy = true;
88    host.invalidate();
89    try {
90      const commands = commandsOf(settings, {
91        live: mode !== 'overview',
92        spectrum: mode === 'waterfall' ? spectrumOf(size.columns, size.rows) : null,
93      });
94      const [capture, radar] = await Promise.all([runCli(commands.capture), runCli(commands.radar)]);
95      // $.clock.now() resolves a promise of epoch milliseconds.
96      model = modelOf(resultOf(capture), resultOf(radar), await host.now());
97      history = historyWith(history, model);
98      spectrum = spectrumWith(spectrum, model.spectrum);
99      lag = Object.fromEntries(Object.entries(lag).filter(([key]) => spectrum.some((s) => s.key === key)));
100      // New frames join the replay queue: shown at their arrival rate, not at once.
101      for (const s of model.spectrum) {
102        const held = spectrum.find((x) => x.key === s.key)?.frames.length ?? 0;
103        lag[s.key] = Math.min(held, (lag[s.key] ?? 0) + s.frames.length);
104      }
105      host.status(statusOf(model));
106    } finally {
107      busy = false;
108      host.invalidate();
109    }
110  }
111
112  /** Options shared by the full drawing and each animation frame. */
113  const drawOpts = (t) => ({ mode, nodeIndex, lag, history, t, columns: size.columns, rows: size.rows });
114  const shownModel = () => (model ? { ...model, spectrum } : null);
115
116  /** One animation frame: repaint each mounted animated Raster in place. */
117  async function animate() {
118    if (!host || animating) return;
119    animating = true;
120    try {
121      // Real time, the clock the full render reads too: pulses keep the
122      // reported rates and the replay keeps up even when a frame fires late.
123      const t = await host.now();
124      if (!Number.isFinite(t)) return;
125      const dt = Number.isFinite(lastFrame) ? Math.max(0, Math.min(1000, t - lastFrame)) : 0;
126      lastFrame = t;
127      for (const s of spectrum) lag[s.key] = drain(lag[s.key], s.rateHz, dt);
128      const pics = picturesOf(shownModel(), drawOpts(t));
129      // Fire and forget: a blit resolves only once a frame is painted, and the
130      // surface folds blits between frames anyway, so nothing waits on one.
131      for (const key of ANIMATED) {
132        if (!pics[key]) continue;
133        const { columns, rows, cells } = pics[key].grid.toRaster(key);
134        Promise.resolve(host.blit({ requestId: PANE_ID, key, cells, columns, rows })).catch(() => undefined);
135      }
136    } finally {
137      animating = false;
138    }
139  }
140
141  /** Start the refresh timer, the age tick and the animation, and fetch now, unless already polling. */
142  function startPolling() {
143    isOpen = true;
144    if (stopTimer) return;
145    stopTimer = cancelOf(host.every(intervalOf(), () => { void refresh(); }));
146    stopTick = cancelOf(host.every(TICK_MS, () => host.invalidate()));
147    stopAnim = cancelOf(host.every(FRAME_MS, () => { void animate(); }));
148    void refresh();
149  }
150
151  /** Switch view; the live views poll faster, so the timer restarts. */
152  function setMode(next) {
153    if (!MODES.includes(next) || next === mode) return;
154    mode = next;
155    if (isOpen && host) {
156      // Each open sets the size anew: re-request rows for this view, keeping the keys.
157      void host.open(openArgsOf(mode)).catch(() => undefined);
158      stop();
159      startPolling();
160    } else host?.invalidate();
161  }
162
163  async function open(initialMode) {
164    if (MODES.includes(initialMode)) mode = initialMode;
165    await host.open(openArgsOf(mode));
166    stop();
167    startPolling();
168  }
169
170  async function close() {
171    stop();
172    isOpen = false;
173    await host.close({ id: PANE_ID }).catch(() => undefined);
174  }
175
176  on('session.start', async ($, e, next) => {
177    cliPath = cliPathOf($.plugin.root);
178    host = {
179      run: (argv, init) => $.process.run(argv, init),
180      every: (ms, fn) => $.clock.every(ms, fn),
181      now: () => $.clock.now(),
182      open: (pane) => $.ui.open(pane),
183      close: (pane) => $.ui.close(pane),
184      status: (text) => $.ui.status(text),
185      invalidate: () => $.ui.invalidate('ui.render'),
186      blit: (args) => $.ui.blit(args),
187    };
188    await $.command.register({ name: COMMAND, description: 'RuView live sensing pane: CSI nodes, CSI waterfall and radar', argumentHint: '[waterfall|radar|refresh|off]' }).catch(() => undefined);
189    return next(e);
190  });
191
192  on('command.run', { command: COMMAND }, async ($, e, next) => {
193    if (!host) return next(e);
194    const arg = String(e.args || '').trim().toLowerCase();
195    if (arg === 'off') { await close(); host.status(undefined); return { text: 'RuView pane closed.' }; }
196    if (arg === 'refresh') { await refresh(); return { text: statusOf(model) }; }
197    if (MODES.includes(arg)) {
198      if (isOpen) {
199        if (arg === mode) await host.open(openArgsOf(mode)).catch(() => undefined);
200        else setMode(arg);
201      } else await open(arg);
202      return { text: `RuView pane: ${arg} view.` };
203    }
204    if (isOpen) { await close(); return { text: 'RuView pane closed.' }; }
205    await open();
206    return { text: `RuView pane open: nodes on UDP ${settings.udpPort}${settings.radarHost ? `, radar ${settings.radarHost}` : ''}; keys 1 overview · 2 CSI waterfall · 3 radar.` };
207  });
208
209  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
210    if (e.requestId !== PANE_ID) return next(e);
211    // A reload (hot reload, or a resumed session) re-runs register with fresh
212    // variables while the engine keeps the pane open: drawing it means it is
213    // open, so resume polling instead of waiting for the first capture forever.
214    if (host && !stopTimer) startPolling();
215    size = {
216      columns: e.props?.bodyColumns ?? e.viewport?.columns ?? 100,
217      rows: e.props?.scroll?.bodyRows ?? e.viewport?.rows ?? 30,
218    };
219    const ui = await $.ui.resolve(e);
220    const now = await $.clock.now();
221    return viewOf(ui, shownModel(), {
222      ...drawOpts(now), refreshMs: settings.refreshMs, liveRefreshMs: settings.liveRefreshMs, busy, now,
223      focused: e.props?.isFocused !== false,
224      udpPort: settings.udpPort, radarConfigured: Boolean(settings.radarHost),
225      onRefresh: () => { void refresh(); },
226      onClose: () => { void close(); },
227      onMode: (m) => setMode(m),
228      onNextNode: () => { nodeIndex += 1; host?.invalidate(); },
229    });
230  });
231
232  on('ui.close', { id: PANE_ID }, async ($, e, next) => {
233    stop();
234    isOpen = false;
235    return next(e);
236  });
237
238  on('session.end', async ($, e, next) => {
239    stop();
240    return next(e);
241  });
242}
243
hooks/model.mjs 170 lines
1// SPDX-License-Identifier: MIT
2// ruview-live data: settings, CLI argv, result parsing, the pane model, trend
3// history and the status line. Pure functions, tested under plain Node.
4
5const HOST_RE = /^[A-Za-z0-9][A-Za-z0-9.-]{0,252}$/;
6export const HISTORY = 48;
7export const MODES = Object.freeze(['overview', 'waterfall', 'radar']);
8
9/**
10 * The harness CLI beside this mod: the plugin root is <pkg>/mod, so the CLI is
11 * <pkg>/bin/cli.js. Mods import nothing but their own files, so the engine's
12 * `$.plugin.root` locates it.
13 */
14export function cliPathOf(pluginRoot) {
15  return `${String(pluginRoot).replace(/[\\/]+$/, '')}/../bin/cli.js`;
16}
17
18/** Normalise plugin options (userConfig) into bounded settings. */
19export function settingsOf(options = {}) {
20  const num = (v, d, lo, hi) => {
21    const n = Number(v);
22    return Number.isFinite(n) ? Math.min(Math.max(Math.round(n), lo), hi) : d;
23  };
24  const host = typeof options.radarHost === 'string' ? options.radarHost.trim() : '';
25  return {
26    udpPort: num(options.udpPort, 5005, 1024, 65535),
27    radarHost: HOST_RE.test(host) ? host : '',
28    refreshMs: num(options.refreshSeconds, 15, 5, 3600) * 1000,
29    captureSeconds: num(options.captureSeconds, 3, 1, 10),
30    liveRefreshMs: num(options.liveRefreshSeconds, 4, 2, 60) * 1000,
31  };
32}
33
34/**
35 * argv for one capture and (optionally) one radar read. In a live view the
36 * capture is shorter, and the waterfall asks for binned amplitude frames.
37 */
38export function commandsOf(settings, { live = false, spectrum = null } = {}) {
39  const s = String(live ? Math.min(settings.captureSeconds, 2) : settings.captureSeconds);
40  const capture = ['esp32', '--seconds', s, '--udp-port', String(settings.udpPort), '--json'];
41  if (spectrum) capture.push('--spectrum', '--spectrum-bins', String(spectrum.bins), '--spectrum-frames', String(spectrum.frames));
42  return {
43    capture,
44    radar: settings.radarHost ? ['mmwave', '--source', 'esphome', '--host', settings.radarHost, '--seconds', s, '--json'] : null,
45  };
46}
47
48/** Parse a CLI run into its JSON result, or an honest failure. */
49export function resultOf(run) {
50  if (!run) return null;
51  try {
52    const parsed = JSON.parse(run.stdout);
53    if (parsed && typeof parsed === 'object') return parsed;
54  } catch { /* fall through */ }
55  return { ok: false, reason: 'cli_error', detail: String(run.stderr || run.stdout || `exit ${run.exitCode}`).trim().slice(0, 300) };
56}
57
58const fixed = (v, d = 1) => (typeof v === 'number' && Number.isFinite(v) ? v.toFixed(d) : '—');
59const pct = (v) => (typeof v === 'number' ? `${(v * 100).toFixed(1)}%` : '—');
60const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
61
62/** Resting ranges outside which a device-reported vital is flagged, not trusted. */
63export const RANGES = Object.freeze({ heart: [40, 180], breathing: [4, 40] });
64
65/** Why a device-reported vital looks implausible, or null. */
66export function plausibilityOf(kind, value) {
67  if (value == null || !RANGES[kind]) return null;
68  const [lo, hi] = RANGES[kind];
69  return value < lo || value > hi ? `outside ${lo}–${hi} bpm` : null;
70}
71
72const SPARK = '▁▂▃▄▅▆▇█';
73/** Unicode sparkline of the last `width` finite values ('' below two points). */
74export function sparkline(values, width = 16) {
75  const v = (values || []).filter((x) => typeof x === 'number' && Number.isFinite(x)).slice(-width);
76  if (v.length < 2) return '';
77  const lo = Math.min(...v);
78  const hi = Math.max(...v);
79  return v.map((x) => SPARK[hi === lo ? 3 : Math.round(((x - lo) / (hi - lo)) * 7)]).join('');
80}
81
82/** "12s ago" / "3m ago" from a millisecond age, or '' when unknown. */
83export function ageOf(ms) {
84  if (!Number.isFinite(ms) || ms < 0) return '';
85  const s = Math.round(ms / 1000);
86  return s < 60 ? `${s}s ago` : `${Math.round(s / 60)}m ago`;
87}
88
89/** The pane's model: plain data, drawn by the views and summarised by statusOf. */
90export function modelOf(capture, radar, at) {
91  const alerts = [];
92  // "No packets" is shown inside the nodes card; only real faults become alerts.
93  if (capture && capture.ok === false && capture.reason !== 'no_packets') {
94    alerts.push({ level: 'bad', text: `nodes: ${String(capture.reason).replace(/_/g, ' ')}${capture.remedy ? ` — ${capture.remedy}` : ''}` });
95  }
96  for (const hb of capture?.heartbeatOnlySenders || []) alerts.push({ level: 'warn', text: `${hb.address} sends heartbeats but no CSI (run \`ruview monitor --baud 1500000\` on it: "lack of csi buf" = buffer starvation)` });
97  if (radar && radar.ok === false) alerts.push({ level: 'bad', text: `radar: ${String(radar.reason).replace(/_/g, ' ')}${radar.detail ? ` — ${radar.detail}` : ''}` });
98  const nodes = (capture?.nodes || []).map((n) => ({
99    key: `${n.source}:${n.nodeId}`,
100    label: `${n.source === 'realtek' ? 'realtek' : 'esp32'} ${n.nodeId}`,
101    rateHz: num(n.csiRateHz),
102    rate: `${fixed(n.csiRateHz)} Hz`,
103    loss: pct(n.csiLossFraction),
104    rssi: `${fixed(n.rssiMean)} dBm`,
105    shape: n.csi?.shape || '—',
106    lossy: typeof n.csiLossFraction === 'number' && n.csiLossFraction > 0.05,
107    synthetic: Boolean(n.csi?.synthetic),
108  }));
109  const spectrum = (capture?.spectrum || []).map((s) => ({
110    key: `${s.source}:${s.nodeId}`,
111    label: `${s.source === 'realtek' ? 'realtek' : 'esp32'} ${s.nodeId}`,
112    shape: s.shape ?? null, subcarriers: s.subcarriers, bins: s.bins, frames: Array.isArray(s.frames) ? s.frames : [],
113    rateHz: num(s.rateHz), synthetic: Boolean(s.synthetic),
114  }));
115  const radarRow = radar && radar.ok !== false ? {
116    name: radar.device?.name || radar.host || 'radar',
117    present: radar.presentNow ?? (radar.presentFraction == null ? null : radar.presentFraction >= 0.5),
118    targets: num(radar.targetsMax),
119    distanceCm: num(radar.distanceCmMean),
120    heartBpm: num(radar.heartBpmMean),
121    breathingBpm: num(radar.breathingBpmMean),
122    distance: radar.distanceCmMean == null ? '—' : `${fixed(radar.distanceCmMean)} cm`,
123    heart: radar.heartBpmMean == null ? '—' : `${fixed(radar.heartBpmMean)} bpm`,
124    breathing: radar.breathingBpmMean == null ? '—' : `${fixed(radar.breathingBpmMean)} bpm`,
125  } : null;
126  return {
127    at, packets: capture?.packets ?? 0, decoded: capture?.decodedPackets ?? 0,
128    noPackets: capture?.reason === 'no_packets', nodes, spectrum, radar: radarRow, alerts,
129  };
130}
131
132/** Append one model to a bounded history of trend values. */
133export function historyWith(history, model, max = HISTORY) {
134  const h = history || { heart: [], breathing: [], distance: [], rates: {} };
135  const cap = (arr, v) => [...arr, v].slice(-max);
136  const rates = { ...h.rates };
137  for (const n of model?.nodes || []) rates[n.key] = cap(rates[n.key] || [], n.rateHz);
138  return {
139    heart: cap(h.heart, model?.radar?.heartBpm ?? null),
140    breathing: cap(h.breathing, model?.radar?.breathingBpm ?? null),
141    distance: cap(h.distance, model?.radar?.distanceCm ?? null),
142    rates,
143  };
144}
145
146/**
147 * Carry each node's waterfall frames across captures, so the picture scrolls
148 * instead of restarting every refresh. Only nodes in this capture remain: a
149 * node that stopped streaming (or a failed capture) leaves the waterfall, so
150 * old frames are never shown as live. A node whose CSI shape or bin count
151 * changed starts over rather than joining two subcarrier layouts.
152 */
153export function spectrumWith(previous, spectrum, max = 128) {
154  const byKey = new Map((previous || []).map((s) => [s.key, s]));
155  return (spectrum || []).map((s) => {
156    const old = byKey.get(s.key);
157    const same = old && old.bins === s.bins && old.shape === s.shape;
158    return { ...s, frames: (same ? [...old.frames, ...s.frames] : [...s.frames]).slice(-max) };
159  });
160}
161
162/** One line for the status bar. */
163export function statusOf(model) {
164  if (!model) return 'RuView · starting';
165  const parts = [`RuView · ${model.nodes.length} node${model.nodes.length === 1 ? '' : 's'}`];
166  if (model.radar) parts.push(`radar ${model.radar.present == null ? '?' : model.radar.present ? 'present' : 'clear'}`);
167  if (model.alerts.length) parts.push(`${model.alerts.length} alert${model.alerts.length === 1 ? '' : 's'}`);
168  return parts.join(' · ');
169}
170
hooks/anim.mjs 74 lines
1// SPDX-License-Identifier: MIT
2// ruview-live animation frames (ADR-378): pure functions of time that the
3// hooks module repaints in place with `$.ui.blit` (no redraw). Decoration is
4// labelled as decoration; anything driven by a reading says which reading.
5
6import { Grid, rgb } from './raster.mjs';
7
8const mix = (a, b, f) => rgb(
9  ((a >> 16) & 255) + ((((b >> 16) & 255) - ((a >> 16) & 255)) * f),
10  ((a >> 8) & 255) + ((((b >> 8) & 255) - ((a >> 8) & 255)) * f),
11  (a & 255) + (((b & 255) - (a & 255)) * f),
12);
13const CYAN = rgb(0, 190, 220);
14const EMERALD = rgb(40, 210, 140);
15const DARK = rgb(16, 22, 30);
16
17/** A one-row cyan→emerald rule with a highlight sweeping across it (pure decoration). */
18export function shimmer(columns, tMs) {
19  const g = new Grid(columns, 1);
20  const head = ((tMs / 2400) % 1) * (g.columns + 24) - 12;
21  for (let x = 0; x < g.columns; x++) {
22    const base = mix(CYAN, EMERALD, x / Math.max(1, g.columns - 1));
23    const glow = Math.max(0, 1 - Math.abs(x - head) / 10);
24    g.set(x, 0, 0x2501, mix(mix(DARK, base, 0.55), rgb(235, 255, 250), glow * glow));
25  }
26  return g;
27}
28
29/** Phase in [0, 1) of a rhythm at `perMinute` (0 when unknown). */
30export function phaseOf(tMs, perMinute) {
31  if (!Number.isFinite(perMinute) || perMinute <= 0) return 0;
32  return ((tMs / 60000) * perMinute) % 1;
33}
34
35/**
36 * A heart icon that beats, and a breathing gauge that fills and empties, at
37 * the device-reported rates: a metronome of the reading, not a waveform.
38 * Unknown rates draw a still, dim strip.
39 */
40export function pulse(columns, tMs, { heartBpm = null, breathingBpm = null } = {}) {
41  const g = new Grid(columns, 1);
42  const beat = Number.isFinite(heartBpm) ? Math.exp(-phaseOf(tMs, heartBpm) * 7) : 0;
43  g.set(0, 0, 0x2665, Number.isFinite(heartBpm) ? mix(rgb(90, 20, 35), rgb(255, 90, 120), beat) : rgb(70, 76, 84));
44  const width = g.columns - 3;
45  const breath = Number.isFinite(breathingBpm) ? 0.5 - 0.5 * Math.cos(2 * Math.PI * phaseOf(tMs, breathingBpm)) : 0;
46  const fill = breath * width;
47  for (let x = 0; x < width; x++) {
48    const f = Math.max(0, Math.min(1, fill - x));
49    const on = mix(rgb(40, 120, 200), rgb(140, 220, 255), x / Math.max(1, width));
50    g.set(x + 3, 0, f > 0 ? 0x2588 : 0x2500, f > 0 ? mix(DARK, on, f) : rgb(40, 46, 54));
51  }
52  return g;
53}
54
55/**
56 * Waterfall replay: frames arrive a capture at a time, so the view shows them
57 * at their arrival rate instead of jumping. `lag` is how many received frames
58 * are not shown yet; it drains at `rateHz` and never exceeds what is held.
59 */
60export function drain(lag, rateHz, dtMs) {
61  if (!Number.isFinite(lag) || lag <= 0) return 0;
62  const rate = Number.isFinite(rateHz) && rateHz > 0 ? rateHz : 10;
63  return Math.max(0, lag - (rate * dtMs) / 1000);
64}
65
66/** Frames to show now: all but the `lag` newest. */
67export function shownFrames(frames, lag) {
68  const hold = Math.min(frames.length, Math.max(0, Math.floor(lag || 0)));
69  return hold ? frames.slice(0, frames.length - hold) : frames;
70}
71
72/** Sonar ping phase for the radar fan: a ring leaving the sensor every 1.6 s. */
73export const pingOf = (tMs) => (tMs % 1600) / 1600;
74
hooks/views.mjs 232 lines
1// SPDX-License-Identifier: MIT
2// ruview-live drawing (ADR-377, ADR-378): the overview cards and the two
3// showcase views (CSI waterfall, radar fan + vitals charts), built from the
4// surface's own elements. Pictures are `Raster`s (terminal); other surfaces
5// get an honest note instead. `picturesOf` builds every animated picture, so
6// the first paint and each `$.ui.blit` frame share one size and one source.
7
8import { pingOf, pulse, shimmer, shownFrames } from './anim.mjs';
9import { ageOf, plausibilityOf, RANGES, sparkline } from './model.mjs';
10import { colourbar, lineChart, radarFan, rgb, waterfall } from './raster.mjs';
11
12const TABS = [['overview', '1', 'Overview'], ['waterfall', '2', 'CSI waterfall'], ['radar', '3', 'Radar']];
13/** Keys of the pictures the hooks module repaints with `$.ui.blit`. */
14export const ANIMATED = Object.freeze(['shimmer', 'waterfall', 'fan', 'pulse']);
15
16/** Picture sizes that fit the pane body. */
17export function sizesOf(columns = 80, rows = 30) {
18  const width = Math.max(24, Math.min(128, columns - 6));
19  const height = Math.max(6, Math.min(26, rows - 15)); // the header, tabs, card chrome and footer take ~15 rows
20  return { width, height };
21}
22
23const wrapIndex = (i, n) => ((i % n) + n) % n;
24const radarLayout = (columns, rows) => {
25  const { width, height } = sizesOf(columns, rows);
26  const wide = (columns ?? 80) >= 110;
27  const fanW = wide ? Math.floor(width * 0.55) : width;
28  // A 120° wedge is about width / (4·sin 60°) rows tall.
29  const fanH = Math.max(6, Math.min(height, Math.round(fanW / 3.4) + 1));
30  return { width, wide, fanW, fanH, chartW: wide ? Math.max(20, width - fanW - 6) : width };
31};
32
33/**
34 * Every animated picture for this mode at time `t` (ms): `{ shimmer, waterfall,
35 * fan, pulse }`, each `{ grid, ... }` or null. opts: { mode, columns, rows, t,
36 * nodeIndex, lag (per node key), history }.
37 */
38export function picturesOf(model, opts) {
39  const mode = opts.mode || 'overview';
40  const t = Number.isFinite(opts.t) ? opts.t : 0;
41  const columns = opts.columns ?? 80;
42  const out = { shimmer: { grid: shimmer(Math.max(8, Math.min(160, columns - 4)), t) }, waterfall: null, fan: null, pulse: null };
43  if (!model) return out;
44  if (mode === 'waterfall' && model.spectrum?.length) {
45    const nodes = model.spectrum;
46    const node = nodes[wrapIndex(opts.nodeIndex ?? 0, nodes.length)];
47    const { width, height } = sizesOf(columns, opts.rows);
48    out.waterfall = { ...waterfall(shownFrames(node.frames, opts.lag?.[node.key]), width, height), node, nodes };
49  }
50  if (mode === 'radar' && model.radar) {
51    const history = opts.history || { distance: [] };
52    const { fanW, fanH, chartW } = radarLayout(columns, opts.rows);
53    out.fan = radarFan(fanW, fanH, { distances: history.distance, present: model.radar.present, ping: pingOf(t) });
54    out.pulse = { grid: pulse(Math.min(chartW, 40), t, { heartBpm: model.radar.heartBpm, breathingBpm: model.radar.breathingBpm }) };
55  }
56  return out;
57}
58
59/**
60 * Draw the pane. opts: { mode, nodeIndex, refreshMs, liveRefreshMs, busy, now, t, lag, columns, rows,
61 * history, udpPort, radarConfigured, onRefresh, onClose, onMode(mode), onNextNode }.
62 */
63export function viewOf(ui, model, opts) {
64  const { Box, Text, Button } = ui;
65  const mode = opts.mode || 'overview';
66  const live = mode !== 'overview';
67  const interval = live ? (opts.liveRefreshMs ?? opts.refreshMs) : opts.refreshMs;
68  const t = (children, props = {}) => Text({ wrap: 'truncate-end', ...props, children });
69  const row = (children, props = {}) => Box({ flexDirection: 'row', gap: 1, ...props, children: children.filter(Boolean) });
70  const pics = ui.Raster ? picturesOf(model, opts) : {};
71
72  // Header: freshness badge and age, then the signal rule.
73  const finiteAt = model && Number.isFinite(model.at);
74  const age = finiteAt && Number.isFinite(opts.now) ? opts.now - model.at : NaN;
75  const fresh = Number.isFinite(age) && age <= interval * 2 + 5000;
76  const when = finiteAt ? new Date(model.at).toLocaleTimeString() : '—';
77  const badge = !model ? t('○ STARTING', { dimColor: true, bold: true })
78    : fresh ? t('● LIVE', { color: 'green', bold: true }) : t('○ STALE', { color: 'yellow', bold: true });
79  const header = row([
80    t('RuView', { color: 'cyan', bold: true }),
81    badge,
82    t(model ? `updated ${when}${ageOf(age) ? ` (${ageOf(age)})` : ''} · every ${Math.round(interval / 1000)}s` : `waiting for the first capture… · every ${Math.round(interval / 1000)}s`, { dimColor: true }),
83    opts.busy ? t('⟳ refreshing', { color: 'cyan' }) : null,
84  ]);
85  const rule = pics.shimmer ? ui.Raster(pics.shimmer.grid.toRaster('shimmer')) : null;
86
87  // Hotkeys reach a Pane only while it holds the keyboard; say how to give it.
88  const tabs = opts.onMode ? row([
89    ...TABS.map(([m, key, label]) => Button({
90      key: `tab-${m}`, hotkey: key, label: `${m === mode ? '▸ ' : ''}${label} (${key})`, onPress: () => opts.onMode(m),
91    })),
92    opts.focused === false ? t('click the pane or press ctrl+x tab to use the keys', { dimColor: true, italic: true }) : null,
93  ], { gap: 2 }) : null;
94
95  const alerts = (model?.alerts || []).map((a) => Text({ color: a.level === 'bad' ? 'red' : 'yellow', wrap: 'wrap', children: `! ${a.text}` }));
96
97  let body;
98  if (mode === 'waterfall') body = waterfallView(ui, model, opts, pics, t, row);
99  else if (mode === 'radar') body = radarView(ui, model, opts, pics, t, row);
100  else body = overview(ui, model, opts, t, row);
101
102  const footer = row([
103    Button({ key: 'refresh', label: opts.busy ? 'Refreshing…' : 'Refresh (r)', hotkey: 'r', onPress: opts.onRefresh }),
104    mode === 'waterfall' && opts.onNextNode && (model?.spectrum?.length ?? 0) > 1 ? Button({ key: 'next-node', label: 'Next node (n)', hotkey: 'n', onPress: opts.onNextNode }) : null,
105    Button({ key: 'close', label: 'Close (c)', hotkey: 'c', onPress: opts.onClose }),
106  ], { gap: 2 });
107
108  return Box({ flexDirection: 'column', gap: 1, paddingX: 1, children: [Box({ flexDirection: 'column', children: [header, rule].filter(Boolean) }), tabs, ...alerts, body, footer].filter(Boolean) });
109}
110
111function card(ui, title, subtitle, borderColor, children, t, row) {
112  return ui.Box({
113    flexDirection: 'column', borderStyle: 'round', borderColor, paddingX: 1, flexGrow: 1,
114    children: [row([t(title, { bold: true }), subtitle ? t(subtitle, { dimColor: true }) : null]), ...children.filter(Boolean)],
115  });
116}
117
118function needsTerminal(ui, t) {
119  return ui.Raster ? null : t('This view draws with terminal cells; open it in the terminal. The overview works everywhere.', { dimColor: true });
120}
121
122function overview(ui, model, opts, t, row) {
123  if (!model) return null;
124  const history = opts.history || { heart: [], breathing: [], distance: [], rates: {} };
125  const vital = (label, value, raw, kind, series) => {
126    const warn = kind ? plausibilityOf(kind, raw) : null;
127    return row([
128      t(label.padEnd(10), { dimColor: true }),
129      t(value.padStart(9), { bold: raw != null && !warn, color: warn ? 'yellow' : undefined }),
130      t(sparkline(series), { color: 'cyan' }),
131      warn ? t(`⚠ ${warn}`, { color: 'yellow' }) : null,
132    ]);
133  };
134  let radarCard = null;
135  if (model.radar) {
136    const r = model.radar;
137    const presence = r.present == null ? t('? presence unknown', { dimColor: true })
138      : r.present ? t(`● PRESENCE DETECTED${r.targets ? ` · ${r.targets} target${r.targets === 1 ? '' : 's'}` : ''}`, { color: 'green', bold: true })
139        : t('○ no presence', { dimColor: true });
140    radarCard = card(ui, '60 GHz RADAR', r.name, r.present ? 'green' : 'gray', [
141      presence,
142      vital('distance', r.distance, r.distanceCm, null, history.distance),
143      vital('heart', r.heart, r.heartBpm, 'heart', history.heart),
144      vital('breathing', r.breathing, r.breathingBpm, 'breathing', history.breathing),
145      t('device-reported values, not validated against a reference', { dimColor: true, italic: true }),
146    ], t, row);
147  } else if (!opts.radarConfigured) {
148    radarCard = card(ui, '60 GHz RADAR', 'not configured', 'gray', [
149      t('Set radarHost to an ESPHome radar kit:', { dimColor: true }),
150      t('claude plugin configure ruview-live', { color: 'cyan' }),
151    ], t, row);
152  }
153  const lines = model.nodes.length
154    ? model.nodes.map((n) => row([
155      t(n.label.padEnd(10), { bold: true }),
156      t(n.rate.padStart(9)),
157      t(sparkline(history.rates[n.key]), { color: 'cyan' }),
158      t(`loss ${n.loss}`, n.lossy ? { color: 'yellow' } : { dimColor: true }),
159      t(n.rssi, { dimColor: true }),
160      t(n.shape, { dimColor: true }),
161      n.synthetic ? t('SYNTHETIC', { color: 'yellow', bold: true }) : null,
162    ]))
163    : [
164      t('none streaming to this machine', { dimColor: true }),
165      model.noPackets ? t(`check node target IP/port · firewall UDP ${opts.udpPort ?? 5005}`, { dimColor: true }) : null,
166    ];
167  const nodesCard = card(ui, 'CSI NODES', `UDP ${opts.udpPort ?? 5005} · ${model.decoded}/${model.packets} decoded`, model.nodes.length ? 'cyan' : 'gray', [
168    ...lines,
169    model.nodes.length ? t('press 2 for the live CSI waterfall', { dimColor: true, italic: true }) : null,
170  ], t, row);
171  const cards = [radarCard, nodesCard].filter(Boolean);
172  return ui.Box({ flexDirection: (opts.columns ?? 80) >= 100 ? 'row' : 'column', gap: 1, children: cards });
173}
174
175function waterfallView(ui, model, opts, pics, t, row) {
176  const note = needsTerminal(ui, t);
177  if (note) return note;
178  if (!model) return t('waiting for the first capture…', { dimColor: true });
179  if (!pics.waterfall) {
180    return card(ui, 'CSI WATERFALL', 'no CSI frames', 'gray', [
181      t('No CSI node is streaming to this machine, so there is nothing to draw.', { dimColor: true }),
182      t(`Point a node at this host's UDP ${opts.udpPort ?? 5005} (provision.py --target-ip), then this view fills by itself.`, { dimColor: true }),
183    ], t, row);
184  }
185  const { grid, lo, hi, node: n, nodes } = pics.waterfall;
186  const legend = colourbar(Math.min(32, grid.columns - 20));
187  return card(ui, 'CSI WATERFALL', `${n.label} · ${n.subcarriers} subcarriers → ${n.bins} bins · ${n.rateHz ?? '—'} Hz${nodes.length > 1 ? ` · node ${nodes.indexOf(n) + 1}/${nodes.length}` : ''}`, n.synthetic ? 'yellow' : 'cyan', [
188    n.synthetic ? t('SYNTHETIC — simulator frames, not a live measurement', { color: 'yellow', bold: true }) : t('MEASURED — live CSI amplitude received on this host', { color: 'green' }),
189    ui.Raster(grid.toRaster('waterfall')),
190    row([t('subcarrier →', { dimColor: true }), t('newest at the bottom · replayed at the arrival rate', { dimColor: true })], { justifyContent: 'space-between' }),
191    row([t(lo.toFixed(0), { dimColor: true }), ui.Raster(legend.toRaster('legend')), t(`${hi.toFixed(0)} amplitude (5th–95th pct)`, { dimColor: true })]),
192  ], t, row);
193}
194
195function radarView(ui, model, opts, pics, t, row) {
196  const note = needsTerminal(ui, t);
197  if (note) return note;
198  if (!model) return t('waiting for the first capture…', { dimColor: true });
199  if (!opts.radarConfigured) {
200    return card(ui, 'RADAR', 'not configured', 'gray', [t('Set radarHost: claude plugin configure ruview-live', { color: 'cyan' })], t, row);
201  }
202  const r = model.radar;
203  if (!r || !pics.fan) return card(ui, '60 GHz RADAR', 'unreachable', 'red', [t('No reading this refresh; see the alert above.', { dimColor: true })], t, row);
204  const history = opts.history || { heart: [], breathing: [], distance: [] };
205  const { wide, chartW } = radarLayout(opts.columns, opts.rows);
206  const presence = r.present ? t(`● PRESENCE DETECTED · ${r.distance}${r.targets ? ` · ${r.targets} target${r.targets === 1 ? '' : 's'}` : ''}`, { color: 'green', bold: true })
207    : t('○ no presence', { dimColor: true });
208  const fan = card(ui, '60 GHz RADAR', `${r.name} · range ${pics.fan.maxCm / 100} m`, r.present ? 'green' : 'gray', [
209    presence,
210    ui.Raster(pics.fan.grid.toRaster('fan')),
211    t('range only: this kit reports distance, not bearing (arc = every bearing at that range)', { dimColor: true, italic: true }),
212  ], t, row);
213  const chart = (label, series, kind, unit, colour, minSpan) => {
214    const { grid: g, lo, hi } = lineChart(series, chartW, 3, { band: kind ? RANGES[kind] : null, colour, minSpan });
215    const last = [...series].reverse().find((v) => Number.isFinite(v));
216    const warn = kind ? plausibilityOf(kind, last ?? null) : null;
217    return [
218      row([t(label, { bold: true }), t(last == null ? '—' : `${last.toFixed(1)} ${unit}`, { color: warn ? 'yellow' : undefined }), warn ? t(`⚠ ${warn}`, { color: 'yellow' }) : null, t(`${lo.toFixed(0)}–${hi.toFixed(0)}`, { dimColor: true })]),
219      ui.Raster(g.toRaster(`chart-${label}`)),
220    ];
221  };
222  const charts = card(ui, 'VITALS', 'device-reported, not validated', 'gray', [
223    pics.pulse ? ui.Raster(pics.pulse.grid.toRaster('pulse')) : null,
224    pics.pulse ? t('♥ beats and the gauge breathes at the reported rates (a metronome, not a waveform)', { dimColor: true, italic: true }) : null,
225    ...chart('heart', history.heart, 'heart', 'bpm', rgb(255, 110, 130), 10),
226    ...chart('breathing', history.breathing, 'breathing', 'bpm', rgb(110, 200, 255), 4),
227    ...chart('distance', history.distance, null, 'cm', rgb(120, 255, 170), 20),
228    t('shaded = inside the plausible resting range', { dimColor: true, italic: true }),
229  ], t, row);
230  return ui.Box({ flexDirection: wide ? 'row' : 'column', gap: 1, children: [fan, charts] });
231}
232
hooks/raster.mjs 258 lines
1// SPDX-License-Identifier: MIT
2// Cell graphics for the ruview-live showcase (ADR-378): every picture is a
3// terminal `Raster`, a grid of width-1 characters with 24-bit colours, so it
4// draws in any terminal (Windows Terminal included), not only pixel-capable
5// ones. Pure functions: no engine calls, so Node tests can check them.
6
7/** The terminal's own colour (bit 24 alone), per the Raster contract. */
8export const DEFAULT = 0x01000000;
9
10/** 0xRRGGBB from components. */
11export const rgb = (r, g, b) => (((r & 255) << 16) | ((g & 255) << 8) | (b & 255)) >>> 0;
12
13const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
14
15/** Standard padded base64 of a byte array (the mod environment has no Node Buffer). */
16export function toBase64(bytes) {
17  let out = '';
18  let i = 0;
19  for (; i + 2 < bytes.length; i += 3) {
20    const n = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2];
21    out += B64[(n >> 18) & 63] + B64[(n >> 12) & 63] + B64[(n >> 6) & 63] + B64[n & 63];
22  }
23  const rest = bytes.length - i;
24  if (rest === 1) {
25    const n = bytes[i] << 16;
26    out += `${B64[(n >> 18) & 63]}${B64[(n >> 12) & 63]}==`;
27  } else if (rest === 2) {
28    const n = (bytes[i] << 16) | (bytes[i + 1] << 8);
29    out += `${B64[(n >> 18) & 63]}${B64[(n >> 12) & 63]}${B64[(n >> 6) & 63]}=`;
30  }
31  return out;
32}
33
34/** A grid of cells: code point, foreground, background. */
35export class Grid {
36  constructor(columns, rows) {
37    this.columns = Math.max(1, Math.min(512, Math.floor(columns)));
38    this.rows = Math.max(1, Math.min(256, Math.floor(rows)));
39    this.cells = new Uint32Array(this.columns * this.rows * 3);
40    for (let i = 0; i < this.columns * this.rows; i++) {
41      this.cells[i * 3] = 0x20;
42      this.cells[i * 3 + 1] = DEFAULT;
43      this.cells[i * 3 + 2] = DEFAULT;
44    }
45  }
46  set(x, y, ch, fg = DEFAULT, bg = DEFAULT) {
47    if (x < 0 || y < 0 || x >= this.columns || y >= this.rows) return;
48    const i = (Math.floor(y) * this.columns + Math.floor(x)) * 3;
49    this.cells[i] = typeof ch === 'number' ? ch : ch.codePointAt(0);
50    this.cells[i + 1] = fg >>> 0;
51    this.cells[i + 2] = bg >>> 0;
52  }
53  get(x, y) {
54    const i = (y * this.columns + x) * 3;
55    return [this.cells[i], this.cells[i + 1], this.cells[i + 2]];
56  }
57  /** Write a string left to right (labels over a picture). */
58  text(x, y, str, fg = DEFAULT, bg = DEFAULT) {
59    [...str].forEach((ch, k) => this.set(x + k, y, ch, fg, bg));
60  }
61  /** Props for the surface's `Raster` element. */
62  toRaster(key) {
63    const bytes = new Uint8Array(this.cells.buffer, this.cells.byteOffset, this.cells.byteLength);
64    return { key, columns: this.columns, rows: this.rows, cells: toBase64(bytes) };
65  }
66}
67
68// Perceptual-ish ramp on near-black: indigo → cyan → emerald → amber → white.
69const STOPS = [
70  [0.0, [10, 12, 22]], [0.22, [36, 38, 120]], [0.45, [0, 150, 200]],
71  [0.65, [30, 205, 140]], [0.85, [235, 205, 70]], [1.0, [255, 250, 235]],
72];
73
74/** Colour for t in [0, 1]. */
75export function colormap(t) {
76  const x = Math.max(0, Math.min(1, Number.isFinite(t) ? t : 0));
77  for (let k = 1; k < STOPS.length; k++) {
78    const [t1, c1] = STOPS[k];
79    if (x <= t1) {
80      const [t0, c0] = STOPS[k - 1];
81      const f = (x - t0) / (t1 - t0 || 1);
82      return rgb(c0[0] + (c1[0] - c0[0]) * f, c0[1] + (c1[1] - c0[1]) * f, c0[2] + (c1[2] - c0[2]) * f);
83    }
84  }
85  return rgb(255, 250, 235);
86}
87
88/** The values at fractions p of a sample (robust colour limits). */
89export function percentiles(values, ps) {
90  const v = values.filter((x) => Number.isFinite(x)).sort((a, b) => a - b);
91  if (!v.length) return ps.map(() => 0);
92  return ps.map((p) => v[Math.min(v.length - 1, Math.max(0, Math.round(p * (v.length - 1))))]);
93}
94
95/**
96 * Waterfall of amplitude frames (newest last): subcarrier bins across, time
97 * down, two frames per text row through the upper-half block (fg = upper
98 * frame, bg = lower frame). Colour limits are the 5th–95th percentile of the
99 * shown frames. Returns { grid, lo, hi, shown }.
100 */
101export function waterfall(frames, columns, rows) {
102  const g = new Grid(columns, rows);
103  const shown = (frames || []).slice(-(g.rows * 2));
104  const [lo, hi] = percentiles(shown.flat(), [0.05, 0.95]);
105  const span = hi - lo || 1;
106  const offset = g.rows * 2 - shown.length; // empty frame slots at the top
107  const colourAt = (slot, x) => {
108    const f = shown[slot - offset];
109    if (!f || !f.length) return rgb(10, 12, 22);
110    const bin = Math.min(f.length - 1, Math.floor((x * f.length) / g.columns));
111    return colormap((f[bin] - lo) / span);
112  };
113  for (let y = 0; y < g.rows; y++) {
114    for (let x = 0; x < g.columns; x++) g.set(x, y, 0x2580, colourAt(y * 2, x), colourAt(y * 2 + 1, x));
115  }
116  return { grid: g, lo, hi, shown: shown.length };
117}
118
119/** A one-row colour bar for a legend. */
120export function colourbar(columns) {
121  const g = new Grid(columns, 1);
122  for (let x = 0; x < g.columns; x++) g.set(x, 0, 0x2588, colormap(x / Math.max(1, g.columns - 1)));
123  return g;
124}
125
126const BRAILLE_BITS = [[0x01, 0x08], [0x02, 0x10], [0x04, 0x20], [0x40, 0x80]];
127
128/** A braille canvas: 2×4 dots per cell, one colour per cell (highest priority wins). */
129export class Braille {
130  constructor(columns, rows) {
131    this.columns = Math.max(1, Math.min(512, Math.floor(columns)));
132    this.rows = Math.max(1, Math.min(256, Math.floor(rows)));
133    this.width = this.columns * 2;
134    this.height = this.rows * 4;
135    this.bits = new Uint8Array(this.columns * this.rows);
136    this.colour = new Uint32Array(this.columns * this.rows).fill(DEFAULT);
137    this.prio = new Int8Array(this.columns * this.rows).fill(-1);
138    this.bg = new Uint32Array(this.columns * this.rows).fill(DEFAULT);
139  }
140  plot(px, py, colour = DEFAULT, prio = 0) {
141    const x = Math.round(px);
142    const y = Math.round(py);
143    if (x < 0 || y < 0 || x >= this.width || y >= this.height) return;
144    const c = (y >> 2) * this.columns + (x >> 1);
145    this.bits[c] |= BRAILLE_BITS[y & 3][x & 1];
146    if (prio >= this.prio[c]) { this.prio[c] = prio; this.colour[c] = colour; }
147  }
148  /** A straight segment between two dot positions. */
149  line(x0, y0, x1, y1, colour, prio = 0) {
150    const steps = Math.max(1, Math.ceil(Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0))));
151    for (let s = 0; s <= steps; s++) this.plot(x0 + ((x1 - x0) * s) / steps, y0 + ((y1 - y0) * s) / steps, colour, prio);
152  }
153  /** Shade a cell's background (bands, glows). */
154  shade(cx, cy, colour) {
155    if (cx < 0 || cy < 0 || cx >= this.columns || cy >= this.rows) return;
156    this.bg[cy * this.columns + cx] = colour;
157  }
158  toGrid() {
159    const g = new Grid(this.columns, this.rows);
160    for (let y = 0; y < this.rows; y++) {
161      for (let x = 0; x < this.columns; x++) {
162        const c = y * this.columns + x;
163        g.set(x, y, this.bits[c] ? 0x2800 + this.bits[c] : 0x20, this.colour[c], this.bg[c]);
164      }
165    }
166    return g;
167  }
168}
169
170const GREY = rgb(70, 76, 84);
171const DIM = rgb(110, 118, 128);
172
173/**
174 * Top-down radar fan: the sensor at the bottom centre, range rings, the field
175 * of view as a wedge, and the measured distance as an arc across it (this kit
176 * reports range, not bearing), with older distances as fading arcs.
177 * history: distances in cm, oldest first; present: boolean|null.
178 */
179export function radarFan(columns, rows, { distances = [], present = null, fovDeg = 120, ping = null } = {}) {
180  const c = new Braille(columns, rows);
181  const ox = c.width / 2;
182  const oy = c.height - 1;
183  const known = distances.filter((d) => Number.isFinite(d) && d > 0);
184  const latest = known.length ? known[known.length - 1] : null;
185  const maxCm = Math.max(150, Math.min(600, Math.ceil(((latest ?? 100) * 1.4) / 50) * 50));
186  const half = (fovDeg * Math.PI) / 360;
187  // Dots per cm (dots are about square): the wedge's half-width is r·sin(half).
188  const scale = Math.min(oy, ox / Math.sin(Math.min(half, Math.PI / 2))) / maxCm;
189  const arc = (rCm, colour, prio, density = 1) => {
190    const r = rCm * scale;
191    const steps = Math.max(8, Math.ceil(r * 2 * half * density));
192    for (let s = 0; s <= steps; s++) {
193      const a = -half + (2 * half * s) / steps;
194      c.plot(ox + r * Math.sin(a), oy - r * Math.cos(a), colour, prio);
195    }
196  };
197  // Wedge edges and rings every 50 cm.
198  for (const a of [-half, half]) c.line(ox, oy, ox + maxCm * scale * Math.sin(a), oy - maxCm * scale * Math.cos(a), GREY, 0);
199  for (let r = 50; r <= maxCm; r += 50) arc(r, GREY, 0, 0.35);
200  // Trail: older distances fade from teal to near-background.
201  const trail = known.slice(-8, -1);
202  trail.forEach((d, k) => {
203    const f = (k + 1) / (trail.length + 1);
204    arc(d, rgb(20 + 20 * f, 70 + 90 * f, 80 + 80 * f), 1);
205  });
206  // Ping (animation): a ring leaving the sensor and reaching the measured range.
207  if (ping != null && latest != null) {
208    const f = Math.max(0, Math.min(1, ping));
209    const fade = 0.35 + 0.65 * f;
210    arc(latest * f, rgb(40 * fade, 160 * fade, 200 * fade), 2, 1);
211  }
212  const hit = ping != null && ping > 0.92;
213  if (latest != null) arc(latest, hit ? rgb(220, 255, 235) : present ? rgb(80, 255, 150) : rgb(230, 235, 240), 3, 1.6);
214  // Sensor marker.
215  c.plot(ox, oy, rgb(255, 220, 90), 4);
216  c.plot(ox - 1, oy, rgb(255, 220, 90), 4);
217  const g = c.toGrid();
218  // Ring labels along the right edge of the wedge.
219  for (let r = 100; r <= maxCm; r += 100) {
220    const px = ox + r * scale * Math.sin(half);
221    const py = oy - r * scale * Math.cos(half);
222    g.text(Math.min(g.columns - 3, Math.round(px / 2) + 1), Math.max(0, Math.round(py / 4)), `${r / 100}m`, DIM);
223  }
224  return { grid: g, maxCm };
225}
226
227/**
228 * Braille line chart of a series (oldest first, nulls skipped). The scale fits
229 * the data (at least `minSpan` wide), so a rhythm stays visible; a plausible
230 * band is shaded where it overlaps the scale. Returns { grid, lo, hi }.
231 */
232export function lineChart(series, columns, rows, { band = null, colour = rgb(0, 200, 220), minSpan = 2 } = {}) {
233  const c = new Braille(columns, rows);
234  const pts = (series || []).map((v, i) => [i, v]).filter(([, v]) => Number.isFinite(v)).slice(-c.width);
235  const vals = pts.map(([, v]) => v);
236  let lo = vals.length ? Math.min(...vals) : (band ? band[0] : 0);
237  let hi = vals.length ? Math.max(...vals) : (band ? band[1] : 1);
238  const pad = Math.max(0, minSpan - (hi - lo)) / 2 + (hi - lo) * 0.1;
239  lo -= pad;
240  hi += pad;
241  if (hi - lo < 1e-9) { lo -= 1; hi += 1; }
242  const yOf = (v) => (c.height - 1) - ((v - lo) / (hi - lo)) * (c.height - 1);
243  if (band && band[1] >= lo && band[0] <= hi) {
244    const top = Math.max(0, Math.floor(yOf(Math.min(band[1], hi)) / 4));
245    const bottom = Math.min(c.rows - 1, Math.floor(yOf(Math.max(band[0], lo)) / 4));
246    for (let y = top; y <= bottom; y++) for (let x = 0; x < c.columns; x++) c.shade(x, y, rgb(14, 34, 30));
247  }
248  const n = pts.length;
249  const xOf = (k) => (n <= 1 ? c.width - 1 : (k * (c.width - 1)) / (n - 1));
250  for (let k = 0; k < n; k++) {
251    const y = yOf(pts[k][1]);
252    if (k > 0) c.line(xOf(k - 1), yOf(pts[k - 1][1]), xOf(k), y, colour, 1);
253    else c.plot(xOf(k), y, colour, 1);
254  }
255  if (n) c.plot(xOf(n - 1), yOf(pts[n - 1][1]), rgb(255, 255, 255), 2);
256  return { grid: c.toGrid(), lo, hi };
257}
258