Compare commits

...

11 Commits

Author SHA1 Message Date
ruv 5518d5d7c1 docs: truth-up README + user-guide on Hugging Face model release
The previous wording in both README.md and docs/user-guide.md claimed
no pretrained weights were released yet. That was wrong — the
contrastive CSI encoder + presence-detection head + per-node LoRA
adapters have been published as
ruvnet/wifi-densepose-pretrained on Hugging Face for several weeks
(124 downloads at time of writing), with 100% presence accuracy on
the validation set and 164,183 emb/s on M4 Pro.

This commit replaces the "no shipped weights" framing with the actual
state, and surfaces a real loader gap discovered during a
before/after benchmark of the sensing-server:

* Baseline run (no --model): server produced presence/motion/vitals
  output at ~19 ticks/s, as expected.
* After run (--model models/wifi-densepose-pretrained.rvf): the
  progressive RVF loader errored with
  "invalid magic at offset 0: expected 0x52564653, got 0x7974227B"
  (0x7974227B is the ASCII bytes {"ty… from the JSONL header).
  v2/.../rvf_container.rs only parses the binary RVF segment
  format; the HF artifact is JSONL RVF. When the load fails the
  pipeline degraded to null output (variance=0, presence=None) rather
  than falling back to heuristic mode.

The docs now describe (a) what works today — Python / training-side
consumption of model.safetensors — and (b) what is gated on a JSONL
adapter or a binary-RVF republish — sensing-server --model loading.
The 17-keypoint pose model remains separately pending (#509,
ADR-079 phases P7–P9).
2026-05-19 13:00:39 -04:00
rUv 8247d28d90 docs(README): truth-up capability table — separate shipped/heuristic/pending (#568 follow-up) (#635)
@xiaofuchen's audit in #568 was technically correct: the project page
claimed capabilities (\"Pose estimation\", \"Presence sensing — trained
model + PIR fusion — 100% accuracy\") that aren't what the code actually
does. PR #573 fixed this in the firmware README; this commit applies
the same truth-up to the main repo README so first-time visitors get
an honest picture.

Specific changes:

1. **Hero paragraph (line 35)** — was \"RuView also supports pose
   estimation (17 COCO keypoints …)\" with no caveat. Now: ships the
   training infrastructure; pretrained weights are not yet released
   (links #509 and ADR-079 P7-P9 Pending).

2. **Capability table (lines 50-61)** — was a single 11-row \"What/How/
   Speed\" table that mixed shipped, heuristic, and pipeline-only
   capabilities under the same emoji. Now a status column with a
   three-tier legend:
   -  shipped + tested on hardware (breathing rate, heart rate,
     motion, fall detection, through-wall, edge intelligence,
     multi-frequency mesh)
   - ⚠️ ships and runs, but is a heuristic/threshold (presence
     indicator, multi-person slot count) — accuracy depends on
     calibration and signal conditions
   - 🔬 implementation + tests in repo, weights/data/eval pending
     (17-keypoint pose estimation, camera-supervised fine-tune,
     3D point cloud fusion)

3. **Hardware capability column (lines 91-93)** — was \"Pose, breathing,
   heartbeat, motion, presence\" for the ESP32 options. Replaced with
   the literal list of capabilities that actually work today (presence
   indicator, motion, breathing, heart rate, fall detection, slot-count
   heuristic) with an explicit \"Pose pending weights — see #509\"
   qualifier.

Pointing also to the v0.6.5-esp32 release-aligned firmware README that
already has the firmware-side truth-up (PR #573).

This is documentation only — no code change, no behaviour change. The
project's capabilities haven't changed; the project page now describes
them honestly.
2026-05-19 11:50:59 -04:00
github-actions[bot] 5d6e50d8a0 chore: update vendor submodules (#634)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-19 10:11:01 -04:00
nai 49fb2ca9f4 feat(ui): UI overhaul — consolidates #305-#309 (keyboard shortcuts, perf monitor, toasts, theme, command palette, activity log, data export, mobile PWA, accessibility, i18n) (#620)
* feat(ui): add keyboard shortcuts, perf monitor, toast system, theme toggle, and WCAG accessibility

- Keyboard shortcuts overlay (press ? for help, 1-8 for tabs, T for theme, P for perf)
- Real-time performance monitor with FPS, memory, latency sparklines (draggable)
- Enhanced toast notification system with stacking, auto-dismiss, progress bars
- Dark/light theme toggle with localStorage persistence and system preference detection
- WCAG accessibility: skip-to-content link, ARIA roles/attributes on tabs and panels,
  arrow key navigation in tab bar, focus-visible outlines
- ESLint config for UI directory with security and quality rules

* feat(ui): add command palette, activity log, data export, fullscreen mode, connection status

- Command palette (Ctrl+K / Cmd+K) with fuzzy search across tabs and actions
- Activity log panel (L key) with real-time console interception, filters, resizable
- Data export utility (E key) for sensor data as JSON/CSV with dialog
- Fullscreen mode (F key / F11) for visualization tabs with exit button
- Connection status widget in header showing WebSocket state and reconnect

* feat(ui): add mobile hamburger nav, PWA support, and 40 unit tests

- Mobile hamburger navigation: slide-out drawer replacing tab bar on <768px,
  swipe-to-close, animated hamburger icon, auto-sync with tab manager
- PWA manifest + service worker: installable dashboard, offline shell caching
  (cache-first for static, network-first for API), auto-cleanup of old caches
- 40 unit tests for ToastManager, ThemeToggle, KeyboardShortcuts, PerfMonitor,
  TabManager - browser-based test runner at ui/tests/unit-tests.html
- PWA meta tags: theme-color, apple-mobile-web-app-capable, manifest link
- Icon generator page for creating PWA icons (ui/icons/generate.html)

* feat(ui): add URL routing, onboarding tour, idle detection, notification center

- Hash router: tabs are bookmarkable/shareable via URL (#demo, #sensing, etc.),
  syncs with TabManager, supports browser back/forward navigation
- Onboarding tour: interactive 6-step first-run walkthrough with spotlight
  highlighting, step indicators, skip/back/next controls, localStorage persistence
- Idle detection: pauses health polling and reduces CSS animations after 3 min
  of inactivity, resumes on user interaction, integrates with Page Visibility API
- Notification center: bell icon in header with unread badge, event history panel
  with mark-read/clear, persists across page views via sessionStorage

* feat(ui): add i18n (EN/PL), screenshot tool, settings panel, reduced motion, uptime clock

- i18n: English/Polish translations with auto-detection, language selector
  in header, data-i18n attributes on dashboard elements, localStorage persistence
- Screenshot tool (S key): captures active tab to clipboard or downloads PNG,
  flash effect, canvas rendering with watermark, fallback for tainted canvases
- Quick settings panel (gear icon): reduced motion toggle, high contrast mode,
  compact layout mode, health polling toggle, clear data, reset onboarding
- Uptime clock: current time + session duration in header
- prefers-reduced-motion: system-level and manual toggle, disables all
  animations and transitions for vestibular accessibility
- High contrast mode: WCAG AAA compliant colors for both light and dark themes
- Compact mode: condensed layout for dense information display
2026-05-19 10:04:59 -04:00
NgoQuocViet2001 3439fb1402 fix(provision): recognize swarm/hopping flags as config values (#617) 2026-05-19 10:03:58 -04:00
Rahul c00f45e296 fix(sensing): finish #611 NaN-panic audit — 7 more sites missed by #613 (#624)
#613 fixed adaptive_classifier.rs:94 (the IQR sort) and called the audit
done, but the grep used `partial_cmp(b).unwrap()` as a literal and missed
seven additional production sites that use comparator variants:

  adaptive_classifier.rs:205  AdaptiveModel::classify() argmax over softmax
                              probs — same per-frame hot path as #611.
                              NaN flows through normalise → logits → softmax
                              and still reaches this site even after the
                              IQR fix.
  adaptive_classifier.rs:480  train() argmax (training accuracy loop)
  adaptive_classifier.rs:500  train() per-class argmax
  main.rs:2446, 2449          count_persons_mincut variance source/sink select
  csi.rs:602, 605             count_persons_mincut variance source/sink select
                              (duplicate of main.rs logic in csi.rs)

For the variance-select sites, note that the *outer* `unwrap_or((0, &0))`
only catches an empty iterator — it cannot rescue a panic raised inside
the comparator. A single NaN in `variances[]` still aborts the process.

Same fix as #613: swap `.unwrap()` for `.unwrap_or(std::cmp::Ordering::Equal)`
inside the comparator closure. Pure behavioural change, no API surface.

Re-audit of the remaining `partial_cmp(...).unwrap()` matches in v2/:
they are all inside `#[cfg(test)]` / `#[test]` blocks (spectrogram.rs:269,
depth.rs:234, connectivity.rs:477, vital_signs.rs:737) where inputs are
controlled and panic-on-NaN is acceptable.
2026-05-19 10:02:08 -04:00
Blossom f54f0285bd fix(ci): build multi-arch wifi-densepose image — linux/arm64 was missing (closes #625) (#631)
PR #547 refreshed the sensing-server docker publish and the README badge
advertises 'Docker: multi-arch amd64 + arm64', but
.github/workflows/sensing-server-docker.yml only sets
'platforms: linux/amd64'. The arm64 layer was never actually wired in.

Consequence on Docker Hub today (ruvnet/wifi-densepose:latest, last pushed
2026-05-14 by #547):

  $ curl -s https://hub.docker.com/v2/repositories/ruvnet/wifi-densepose/tags/latest/
  images:
    arch=amd64    os=linux
    arch=unknown  os=unknown   # the 1.5KB attestation layer, not arm64

So Apple Silicon Macs (the platform in #625) hit:

  docker pull ruvnet/wifi-densepose:latest
  Error: no matching manifest for linux/arm64/v8 in the manifest list

This is the same crash class as the closed-unmerged #136 'Docker error on
MacOS'; #625 is a fresh report (Mac M3 Pro, macOS Tahoe 26.4.1) of the same
bug.

Fix is the standard buildx multi-arch recipe:

  1. Add docker/setup-qemu-action@v3 before setup-buildx so the amd64 runner
     can cross-build the arm64 layer (QEMU user-mode emulation).
  2. Change 'platforms: linux/amd64' -> 'platforms: linux/amd64,linux/arm64'.

docker/Dockerfile.rust is already arch-agnostic — no '--target' flag, no
amd64-only Cargo deps, only 'cc = "1.0"' which is cross-aware — so no
Dockerfile changes are needed. Buildx + QEMU does the rest.

Smoke tests are unaffected: they 'docker pull' on ubuntu-latest (amd64), so
the runner auto-selects the amd64 entry from the multi-arch manifest.
Multi-arch manifests are transparent to single-arch consumers.

Scope discipline: this PR only touches sensing-server-docker.yml (the file
issue #625 is about). nvsim-server-docker.yml has the identical
'platforms: linux/amd64' bug but is out of scope here — happy to file
a follow-up if useful.

Note (not part of this fix): the last 5 runs of this workflow have failed
at the 'Log in to Docker Hub' step (DOCKERHUB_TOKEN secret looks rotated/
expired). That's a separate, secret-side issue I can't touch from a PR.
Once that's resolved, the next push to main will produce a proper
amd64+arm64 manifest for the first time.

Co-authored-by: Mack Ding <mack@claws.ltd>
2026-05-19 10:02:00 -04:00
Winter Lau e964eaf14f fix(deps): bump ndarray 0.15→0.17 and ndarray-npy 0.8→0.10 (closes #626) (#627) 2026-05-19 10:01:52 -04:00
rUv 961c01f4bd Merge pull request #633 from ruvnet/integrate/pr-491-adaptive-person-count
Merge #491: feat(sensing-server): adaptive person count — RollingP95 + dedup_factor (integration on schwarztim's behalf)
2026-05-19 08:26:36 -04:00
ruv 79cc2d7b22 Merge #491: feat(sensing-server): adaptive person count — RollingP95 + dedup_factor runtime API
Integrating @schwarztim's PR #491 into main on their behalf — their fork has
fallen too far behind for a clean rebase (the PR's commit graph dropped
silently during `git rebase origin/main`), so applying as a merge from the
fork head to preserve the diff cleanly.

What this lands:
- `RollingP95` adaptive normaliser for the person-count feature scaling.
  Streaming P95 over a 600-sample / ~30 s sliding window. Cold-start
  (<60 samples) falls back to the legacy denominators (variance/300,
  motion_band_power/250, spectral_power/500) so day-0 behaviour is
  preserved on every deployment.
- `RuntimeConfig` struct + `load_runtime_config` / `save_runtime_config`
  persisted to `data/config.json`. Exposes `dedup_factor` via REST so
  multi-node deployments can tune cluster-deduplication without a rebuild,
  including an auto-tune endpoint that derives optimal dedup from a known
  person count (calibration mode).
- `compute_person_score()` now takes &AppStateInner alongside &FeatureInfo
  so the adaptive denominators are reachable. All 3 call sites updated.
- New `AppStateInner` fields: `p95_variance`, `p95_motion_band_power`,
  `p95_spectral_power`, `dedup_factor`, `data_dir`.

Closes #491. Directly addresses:
- #499 (double skeletons, multi-node) — the slot-clustering problem this
  PR's adaptive normaliser was designed to fix
- #519 Bug 1 (ghost person detection on edge-tier 1 & 2 multi-node)
- #496 (person count over-reporting on single-room single-person)

Verified locally:
- cargo check -p wifi-densepose-sensing-server --no-default-features: 1.0s
- cargo test -p wifi-densepose-sensing-server --no-default-features --lib:
  233/233 passed in 25.0s

Co-authored-by: @schwarztim
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-19 08:25:47 -04:00
Deploy Bot ce7983eb43 feat(sensing-server): adaptive person count — RollingP95 + dedup_factor runtime API
RollingP95 adaptive normalizer (ADR-044 §5.2):
- Streaming P95 estimator (600-sample / ~30 s window) replaces fixed-scale
  denominators (variance/300, motion/250, spectral/500) that saturated against
  live ESP32 values, collapsing dynamic range to zero.
- Cold-start (<60 samples) falls back to legacy denominators — day-0 behaviour
  is preserved.
- Three new fields on AppStateInner: p95_variance, p95_motion_band_power,
  p95_spectral_power (all RollingP95::new(600, 60)).
- compute_person_score() refactored to accept &AppStateInner; all three call
  sites (wifi, wifi-fallback, simulated) updated.
- 5 unit tests in rolling_p95_tests module.

dedup_factor runtime API (ADR-044 §5.3):
- New field dedup_factor: f64 (default 3.0) on AppStateInner.
- fuse_or_fallback() gains dedup_factor param; fallback switches from max() to
  sum/dedup_factor (ceiling), matching the fork's sum-based aggregation.
- RuntimeConfig struct + load/save_runtime_config() for data/config.json
  persistence across restarts.
- Three new REST endpoints:
    GET  /api/v1/config/dedup-factor
    POST /api/v1/config/dedup-factor
    POST /api/v1/config/ground-truth (auto-tune from known person count)

Explicitly NOT included:
- lambda=5.0 (upstream keeps its 0.1 default — deployment-specific tuning)
- CC intensity threshold 0.3 and min-cluster-size 4 hardcodes
- max_cc_size filter removal
2026-04-28 15:32:34 -04:00
39 changed files with 6090 additions and 133 deletions
+11 -1
View File
@@ -50,6 +50,12 @@ jobs:
with:
submodules: recursive
# QEMU is required so the amd64 GitHub runner can cross-build the
# linux/arm64 layer below (Dockerfile.rust is arch-agnostic — no `--target`
# flag — so buildx + QEMU is all that's needed; arm64 builds are emulated
# by the runner, not built on a separate arm64 host).
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
@@ -90,7 +96,11 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64
# README badge advertises `amd64 + arm64`, and #547 promised multi-arch
# as part of the docker publish refresh; arm64 was never actually wired
# in, so Apple Silicon Macs hit `no matching manifest for linux/arm64/v8`
# on `docker pull ruvnet/wifi-densepose:latest` (#136, #625). Build both.
platforms: linux/amd64,linux/arm64
# ---------------------------------------------------------------------
# Smoke-test the freshly-pushed image:
+34
View File
@@ -29,6 +29,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
process. Swapped for `unwrap_or(Ordering::Equal)`, matching the pattern the
same file already used at lines 149-150 and 155. Per-frame hot path; this was
a real production crash vector.
- **Completed the #611 NaN-panic audit across the sensing-server crate** (follow-up
to #613). The original audit grepped for the literal `partial_cmp(b).unwrap()`
and missed seven additional production sites that use comparator variants
(`partial_cmp(b.1).unwrap()`, `partial_cmp(&variances[b]).unwrap()`). All share
the same crash class — a single `NaN` in CSI-derived state panics the whole
sensing-server. Fixed:
- `adaptive_classifier.rs:205``AdaptiveModel::classify()` argmax over softmax
probs. **Same per-frame hot path as #611**; NaN flows through normalise →
logits → softmax and still reaches this site even after the #613 IQR fix.
- `adaptive_classifier.rs:480, 500` — training-loop argmax in `train()`
(training/per-class accuracy reporting).
- `main.rs:2446, 2449` and `csi.rs:602, 605` — variance-based source/sink
selection in `count_persons_mincut`. The outer `unwrap_or((0, &0))` only
catches an empty iterator; it cannot rescue a comparator panic.
Remaining `partial_cmp(...).unwrap()` sites in the workspace are all inside
`#[cfg(test)]` / `#[test]` blocks (`spectrogram.rs:269`, `depth.rs:234`,
`connectivity.rs:477`, `vital_signs.rs:737`) where inputs are controlled.
- **`ui/utils/pose-renderer.js` no longer divides by zero** when two render frames land in the same `performance.now()` tick (issue #519 Bug 2). `deltaTime` is now `Math.max(currentTime - lastFrameTime, 1)` before the `1000 / deltaTime` division, capping displayed FPS at 1000 — far above any real render rate, but finite so the EMA `averageFps = averageFps * 0.9 + fps * 0.1` no longer poisons itself to `Infinity` on a single zero-dt tick.
### Removed
@@ -144,6 +162,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **README: corrected the camera-supervised pose-accuracy claim** (audit finding #5; see PR #535) — "92.9% PCK@20" → the ADR-079 target (35%+; proxy baseline 35.3%), noting P7/P8/P9 are pending.
### Added
- **`RollingP95` adaptive feature normalizer** (`v2/crates/wifi-densepose-sensing-server`) —
Streaming P95 estimator (600-sample / ~30 s sliding window) that self-calibrates
feature normalization to whatever distribution the deployment produces. Replaces
fixed-scale denominators (`variance/300`, `motion/250`, `spectral/500`) which saturated
when live ESP32 values exceeded those limits, collapsing dynamic range to zero.
Cold-start (<60 samples) falls back to the legacy denominators so day-0 behaviour
is preserved. Deployment-neutral: no hardcoded values. (ADR-044 §5.2)
- **`dedup_factor` runtime configuration API** (`v2/crates/wifi-densepose-sensing-server`) —
Exposes the multi-node person-count deduplication divisor at runtime via REST:
- `GET /api/v1/config/dedup-factor` — read current value.
- `POST /api/v1/config/dedup-factor` — set value (clamped 1.010.0, persisted).
- `POST /api/v1/config/ground-truth` — auto-tunes `dedup_factor` from a known
person count (`{"count": N}`); derives optimal divisor from current node-sum.
Config is persisted to `data/config.json` and reloaded on restart. (ADR-044 §5.3)
- **`nvsim` crate — deterministic NV-diamond magnetometer pipeline simulator** (ADR-089) —
New standalone leaf crate at `v2/crates/nvsim` modeling a forward-only
magnetic sensing path: scene → source synthesis (BiotSavart, dipole,
+49 -16
View File
@@ -32,7 +32,7 @@ Built on [RuVector](https://github.com/ruvnet/ruvector/) and [Cognitum Seed](htt
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 also supports pose estimation (17 COCO keypoints via the WiFlow architecture), trained entirely without cameras using 10 sensor signals — a technique pioneered from the original *DensePose From WiFi* research at Carnegie Mellon University.
RuView **ships pretrained CSI weights on Hugging Face** at [`ruvnet/wifi-densepose-pretrained`](https://huggingface.co/ruvnet/wifi-densepose-pretrained) — a self-supervised contrastive CSI encoder (128-dim embeddings, 12.2M training steps, 60K frames) + a presence-detection head reporting 100% accuracy on the validation set + per-node LoRA adapters. Models are released as `.safetensors`, 4-bit/8-bit/2-bit quantized `.bin` (4 KB16 KB), and a JSONL RVF container. The Python training and evaluation tooling consumes these today via `safetensors`. **Pending wiring**: the sensing-server's `--model` flag still expects binary RVF, so live-server consumption of the JSONL bundle is gated on a JSONL adapter (or a re-publish in binary RVF) — see [Pretrained model on Hugging Face](#-pretrained-model-on-hugging-face) below for the workaround. **Not yet released**: a 17-keypoint pose-estimation model — training pipeline is implemented (WiFlow + AETHER + MERIDIAN heads) but camera-supervised fine-tune phases P7P9 of [ADR-079](docs/adr/ADR-079-camera-supervised-pose-finetune.md) are `Pending`, tracked in [#509](https://github.com/ruvnet/RuView/issues/509). The live sensing server therefore drives the on-screen output from signal-based DSP heuristics today.
### Built for low-power edge applications
@@ -47,18 +47,26 @@ RuView also supports pose estimation (17 COCO keypoints via the WiFlow architect
[![crates.io](https://img.shields.io/crates/v/wifi-densepose-ruvector.svg)](https://crates.io/crates/wifi-densepose-ruvector)
> | What | How | Speed |
> |------|-----|-------|
> | 🦴 **Pose estimation** | CSI subcarrier amplitude/phase → 17 COCO keypoints | 171K emb/s (M4 Pro) |
> | 🫁 **Breathing detection** | Bandpass 0.1-0.5 Hz → zero-crossing BPM | 6-30 BPM |
> | 💓 **Heart rate** | Bandpass 0.8-2.0 Hz → zero-crossing BPM | 40-120 BPM |
> | 👤 **Presence sensing** | Trained model + PIR fusion — 100% accuracy | 0.012 ms latency |
> | 🧱 **Through-wall** | Fresnel zone geometry + multipath modeling | Up to 5m depth |
> | 🧠 **Edge intelligence** | 8-dim feature vectors + RVF store on Cognitum Seed | $140 total BOM |
> | 🎯 **Camera-free training** | 10 sensor signals, no labels needed | 84s on M4 Pro |
> | 📷 **Camera-supervised training** | MediaPipe + ESP32 CSI → **35%+ PCK@20 target** (ADR-079; eval phases pending) | ~19 min on laptop (pipeline) |
> | 📡 **Multi-frequency mesh** | Channel hopping across 6 bands, neighbor APs as illuminators | 3x sensing bandwidth |
> | 🌐 **3D point cloud** *(optional fusion)* | Camera depth (MiDaS) + WiFi CSI + mmWave radar → unified spatial model | 22 ms pipeline · 19K+ points/frame |
> | What | Status | How | Speed |
> |------|--------|-----|-------|
> | 🫁 **Breathing rate** | ✅ Works today | Bandpass 0.1-0.5 Hz → zero-crossing BPM, circular variance on wrapped phase ([#593](https://github.com/ruvnet/RuView/issues/593)) | 6-30 BPM |
> | 💓 **Heart rate** | ✅ Works today | Bandpass 0.8-2.0 Hz → zero-crossing BPM | 40-120 BPM (needs good SNR) |
> | 👤 **Presence detection** | ✅ Heuristic in server · 🤗 Trained head on HF (loader wiring pending) | Live server uses phase-variance vs adaptive threshold (60 s ambient calibration). A trained `presence-head.json` reporting 100% validation accuracy is published in [`ruvnet/wifi-densepose-pretrained`](https://huggingface.co/ruvnet/wifi-densepose-pretrained) but the sensing-server's `--model` loader only accepts binary RVF today — JSONL adapter pending. | <1 ms heuristic |
> | 🧬 **CSI embeddings** | 🤗 Trained encoder on HF | 128-dim contrastive encoder, **164,183 emb/s** on M4 Pro. Usable today from Python / training via `model.safetensors`; sensing-server consumption pending the same JSONL loader gap as above. | 8 KB q4 fits ESP32 SRAM |
> | 🚶 **Motion / activity** | ✅ Works today | Motion-band power + phase acceleration | Real-time |
> | 🤸 **Fall detection** | ✅ Works today | Phase acceleration > threshold + 3-frame debounce + 5 s cooldown ([#263](https://github.com/ruvnet/RuView/issues/263)) | < 200 ms |
> | 🧮 **Multi-person slot count** | ⚠️ Heuristic, not learned | Subcarrier diversity divided by 2 (capped). **Not** a learned counter — see [firmware README](firmware/esp32-csi-node/README.md#tier-2--full-pipeline-stable) "Tier 2 caveats". Adaptive normalisation in [#491](https://github.com/ruvnet/RuView/pull/491). | Real-time |
> | 🦴 **17-keypoint pose estimation** | 🔬 Pipeline only, no shipped weights | Training infrastructure complete (WiFlow + AETHER + MERIDIAN heads); the published HF model is presence + embeddings, not keypoints. Tracked in [#509](https://github.com/ruvnet/RuView/issues/509). | Pending data collection |
> | 🧱 **Through-wall sensing** | ✅ Works today | Fresnel zone geometry + multipath modeling | Up to ~5m signal-dependent |
> | 🧠 **Edge intelligence** | ✅ Works today | Optional Cognitum Seed for persistent vector store + kNN + witness chain | $140 total BOM |
> | 🎯 **Camera-free pre-training** | ✅ Shipped weights on HF | Self-supervised contrastive encoder, 12.2M training steps on 60K frames. See [`ruvnet/wifi-densepose-pretrained`](https://huggingface.co/ruvnet/wifi-densepose-pretrained). | 84 s/epoch retrain on M4 Pro |
> | 📷 **Camera-supervised fine-tune** | 🔬 Pipeline only | MediaPipe + ESP32 CSI paired training, [ADR-079](docs/adr/ADR-079-camera-supervised-pose-finetune.md). Target **35%+ PCK@20**. P7P9 (data + train + eval) `Pending`. | ~19 min/epoch on laptop |
> | 📡 **Multi-frequency mesh** | ✅ Works today | Channel hopping across 6 bands, TDM slot scheduling (ADR-029) | 3x sensing bandwidth |
> | 🌐 **3D point cloud fusion** | 🔬 Reference impl | Camera depth (MiDaS) + WiFi CSI + mmWave radar → unified spatial model. Requires camera. | 22 ms pipeline · 19K+ points/frame |
>
> Legend: ✅ shipped + tested on hardware (some have learned weights on [HF](https://huggingface.co/ruvnet/wifi-densepose-pretrained), others are deterministic DSP) · ⚠️ ships and runs, but is a heuristic/threshold (not a learned classifier) — accuracy depends on calibration · 🔬 implementation + tests in repo, weights/data/eval pending
>
> 🤗 **Pretrained weights**: download from [`ruvnet/wifi-densepose-pretrained`](https://huggingface.co/ruvnet/wifi-densepose-pretrained) — see [Loading the pretrained model](#loading-the-pretrained-model) below for one-command setup.
```bash
# Option 1: Docker (simulated data, no hardware needed)
@@ -88,10 +96,10 @@ node scripts/mincut-person-counter.js --port 5006 # Correct person counting
>
> | Option | Hardware | Cost | Full CSI | Capabilities |
> |--------|----------|------|----------|-------------|
> | **ESP32 + Cognitum Seed** (recommended) | ESP32-S3 + [Cognitum Seed](https://cognitum.one) | ~$140 | Yes | Pose, breathing, heartbeat, motion, presence + persistent vector store, kNN search, witness chain, MCP proxy |
> | **ESP32 Mesh** | 3-6x ESP32-S3 + WiFi router | ~$54 | Yes | Pose, breathing, heartbeat, motion, presence |
> | **ESP32 + Cognitum Seed** (recommended) | ESP32-S3 + [Cognitum Seed](https://cognitum.one) | ~$140 | Yes | Presence indicator, motion, breathing rate, heart rate, fall detection, slot-count multi-person heuristic + persistent vector store, kNN search, witness chain, MCP proxy. (Pose pending weights — see [#509](https://github.com/ruvnet/RuView/issues/509).) |
> | **ESP32 Mesh** | 3-6x ESP32-S3 + WiFi router | ~$54 | Yes | Same capabilities as above without the persistent-memory features |
> | **Research NIC** | Intel 5300 / Atheros AR9580 | ~$50-100 | Yes | Full CSI with 3x3 MIMO |
> | **Any WiFi** | Windows, macOS, or Linux laptop | $0 | No | RSSI-only: coarse presence and motion |
> | **Any WiFi** | Windows, macOS, or Linux laptop | $0 | No | RSSI-only: coarse presence and motion (see [tutorial #36](https://github.com/ruvnet/RuView/issues/36)) |
>
> No hardware? Verify the signal processing pipeline with the deterministic reference signal: `python archive/v1/data/proof/verify.py`
>
@@ -115,6 +123,31 @@ node scripts/mincut-person-counter.js --port 5006 # Correct person counting
> **Live ESP32 pipeline**: Connect an ESP32-S3 node → run the [sensing server](#sensing-server) → open the [pose fusion demo](https://ruvnet.github.io/RuView/pose-fusion.html) for real-time dual-modal pose estimation (webcam + WiFi CSI). See [ADR-059](docs/adr/ADR-059-live-esp32-csi-pipeline.md).
## 🤗 Pretrained model on Hugging Face
Pretrained CSI weights live at [`ruvnet/wifi-densepose-pretrained`](https://huggingface.co/ruvnet/wifi-densepose-pretrained) — 12.2M training steps on 60K frames / 610K contrastive triplets, **100% presence accuracy** on the validation set, 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.
```bash
# 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:**
| Consumer | Format used | Status |
|----------|-------------|--------|
| Python training / evaluation / embedding extraction | `model.safetensors` | ✅ Works — load with `safetensors.torch.load_file` |
| Inspect / re-export the bundle | `model.rvf.jsonl` (line-by-line JSON) | ✅ Works — plain JSONL |
| Sensing-server `--model <PATH>` flag | binary RVF (`RVFS` magic) | ⚠️ Loader does not yet accept the JSONL container |
**Known gap:** the HF model ships in JSONL RVF format, but `v2/crates/wifi-densepose-sensing-server/src/rvf_container.rs` only parses the binary RVF segment format. Pointing `--model` at `model.rvf.jsonl` currently errors with `invalid magic at offset 0: expected 0x52564653, got 0x7974227B` and the live pipeline degrades to null output rather than falling back to heuristic mode — so for the live sensing-server, run **without** `--model` until a JSONL adapter lands (or the model is re-published as binary RVF). Use the weights from Python / training in the meantime.
**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 not in this release — pipeline is implemented but keypoint weights are still pending. Tracked in [#509](https://github.com/ruvnet/RuView/issues/509); see [ADR-079](docs/adr/ADR-079-camera-supervised-pose-finetune.md) phases P7P9.
## 🔬 How It Works
WiFi routers flood every room with radio waves. When a person moves — or even breathes — those waves scatter differently. WiFi DensePose reads that scattering pattern and reconstructs what happened:
+65 -3
View File
@@ -29,13 +29,14 @@ WiFi DensePose turns commodity WiFi signals into real-time human pose estimation
8. [Vital Sign Detection](#vital-sign-detection)
9. [CLI Reference](#cli-reference)
10. [Observatory Visualization](#observatory-visualization)
11. [Adaptive Classifier](#adaptive-classifier)
11. [Loading the Pretrained Model from Hugging Face](#loading-the-pretrained-model-from-hugging-face)
12. [Adaptive Classifier](#adaptive-classifier)
- [Recording Training Data](#recording-training-data)
- [Training the Model](#training-the-model)
- [Using the Trained Model](#using-the-trained-model)
12. [Training a Model](#training-a-model)
13. [Training a Model](#training-a-model)
- [CRV Signal-Line Protocol](#crv-signal-line-protocol)
13. [RVF Model Containers](#rvf-model-containers)
14. [RVF Model Containers](#rvf-model-containers)
14. [Hardware Setup](#hardware-setup)
- [ESP32-S3 Mesh](#esp32-s3-mesh)
- [Intel 5300 / Atheros NIC](#intel-5300--atheros-nic)
@@ -793,6 +794,67 @@ The Observatory is an immersive Three.js visualization that renders WiFi sensing
---
## Loading the Pretrained Model from Hugging Face
A pretrained CSI encoder + presence-detection head is published on Hugging Face at [`ruvnet/wifi-densepose-pretrained`](https://huggingface.co/ruvnet/wifi-densepose-pretrained). It was trained on 60,630 frames / 610,615 contrastive triplets (12.2M steps, final loss 0.065) and reports 100% presence accuracy and ~164k embeddings/sec on an Apple M4 Pro.
What it ships (and what it does not):
| Capability | Status |
|------------|--------|
| Presence detection (occupied / empty) | ✅ Trained head — 100% accuracy on validation |
| 128-dim CSI embeddings (re-ID, similarity, downstream training) | ✅ Trained encoder |
| Single-person breathing / heart-rate | ⚠️ Server still uses heuristic DSP — model does not replace this yet |
| 17-keypoint full-body pose | 🔬 No keypoint weights shipped yet — pose pipeline runs but without a learned head |
### Download
```bash
pip install huggingface_hub
huggingface-cli download ruvnet/wifi-densepose-pretrained \
--local-dir models/wifi-densepose-pretrained
```
The download yields a small set of files (the `.rvf.jsonl` is the canonical container the sensing server reads):
```
models/wifi-densepose-pretrained/
model.rvf.jsonl # RVF container (encoder + presence head + lora)
model.safetensors # 48 KB — same encoder weights, safetensors format
model-q4.bin # 8 KB — recommended quantization for edge
presence-head.json # presence classifier head
config.json # sona-lora rank=8 alpha=16, target encoder + task_heads
```
### Using the weights
The HF artifact is in **JSONL RVF** format (one JSON object per line: `metadata`, `encoder`, `lora`). What you can do with it today:
| Consumer | Format it reads | Status |
|----------|-----------------|--------|
| Python / PyTorch training pipeline | `model.safetensors` | ✅ Works — load with `safetensors.torch.load_file` |
| RVF JSONL inspection / re-export | `model.rvf.jsonl` | ✅ Works — plain JSONL, parse line-by-line |
| Sensing-server `--model <PATH>` flag | binary RVF (`RVFS` magic) | ⚠️ Does **not** accept the JSONL file yet — see gap below |
**Known gap (tracked):** `v2/crates/wifi-densepose-sensing-server/src/rvf_container.rs` only parses the binary RVF segment format (magic `0x52564653`). Pointing `--model` at `model.rvf.jsonl` causes the progressive loader to error with `invalid magic at offset 0: expected 0x52564653, got 0x7974227B` (`0x7974227B` is the ASCII bytes `{"ty…` from the JSONL header), and the live pipeline degrades to null output rather than falling back to heuristic mode. Until a JSONL adapter lands (or the model is re-published as binary RVF), run the sensing-server **without** `--model` and consume the HF weights from Python or the training pipeline.
```bash
# Works today — Python side (training, evaluation, embedding extraction):
python -c "
from safetensors.torch import load_file
state = load_file('models/wifi-densepose-pretrained/model.safetensors')
print({k: tuple(v.shape) for k, v in state.items()})
"
# Sensing server — run heuristic for now:
cargo run -p wifi-densepose-sensing-server --release -- \
--source esp32 --udp-port 5005 --http-port 3000
```
See [RVF Model Containers](#rvf-model-containers) for the binary format the loader expects, and [Training a Model](#training-a-model) for using the encoder as a starting point for environment-specific fine-tuning.
---
## Adaptive Classifier
The adaptive classifier (ADR-048) learns your environment's specific WiFi signal patterns from labeled recordings. It replaces static threshold-based classification with a trained logistic regression model that uses 15 features (7 server-computed + 8 subcarrier-derived statistics).
+34 -11
View File
@@ -37,6 +37,39 @@ NVS_PARTITION_OFFSET = 0x9000
NVS_PARTITION_SIZE = 0x6000 # 24 KiB
CONFIG_VALUE_CHECKS = [
("ssid", bool),
("password", lambda value: value is not None),
("target_ip", bool),
("target_port", lambda value: value is not None),
("node_id", lambda value: value is not None),
("tdm_slot", lambda value: value is not None),
("tdm_total", lambda value: value is not None),
("edge_tier", lambda value: value is not None),
("pres_thresh", lambda value: value is not None),
("fall_thresh", lambda value: value is not None),
("vital_win", lambda value: value is not None),
("vital_int", lambda value: value is not None),
("subk_count", lambda value: value is not None),
("channel", lambda value: value is not None),
("filter_mac", lambda value: value is not None),
("hop_channels", lambda value: value is not None),
("seed_url", lambda value: value is not None),
("seed_token", lambda value: value is not None),
("zone", lambda value: value is not None),
("swarm_hb", lambda value: value is not None),
("swarm_ingest", lambda value: value is not None),
]
def has_config_value(args):
"""Return True when args include at least one NVS-writing config value."""
return any(
check(getattr(args, name, None))
for name, check in CONFIG_VALUE_CHECKS
)
def build_nvs_csv(args):
"""Build an NVS CSV string for the csi_cfg namespace."""
buf = io.StringIO()
@@ -223,17 +256,7 @@ def main():
args = parser.parse_args()
has_value = any([
args.ssid, args.password is not None, args.target_ip,
args.target_port, args.node_id is not None,
args.tdm_slot is not None, args.tdm_total is not None,
args.edge_tier is not None, args.pres_thresh is not None,
args.fall_thresh is not None, args.vital_win is not None,
args.vital_int is not None, args.subk_count is not None,
args.channel is not None, args.filter_mac is not None,
args.seed_url is not None, args.zone is not None,
])
if not has_value:
if not has_config_value(args):
parser.error("At least one config value must be specified")
# Bug 2 (#391): Prevent silent wipe of WiFi credentials on partial invocations.
@@ -0,0 +1,63 @@
import csv
import importlib.util
import io
import types
import unittest
from pathlib import Path
PROVISION_PATH = Path(__file__).resolve().parents[1] / "provision.py"
SPEC = importlib.util.spec_from_file_location("provision", PROVISION_PATH)
provision = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(provision)
def make_args(**overrides):
values = {name: None for name, _ in provision.CONFIG_VALUE_CHECKS}
values["hop_dwell"] = 200
values.update(overrides)
return types.SimpleNamespace(**values)
def csv_rows(content):
return list(csv.DictReader(io.StringIO(content)))
class ProvisionConfigValueTests(unittest.TestCase):
def test_swarm_and_hopping_flags_count_as_config_values(self):
cases = [
{"hop_channels": "1,6,11"},
{"seed_token": "token-123"},
{"swarm_hb": 15},
{"swarm_ingest": 3},
]
for values in cases:
with self.subTest(values=values):
self.assertTrue(provision.has_config_value(make_args(**values)))
def test_operational_flags_alone_do_not_count_as_config_values(self):
self.assertFalse(provision.has_config_value(make_args()))
def test_swarm_and_hopping_values_are_written_to_csv(self):
args = make_args(
hop_channels="1,6,11",
hop_dwell=250,
seed_token="token-123",
swarm_hb=15,
swarm_ingest=3,
)
rows = csv_rows(provision.build_nvs_csv(args))
values_by_key = {row["key"]: row["value"] for row in rows}
self.assertEqual(values_by_key["hop_count"], "3")
self.assertEqual(values_by_key["chan_list"], "01060b")
self.assertEqual(values_by_key["dwell_ms"], "250")
self.assertEqual(values_by_key["seed_token"], "token-123")
self.assertEqual(values_by_key["swarm_hb"], "15")
self.assertEqual(values_by_key["swarm_ingest"], "3")
if __name__ == "__main__":
unittest.main()
+33
View File
@@ -0,0 +1,33 @@
{
"env": {
"browser": true,
"es2022": true
},
"parserOptions": {
"ecmaVersion": 2022,
"sourceType": "module"
},
"rules": {
"no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }],
"no-undef": "error",
"no-var": "error",
"prefer-const": "warn",
"eqeqeq": ["error", "always"],
"no-eval": "error",
"no-implied-eval": "error",
"no-new-func": "error",
"no-script-url": "error",
"no-alert": "warn",
"no-console": ["warn", { "allow": ["warn", "error", "info"] }],
"curly": ["warn", "multi-line"],
"no-throw-literal": "error",
"prefer-template": "warn",
"no-duplicate-imports": "error"
},
"ignorePatterns": [
"node_modules/",
"mobile/",
"vendor/",
"*.min.js"
]
}
+163 -38
View File
@@ -10,6 +10,24 @@ import { wsService } from './services/websocket.service.js';
import { healthService } from './services/health.service.js';
import { sensingService } from './services/sensing.service.js';
import { backendDetector } from './utils/backend-detector.js';
import { KeyboardShortcuts } from './utils/keyboard-shortcuts.js';
import { PerfMonitor } from './utils/perf-monitor.js';
import { toastManager } from './utils/toast.js';
import { ThemeToggle } from './utils/theme-toggle.js';
import { CommandPalette } from './utils/command-palette.js';
import { ActivityLog } from './utils/activity-log.js';
import { DataExport } from './utils/data-export.js';
import { FullscreenManager } from './utils/fullscreen.js';
import { ConnectionStatus } from './utils/connection-status.js';
import { MobileNav } from './utils/mobile-nav.js';
import { Router } from './utils/router.js';
import { Onboarding } from './utils/onboarding.js';
import { IdleManager } from './utils/idle-manager.js';
import { NotificationCenter } from './utils/notification-center.js';
import { i18n } from './utils/i18n.js';
import { ScreenshotTool } from './utils/screenshot.js';
import { UptimeClock } from './utils/uptime-clock.js';
import { QuickSettings } from './utils/quick-settings.js';
class WiFiDensePoseApp {
constructor() {
@@ -30,10 +48,13 @@ class WiFiDensePoseApp {
// Initialize UI components
this.initializeComponents();
// Initialize enhancements
this.initializeEnhancements();
// Set up global event listeners
this.setupEventListeners();
this.isInitialized = true;
console.log('WiFi DensePose UI initialized successfully');
@@ -167,6 +188,118 @@ class WiFiDensePoseApp {
}
}
// Initialize enhancement modules
initializeEnhancements() {
// Toast notifications
toastManager.init();
// Connection status widget in header
this.connectionStatus = new ConnectionStatus();
this.connectionStatus.init();
// Theme toggle
this.themeToggle = new ThemeToggle();
this.themeToggle.init();
// Performance monitor
this.perfMonitor = new PerfMonitor();
this.perfMonitor.init();
// Activity log
this.activityLog = new ActivityLog();
this.activityLog.init();
// Data export
this.dataExport = new DataExport();
this.dataExport.init();
// Fullscreen manager
this.fullscreenManager = new FullscreenManager();
this.fullscreenManager.init();
// Command palette (Ctrl+K)
this.commandPalette = new CommandPalette(this);
this.commandPalette.init();
// Mobile navigation (hamburger menu for small screens)
this.mobileNav = new MobileNav();
this.mobileNav.init();
// Notification center (bell icon in header)
this.notificationCenter = new NotificationCenter();
this.notificationCenter.init();
// Screenshot tool
this.screenshotTool = new ScreenshotTool();
this.screenshotTool.init();
// Uptime clock
this.uptimeClock = new UptimeClock();
this.uptimeClock.init();
// Quick settings panel
this.quickSettings = new QuickSettings(this);
this.quickSettings.init();
// Internationalization (EN/PL)
i18n.init();
// Keyboard shortcuts (pass app reference for tab switching)
this.keyboardShortcuts = new KeyboardShortcuts(this);
this.keyboardShortcuts.register('l', 'Toggle activity log', () => {
document.dispatchEvent(new CustomEvent('toggle-activity-log'));
});
this.keyboardShortcuts.register('e', 'Export sensor data', () => {
document.dispatchEvent(new CustomEvent('export-data'));
});
this.keyboardShortcuts.register('f', 'Toggle fullscreen', () => {
document.dispatchEvent(new CustomEvent('toggle-fullscreen'));
});
this.keyboardShortcuts.register('s', 'Take screenshot', () => {
document.dispatchEvent(new CustomEvent('take-screenshot'));
});
this.keyboardShortcuts.init();
// Listen for show-shortcuts from command palette
document.addEventListener('show-shortcuts', () => {
this.keyboardShortcuts.showHelp();
});
// Register PWA service worker
this.registerServiceWorker();
// URL hash router (bookmarkable tabs)
this.router = new Router(this);
this.router.init();
// Idle detection (pause updates when inactive)
this.idleManager = new IdleManager();
this.idleManager.onIdle(() => {
healthService.stopHealthMonitoring();
console.info('[App] Paused health monitoring (idle)');
});
this.idleManager.onActive(() => {
healthService.startHealthMonitoring();
console.info('[App] Resumed health monitoring (active)');
});
this.idleManager.init();
// Onboarding tour (first-run walkthrough)
this.onboarding = new Onboarding(this);
this.onboarding.init();
}
// Register service worker for offline capability
registerServiceWorker() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('./sw.js').then(reg => {
console.info('Service worker registered:', reg.scope);
}).catch(err => {
console.warn('Service worker registration failed:', err);
});
}
}
// Handle tab changes
handleTabChange(newTab, oldTab) {
console.log(`Tab changed from ${oldTab} to ${newTab}`);
@@ -272,45 +405,17 @@ class WiFiDensePoseApp {
});
}
// Show backend status notification
// Show backend status notification (uses enhanced toast system)
showBackendStatus(message, type) {
// Create status notification if it doesn't exist
let statusToast = document.getElementById('backendStatusToast');
if (!statusToast) {
statusToast = document.createElement('div');
statusToast.id = 'backendStatusToast';
statusToast.className = 'backend-status-toast';
document.body.appendChild(statusToast);
}
statusToast.textContent = message;
statusToast.className = `backend-status-toast ${type}`;
statusToast.classList.add('show');
// Auto-hide success messages, keep warnings and errors longer
const timeout = type === 'success' ? 3000 : 8000;
setTimeout(() => {
statusToast.classList.remove('show');
}, timeout);
const toastType = type === 'success' ? 'success' : 'warning';
toastManager[toastType](message, {
duration: type === 'success' ? 3000 : 8000
});
}
// Show global error message
// Show global error message (uses enhanced toast system)
showGlobalError(message) {
// Create error toast if it doesn't exist
let errorToast = document.getElementById('globalErrorToast');
if (!errorToast) {
errorToast = document.createElement('div');
errorToast.id = 'globalErrorToast';
errorToast.className = 'error-toast';
document.body.appendChild(errorToast);
}
errorToast.textContent = message;
errorToast.classList.add('show');
setTimeout(() => {
errorToast.classList.remove('show');
}, 5000);
toastManager.error(message, { duration: 6000 });
}
// Clean up resources
@@ -326,9 +431,29 @@ class WiFiDensePoseApp {
// Disconnect all WebSocket connections
wsService.disconnectAll();
// Stop health monitoring
healthService.dispose();
// Dispose enhancements
if (this.keyboardShortcuts) this.keyboardShortcuts.dispose();
if (this.perfMonitor) this.perfMonitor.dispose();
if (this.themeToggle) this.themeToggle.dispose();
if (this.commandPalette) this.commandPalette.dispose();
if (this.activityLog) this.activityLog.dispose();
if (this.dataExport) this.dataExport.dispose();
if (this.fullscreenManager) this.fullscreenManager.dispose();
if (this.connectionStatus) this.connectionStatus.dispose();
if (this.mobileNav) this.mobileNav.dispose();
if (this.router) this.router.dispose();
if (this.onboarding) this.onboarding.dispose();
if (this.idleManager) this.idleManager.dispose();
if (this.notificationCenter) this.notificationCenter.dispose();
if (this.screenshotTool) this.screenshotTool.dispose();
if (this.uptimeClock) this.uptimeClock.dispose();
if (this.quickSettings) this.quickSettings.dispose();
i18n.dispose();
toastManager.dispose();
}
// Public API
+39 -4
View File
@@ -19,6 +19,33 @@ export class TabManager {
tab.addEventListener('click', () => this.switchTab(tab));
});
// Arrow key navigation within tab bar (WCAG)
const nav = this.container.querySelector('.nav-tabs');
if (nav) {
nav.addEventListener('keydown', (e) => {
const buttonTabs = this.tabs.filter(t => t.tagName === 'BUTTON' && !t.disabled);
const currentIndex = buttonTabs.indexOf(document.activeElement);
if (currentIndex === -1) return;
let nextIndex = -1;
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
nextIndex = (currentIndex + 1) % buttonTabs.length;
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
nextIndex = (currentIndex - 1 + buttonTabs.length) % buttonTabs.length;
} else if (e.key === 'Home') {
nextIndex = 0;
} else if (e.key === 'End') {
nextIndex = buttonTabs.length - 1;
}
if (nextIndex >= 0) {
e.preventDefault();
buttonTabs[nextIndex].focus();
this.switchTab(buttonTabs[nextIndex]);
}
});
}
// Activate first tab if none active
const activeTab = this.tabs.find(tab => tab.classList.contains('active'));
if (activeTab) {
@@ -36,14 +63,22 @@ export class TabManager {
return;
}
// Update tab states
// Update tab states and ARIA attributes
this.tabs.forEach(tab => {
tab.classList.toggle('active', tab === tabElement);
const isActive = tab === tabElement;
tab.classList.toggle('active', isActive);
if (tab.hasAttribute('aria-selected')) {
tab.setAttribute('aria-selected', String(isActive));
}
});
// Update content visibility
// Update content visibility and ARIA
this.tabContents.forEach(content => {
content.classList.toggle('active', content.id === tabId);
const isActive = content.id === tabId;
content.classList.toggle('active', isActive);
if (content.hasAttribute('role')) {
content.setAttribute('aria-hidden', String(!isActive));
}
});
// Update active tab
+66
View File
@@ -0,0 +1,66 @@
<!DOCTYPE html>
<html>
<head><title>RuView Icon Generator</title></head>
<body>
<p>Open this file in a browser and right-click to save the canvas images as icon-192.png and icon-512.png</p>
<canvas id="c192" width="192" height="192"></canvas>
<canvas id="c512" width="512" height="512"></canvas>
<script>
function drawIcon(canvas) {
const ctx = canvas.getContext('2d');
const s = canvas.width;
// Background
ctx.fillStyle = '#1f2121';
ctx.beginPath();
ctx.roundRect(0, 0, s, s, s * 0.15);
ctx.fill();
// WiFi arcs
ctx.strokeStyle = '#32b8c6';
ctx.lineWidth = s * 0.035;
ctx.lineCap = 'round';
const cx = s * 0.5, cy = s * 0.55;
[0.35, 0.25, 0.15].forEach(r => {
ctx.beginPath();
ctx.arc(cx, cy, s * r, -Math.PI * 0.75, -Math.PI * 0.25);
ctx.stroke();
});
// Center dot
ctx.fillStyle = '#32b8c6';
ctx.beginPath();
ctx.arc(cx, cy, s * 0.03, 0, Math.PI * 2);
ctx.fill();
// Person silhouette
ctx.strokeStyle = '#21808d';
ctx.lineWidth = s * 0.025;
// Head
ctx.beginPath();
ctx.arc(cx, cy - s * 0.15, s * 0.045, 0, Math.PI * 2);
ctx.stroke();
// Body
ctx.beginPath();
ctx.moveTo(cx, cy - s * 0.1);
ctx.lineTo(cx, cy + s * 0.05);
ctx.stroke();
// Arms
ctx.beginPath();
ctx.moveTo(cx - s * 0.08, cy - s * 0.04);
ctx.lineTo(cx + s * 0.08, cy - s * 0.04);
ctx.stroke();
// Legs
ctx.beginPath();
ctx.moveTo(cx, cy + s * 0.05);
ctx.lineTo(cx - s * 0.06, cy + s * 0.15);
ctx.moveTo(cx, cy + s * 0.05);
ctx.lineTo(cx + s * 0.06, cy + s * 0.15);
ctx.stroke();
// Text
ctx.fillStyle = '#f5f5f5';
ctx.font = `bold ${s * 0.08}px sans-serif`;
ctx.textAlign = 'center';
ctx.fillText('RuView', cx, s * 0.88);
}
drawIcon(document.getElementById('c192'));
drawIcon(document.getElementById('c512'));
</script>
</body>
</html>
+38 -30
View File
@@ -3,40 +3,48 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#21808d">
<meta name="description" content="WiFi-based human pose estimation, vital sign detection, and presence sensing through walls">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>WiFi DensePose: Human Tracking Through Walls</title>
<link rel="stylesheet" href="style.css">
<link rel="manifest" href="manifest.json">
</head>
<body>
<!-- Skip to main content link for keyboard/screen reader users -->
<a href="#dashboard" class="skip-to-content">Skip to main content</a>
<div class="container">
<!-- Header -->
<header class="header">
<header class="header" role="banner">
<h1>WiFi DensePose</h1>
<p class="subtitle">Human Tracking Through Walls Using WiFi Signals</p>
<p class="subtitle" data-i18n="dashboard.subtitle">Human Tracking Through Walls Using WiFi Signals</p>
<div class="header-info">
<span class="api-version"></span>
<span class="api-environment"></span>
<span class="overall-health"></span>
<span class="api-version" aria-label="API version"></span>
<span class="api-environment" aria-label="Environment"></span>
<span class="overall-health" role="status" aria-live="polite" aria-label="System health"></span>
</div>
</header>
<!-- Navigation -->
<nav class="nav-tabs">
<button class="nav-tab active" data-tab="dashboard">Dashboard</button>
<button class="nav-tab" data-tab="hardware">Hardware</button>
<button class="nav-tab" data-tab="demo">Live Demo</button>
<button class="nav-tab" data-tab="architecture">Architecture</button>
<button class="nav-tab" data-tab="performance">Performance</button>
<button class="nav-tab" data-tab="applications">Applications</button>
<button class="nav-tab" data-tab="sensing">Sensing</button>
<button class="nav-tab" data-tab="training">Training</button>
<nav class="nav-tabs" role="tablist" aria-label="Main navigation">
<button class="nav-tab active" data-tab="dashboard" role="tab" aria-selected="true" aria-controls="dashboard">Dashboard</button>
<button class="nav-tab" data-tab="hardware" role="tab" aria-selected="false" aria-controls="hardware">Hardware</button>
<button class="nav-tab" data-tab="demo" role="tab" aria-selected="false" aria-controls="demo">Live Demo</button>
<button class="nav-tab" data-tab="architecture" role="tab" aria-selected="false" aria-controls="architecture">Architecture</button>
<button class="nav-tab" data-tab="performance" role="tab" aria-selected="false" aria-controls="performance">Performance</button>
<button class="nav-tab" data-tab="applications" role="tab" aria-selected="false" aria-controls="applications">Applications</button>
<button class="nav-tab" data-tab="sensing" role="tab" aria-selected="false" aria-controls="sensing">Sensing</button>
<button class="nav-tab" data-tab="training" role="tab" aria-selected="false" aria-controls="training">Training</button>
<a href="pose-fusion.html" class="nav-tab" style="text-decoration:none">Pose Fusion</a>
<a href="observatory.html" class="nav-tab" style="text-decoration:none">Observatory</a>
</nav>
<!-- Dashboard Tab -->
<section id="dashboard" class="tab-content active">
<section id="dashboard" class="tab-content active" role="tabpanel" aria-labelledby="dashboard">
<div class="hero-section">
<h2>Revolutionary WiFi-Based Human Pose Detection</h2>
<h2 data-i18n="dashboard.title">Revolutionary WiFi-Based Human Pose Detection</h2>
<p class="hero-description">
AI can track your full-body movement through walls using just WiFi signals.
Researchers at Carnegie Mellon have trained a neural network to turn basic WiFi
@@ -48,7 +56,7 @@
<!-- Live Status Panel -->
<div class="live-status-panel">
<h3>System Status</h3>
<h3 data-i18n="dashboard.status">System Status</h3>
<div class="status-grid">
<div class="component-status" data-component="api">
<span class="component-name">API Server</span>
@@ -80,24 +88,24 @@
<!-- System Metrics -->
<div class="system-metrics-panel">
<h3>System Metrics</h3>
<h3 data-i18n="dashboard.metrics">System Metrics</h3>
<div class="metrics-grid">
<div class="metric-item">
<span class="metric-label">CPU Usage</span>
<span class="metric-label" data-i18n="metrics.cpu">CPU Usage</span>
<div class="progress-bar" data-type="cpu">
<div class="progress-fill normal" style="width: 0%"></div>
</div>
<span class="cpu-usage">0%</span>
</div>
<div class="metric-item">
<span class="metric-label">Memory Usage</span>
<span class="metric-label" data-i18n="metrics.memory">Memory Usage</span>
<div class="progress-bar" data-type="memory">
<div class="progress-fill normal" style="width: 0%"></div>
</div>
<span class="memory-usage">0%</span>
</div>
<div class="metric-item">
<span class="metric-label">Disk Usage</span>
<span class="metric-label" data-i18n="metrics.disk">Disk Usage</span>
<div class="progress-bar" data-type="disk">
<div class="progress-fill normal" style="width: 0%"></div>
</div>
@@ -108,13 +116,13 @@
<!-- Features Status -->
<div class="features-panel">
<h3>Features</h3>
<h3 data-i18n="dashboard.features">Features</h3>
<div class="features-status"></div>
</div>
<!-- Live Statistics -->
<div class="live-stats-panel">
<h3>Live Statistics</h3>
<h3 data-i18n="dashboard.liveStats">Live Statistics</h3>
<div class="stats-grid">
<div class="stat-item">
<span class="stat-label">Active Persons</span>
@@ -181,7 +189,7 @@
</section>
<!-- Hardware Tab -->
<section id="hardware" class="tab-content">
<section id="hardware" class="tab-content" role="tabpanel" aria-labelledby="hardware" aria-hidden="true">
<h2>Hardware Configuration</h2>
<div class="hardware-grid">
@@ -259,7 +267,7 @@
</section>
<!-- Demo Tab -->
<section id="demo" class="tab-content">
<section id="demo" class="tab-content" role="tabpanel" aria-labelledby="demo" aria-hidden="true">
<h2>Live Demonstration</h2>
<div class="demo-controls">
@@ -312,7 +320,7 @@
</section>
<!-- Architecture Tab -->
<section id="architecture" class="tab-content">
<section id="architecture" class="tab-content" role="tabpanel" aria-labelledby="architecture" aria-hidden="true">
<h2>System Architecture</h2>
<div class="architecture-flow">
@@ -350,7 +358,7 @@
</section>
<!-- Performance Tab -->
<section id="performance" class="tab-content">
<section id="performance" class="tab-content" role="tabpanel" aria-labelledby="performance" aria-hidden="true">
<h2>Performance Analysis</h2>
<div class="performance-chart">
@@ -422,7 +430,7 @@
</section>
<!-- Applications Tab -->
<section id="applications" class="tab-content">
<section id="applications" class="tab-content" role="tabpanel" aria-labelledby="applications" aria-hidden="true">
<h2>Real-World Applications</h2>
<div class="applications-grid">
@@ -489,10 +497,10 @@
</section>
<!-- Sensing Tab -->
<section id="sensing" class="tab-content"></section>
<section id="sensing" class="tab-content" role="tabpanel" aria-labelledby="sensing" aria-hidden="true"></section>
<!-- Training Tab -->
<section id="training" class="tab-content">
<section id="training" class="tab-content" role="tabpanel" aria-labelledby="training" aria-hidden="true">
<div class="tab-header">
<h2>Model Training</h2>
<p>Record CSI data, train pose estimation models, and manage .rvf files</p>
+25
View File
@@ -0,0 +1,25 @@
{
"name": "RuView - WiFi DensePose",
"short_name": "RuView",
"description": "WiFi-based human pose estimation, vital sign detection, and presence sensing through walls",
"start_url": "/",
"display": "standalone",
"background_color": "#1f2121",
"theme_color": "#21808d",
"orientation": "any",
"categories": ["utilities", "medical"],
"icons": [
{
"src": "icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
+1741
View File
File diff suppressed because it is too large Load Diff
+124
View File
@@ -0,0 +1,124 @@
// RuView Service Worker - Offline caching for the dashboard shell
// Strategy: Network-first for API calls, Cache-first for static assets
const CACHE_NAME = 'ruview-v1';
const SHELL_ASSETS = [
'/',
'/index.html',
'/style.css',
'/app.js',
'/config/api.config.js',
'/components/TabManager.js',
'/components/DashboardTab.js',
'/components/HardwareTab.js',
'/components/LiveDemoTab.js',
'/components/SensingTab.js',
'/components/PoseDetectionCanvas.js',
'/services/api.service.js',
'/services/websocket.service.js',
'/services/health.service.js',
'/services/sensing.service.js',
'/services/pose.service.js',
'/services/stream.service.js',
'/utils/backend-detector.js',
'/utils/keyboard-shortcuts.js',
'/utils/perf-monitor.js',
'/utils/toast.js',
'/utils/theme-toggle.js',
'/utils/command-palette.js',
'/utils/activity-log.js',
'/utils/data-export.js',
'/utils/fullscreen.js',
'/utils/connection-status.js',
'/utils/mobile-nav.js'
];
// Install - cache shell assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(SHELL_ASSETS).catch((err) => {
// Don't fail install if some assets are missing (dev mode)
console.warn('[SW] Some assets failed to cache:', err);
});
})
);
self.skipWaiting();
});
// Activate - clean old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) => {
return Promise.all(
keys
.filter((key) => key !== CACHE_NAME)
.map((key) => caches.delete(key))
);
})
);
self.clients.claim();
});
// Fetch - network-first for API, cache-first for static
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Skip non-GET requests
if (request.method !== 'GET') return;
// Skip WebSocket upgrade requests
if (request.headers.get('Upgrade') === 'websocket') return;
// Skip cross-origin requests
if (url.origin !== self.location.origin) return;
// API calls: network-first with cache fallback
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/health/')) {
event.respondWith(networkFirst(request));
return;
}
// Static assets: cache-first with network fallback
event.respondWith(cacheFirst(request));
});
async function cacheFirst(request) {
const cached = await caches.match(request);
if (cached) return cached;
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
}
return response;
} catch {
// Return offline fallback for HTML navigation
if (request.headers.get('Accept')?.includes('text/html')) {
const fallback = await caches.match('/index.html');
if (fallback) return fallback;
}
return new Response('Offline', { status: 503, statusText: 'Service Unavailable' });
}
}
async function networkFirst(request) {
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
}
return response;
} catch {
const cached = await caches.match(request);
if (cached) return cached;
return new Response(JSON.stringify({ error: 'offline' }), {
status: 503,
headers: { 'Content-Type': 'application/json' }
});
}
}
+472
View File
@@ -0,0 +1,472 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RuView UI - Unit Tests</title>
<style>
* { margin: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #1a1a2e; color: #e0e0e0; padding: 24px; }
h1 { font-size: 20px; margin-bottom: 4px; color: #32b8c6; }
.subtitle { font-size: 13px; color: #a7a9a9; margin-bottom: 20px; }
.suite { margin-bottom: 16px; }
.suite-name { font-size: 14px; font-weight: 600; margin-bottom: 6px; color: #a7a9a9; }
.test { padding: 4px 0 4px 16px; font-size: 13px; font-family: monospace; }
.pass { color: #32b8c6; }
.fail { color: #ff5459; }
.pass::before { content: "PASS "; font-weight: bold; }
.fail::before { content: "FAIL "; font-weight: bold; }
.summary { margin-top: 24px; padding: 12px; border-top: 1px solid #333; font-size: 14px; font-weight: 600; }
.error-detail { color: #ff8a8a; font-size: 12px; padding-left: 32px; white-space: pre-wrap; }
</style>
</head>
<body>
<h1>RuView UI - Unit Tests</h1>
<p class="subtitle">Tests for UI components and utility modules</p>
<div id="output"></div>
<div id="summary" class="summary"></div>
<script type="module">
// ---- Minimal test framework (zero deps) ----
const results = [];
let currentSuite = '';
function describe(name, fn) { currentSuite = name; fn(); }
function it(name, fn) {
try { fn(); results.push({ suite: currentSuite, name, passed: true }); }
catch (e) { results.push({ suite: currentSuite, name, passed: false, error: e.message }); }
}
function expect(actual) {
return {
toBe(exp) { if (actual !== exp) throw new Error(`Expected ${JSON.stringify(exp)}, got ${JSON.stringify(actual)}`); },
toEqual(exp) { if (JSON.stringify(actual) !== JSON.stringify(exp)) throw new Error(`Expected ${JSON.stringify(exp)}, got ${JSON.stringify(actual)}`); },
toBeTruthy() { if (!actual) throw new Error(`Expected truthy, got ${JSON.stringify(actual)}`); },
toBeFalsy() { if (actual) throw new Error(`Expected falsy, got ${JSON.stringify(actual)}`); },
toBeGreaterThan(n) { if (!(actual > n)) throw new Error(`Expected ${actual} > ${n}`); },
toContain(str) { if (typeof actual === 'string' ? !actual.includes(str) : !actual.includes(str)) throw new Error(`Expected to contain "${str}"`); },
not: {
toBe(exp) { if (actual === exp) throw new Error(`Expected not ${JSON.stringify(exp)}`); },
toContain(str) { if (typeof actual === 'string' && actual.includes(str)) throw new Error(`Expected not to contain "${str}"`); }
}
};
}
function mockDOM() {
const c = document.createElement('div');
c.className = 'container';
c.innerHTML = `
<header class="header"><div class="header-info"></div></header>
<nav class="nav-tabs">
<button class="nav-tab active" data-tab="dashboard" role="tab" aria-selected="true">Dashboard</button>
<button class="nav-tab" data-tab="hardware" role="tab" aria-selected="false">Hardware</button>
<button class="nav-tab" data-tab="demo" role="tab" aria-selected="false">Live Demo</button>
</nav>
<section id="dashboard" class="tab-content active" role="tabpanel"></section>
<section id="hardware" class="tab-content" role="tabpanel"></section>
<section id="demo" class="tab-content" role="tabpanel"></section>
`;
document.body.appendChild(c);
return c;
}
// ===== ToastManager =====
const { ToastManager } = await import('../utils/toast.js');
describe('ToastManager', () => {
it('creates container with role=region on init', () => {
const tm = new ToastManager();
tm.init();
expect(tm.container.getAttribute('role')).toBe('region');
expect(tm.container.getAttribute('aria-live')).toBe('polite');
tm.dispose();
});
it('show() returns unique incremental ids', () => {
const tm = new ToastManager();
tm.init();
const a = tm.show('A'); const b = tm.show('B');
expect(b).toBeGreaterThan(a);
tm.dispose();
});
it('dismiss() removes toast from list', () => {
const tm = new ToastManager();
tm.init();
const id = tm.show('X', { duration: 0 });
expect(tm.toasts.length).toBe(1);
tm.dismiss(id);
expect(tm.toasts.length).toBe(0);
tm.dispose();
});
it('dismiss() is safe to call with unknown id', () => {
const tm = new ToastManager();
tm.init();
tm.dismiss(99999); // should not throw
expect(tm.toasts.length).toBe(0);
tm.dispose();
});
it('success/error/warning/info create correct types', () => {
const tm = new ToastManager();
tm.init();
tm.success('a'); tm.error('b'); tm.warning('c'); tm.info('d');
expect(tm.toasts.length).toBe(4);
tm.dispose();
});
it('escapes HTML entities to prevent XSS', () => {
const tm = new ToastManager();
const safe = tm.escapeHtml('<img src=x onerror=alert(1)>');
expect(safe).not.toContain('<img');
expect(safe).toContain('&lt;img');
});
it('stacks multiple toasts in container', () => {
const tm = new ToastManager();
tm.init();
tm.show('1', { duration: 0 });
tm.show('2', { duration: 0 });
tm.show('3', { duration: 0 });
expect(tm.container.children.length).toBe(3);
tm.dispose();
});
it('dispose() removes container from DOM', () => {
const tm = new ToastManager();
tm.init();
tm.show('Z', { duration: 0 });
const c = tm.container;
tm.dispose();
expect(c.parentNode).toBeFalsy();
expect(tm.toasts.length).toBe(0);
});
});
// ===== ThemeToggle =====
const { ThemeToggle } = await import('../utils/theme-toggle.js');
describe('ThemeToggle', () => {
const dom = mockDOM();
it('detects system theme as dark or light', () => {
const tt = new ThemeToggle();
const t = tt.getSystemTheme();
expect(t === 'dark' || t === 'light').toBeTruthy();
});
it('creates button with aria-label in header', () => {
const tt = new ThemeToggle();
tt.init();
expect(tt.button).toBeTruthy();
expect(tt.button.getAttribute('aria-label')).toBeTruthy();
tt.dispose();
});
it('toggle() alternates between dark and light', () => {
const tt = new ThemeToggle();
tt.init();
const initial = tt.currentTheme;
tt.toggle();
expect(tt.currentTheme).not.toBe(initial);
tt.toggle();
expect(tt.currentTheme).toBe(initial);
tt.dispose();
});
it('applyTheme() sets data-color-scheme on <html>', () => {
const tt = new ThemeToggle();
tt.applyTheme('dark');
expect(document.documentElement.getAttribute('data-color-scheme')).toBe('dark');
tt.applyTheme('light');
expect(document.documentElement.getAttribute('data-color-scheme')).toBe('light');
});
it('persists and retrieves theme from localStorage', () => {
const tt = new ThemeToggle();
tt.saveTheme('dark');
expect(tt.getSavedTheme()).toBe('dark');
tt.saveTheme('light');
expect(tt.getSavedTheme()).toBe('light');
localStorage.removeItem('ruview-theme');
});
dom.remove();
});
// ===== KeyboardShortcuts =====
const { KeyboardShortcuts } = await import('../utils/keyboard-shortcuts.js');
describe('KeyboardShortcuts', () => {
it('has default shortcuts for ?, Escape, and number keys', () => {
const ks = new KeyboardShortcuts(null);
expect(ks.shortcuts.has('?')).toBeTruthy();
expect(ks.shortcuts.has('Escape')).toBeTruthy();
expect(ks.shortcuts.has('1')).toBeTruthy();
expect(ks.shortcuts.has('8')).toBeTruthy();
ks.dispose();
});
it('register() adds custom handler', () => {
const ks = new KeyboardShortcuts(null);
let ran = false;
ks.register('z', 'Test', () => { ran = true; });
expect(ks.shortcuts.has('z')).toBeTruthy();
ks.shortcuts.get('z').handler();
expect(ran).toBeTruthy();
ks.dispose();
});
it('formatKey() maps Escape to Esc', () => {
const ks = new KeyboardShortcuts(null);
expect(ks.formatKey('Escape')).toBe('Esc');
expect(ks.formatKey('a')).toBe('A');
ks.dispose();
});
it('init() creates dialog overlay', () => {
const ks = new KeyboardShortcuts(null);
ks.init();
expect(ks.overlay).toBeTruthy();
expect(ks.overlay.getAttribute('role')).toBe('dialog');
expect(ks.overlay.getAttribute('aria-modal')).toBe('true');
ks.dispose();
});
it('showHelp/hideHelp toggles overlay visibility', () => {
const ks = new KeyboardShortcuts(null);
ks.init();
ks.showHelp();
expect(ks.helpVisible).toBeTruthy();
expect(ks.overlay.classList.contains('visible')).toBeTruthy();
ks.hideHelp();
expect(ks.helpVisible).toBeFalsy();
ks.dispose();
});
it('buildHelpHTML() includes Navigation/Actions/General groups', () => {
const ks = new KeyboardShortcuts(null);
const html = ks.buildHelpHTML();
expect(html).toContain('Navigation');
expect(html).toContain('Actions');
expect(html).toContain('General');
ks.dispose();
});
it('dispose() removes overlay from DOM', () => {
const ks = new KeyboardShortcuts(null);
ks.init();
const o = ks.overlay;
ks.dispose();
expect(o.parentNode).toBeFalsy();
});
});
// ===== PerfMonitor =====
const { PerfMonitor } = await import('../utils/perf-monitor.js');
describe('PerfMonitor', () => {
it('creates panel with role=status and aria-label', () => {
const pm = new PerfMonitor();
pm.init();
expect(pm.panel.getAttribute('role')).toBe('status');
expect(pm.panel.getAttribute('aria-label')).toBe('Performance monitor');
pm.dispose();
});
it('show/hide updates visible state', () => {
const pm = new PerfMonitor();
pm.init();
pm.show();
expect(pm.visible).toBeTruthy();
expect(pm.panel.classList.contains('visible')).toBeTruthy();
pm.hide();
expect(pm.visible).toBeFalsy();
pm.dispose();
});
it('toggle() flips visibility', () => {
const pm = new PerfMonitor();
pm.init();
pm.toggle();
expect(pm.visible).toBeTruthy();
pm.toggle();
expect(pm.visible).toBeFalsy();
pm.dispose();
});
it('updateMetric() sets text and CSS class', () => {
const pm = new PerfMonitor();
pm.init();
pm.updateMetric('fps', 60, 'ok');
const el = pm.panel.querySelector('[data-metric="fps"]');
expect(el.textContent).toBe('60');
expect(el.className).toContain('perf-ok');
pm.updateMetric('fps', 15, 'warning');
expect(el.className).toContain('perf-warning');
pm.dispose();
});
it('pushSpark() appends data and caps at 60', () => {
const pm = new PerfMonitor();
pm.init();
for (let i = 0; i < 70; i++) pm.pushSpark('fps', i, 0, 120);
expect(pm.sparkData.fps.length).toBe(60);
pm.dispose();
});
it('dispose() cleans up panel', () => {
const pm = new PerfMonitor();
pm.init();
pm.show();
const p = pm.panel;
pm.dispose();
expect(p.parentNode).toBeFalsy();
});
});
// ===== TabManager =====
const { TabManager } = await import('../components/TabManager.js');
describe('TabManager', () => {
it('initializes and finds all tabs', () => {
const d = mockDOM();
const tm = new TabManager(d);
tm.init();
expect(tm.tabs.length).toBe(3);
expect(tm.activeTab).toBe('dashboard');
d.remove();
});
it('switchToTab() changes active tab', () => {
const d = mockDOM();
const tm = new TabManager(d);
tm.init();
tm.switchToTab('hardware');
expect(tm.activeTab).toBe('hardware');
expect(d.querySelector('[data-tab="hardware"]').classList.contains('active')).toBeTruthy();
expect(d.querySelector('[data-tab="dashboard"]').classList.contains('active')).toBeFalsy();
d.remove();
});
it('updates aria-selected on tab switch', () => {
const d = mockDOM();
const tm = new TabManager(d);
tm.init();
tm.switchToTab('demo');
expect(d.querySelector('[data-tab="dashboard"]').getAttribute('aria-selected')).toBe('false');
expect(d.querySelector('[data-tab="demo"]').getAttribute('aria-selected')).toBe('true');
d.remove();
});
it('fires onTabChange callbacks with correct args', () => {
const d = mockDOM();
const tm = new TabManager(d);
tm.init();
let newId = '', oldId = '';
tm.onTabChange((n, o) => { newId = n; oldId = o; });
tm.switchToTab('hardware');
expect(newId).toBe('hardware');
expect(oldId).toBe('dashboard');
d.remove();
});
it('does not fire callback when switching to already active tab', () => {
const d = mockDOM();
const tm = new TabManager(d);
tm.init();
let count = 0;
tm.onTabChange(() => { count++; });
tm.switchToTab('dashboard');
expect(count).toBe(0);
d.remove();
});
it('onTabChange() returns unsubscribe function', () => {
const d = mockDOM();
const tm = new TabManager(d);
tm.init();
let count = 0;
const unsub = tm.onTabChange(() => { count++; });
tm.switchToTab('hardware');
expect(count).toBe(1);
unsub();
tm.switchToTab('demo');
expect(count).toBe(1); // not incremented
d.remove();
});
it('setTabEnabled(false) disables tab button', () => {
const d = mockDOM();
const tm = new TabManager(d);
tm.init();
tm.setTabEnabled('hardware', false);
const btn = d.querySelector('[data-tab="hardware"]');
expect(btn.disabled).toBeTruthy();
expect(btn.classList.contains('disabled')).toBeTruthy();
tm.setTabEnabled('hardware', true);
expect(btn.disabled).toBeFalsy();
d.remove();
});
it('setTabVisible(false) hides tab', () => {
const d = mockDOM();
const tm = new TabManager(d);
tm.init();
tm.setTabVisible('demo', false);
expect(d.querySelector('[data-tab="demo"]').style.display).toBe('none');
tm.setTabVisible('demo', true);
expect(d.querySelector('[data-tab="demo"]').style.display).toBe('');
d.remove();
});
it('setTabBadge() adds/removes badge', () => {
const d = mockDOM();
const tm = new TabManager(d);
tm.init();
tm.setTabBadge('hardware', '3');
const badge = d.querySelector('[data-tab="hardware"] .tab-badge');
expect(badge).toBeTruthy();
expect(badge.textContent).toBe('3');
tm.setTabBadge('hardware', null);
expect(d.querySelector('[data-tab="hardware"] .tab-badge')).toBeFalsy();
d.remove();
});
});
// ===== RENDER RESULTS =====
const output = document.getElementById('output');
let lastSuite = '', passed = 0, failed = 0;
results.forEach(r => {
if (r.suite !== lastSuite) {
lastSuite = r.suite;
const s = document.createElement('div');
s.className = 'suite';
s.innerHTML = `<div class="suite-name">${r.suite}</div>`;
output.appendChild(s);
}
const t = document.createElement('div');
t.className = `test ${r.passed ? 'pass' : 'fail'}`;
t.textContent = r.name;
output.lastChild.appendChild(t);
if (!r.passed) {
const e = document.createElement('div');
e.className = 'error-detail';
e.textContent = r.error;
output.lastChild.appendChild(e);
}
r.passed ? passed++ : failed++;
});
const summary = document.getElementById('summary');
summary.textContent = `${passed + failed} tests: ${passed} passed, ${failed} failed`;
summary.style.color = failed === 0 ? '#32b8c6' : '#ff5459';
console.info(`[UNIT-TESTS] ${passed + failed} tests: ${passed} passed, ${failed} failed`);
if (failed > 0) results.filter(r => !r.passed).forEach(r => console.error(`[FAIL] ${r.suite} > ${r.name}: ${r.error}`));
</script>
</body>
</html>
+181
View File
@@ -0,0 +1,181 @@
// Activity Log - Scrollable panel showing system events in real-time
// Toggle with 'L' key or command palette
export class ActivityLog {
constructor() {
this.panel = null;
this.visible = false;
this.entries = [];
this.maxEntries = 200;
this.logBody = null;
this.filters = { info: true, warning: true, error: true, connection: true };
}
init() {
this.createPanel();
this.interceptConsole();
document.addEventListener('toggle-activity-log', () => this.toggle());
}
createPanel() {
this.panel = document.createElement('div');
this.panel.className = 'activity-log';
this.panel.setAttribute('role', 'log');
this.panel.setAttribute('aria-label', 'Activity log');
this.panel.innerHTML = `
<div class="activity-log-header">
<span class="activity-log-title">Activity Log</span>
<div class="activity-log-controls">
<button class="activity-log-filter active" data-filter="info" aria-label="Toggle info messages" title="Info">I</button>
<button class="activity-log-filter active" data-filter="warning" aria-label="Toggle warnings" title="Warnings">W</button>
<button class="activity-log-filter active" data-filter="error" aria-label="Toggle errors" title="Errors">E</button>
<button class="activity-log-filter active" data-filter="connection" aria-label="Toggle connection events" title="Connection">C</button>
<button class="activity-log-clear" aria-label="Clear log" title="Clear">Clear</button>
<button class="activity-log-close" aria-label="Close activity log">&times;</button>
</div>
</div>
<div class="activity-log-body"></div>
`;
this.logBody = this.panel.querySelector('.activity-log-body');
// Filter toggles
this.panel.querySelectorAll('.activity-log-filter').forEach(btn => {
btn.addEventListener('click', () => {
const filter = btn.dataset.filter;
this.filters[filter] = !this.filters[filter];
btn.classList.toggle('active', this.filters[filter]);
this.rerender();
});
});
// Clear button
this.panel.querySelector('.activity-log-clear').addEventListener('click', () => {
this.entries = [];
this.rerender();
});
// Close button
this.panel.querySelector('.activity-log-close').addEventListener('click', () => this.hide());
// Make resizable by dragging top edge
this.makeResizable();
document.body.appendChild(this.panel);
}
makeResizable() {
let resizing = false;
let startY = 0;
let startHeight = 0;
this.panel.addEventListener('mousedown', (e) => {
// Only top 5px edge
const rect = this.panel.getBoundingClientRect();
if (e.clientY - rect.top > 5) return;
resizing = true;
startY = e.clientY;
startHeight = rect.height;
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (!resizing) return;
const delta = startY - e.clientY;
const newHeight = Math.max(150, Math.min(window.innerHeight * 0.7, startHeight + delta));
this.panel.style.height = `${newHeight}px`;
});
document.addEventListener('mouseup', () => { resizing = false; });
}
interceptConsole() {
const origInfo = console.info;
const origWarn = console.warn;
const origError = console.error;
console.info = (...args) => {
origInfo.apply(console, args);
this.addEntry('info', args.map(String).join(' '));
};
console.warn = (...args) => {
origWarn.apply(console, args);
const msg = args.map(String).join(' ');
const type = msg.includes('[WS-') || msg.includes('connect') ? 'connection' : 'warning';
this.addEntry(type, msg);
};
console.error = (...args) => {
origError.apply(console, args);
this.addEntry('error', args.map(String).join(' '));
};
}
addEntry(type, message) {
const entry = {
time: new Date(),
type,
message: this.truncate(message, 300)
};
this.entries.push(entry);
if (this.entries.length > this.maxEntries) {
this.entries.shift();
}
if (this.visible && this.filters[type]) {
this.appendEntry(entry);
// Auto-scroll to bottom
this.logBody.scrollTop = this.logBody.scrollHeight;
}
}
appendEntry(entry) {
const el = document.createElement('div');
el.className = `activity-log-entry activity-log-${entry.type}`;
const time = entry.time.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
el.innerHTML = `<span class="activity-log-time">${time}</span><span class="activity-log-type">${entry.type.toUpperCase().charAt(0)}</span><span class="activity-log-msg">${this.escapeHtml(entry.message)}</span>`;
this.logBody.appendChild(el);
}
rerender() {
this.logBody.innerHTML = '';
this.entries
.filter(e => this.filters[e.type])
.forEach(e => this.appendEntry(e));
this.logBody.scrollTop = this.logBody.scrollHeight;
}
toggle() {
this.visible ? this.hide() : this.show();
}
show() {
this.visible = true;
this.panel.classList.add('visible');
this.rerender();
}
hide() {
this.visible = false;
this.panel.classList.remove('visible');
}
truncate(str, max) {
return str.length > max ? str.slice(0, max) + '...' : str;
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
dispose() {
this.hide();
if (this.panel?.parentNode) {
this.panel.parentNode.removeChild(this.panel);
}
}
}
+311
View File
@@ -0,0 +1,311 @@
// Command Palette - Ctrl+K / Cmd+K to search and execute commands
// Fuzzy search across tabs, actions, and settings
export class CommandPalette {
constructor(app) {
this.app = app;
this.overlay = null;
this.input = null;
this.results = null;
this.visible = false;
this.commands = [];
this.selectedIndex = 0;
this.filteredCommands = [];
}
init() {
this.registerCommands();
this.createDOM();
this.bindGlobalShortcut();
}
registerCommands() {
// Navigation commands
const tabs = [
{ id: 'dashboard', label: 'Dashboard', icon: 'grid' },
{ id: 'hardware', label: 'Hardware', icon: 'cpu' },
{ id: 'demo', label: 'Live Demo', icon: 'play' },
{ id: 'architecture', label: 'Architecture', icon: 'layers' },
{ id: 'performance', label: 'Performance', icon: 'zap' },
{ id: 'applications', label: 'Applications', icon: 'box' },
{ id: 'sensing', label: 'Sensing', icon: 'wifi' },
{ id: 'training', label: 'Training', icon: 'database' },
];
tabs.forEach(tab => {
this.commands.push({
category: 'Navigation',
label: `Go to ${tab.label}`,
keywords: [tab.id, tab.label.toLowerCase()],
icon: tab.icon,
action: () => {
const tm = this.app?.getComponent?.('tabManager');
if (tm) tm.switchToTab(tab.id);
}
});
});
// External pages
this.commands.push({
category: 'Navigation',
label: 'Open Pose Fusion',
keywords: ['pose', 'fusion', 'camera'],
icon: 'external',
action: () => { window.location.href = 'pose-fusion.html'; }
});
this.commands.push({
category: 'Navigation',
label: 'Open Observatory',
keywords: ['observatory', '3d', 'signal'],
icon: 'external',
action: () => { window.location.href = 'observatory.html'; }
});
// Actions
this.commands.push({
category: 'Actions',
label: 'Toggle Dark/Light Theme',
keywords: ['theme', 'dark', 'light', 'mode', 'color'],
icon: 'moon',
action: () => document.dispatchEvent(new CustomEvent('toggle-theme'))
});
this.commands.push({
category: 'Actions',
label: 'Toggle Performance Monitor',
keywords: ['perf', 'fps', 'memory', 'performance', 'monitor'],
icon: 'activity',
action: () => document.dispatchEvent(new CustomEvent('toggle-perf-monitor'))
});
this.commands.push({
category: 'Actions',
label: 'Toggle Activity Log',
keywords: ['log', 'events', 'activity', 'history'],
icon: 'list',
action: () => document.dispatchEvent(new CustomEvent('toggle-activity-log'))
});
this.commands.push({
category: 'Actions',
label: 'Export Sensor Data',
keywords: ['export', 'download', 'csv', 'json', 'data', 'save'],
icon: 'download',
action: () => document.dispatchEvent(new CustomEvent('export-data'))
});
this.commands.push({
category: 'Actions',
label: 'Toggle Fullscreen',
keywords: ['fullscreen', 'full', 'screen', 'maximize'],
icon: 'maximize',
action: () => document.dispatchEvent(new CustomEvent('toggle-fullscreen'))
});
this.commands.push({
category: 'Actions',
label: 'Show Keyboard Shortcuts',
keywords: ['keyboard', 'shortcuts', 'keys', 'help'],
icon: 'keyboard',
action: () => document.dispatchEvent(new CustomEvent('show-shortcuts'))
});
}
createDOM() {
this.overlay = document.createElement('div');
this.overlay.className = 'cmd-palette-overlay';
this.overlay.setAttribute('role', 'dialog');
this.overlay.setAttribute('aria-label', 'Command palette');
this.overlay.setAttribute('aria-modal', 'true');
this.overlay.innerHTML = `
<div class="cmd-palette">
<div class="cmd-palette-input-wrap">
<svg class="cmd-palette-search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" class="cmd-palette-input" placeholder="Type a command..." aria-label="Search commands" autocomplete="off" spellcheck="false">
<kbd class="cmd-palette-hint">Esc</kbd>
</div>
<div class="cmd-palette-results" role="listbox" aria-label="Commands"></div>
<div class="cmd-palette-footer">
<span><kbd>Up</kbd><kbd>Down</kbd> navigate</span>
<span><kbd>Enter</kbd> execute</span>
<span><kbd>Esc</kbd> close</span>
</div>
</div>
`;
this.overlay.addEventListener('click', (e) => {
if (e.target === this.overlay) this.hide();
});
this.input = this.overlay.querySelector('.cmd-palette-input');
this.results = this.overlay.querySelector('.cmd-palette-results');
this.input.addEventListener('input', () => this.onInput());
this.input.addEventListener('keydown', (e) => this.onKeydown(e));
document.body.appendChild(this.overlay);
}
bindGlobalShortcut() {
document.addEventListener('keydown', (e) => {
// Ctrl+K or Cmd+K
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
this.toggle();
}
});
}
toggle() {
this.visible ? this.hide() : this.show();
}
show() {
this.visible = true;
this.overlay.classList.add('visible');
this.input.value = '';
this.selectedIndex = 0;
this.filteredCommands = [...this.commands];
this.renderResults();
this.input.focus();
}
hide() {
this.visible = false;
this.overlay.classList.remove('visible');
}
onInput() {
const query = this.input.value.toLowerCase().trim();
if (!query) {
this.filteredCommands = [...this.commands];
} else {
this.filteredCommands = this.commands
.map(cmd => {
const score = this.fuzzyScore(query, cmd);
return { ...cmd, score };
})
.filter(cmd => cmd.score > 0)
.sort((a, b) => b.score - a.score);
}
this.selectedIndex = 0;
this.renderResults();
}
fuzzyScore(query, cmd) {
const targets = [cmd.label.toLowerCase(), ...cmd.keywords, cmd.category.toLowerCase()];
let best = 0;
for (const target of targets) {
if (target === query) return 100;
if (target.startsWith(query)) best = Math.max(best, 80);
if (target.includes(query)) best = Math.max(best, 60);
// Check each word
const words = query.split(/\s+/);
const allMatch = words.every(w => targets.some(t => t.includes(w)));
if (allMatch) best = Math.max(best, 40);
}
return best;
}
renderResults() {
if (this.filteredCommands.length === 0) {
this.results.innerHTML = '<div class="cmd-palette-empty">No matching commands</div>';
return;
}
let lastCategory = '';
let html = '';
this.filteredCommands.forEach((cmd, i) => {
if (cmd.category !== lastCategory) {
lastCategory = cmd.category;
html += `<div class="cmd-palette-category">${cmd.category}</div>`;
}
const selected = i === this.selectedIndex ? ' cmd-palette-item-selected' : '';
html += `
<div class="cmd-palette-item${selected}" data-index="${i}" role="option" aria-selected="${i === this.selectedIndex}">
<span class="cmd-palette-item-icon">${this.getIcon(cmd.icon)}</span>
<span class="cmd-palette-item-label">${cmd.label}</span>
</div>`;
});
this.results.innerHTML = html;
// Click handlers
this.results.querySelectorAll('.cmd-palette-item').forEach(el => {
el.addEventListener('click', () => {
const idx = parseInt(el.dataset.index, 10);
this.executeCommand(idx);
});
el.addEventListener('mouseenter', () => {
this.selectedIndex = parseInt(el.dataset.index, 10);
this.updateSelection();
});
});
// Scroll selected into view
const selectedEl = this.results.querySelector('.cmd-palette-item-selected');
if (selectedEl) selectedEl.scrollIntoView({ block: 'nearest' });
}
updateSelection() {
this.results.querySelectorAll('.cmd-palette-item').forEach((el, i) => {
const isSelected = parseInt(el.dataset.index, 10) === this.selectedIndex;
el.classList.toggle('cmd-palette-item-selected', isSelected);
el.setAttribute('aria-selected', String(isSelected));
});
}
onKeydown(e) {
if (e.key === 'ArrowDown') {
e.preventDefault();
this.selectedIndex = Math.min(this.selectedIndex + 1, this.filteredCommands.length - 1);
this.updateSelection();
const sel = this.results.querySelector('.cmd-palette-item-selected');
if (sel) sel.scrollIntoView({ block: 'nearest' });
} else if (e.key === 'ArrowUp') {
e.preventDefault();
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
this.updateSelection();
const sel = this.results.querySelector('.cmd-palette-item-selected');
if (sel) sel.scrollIntoView({ block: 'nearest' });
} else if (e.key === 'Enter') {
e.preventDefault();
this.executeCommand(this.selectedIndex);
} else if (e.key === 'Escape') {
e.preventDefault();
this.hide();
}
}
executeCommand(index) {
const cmd = this.filteredCommands[index];
if (cmd) {
this.hide();
cmd.action();
}
}
getIcon(name) {
const icons = {
grid: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>',
cpu: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><line x1="9" y1="1" x2="9" y2="4"/><line x1="15" y1="1" x2="15" y2="4"/><line x1="9" y1="20" x2="9" y2="23"/><line x1="15" y1="20" x2="15" y2="23"/></svg>',
play: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>',
layers: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>',
zap: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>',
box: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>',
wifi: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg>',
database: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>',
external: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>',
moon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>',
activity: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>',
list: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>',
download: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>',
maximize: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>',
keyboard: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="4" width="20" height="16" rx="2"/><line x1="6" y1="8" x2="6.01" y2="8"/><line x1="10" y1="8" x2="10.01" y2="8"/><line x1="14" y1="8" x2="14.01" y2="8"/><line x1="18" y1="8" x2="18.01" y2="8"/><line x1="8" y1="12" x2="8.01" y2="12"/><line x1="12" y1="12" x2="12.01" y2="12"/><line x1="16" y1="12" x2="16.01" y2="12"/><line x1="7" y1="16" x2="17" y2="16"/></svg>'
};
return icons[name] || '';
}
dispose() {
if (this.overlay?.parentNode) {
this.overlay.parentNode.removeChild(this.overlay);
}
}
}
+84
View File
@@ -0,0 +1,84 @@
// Connection Status Widget - Persistent indicator in header
// Shows WebSocket and API connection state with reconnect button
import { sensingService } from '../services/sensing.service.js';
export class ConnectionStatus {
constructor() {
this.widget = null;
this._unsub = null;
}
init() {
this.createWidget();
this.subscribe();
}
createWidget() {
this.widget = document.createElement('div');
this.widget.className = 'conn-status';
this.widget.setAttribute('role', 'status');
this.widget.setAttribute('aria-live', 'polite');
this.widget.innerHTML = `
<span class="conn-status-dot"></span>
<span class="conn-status-label">Connecting</span>
<button class="conn-status-reconnect" aria-label="Reconnect" title="Reconnect" style="display:none">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
</button>
`;
this.widget.querySelector('.conn-status-reconnect').addEventListener('click', () => {
this.setStatus('reconnecting', 'Reconnecting...');
sensingService.reconnect?.();
});
// Insert into header-info, after theme toggle if present
const headerInfo = document.querySelector('.header-info');
if (headerInfo) {
headerInfo.prepend(this.widget);
}
}
subscribe() {
this._unsub = sensingService.onStateChange(() => {
this.update();
});
// Initial
this.update();
}
update() {
const state = sensingService.state;
const source = sensingService.dataSource;
if (state === 'connected' || state === 'streaming') {
const label = source === 'live' ? 'Live' :
source === 'server-simulated' ? 'Simulated' :
'Connected';
this.setStatus('connected', label);
} else if (state === 'connecting' || state === 'reconnecting') {
this.setStatus('reconnecting', 'Connecting...');
} else if (state === 'error') {
this.setStatus('error', 'Error');
} else {
this.setStatus('disconnected', 'Offline');
}
}
setStatus(status, label) {
if (!this.widget) return;
this.widget.className = `conn-status conn-status-${status}`;
this.widget.querySelector('.conn-status-label').textContent = label;
const reconnectBtn = this.widget.querySelector('.conn-status-reconnect');
reconnectBtn.style.display =
(status === 'disconnected' || status === 'error') ? '' : 'none';
}
dispose() {
if (this._unsub) this._unsub();
if (this.widget?.parentNode) {
this.widget.parentNode.removeChild(this.widget);
}
}
}
+148
View File
@@ -0,0 +1,148 @@
// Data Export Utility - Export sensor/pose data as JSON or CSV
import { sensingService } from '../services/sensing.service.js';
import { toastManager } from './toast.js';
export class DataExport {
constructor() {
this.buffer = [];
this.maxBuffer = 1000;
this.recording = false;
this._unsub = null;
}
init() {
document.addEventListener('export-data', () => this.showExportDialog());
// Continuously buffer sensing data when available
this._unsub = sensingService.onData((data) => {
if (this.buffer.length >= this.maxBuffer) {
this.buffer.shift();
}
this.buffer.push({
timestamp: new Date().toISOString(),
...this.extractFields(data)
});
});
}
extractFields(data) {
// Extract relevant fields from sensing data
return {
rssi: data.rssi ?? null,
variance: data.variance ?? null,
motion_band: data.motion_band ?? null,
breathing_band: data.breathing_band ?? null,
classification: data.classification ?? null,
person_count: data.person_count ?? data.persons ?? null,
subcarriers: data.subcarrier_count ?? null,
source: data.source ?? null
};
}
showExportDialog() {
if (this.buffer.length === 0) {
toastManager.warning('No sensor data to export. Connect to a data source first.');
return;
}
// Create dialog
const overlay = document.createElement('div');
overlay.className = 'export-dialog-overlay';
overlay.innerHTML = `
<div class="export-dialog" role="dialog" aria-label="Export data" aria-modal="true">
<h3>Export Sensor Data</h3>
<p class="export-dialog-info">${this.buffer.length} data points available</p>
<div class="export-dialog-options">
<label class="export-option">
<input type="radio" name="export-format" value="json" checked>
<span>JSON</span>
<small>Full data with nested fields</small>
</label>
<label class="export-option">
<input type="radio" name="export-format" value="csv">
<span>CSV</span>
<small>Flat table, spreadsheet-ready</small>
</label>
</div>
<div class="export-dialog-range">
<label>
Last <input type="number" id="export-count" value="${Math.min(this.buffer.length, 500)}" min="1" max="${this.buffer.length}"> data points
</label>
</div>
<div class="export-dialog-actions">
<button class="btn btn--secondary export-cancel">Cancel</button>
<button class="btn btn--primary export-confirm">Export</button>
</div>
</div>
`;
overlay.addEventListener('click', (e) => {
if (e.target === overlay) overlay.remove();
});
overlay.querySelector('.export-cancel').addEventListener('click', () => overlay.remove());
overlay.querySelector('.export-confirm').addEventListener('click', () => {
const format = overlay.querySelector('input[name="export-format"]:checked').value;
const count = parseInt(overlay.querySelector('#export-count').value, 10) || this.buffer.length;
this.exportData(format, count);
overlay.remove();
});
document.body.appendChild(overlay);
overlay.querySelector('.export-confirm').focus();
}
exportData(format, count) {
const data = this.buffer.slice(-count);
let content, filename, mimeType;
if (format === 'json') {
content = JSON.stringify(data, null, 2);
filename = `ruview-data-${this.timestamp()}.json`;
mimeType = 'application/json';
} else {
content = this.toCSV(data);
filename = `ruview-data-${this.timestamp()}.csv`;
mimeType = 'text/csv';
}
this.downloadFile(content, filename, mimeType);
toastManager.success(`Exported ${data.length} data points as ${format.toUpperCase()}`);
}
toCSV(data) {
if (data.length === 0) return '';
const headers = Object.keys(data[0]);
const rows = data.map(row => headers.map(h => {
const val = row[h];
if (val === null || val === undefined) return '';
if (typeof val === 'string' && (val.includes(',') || val.includes('"'))) {
return `"${val.replace(/"/g, '""')}"`;
}
return String(val);
}).join(','));
return [headers.join(','), ...rows].join('\n');
}
downloadFile(content, filename, mimeType) {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
timestamp() {
return new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
}
dispose() {
if (this._unsub) this._unsub();
}
}
+79
View File
@@ -0,0 +1,79 @@
// Fullscreen Mode - Toggle fullscreen on visualization tabs
// Activated via F11 key, command palette, or button
export class FullscreenManager {
constructor() {
this.isFullscreen = false;
this.targetElement = null;
}
init() {
document.addEventListener('toggle-fullscreen', () => this.toggle());
document.addEventListener('keydown', (e) => {
if (e.key === 'F11') {
e.preventDefault();
this.toggle();
}
});
document.addEventListener('fullscreenchange', () => {
this.isFullscreen = !!document.fullscreenElement;
this.updateUI();
});
}
toggle() {
if (this.isFullscreen) {
this.exit();
} else {
this.enter();
}
}
enter() {
// Find the active tab content
const activePanel = document.querySelector('.tab-content.active');
if (!activePanel) return;
this.targetElement = activePanel;
if (activePanel.requestFullscreen) {
activePanel.requestFullscreen();
} else if (activePanel.webkitRequestFullscreen) {
activePanel.webkitRequestFullscreen();
}
}
exit() {
if (document.exitFullscreen) {
document.exitFullscreen();
} else if (document.webkitExitFullscreen) {
document.webkitExitFullscreen();
}
this.targetElement = null;
}
updateUI() {
document.body.classList.toggle('is-fullscreen', this.isFullscreen);
// Add/remove exit button when in fullscreen
let exitBtn = document.getElementById('fullscreen-exit-btn');
if (this.isFullscreen && !exitBtn) {
exitBtn = document.createElement('button');
exitBtn.id = 'fullscreen-exit-btn';
exitBtn.className = 'fullscreen-exit-btn';
exitBtn.setAttribute('aria-label', 'Exit fullscreen');
exitBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>';
exitBtn.title = 'Exit fullscreen (F11)';
exitBtn.addEventListener('click', () => this.exit());
document.body.appendChild(exitBtn);
} else if (!this.isFullscreen && exitBtn) {
exitBtn.remove();
}
}
dispose() {
if (this.isFullscreen) this.exit();
}
}
+264
View File
@@ -0,0 +1,264 @@
// Internationalization - EN/PL language support
// Detects browser language, persists choice, translates UI strings
const translations = {
en: {
// Navigation
'nav.dashboard': 'Dashboard',
'nav.hardware': 'Hardware',
'nav.demo': 'Live Demo',
'nav.architecture': 'Architecture',
'nav.performance': 'Performance',
'nav.applications': 'Applications',
'nav.sensing': 'Sensing',
'nav.training': 'Training',
// Dashboard
'dashboard.title': 'Revolutionary WiFi-Based Human Pose Detection',
'dashboard.subtitle': 'Human Tracking Through Walls Using WiFi Signals',
'dashboard.description': 'AI can track your full-body movement through walls using just WiFi signals. Researchers at Carnegie Mellon have trained a neural network to turn basic WiFi signals into detailed wireframe models of human bodies.',
'dashboard.status': 'System Status',
'dashboard.metrics': 'System Metrics',
'dashboard.features': 'Features',
'dashboard.liveStats': 'Live Statistics',
'dashboard.activePersons': 'Active Persons',
'dashboard.avgConfidence': 'Avg Confidence',
'dashboard.totalDetections': 'Total Detections',
'dashboard.zoneOccupancy': 'Zone Occupancy',
// Status
'status.apiServer': 'API Server',
'status.hardware': 'Hardware',
'status.inference': 'Inference',
'status.streaming': 'Streaming',
'status.dataSource': 'Data Source',
// Metrics
'metrics.cpu': 'CPU Usage',
'metrics.memory': 'Memory Usage',
'metrics.disk': 'Disk Usage',
// Benefits
'benefit.throughWalls': 'Through Walls',
'benefit.throughWallsDesc': 'Works through solid barriers with no line of sight required',
'benefit.privacy': 'Privacy-Preserving',
'benefit.privacyDesc': 'No cameras or visual recording - just WiFi signal analysis',
'benefit.realtime': 'Real-Time',
'benefit.realtimeDesc': 'Maps 24 body regions in real-time at 100Hz sampling rate',
'benefit.lowCost': 'Low Cost',
'benefit.lowCostDesc': 'Built using $30 commercial WiFi hardware',
// Stats
'stat.bodyRegions': 'Body Regions',
'stat.samplingRate': 'Sampling Rate',
'stat.accuracy': 'Accuracy (AP@50)',
'stat.hardwareCost': 'Hardware Cost',
// Actions
'action.startDetection': 'Start Detection',
'action.stopDetection': 'Stop Detection',
'action.toggleTheme': 'Toggle theme',
'action.exportData': 'Export data',
'action.screenshot': 'Take screenshot',
// Connection
'conn.connected': 'Connected',
'conn.connecting': 'Connecting...',
'conn.offline': 'Offline',
'conn.reconnecting': 'Reconnecting...',
'conn.live': 'Live',
'conn.simulated': 'Simulated',
// Misc
'misc.loading': 'Loading...',
'misc.error': 'An error occurred',
'misc.noData': 'No data available',
'misc.close': 'Close',
'misc.cancel': 'Cancel',
'misc.confirm': 'Confirm',
'misc.settings': 'Settings',
'misc.language': 'Language'
},
pl: {
// Navigation
'nav.dashboard': 'Panel',
'nav.hardware': 'Sprzet',
'nav.demo': 'Demo na zywo',
'nav.architecture': 'Architektura',
'nav.performance': 'Wydajnosc',
'nav.applications': 'Aplikacje',
'nav.sensing': 'Czujniki',
'nav.training': 'Trening',
// Dashboard
'dashboard.title': 'Rewolucyjne wykrywanie pozy czlowieka przez WiFi',
'dashboard.subtitle': 'Sledzenie ludzi przez sciany za pomoca sygnalow WiFi',
'dashboard.description': 'AI moze sledzic ruchy calego ciala przez sciany uzywajac jedynie sygnalow WiFi. Badacze z Carnegie Mellon wytrenowali siec neuronowa do zamiany sygnalow WiFi w szczegolowe modele szkieletowe.',
'dashboard.status': 'Status systemu',
'dashboard.metrics': 'Metryki systemu',
'dashboard.features': 'Funkcje',
'dashboard.liveStats': 'Statystyki na zywo',
'dashboard.activePersons': 'Aktywne osoby',
'dashboard.avgConfidence': 'Srednia pewnosc',
'dashboard.totalDetections': 'Laczne detekcje',
'dashboard.zoneOccupancy': 'Zajecie stref',
// Status
'status.apiServer': 'Serwer API',
'status.hardware': 'Sprzet',
'status.inference': 'Wnioskowanie',
'status.streaming': 'Streaming',
'status.dataSource': 'Zrodlo danych',
// Metrics
'metrics.cpu': 'Uzycie CPU',
'metrics.memory': 'Uzycie pamieci',
'metrics.disk': 'Uzycie dysku',
// Benefits
'benefit.throughWalls': 'Przez sciany',
'benefit.throughWallsDesc': 'Dziala przez przeszkody stale bez linii wzroku',
'benefit.privacy': 'Ochrona prywatnosci',
'benefit.privacyDesc': 'Brak kamer i nagrywania - tylko analiza sygnalow WiFi',
'benefit.realtime': 'Czas rzeczywisty',
'benefit.realtimeDesc': 'Mapuje 24 regiony ciala w czasie rzeczywistym przy 100Hz',
'benefit.lowCost': 'Niski koszt',
'benefit.lowCostDesc': 'Zbudowany z komercyjnego sprzetu WiFi za $30',
// Stats
'stat.bodyRegions': 'Regiony ciala',
'stat.samplingRate': 'Czestotliwosc',
'stat.accuracy': 'Dokladnosc (AP@50)',
'stat.hardwareCost': 'Koszt sprzetu',
// Actions
'action.startDetection': 'Rozpocznij detekcje',
'action.stopDetection': 'Zatrzymaj detekcje',
'action.toggleTheme': 'Zmien motyw',
'action.exportData': 'Eksportuj dane',
'action.screenshot': 'Zrob zrzut ekranu',
// Connection
'conn.connected': 'Polaczono',
'conn.connecting': 'Laczenie...',
'conn.offline': 'Offline',
'conn.reconnecting': 'Ponowne laczenie...',
'conn.live': 'Na zywo',
'conn.simulated': 'Symulacja',
// Misc
'misc.loading': 'Ladowanie...',
'misc.error': 'Wystapil blad',
'misc.noData': 'Brak danych',
'misc.close': 'Zamknij',
'misc.cancel': 'Anuluj',
'misc.confirm': 'Potwierdz',
'misc.settings': 'Ustawienia',
'misc.language': 'Jezyk'
}
};
export class I18n {
constructor() {
this.locale = this.getSavedLocale() || this.detectLocale();
this.listeners = [];
}
init() {
this.createSelector();
this.applyTranslations();
}
detectLocale() {
const lang = navigator.language?.toLowerCase() || 'en';
if (lang.startsWith('pl')) return 'pl';
return 'en';
}
getSavedLocale() {
try { return localStorage.getItem('ruview-locale'); }
catch { return null; }
}
saveLocale(locale) {
try { localStorage.setItem('ruview-locale', locale); }
catch { /* noop */ }
}
t(key) {
const dict = translations[this.locale] || translations.en;
return dict[key] || translations.en[key] || key;
}
setLocale(locale) {
if (!translations[locale]) return;
this.locale = locale;
this.saveLocale(locale);
document.documentElement.setAttribute('lang', locale);
this.applyTranslations();
this.listeners.forEach(cb => { try { cb(locale); } catch { /* noop */ } });
}
onLocaleChange(callback) {
this.listeners.push(callback);
return () => {
const i = this.listeners.indexOf(callback);
if (i > -1) this.listeners.splice(i, 1);
};
}
applyTranslations() {
// Translate elements with data-i18n attribute
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
el.textContent = this.t(key);
});
// Translate placeholders
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const key = el.getAttribute('data-i18n-placeholder');
el.placeholder = this.t(key);
});
// Translate aria-labels
document.querySelectorAll('[data-i18n-aria]').forEach(el => {
const key = el.getAttribute('data-i18n-aria');
el.setAttribute('aria-label', this.t(key));
});
// Update language selector
const selector = document.getElementById('lang-selector');
if (selector) selector.value = this.locale;
}
createSelector() {
const wrapper = document.createElement('div');
wrapper.className = 'lang-selector-wrap';
wrapper.innerHTML = `
<select id="lang-selector" class="lang-selector" aria-label="Language">
<option value="en">EN</option>
<option value="pl">PL</option>
</select>
`;
const select = wrapper.querySelector('select');
select.value = this.locale;
select.addEventListener('change', () => this.setLocale(select.value));
const headerInfo = document.querySelector('.header-info');
if (headerInfo) {
headerInfo.appendChild(wrapper);
}
}
getAvailableLocales() {
return Object.keys(translations);
}
dispose() {
this.listeners = [];
}
}
export const i18n = new I18n();
+83
View File
@@ -0,0 +1,83 @@
// Idle Manager - Pauses animations, polling, and WebSocket pings when user is inactive
// Reduces CPU/battery usage on idle dashboards
export class IdleManager {
constructor() {
this.idleTimeout = 3 * 60 * 1000; // 3 minutes
this.isIdle = false;
this.timer = null;
this.callbacks = { idle: [], active: [] };
this.events = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'scroll'];
}
init() {
this.resetTimer();
this.events.forEach(evt => {
document.addEventListener(evt, () => this.onActivity(), { passive: true, capture: true });
});
// Also use Page Visibility API
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
this.goIdle();
} else {
this.goActive();
}
});
}
onActivity() {
if (this.isIdle) {
this.goActive();
}
this.resetTimer();
}
resetTimer() {
if (this.timer) clearTimeout(this.timer);
this.timer = setTimeout(() => this.goIdle(), this.idleTimeout);
}
goIdle() {
if (this.isIdle) return;
this.isIdle = true;
console.info('[Idle] User inactive - pausing background tasks');
this.notify('idle');
document.body.classList.add('user-idle');
}
goActive() {
if (!this.isIdle) return;
this.isIdle = false;
console.info('[Idle] User active - resuming background tasks');
this.notify('active');
document.body.classList.remove('user-idle');
this.resetTimer();
}
onIdle(callback) {
this.callbacks.idle.push(callback);
return () => {
const i = this.callbacks.idle.indexOf(callback);
if (i > -1) this.callbacks.idle.splice(i, 1);
};
}
onActive(callback) {
this.callbacks.active.push(callback);
return () => {
const i = this.callbacks.active.indexOf(callback);
if (i > -1) this.callbacks.active.splice(i, 1);
};
}
notify(type) {
this.callbacks[type].forEach(cb => {
try { cb(); } catch (e) { console.error('[Idle] Callback error:', e); }
});
}
dispose() {
if (this.timer) clearTimeout(this.timer);
this.callbacks = { idle: [], active: [] };
}
}
+168
View File
@@ -0,0 +1,168 @@
// Keyboard Shortcuts System
// Press '?' to show help overlay, number keys to switch tabs, etc.
export class KeyboardShortcuts {
constructor(app) {
this.app = app;
this.shortcuts = new Map();
this.helpVisible = false;
this.enabled = true;
this.overlay = null;
this.registerDefaults();
}
registerDefaults() {
this.register('?', 'Show keyboard shortcuts', () => this.toggleHelp());
this.register('Escape', 'Close overlay / dialog', () => this.closeAll());
this.register('1', 'Switch to Dashboard tab', () => this.switchTab('dashboard'));
this.register('2', 'Switch to Hardware tab', () => this.switchTab('hardware'));
this.register('3', 'Switch to Live Demo tab', () => this.switchTab('demo'));
this.register('4', 'Switch to Architecture tab', () => this.switchTab('architecture'));
this.register('5', 'Switch to Performance tab', () => this.switchTab('performance'));
this.register('6', 'Switch to Applications tab', () => this.switchTab('applications'));
this.register('7', 'Switch to Sensing tab', () => this.switchTab('sensing'));
this.register('8', 'Switch to Training tab', () => this.switchTab('training'));
this.register('p', 'Toggle performance monitor', () => this.togglePerfMonitor());
this.register('t', 'Toggle dark/light theme', () => this.toggleTheme());
}
register(key, description, handler) {
this.shortcuts.set(key, { description, handler });
}
init() {
document.addEventListener('keydown', (e) => this.handleKeydown(e));
this.createOverlay();
}
handleKeydown(e) {
if (!this.enabled) return;
// Ignore when typing in inputs
const tag = e.target.tagName.toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select' || e.target.isContentEditable) {
if (e.key === 'Escape') {
e.target.blur();
}
return;
}
// Ignore modified keys (except shift for '?')
if (e.ctrlKey || e.altKey || e.metaKey) return;
const shortcut = this.shortcuts.get(e.key);
if (shortcut) {
e.preventDefault();
shortcut.handler();
}
}
switchTab(tabId) {
const tabManager = this.app?.getComponent?.('tabManager');
if (tabManager) {
tabManager.switchToTab(tabId);
}
}
togglePerfMonitor() {
const event = new CustomEvent('toggle-perf-monitor');
document.dispatchEvent(event);
}
toggleTheme() {
const event = new CustomEvent('toggle-theme');
document.dispatchEvent(event);
}
closeAll() {
if (this.helpVisible) {
this.hideHelp();
}
}
createOverlay() {
this.overlay = document.createElement('div');
this.overlay.className = 'shortcuts-overlay';
this.overlay.setAttribute('role', 'dialog');
this.overlay.setAttribute('aria-label', 'Keyboard shortcuts');
this.overlay.setAttribute('aria-modal', 'true');
this.overlay.innerHTML = this.buildHelpHTML();
this.overlay.addEventListener('click', (e) => {
if (e.target === this.overlay) this.hideHelp();
});
document.body.appendChild(this.overlay);
}
buildHelpHTML() {
const groups = [
{
title: 'Navigation',
items: Array.from(this.shortcuts.entries())
.filter(([key]) => /^[1-8]$/.test(key))
},
{
title: 'Actions',
items: Array.from(this.shortcuts.entries())
.filter(([key]) => /^[a-z]$/.test(key))
},
{
title: 'General',
items: Array.from(this.shortcuts.entries())
.filter(([key]) => !/^[1-8a-z]$/.test(key))
}
];
return `
<div class="shortcuts-panel">
<div class="shortcuts-header">
<h2>Keyboard Shortcuts</h2>
<button class="shortcuts-close" aria-label="Close">&times;</button>
</div>
<div class="shortcuts-body">
${groups.map(group => `
<div class="shortcuts-group">
<h3>${group.title}</h3>
${group.items.map(([key, { description }]) => `
<div class="shortcut-row">
<kbd>${this.formatKey(key)}</kbd>
<span>${description}</span>
</div>
`).join('')}
</div>
`).join('')}
</div>
</div>
`;
}
formatKey(key) {
const map = { Escape: 'Esc', '?': '?' };
return map[key] || key.toUpperCase();
}
toggleHelp() {
this.helpVisible ? this.hideHelp() : this.showHelp();
}
showHelp() {
this.overlay.classList.add('visible');
this.helpVisible = true;
// Focus close button
const closeBtn = this.overlay.querySelector('.shortcuts-close');
if (closeBtn) {
closeBtn.onclick = () => this.hideHelp();
closeBtn.focus();
}
}
hideHelp() {
this.overlay.classList.remove('visible');
this.helpVisible = false;
}
dispose() {
if (this.overlay?.parentNode) {
this.overlay.parentNode.removeChild(this.overlay);
}
}
}
+171
View File
@@ -0,0 +1,171 @@
// Mobile Navigation - Hamburger menu for small screens
// Replaces wrapped tab bar with a slide-out drawer on mobile
export class MobileNav {
constructor() {
this.drawer = null;
this.backdrop = null;
this.hamburger = null;
this.isOpen = false;
this.mql = window.matchMedia('(max-width: 768px)');
}
init() {
this.createHamburger();
this.createDrawer();
this.bindEvents();
this.onMediaChange(this.mql);
}
createHamburger() {
this.hamburger = document.createElement('button');
this.hamburger.className = 'mobile-hamburger';
this.hamburger.setAttribute('aria-label', 'Open navigation menu');
this.hamburger.setAttribute('aria-expanded', 'false');
this.hamburger.innerHTML = `
<span class="hamburger-line"></span>
<span class="hamburger-line"></span>
<span class="hamburger-line"></span>
`;
this.hamburger.addEventListener('click', () => this.toggle());
const header = document.querySelector('.header');
if (header) {
header.style.position = 'relative';
header.appendChild(this.hamburger);
}
}
createDrawer() {
// Backdrop
this.backdrop = document.createElement('div');
this.backdrop.className = 'mobile-nav-backdrop';
this.backdrop.addEventListener('click', () => this.close());
document.body.appendChild(this.backdrop);
// Drawer
this.drawer = document.createElement('nav');
this.drawer.className = 'mobile-nav-drawer';
this.drawer.setAttribute('role', 'navigation');
this.drawer.setAttribute('aria-label', 'Mobile navigation');
// Clone tabs into drawer
const tabs = document.querySelectorAll('.nav-tabs .nav-tab');
const list = document.createElement('div');
list.className = 'mobile-nav-list';
tabs.forEach(tab => {
const item = document.createElement(tab.tagName === 'A' ? 'a' : 'button');
item.className = 'mobile-nav-item';
item.textContent = tab.textContent.trim();
if (tab.tagName === 'A') {
item.href = tab.href;
} else {
const tabId = tab.getAttribute('data-tab');
item.dataset.tab = tabId;
if (tab.classList.contains('active')) {
item.classList.add('active');
}
item.addEventListener('click', () => {
// Activate tab via the original tab manager
tab.click();
this.close();
// Update active states in drawer
list.querySelectorAll('.mobile-nav-item').forEach(i => i.classList.remove('active'));
item.classList.add('active');
});
}
list.appendChild(item);
});
this.drawer.appendChild(list);
// Keyboard hint at bottom
const hint = document.createElement('div');
hint.className = 'mobile-nav-hint';
hint.textContent = 'Tip: Press Ctrl+K for command palette';
this.drawer.appendChild(hint);
document.body.appendChild(this.drawer);
// Sync active tab when tabs change externally
const observer = new MutationObserver(() => {
const activeTab = document.querySelector('.nav-tabs .nav-tab.active');
if (activeTab) {
const activeId = activeTab.getAttribute('data-tab');
list.querySelectorAll('.mobile-nav-item').forEach(item => {
item.classList.toggle('active', item.dataset.tab === activeId);
});
}
});
const navTabs = document.querySelector('.nav-tabs');
if (navTabs) {
observer.observe(navTabs, { attributes: true, subtree: true, attributeFilter: ['class'] });
}
}
bindEvents() {
// Listen for media query changes
this.mql.addEventListener('change', (e) => this.onMediaChange(e));
// Close on escape
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && this.isOpen) this.close();
});
// Swipe to close
let touchStartX = 0;
this.drawer.addEventListener('touchstart', (e) => {
touchStartX = e.touches[0].clientX;
}, { passive: true });
this.drawer.addEventListener('touchend', (e) => {
const deltaX = e.changedTouches[0].clientX - touchStartX;
if (deltaX < -50) this.close(); // Swipe left to close
}, { passive: true });
}
onMediaChange(mql) {
const isMobile = mql.matches !== undefined ? mql.matches : mql;
document.body.classList.toggle('mobile-nav-active', isMobile);
if (!isMobile && this.isOpen) {
this.close();
}
}
toggle() {
this.isOpen ? this.close() : this.open();
}
open() {
this.isOpen = true;
this.drawer.classList.add('open');
this.backdrop.classList.add('open');
this.hamburger.classList.add('open');
this.hamburger.setAttribute('aria-expanded', 'true');
document.body.style.overflow = 'hidden';
// Focus first item
const first = this.drawer.querySelector('.mobile-nav-item');
if (first) first.focus();
}
close() {
this.isOpen = false;
this.drawer.classList.remove('open');
this.backdrop.classList.remove('open');
this.hamburger.classList.remove('open');
this.hamburger.setAttribute('aria-expanded', 'false');
document.body.style.overflow = '';
}
dispose() {
this.close();
this.hamburger?.remove();
this.drawer?.remove();
this.backdrop?.remove();
}
}
+233
View File
@@ -0,0 +1,233 @@
// Notification Center - Bell icon with event history
// Persists notifications across page views (sessionStorage)
export class NotificationCenter {
constructor() {
this.button = null;
this.panel = null;
this.notifications = [];
this.maxNotifications = 50;
this.isOpen = false;
this.unreadCount = 0;
this.storageKey = 'ruview-notifications';
}
init() {
this.loadFromStorage();
this.createButton();
this.createPanel();
this.interceptEvents();
}
createButton() {
this.button = document.createElement('button');
this.button.className = 'notif-bell';
this.button.setAttribute('aria-label', 'Notifications');
this.button.setAttribute('title', 'Notifications');
this.button.innerHTML = `
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
</svg>
<span class="notif-badge" style="display:none">0</span>
`;
this.button.addEventListener('click', () => this.toggle());
const headerInfo = document.querySelector('.header-info');
if (headerInfo) {
headerInfo.prepend(this.button);
}
this.updateBadge();
}
createPanel() {
this.panel = document.createElement('div');
this.panel.className = 'notif-panel';
this.panel.setAttribute('role', 'region');
this.panel.setAttribute('aria-label', 'Notification history');
this.panel.innerHTML = `
<div class="notif-panel-header">
<span>Notifications</span>
<div class="notif-panel-actions">
<button class="notif-mark-read" title="Mark all read">Mark read</button>
<button class="notif-clear" title="Clear all">Clear</button>
</div>
</div>
<div class="notif-panel-body"></div>
`;
this.panel.querySelector('.notif-mark-read').addEventListener('click', () => {
this.notifications.forEach(n => n.read = true);
this.unreadCount = 0;
this.updateBadge();
this.renderList();
this.saveToStorage();
});
this.panel.querySelector('.notif-clear').addEventListener('click', () => {
this.notifications = [];
this.unreadCount = 0;
this.updateBadge();
this.renderList();
this.saveToStorage();
});
document.body.appendChild(this.panel);
// Close on outside click
document.addEventListener('click', (e) => {
if (this.isOpen && !this.panel.contains(e.target) && !this.button.contains(e.target)) {
this.close();
}
});
}
interceptEvents() {
// Listen for toast events to capture as notifications
const origInfo = console.info;
console.info = (...args) => {
origInfo.apply(console, args);
const msg = args.map(String).join(' ');
// Only capture app-relevant messages
if (msg.includes('[WS-') || msg.includes('Backend') || msg.includes('Service worker') ||
msg.includes('connected') || msg.includes('initialized') || msg.includes('sensing')) {
this.add(msg, 'info');
}
};
const origWarn = console.warn;
console.warn = (...args) => {
origWarn.apply(console, args);
const msg = args.map(String).join(' ');
if (msg.includes('Backend') || msg.includes('unavailable') || msg.includes('[WS-') ||
msg.includes('connection') || msg.includes('timeout')) {
this.add(msg, 'warning');
}
};
const origError = console.error;
console.error = (...args) => {
origError.apply(console, args);
const msg = args.map(String).join(' ');
if (msg.includes('Failed') || msg.includes('Error') || msg.includes('error')) {
this.add(msg, 'error');
}
};
}
add(message, type = 'info') {
const notification = {
id: Date.now() + Math.random(),
message: this.truncate(message, 200),
type,
time: new Date().toISOString(),
read: false
};
this.notifications.unshift(notification);
if (this.notifications.length > this.maxNotifications) {
this.notifications.pop();
}
this.unreadCount++;
this.updateBadge();
this.saveToStorage();
if (this.isOpen) {
this.renderList();
}
}
toggle() {
this.isOpen ? this.close() : this.open();
}
open() {
this.isOpen = true;
this.panel.classList.add('open');
this.renderList();
}
close() {
this.isOpen = false;
this.panel.classList.remove('open');
}
renderList() {
const body = this.panel.querySelector('.notif-panel-body');
if (this.notifications.length === 0) {
body.innerHTML = '<div class="notif-empty">No notifications</div>';
return;
}
body.innerHTML = this.notifications.map(n => {
const time = new Date(n.time);
const ago = this.timeAgo(time);
return `
<div class="notif-item notif-${n.type} ${n.read ? 'read' : 'unread'}">
<div class="notif-item-dot"></div>
<div class="notif-item-content">
<span class="notif-item-msg">${this.escapeHtml(n.message)}</span>
<span class="notif-item-time">${ago}</span>
</div>
</div>
`;
}).join('');
}
updateBadge() {
const badge = this.button?.querySelector('.notif-badge');
if (!badge) return;
if (this.unreadCount > 0) {
badge.textContent = this.unreadCount > 99 ? '99+' : this.unreadCount;
badge.style.display = '';
} else {
badge.style.display = 'none';
}
}
timeAgo(date) {
const seconds = Math.floor((new Date() - date) / 1000);
if (seconds < 60) return 'just now';
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
return date.toLocaleDateString();
}
truncate(str, max) {
return str.length > max ? str.slice(0, max) + '...' : str;
}
escapeHtml(text) {
const d = document.createElement('div');
d.textContent = text;
return d.innerHTML;
}
loadFromStorage() {
try {
const data = sessionStorage.getItem(this.storageKey);
if (data) {
const parsed = JSON.parse(data);
this.notifications = parsed.notifications || [];
this.unreadCount = parsed.unreadCount || 0;
}
} catch { /* noop */ }
}
saveToStorage() {
try {
sessionStorage.setItem(this.storageKey, JSON.stringify({
notifications: this.notifications.slice(0, 20),
unreadCount: this.unreadCount
}));
} catch { /* noop */ }
}
dispose() {
this.close();
this.button?.remove();
this.panel?.remove();
}
}
+192
View File
@@ -0,0 +1,192 @@
// Onboarding Tour - Interactive first-run walkthrough
// Shows on first visit, can be re-triggered from command palette or help
const STORAGE_KEY = 'ruview-onboarding-done';
export class Onboarding {
constructor(app) {
this.app = app;
this.overlay = null;
this.currentStep = 0;
this.steps = [];
this.active = false;
}
init() {
this.defineSteps();
document.addEventListener('start-onboarding', () => this.start());
// Auto-start on first visit
if (!this.isDone()) {
// Delay to let the app render first
setTimeout(() => this.start(), 800);
}
}
defineSteps() {
this.steps = [
{
title: 'Welcome to RuView',
text: 'WiFi-based human pose estimation that works through walls. Let\'s take a quick tour of the dashboard.',
target: null, // No highlight, centered
position: 'center'
},
{
title: 'System Status',
text: 'Monitor your WiFi sensing hardware and API server status in real time. Green means everything is connected.',
target: '.live-status-panel',
position: 'bottom'
},
{
title: 'Live Demo',
text: 'Switch to the Live Demo tab to see real-time pose detection. Connect an ESP32 sensor or use the built-in simulation.',
target: '[data-tab="demo"]',
position: 'bottom'
},
{
title: 'Sensing Visualization',
text: 'The Sensing tab shows a 3D Gaussian splat visualization of WiFi signal fields, with real-time metrics.',
target: '[data-tab="sensing"]',
position: 'bottom'
},
{
title: 'Keyboard Shortcuts',
text: 'Press ? for shortcuts, Ctrl+K for the command palette, or use number keys 1-8 to switch tabs quickly.',
target: null,
position: 'center'
},
{
title: 'You\'re all set!',
text: 'Explore the dashboard, connect hardware, or start the demo. You can replay this tour anytime from the command palette.',
target: null,
position: 'center'
}
];
}
isDone() {
try { return localStorage.getItem(STORAGE_KEY) === 'true'; }
catch { return false; }
}
markDone() {
try { localStorage.setItem(STORAGE_KEY, 'true'); }
catch { /* noop */ }
}
start() {
this.currentStep = 0;
this.active = true;
this.createOverlay();
this.showStep();
}
createOverlay() {
// Remove existing if any
this.removeOverlay();
this.overlay = document.createElement('div');
this.overlay.className = 'onboarding-overlay';
this.overlay.setAttribute('role', 'dialog');
this.overlay.setAttribute('aria-label', 'Onboarding tour');
this.overlay.setAttribute('aria-modal', 'true');
document.body.appendChild(this.overlay);
}
showStep() {
if (this.currentStep >= this.steps.length) {
this.finish();
return;
}
const step = this.steps[this.currentStep];
const total = this.steps.length;
const isFirst = this.currentStep === 0;
const isLast = this.currentStep === total - 1;
// Clear highlight
document.querySelectorAll('.onboarding-highlight').forEach(el => el.classList.remove('onboarding-highlight'));
// Highlight target
let targetRect = null;
if (step.target) {
const targetEl = document.querySelector(step.target);
if (targetEl) {
targetEl.classList.add('onboarding-highlight');
targetRect = targetEl.getBoundingClientRect();
}
}
this.overlay.innerHTML = `
<div class="onboarding-backdrop"></div>
<div class="onboarding-tooltip ${step.position}" ${targetRect ? `style="${this.positionTooltip(targetRect, step.position)}"` : ''}>
<div class="onboarding-progress">
${Array.from({ length: total }, (_, i) =>
`<span class="onboarding-dot ${i === this.currentStep ? 'active' : i < this.currentStep ? 'done' : ''}"></span>`
).join('')}
</div>
<h3 class="onboarding-title">${step.title}</h3>
<p class="onboarding-text">${step.text}</p>
<div class="onboarding-actions">
<button class="onboarding-skip">Skip tour</button>
<div class="onboarding-nav">
${!isFirst ? '<button class="onboarding-prev">Back</button>' : ''}
<button class="onboarding-next">${isLast ? 'Get started' : 'Next'}</button>
</div>
</div>
</div>
`;
// Bind events
this.overlay.querySelector('.onboarding-skip').addEventListener('click', () => this.finish());
this.overlay.querySelector('.onboarding-next').addEventListener('click', () => {
this.currentStep++;
this.showStep();
});
const prevBtn = this.overlay.querySelector('.onboarding-prev');
if (prevBtn) {
prevBtn.addEventListener('click', () => {
this.currentStep--;
this.showStep();
});
}
this.overlay.querySelector('.onboarding-backdrop').addEventListener('click', () => this.finish());
// Focus next button
this.overlay.querySelector('.onboarding-next').focus();
// Escape to close
this._escHandler = (e) => { if (e.key === 'Escape') this.finish(); };
document.addEventListener('keydown', this._escHandler);
}
positionTooltip(rect, position) {
const margin = 12;
if (position === 'bottom') {
return `left: ${Math.max(16, rect.left + rect.width / 2 - 180)}px; top: ${rect.bottom + margin}px;`;
}
if (position === 'top') {
return `left: ${Math.max(16, rect.left + rect.width / 2 - 180)}px; bottom: ${window.innerHeight - rect.top + margin}px;`;
}
return '';
}
finish() {
this.active = false;
this.markDone();
this.removeOverlay();
document.querySelectorAll('.onboarding-highlight').forEach(el => el.classList.remove('onboarding-highlight'));
if (this._escHandler) document.removeEventListener('keydown', this._escHandler);
}
removeOverlay() {
if (this.overlay?.parentNode) {
this.overlay.parentNode.removeChild(this.overlay);
this.overlay = null;
}
}
dispose() {
this.finish();
}
}
+216
View File
@@ -0,0 +1,216 @@
// Performance Monitor Overlay
// Shows FPS, memory usage, and network latency in real-time
export class PerfMonitor {
constructor() {
this.visible = false;
this.panel = null;
this.frames = [];
this.lastFrameTime = 0;
this.rafId = null;
this.latencyHistory = [];
this.maxHistory = 60;
}
init() {
this.createPanel();
document.addEventListener('toggle-perf-monitor', () => this.toggle());
}
createPanel() {
this.panel = document.createElement('div');
this.panel.className = 'perf-monitor';
this.panel.setAttribute('role', 'status');
this.panel.setAttribute('aria-label', 'Performance monitor');
this.panel.innerHTML = `
<div class="perf-header">
<span>PERF</span>
<button class="perf-close" aria-label="Close performance monitor">&times;</button>
</div>
<div class="perf-metrics">
<div class="perf-row">
<span class="perf-label">FPS</span>
<span class="perf-value" data-metric="fps">--</span>
<canvas class="perf-spark" data-spark="fps" width="60" height="20"></canvas>
</div>
<div class="perf-row">
<span class="perf-label">MEM</span>
<span class="perf-value" data-metric="memory">--</span>
<canvas class="perf-spark" data-spark="memory" width="60" height="20"></canvas>
</div>
<div class="perf-row">
<span class="perf-label">LAT</span>
<span class="perf-value" data-metric="latency">--</span>
<canvas class="perf-spark" data-spark="latency" width="60" height="20"></canvas>
</div>
<div class="perf-row">
<span class="perf-label">DOM</span>
<span class="perf-value" data-metric="dom">--</span>
</div>
</div>
`;
this.panel.querySelector('.perf-close').addEventListener('click', () => this.hide());
// Make it draggable
this.makeDraggable();
document.body.appendChild(this.panel);
this.sparkData = {
fps: [],
memory: [],
latency: []
};
}
makeDraggable() {
const header = this.panel.querySelector('.perf-header');
let dragging = false;
let offsetX = 0;
let offsetY = 0;
header.addEventListener('mousedown', (e) => {
if (e.target.tagName === 'BUTTON') return;
dragging = true;
offsetX = e.clientX - this.panel.offsetLeft;
offsetY = e.clientY - this.panel.offsetTop;
header.style.cursor = 'grabbing';
});
document.addEventListener('mousemove', (e) => {
if (!dragging) return;
this.panel.style.left = `${e.clientX - offsetX}px`;
this.panel.style.top = `${e.clientY - offsetY}px`;
this.panel.style.right = 'auto';
this.panel.style.bottom = 'auto';
});
document.addEventListener('mouseup', () => {
dragging = false;
header.style.cursor = 'grab';
});
}
toggle() {
this.visible ? this.hide() : this.show();
}
show() {
this.panel.classList.add('visible');
this.visible = true;
this.lastFrameTime = performance.now();
this.tick();
}
hide() {
this.panel.classList.remove('visible');
this.visible = false;
if (this.rafId) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
}
tick() {
if (!this.visible) return;
const now = performance.now();
this.frames.push(now);
// Keep only last second of frames
while (this.frames.length > 0 && this.frames[0] < now - 1000) {
this.frames.shift();
}
const fps = this.frames.length;
this.updateMetric('fps', fps, 'fps');
this.pushSpark('fps', fps, 0, 120);
// Memory (if available)
if (performance.memory) {
const mb = Math.round(performance.memory.usedJSHeapSize / (1024 * 1024));
const total = Math.round(performance.memory.jsHeapSizeLimit / (1024 * 1024));
this.updateMetric('memory', `${mb}MB`, mb > total * 0.8 ? 'warning' : 'ok');
this.pushSpark('memory', mb, 0, total);
} else {
this.updateMetric('memory', 'N/A', 'na');
}
// DOM node count
const domNodes = document.querySelectorAll('*').length;
this.updateMetric('dom', domNodes, domNodes > 3000 ? 'warning' : 'ok');
// Estimate latency from last navigation or resource timing
this.measureLatency();
this.rafId = requestAnimationFrame(() => this.tick());
}
measureLatency() {
const entries = performance.getEntriesByType('resource');
if (entries.length > 0) {
const last = entries[entries.length - 1];
const latency = Math.round(last.responseEnd - last.requestStart);
if (latency > 0 && latency < 30000) {
this.latencyHistory.push(latency);
if (this.latencyHistory.length > this.maxHistory) {
this.latencyHistory.shift();
}
const avg = Math.round(
this.latencyHistory.reduce((a, b) => a + b, 0) / this.latencyHistory.length
);
this.updateMetric('latency', `${avg}ms`, avg > 500 ? 'warning' : 'ok');
this.pushSpark('latency', avg, 0, 1000);
}
}
}
updateMetric(metric, value, status) {
const el = this.panel.querySelector(`[data-metric="${metric}"]`);
if (!el) return;
el.textContent = value;
el.className = `perf-value perf-${status}`;
}
pushSpark(name, value, min, max) {
const data = this.sparkData[name];
if (!data) return;
data.push(value);
if (data.length > 60) data.shift();
this.drawSpark(name, data, min, max);
}
drawSpark(name, data, min, max) {
const canvas = this.panel.querySelector(`[data-spark="${name}"]`);
if (!canvas) return;
const ctx = canvas.getContext('2d');
const w = canvas.width;
const h = canvas.height;
ctx.clearRect(0, 0, w, h);
if (data.length < 2) return;
const range = max - min || 1;
ctx.beginPath();
ctx.strokeStyle = 'rgba(50, 184, 198, 0.8)';
ctx.lineWidth = 1.5;
data.forEach((val, i) => {
const x = (i / (data.length - 1)) * w;
const y = h - ((val - min) / range) * h;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.stroke();
}
dispose() {
this.hide();
if (this.panel?.parentNode) {
this.panel.parentNode.removeChild(this.panel);
}
}
}
+191
View File
@@ -0,0 +1,191 @@
// Quick Settings Panel - Centralized configuration for all UI features
// Accessible via gear icon in header
export class QuickSettings {
constructor(app) {
this.app = app;
this.button = null;
this.panel = null;
this.isOpen = false;
}
init() {
this.createButton();
this.createPanel();
}
createButton() {
this.button = document.createElement('button');
this.button.className = 'settings-gear';
this.button.setAttribute('aria-label', 'Settings');
this.button.setAttribute('title', 'Quick settings');
this.button.innerHTML = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>`;
this.button.addEventListener('click', () => this.toggle());
const headerInfo = document.querySelector('.header-info');
if (headerInfo) headerInfo.appendChild(this.button);
}
createPanel() {
this.panel = document.createElement('div');
this.panel.className = 'quick-settings-panel';
this.panel.setAttribute('role', 'dialog');
this.panel.setAttribute('aria-label', 'Quick settings');
this.panel.innerHTML = `
<div class="qs-header">
<h3>Settings</h3>
<button class="qs-close" aria-label="Close">&times;</button>
</div>
<div class="qs-body">
<div class="qs-section">
<div class="qs-section-title">Display</div>
<label class="qs-toggle">
<span>Reduced motion</span>
<input type="checkbox" id="qs-reduced-motion" ${this.prefersReducedMotion() ? 'checked' : ''}>
<span class="qs-switch"></span>
</label>
<label class="qs-toggle">
<span>High contrast</span>
<input type="checkbox" id="qs-high-contrast">
<span class="qs-switch"></span>
</label>
<label class="qs-toggle">
<span>Compact mode</span>
<input type="checkbox" id="qs-compact" ${this.getSetting('compact') ? 'checked' : ''}>
<span class="qs-switch"></span>
</label>
</div>
<div class="qs-section">
<div class="qs-section-title">Monitoring</div>
<label class="qs-toggle">
<span>Health polling</span>
<input type="checkbox" id="qs-health-polling" checked>
<span class="qs-switch"></span>
</label>
<label class="qs-toggle">
<span>Auto-reconnect</span>
<input type="checkbox" id="qs-auto-reconnect" checked>
<span class="qs-switch"></span>
</label>
</div>
<div class="qs-section">
<div class="qs-section-title">Data</div>
<div class="qs-row">
<span>Clear local data</span>
<button class="qs-btn-danger" id="qs-clear-data">Clear</button>
</div>
<div class="qs-row">
<span>Reset onboarding</span>
<button class="qs-btn" id="qs-reset-tour">Reset</button>
</div>
</div>
</div>
`;
// Bind events
this.panel.querySelector('.qs-close').addEventListener('click', () => this.close());
this.panel.querySelector('#qs-reduced-motion').addEventListener('change', (e) => {
document.body.classList.toggle('reduced-motion', e.target.checked);
this.saveSetting('reduced-motion', e.target.checked);
});
this.panel.querySelector('#qs-high-contrast').addEventListener('change', (e) => {
document.body.classList.toggle('high-contrast', e.target.checked);
this.saveSetting('high-contrast', e.target.checked);
});
this.panel.querySelector('#qs-compact').addEventListener('change', (e) => {
document.body.classList.toggle('compact-mode', e.target.checked);
this.saveSetting('compact', e.target.checked);
});
this.panel.querySelector('#qs-health-polling').addEventListener('change', (e) => {
const healthService = this.app?.components?.dashboard?.healthSubscription;
if (e.target.checked) {
// Resume would need import - just dispatch event
document.dispatchEvent(new CustomEvent('health-polling-toggle', { detail: true }));
} else {
document.dispatchEvent(new CustomEvent('health-polling-toggle', { detail: false }));
}
});
this.panel.querySelector('#qs-clear-data').addEventListener('click', () => {
try {
localStorage.clear();
sessionStorage.clear();
} catch { /* noop */ }
this.close();
window.location.reload();
});
this.panel.querySelector('#qs-reset-tour').addEventListener('click', () => {
try { localStorage.removeItem('ruview-onboarding-done'); } catch { /* noop */ }
this.close();
document.dispatchEvent(new CustomEvent('start-onboarding'));
});
document.body.appendChild(this.panel);
// Close on outside click
document.addEventListener('click', (e) => {
if (this.isOpen && !this.panel.contains(e.target) && !this.button.contains(e.target)) {
this.close();
}
});
// Apply saved settings on init
this.applySavedSettings();
}
applySavedSettings() {
if (this.getSetting('reduced-motion') || this.prefersReducedMotion()) {
document.body.classList.add('reduced-motion');
const cb = this.panel.querySelector('#qs-reduced-motion');
if (cb) cb.checked = true;
}
if (this.getSetting('high-contrast')) {
document.body.classList.add('high-contrast');
const cb = this.panel.querySelector('#qs-high-contrast');
if (cb) cb.checked = true;
}
if (this.getSetting('compact')) {
document.body.classList.add('compact-mode');
}
}
prefersReducedMotion() {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
toggle() {
this.isOpen ? this.close() : this.open();
}
open() {
this.isOpen = true;
this.panel.classList.add('open');
}
close() {
this.isOpen = false;
this.panel.classList.remove('open');
}
getSetting(key) {
try { return JSON.parse(localStorage.getItem(`ruview-setting-${key}`)); }
catch { return null; }
}
saveSetting(key, value) {
try { localStorage.setItem(`ruview-setting-${key}`, JSON.stringify(value)); }
catch { /* noop */ }
}
dispose() {
this.button?.remove();
this.panel?.remove();
}
}
+47
View File
@@ -0,0 +1,47 @@
// Hash Router - Makes tabs bookmarkable and shareable
// URL format: #dashboard, #demo, #sensing, etc.
export class Router {
constructor(app) {
this.app = app;
this.validTabs = ['dashboard', 'hardware', 'demo', 'architecture', 'performance', 'applications', 'sensing', 'training'];
}
init() {
// Navigate to hash on load
this.onHashChange();
// Listen for hash changes (back/forward navigation)
window.addEventListener('hashchange', () => this.onHashChange());
// Update hash when tab changes
const tabManager = this.app?.getComponent?.('tabManager');
if (tabManager) {
tabManager.onTabChange((tabId) => {
this.setHash(tabId);
});
}
}
onHashChange() {
const hash = window.location.hash.replace('#', '').toLowerCase();
if (hash && this.validTabs.includes(hash)) {
const tabManager = this.app?.getComponent?.('tabManager');
if (tabManager && tabManager.getActiveTab() !== hash) {
tabManager.switchToTab(hash);
}
}
}
setHash(tabId) {
// Only update if different to avoid infinite loop
const current = window.location.hash.replace('#', '');
if (current !== tabId) {
history.replaceState(null, '', `#${tabId}`);
}
}
dispose() {
// No explicit cleanup needed - event listeners are on window
}
}
+160
View File
@@ -0,0 +1,160 @@
// Screenshot Tool - Capture current tab view as PNG
// Uses html2canvas-like approach with native Canvas API
import { toastManager } from './toast.js';
export class ScreenshotTool {
constructor() {
this.capturing = false;
}
init() {
document.addEventListener('take-screenshot', () => this.capture());
}
async capture() {
if (this.capturing) return;
this.capturing = true;
const activeTab = document.querySelector('.tab-content.active');
if (!activeTab) {
toastManager.warning('No active tab to capture');
this.capturing = false;
return;
}
try {
// Flash effect
this.flashEffect();
// Try native ClipboardItem API first (modern browsers)
if (typeof ClipboardItem !== 'undefined') {
await this.captureToClipboard(activeTab);
toastManager.success('Screenshot copied to clipboard', { duration: 3000 });
} else {
// Fallback: download as file
await this.captureToFile(activeTab);
toastManager.success('Screenshot saved as file', { duration: 3000 });
}
} catch (err) {
console.error('Screenshot failed:', err);
// Fallback: capture visible canvases + basic layout
try {
await this.captureCanvasFallback(activeTab);
toastManager.success('Screenshot saved (canvas only)', { duration: 3000 });
} catch {
toastManager.error('Screenshot failed. Try using browser\'s built-in screenshot tool.');
}
}
this.capturing = false;
}
async captureToClipboard(element) {
const canvas = await this.renderToCanvas(element);
const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
await navigator.clipboard.write([
new ClipboardItem({ 'image/png': blob })
]);
}
async captureToFile(element) {
const canvas = await this.renderToCanvas(element);
const dataUrl = canvas.toDataURL('image/png');
const link = document.createElement('a');
link.href = dataUrl;
link.download = `ruview-screenshot-${this.timestamp()}.png`;
link.click();
}
async captureCanvasFallback(element) {
// Find any canvas elements and merge them
const canvases = element.querySelectorAll('canvas');
if (canvases.length === 0) throw new Error('No canvas elements found');
const firstCanvas = canvases[0];
const mergedCanvas = document.createElement('canvas');
mergedCanvas.width = firstCanvas.width || 800;
mergedCanvas.height = firstCanvas.height || 600;
const ctx = mergedCanvas.getContext('2d');
// Dark background
ctx.fillStyle = '#1f2121';
ctx.fillRect(0, 0, mergedCanvas.width, mergedCanvas.height);
canvases.forEach(c => {
try { ctx.drawImage(c, 0, 0); } catch { /* tainted canvas */ }
});
// Add timestamp watermark
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.font = '12px monospace';
ctx.fillText(`RuView - ${new Date().toLocaleString()}`, 10, mergedCanvas.height - 10);
const dataUrl = mergedCanvas.toDataURL('image/png');
const link = document.createElement('a');
link.href = dataUrl;
link.download = `ruview-screenshot-${this.timestamp()}.png`;
link.click();
}
async renderToCanvas(element) {
// Simple DOM-to-canvas renderer for basic content
const rect = element.getBoundingClientRect();
const canvas = document.createElement('canvas');
const scale = window.devicePixelRatio || 1;
canvas.width = rect.width * scale;
canvas.height = rect.height * scale;
const ctx = canvas.getContext('2d');
ctx.scale(scale, scale);
// Render background
const styles = getComputedStyle(element);
ctx.fillStyle = styles.backgroundColor || '#1f2121';
ctx.fillRect(0, 0, rect.width, rect.height);
// Render existing canvases
const canvases = element.querySelectorAll('canvas');
canvases.forEach(c => {
const cRect = c.getBoundingClientRect();
const x = cRect.left - rect.left;
const y = cRect.top - rect.top;
try { ctx.drawImage(c, x, y, cRect.width, cRect.height); } catch { /* tainted */ }
});
// Render text content
ctx.fillStyle = styles.color || '#e0e0e0';
ctx.font = `14px ${styles.fontFamily || 'sans-serif'}`;
let textY = 30;
element.querySelectorAll('h2, h3, .stat-value, .metric-label').forEach(el => {
const text = el.textContent.trim();
if (text && textY < rect.height - 20) {
const elStyles = getComputedStyle(el);
ctx.font = `${elStyles.fontWeight} ${elStyles.fontSize} ${styles.fontFamily || 'sans-serif'}`;
ctx.fillStyle = elStyles.color;
ctx.fillText(text, 20, textY);
textY += parseInt(elStyles.fontSize) + 8;
}
});
// Watermark
ctx.fillStyle = 'rgba(255,255,255,0.15)';
ctx.font = '11px monospace';
ctx.fillText(`RuView - ${new Date().toLocaleString()}`, 10, rect.height - 10);
return canvas;
}
flashEffect() {
const flash = document.createElement('div');
flash.className = 'screenshot-flash';
document.body.appendChild(flash);
flash.addEventListener('animationend', () => flash.remove());
}
timestamp() {
return new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
}
dispose() {}
}
+86
View File
@@ -0,0 +1,86 @@
// Theme Toggle - Manual dark/light mode switch with persistence
export class ThemeToggle {
constructor() {
this.button = null;
this.currentTheme = this.getSavedTheme() || this.getSystemTheme();
}
init() {
this.createButton();
this.applyTheme(this.currentTheme);
document.addEventListener('toggle-theme', () => this.toggle());
// Listen for system theme changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
if (!this.getSavedTheme()) {
this.applyTheme(e.matches ? 'dark' : 'light');
}
});
}
createButton() {
this.button = document.createElement('button');
this.button.className = 'theme-toggle';
this.button.setAttribute('aria-label', 'Toggle dark/light theme');
this.button.setAttribute('title', 'Toggle theme (T)');
this.updateIcon();
this.button.addEventListener('click', () => this.toggle());
// Insert into header
const headerInfo = document.querySelector('.header-info');
if (headerInfo) {
headerInfo.prepend(this.button);
} else {
const header = document.querySelector('.header');
if (header) header.appendChild(this.button);
}
}
toggle() {
this.currentTheme = this.currentTheme === 'dark' ? 'light' : 'dark';
this.applyTheme(this.currentTheme);
this.saveTheme(this.currentTheme);
}
applyTheme(theme) {
this.currentTheme = theme;
document.documentElement.setAttribute('data-color-scheme', theme);
this.updateIcon();
}
updateIcon() {
if (!this.button) return;
const isDark = this.currentTheme === 'dark';
this.button.innerHTML = isDark
? '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>'
: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>';
this.button.setAttribute('aria-label', isDark ? 'Switch to light theme' : 'Switch to dark theme');
}
getSystemTheme() {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
getSavedTheme() {
try {
return localStorage.getItem('ruview-theme');
} catch {
return null;
}
}
saveTheme(theme) {
try {
localStorage.setItem('ruview-theme', theme);
} catch {
// localStorage not available
}
}
dispose() {
if (this.button?.parentNode) {
this.button.parentNode.removeChild(this.button);
}
}
}
+150
View File
@@ -0,0 +1,150 @@
// Enhanced Toast Notification System
// Supports multiple types: success, error, warning, info
// Stacking, auto-dismiss, manual close, progress bar
export class ToastManager {
constructor() {
this.container = null;
this.toasts = [];
this.idCounter = 0;
}
init() {
this.container = document.createElement('div');
this.container.className = 'toast-container';
this.container.setAttribute('role', 'region');
this.container.setAttribute('aria-label', 'Notifications');
this.container.setAttribute('aria-live', 'polite');
document.body.appendChild(this.container);
}
show(message, options = {}) {
const {
type = 'info',
duration = 5000,
closable = true,
icon = null,
action = null
} = options;
const id = ++this.idCounter;
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.setAttribute('role', 'alert');
toast.dataset.toastId = id;
const iconMap = {
success: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M13.5 4.5L6 12L2.5 8.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>',
error: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M12 4L4 12M4 4l8 8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>',
warning: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M8 5v4M8 11h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><path d="M7.13 2.22L1.09 12.5a1 1 0 00.87 1.5h12.08a1 1 0 00.87-1.5L8.87 2.22a1 1 0 00-1.74 0z" stroke="currentColor" stroke-width="1.5"/></svg>',
info: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6.5" stroke="currentColor" stroke-width="1.5"/><path d="M8 7v4M8 5h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>'
};
const displayIcon = icon || iconMap[type] || iconMap.info;
toast.innerHTML = `
<div class="toast-icon">${displayIcon}</div>
<div class="toast-content">
<span class="toast-message">${this.escapeHtml(message)}</span>
${action ? `<button class="toast-action">${this.escapeHtml(action.label)}</button>` : ''}
</div>
${closable ? '<button class="toast-dismiss" aria-label="Dismiss">&times;</button>' : ''}
${duration > 0 ? '<div class="toast-progress"><div class="toast-progress-bar"></div></div>' : ''}
`;
// Bind events
if (closable) {
toast.querySelector('.toast-dismiss').addEventListener('click', () => this.dismiss(id));
}
if (action?.onClick) {
toast.querySelector('.toast-action')?.addEventListener('click', () => {
action.onClick();
this.dismiss(id);
});
}
this.container.appendChild(toast);
// Trigger enter animation
requestAnimationFrame(() => toast.classList.add('toast-enter'));
// Auto-dismiss
let timeoutId = null;
if (duration > 0) {
const progressBar = toast.querySelector('.toast-progress-bar');
if (progressBar) {
progressBar.style.animationDuration = `${duration}ms`;
progressBar.classList.add('toast-progress-animate');
}
timeoutId = setTimeout(() => this.dismiss(id), duration);
}
// Pause on hover
toast.addEventListener('mouseenter', () => {
if (timeoutId) {
clearTimeout(timeoutId);
const bar = toast.querySelector('.toast-progress-bar');
if (bar) bar.style.animationPlayState = 'paused';
}
});
toast.addEventListener('mouseleave', () => {
if (duration > 0) {
const bar = toast.querySelector('.toast-progress-bar');
if (bar) bar.style.animationPlayState = 'running';
timeoutId = setTimeout(() => this.dismiss(id), duration / 2);
}
});
this.toasts.push({ id, toast, timeoutId });
return id;
}
dismiss(id) {
const index = this.toasts.findIndex(t => t.id === id);
if (index === -1) return;
const { toast, timeoutId } = this.toasts[index];
if (timeoutId) clearTimeout(timeoutId);
toast.classList.add('toast-exit');
toast.addEventListener('animationend', () => {
toast.remove();
}, { once: true });
this.toasts.splice(index, 1);
}
success(message, options = {}) {
return this.show(message, { ...options, type: 'success' });
}
error(message, options = {}) {
return this.show(message, { ...options, type: 'error', duration: options.duration || 8000 });
}
warning(message, options = {}) {
return this.show(message, { ...options, type: 'warning', duration: options.duration || 6000 });
}
info(message, options = {}) {
return this.show(message, { ...options, type: 'info' });
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
dispose() {
this.toasts.forEach(({ timeoutId }) => {
if (timeoutId) clearTimeout(timeoutId);
});
this.toasts = [];
if (this.container?.parentNode) {
this.container.parentNode.removeChild(this.container);
}
}
}
export const toastManager = new ToastManager();
+61
View File
@@ -0,0 +1,61 @@
// Uptime Clock - Shows system uptime and current time in header
export class UptimeClock {
constructor() {
this.widget = null;
this.startTime = Date.now();
this.intervalId = null;
}
init() {
this.createWidget();
this.update();
this.intervalId = setInterval(() => this.update(), 1000);
}
createWidget() {
this.widget = document.createElement('div');
this.widget.className = 'uptime-clock';
this.widget.setAttribute('aria-label', 'System uptime');
this.widget.innerHTML = `
<span class="uptime-time"></span>
<span class="uptime-separator">|</span>
<span class="uptime-duration" title="Session uptime"></span>
`;
const headerInfo = document.querySelector('.header-info');
if (headerInfo) {
headerInfo.appendChild(this.widget);
}
}
update() {
if (!this.widget) return;
// Current time
const now = new Date();
const time = now.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit' });
this.widget.querySelector('.uptime-time').textContent = time;
// Uptime
const elapsed = Math.floor((Date.now() - this.startTime) / 1000);
this.widget.querySelector('.uptime-duration').textContent = this.formatDuration(elapsed);
}
formatDuration(seconds) {
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}m ${s}s`;
}
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
return `${h}h ${m}m`;
}
dispose() {
if (this.intervalId) clearInterval(this.intervalId);
this.widget?.remove();
}
}
+2 -2
View File
@@ -63,7 +63,7 @@ tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
# Signal processing
ndarray = { version = "0.15", features = ["serde"] }
ndarray = { version = "0.17", features = ["serde"] }
ndarray-linalg = { version = "0.18", features = ["openblas-static"] }
rustfft = "6.1"
num-complex = "0.4"
@@ -105,7 +105,7 @@ pcap = "1.1"
petgraph = "0.6"
# Data loading
ndarray-npy = "0.8"
ndarray-npy = "0.10"
walkdir = "2.4"
# Hashing (for proof)
@@ -200,9 +200,11 @@ impl AdaptiveModel {
probs[c] = ((logits[c] - max_logit).exp()) / exp_sum;
}
// Pick argmax.
// Pick argmax. Same NaN-panic class as #611: if any raw_feature is NaN
// it propagates through normalize → logits → softmax, then partial_cmp
// returns None and unwrap() panics the sensing server on every frame.
let (best_c, best_p) = probs.iter().enumerate()
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
.unwrap();
let label = if best_c < self.class_names.len() {
self.class_names[best_c].clone()
@@ -477,7 +479,7 @@ pub fn train_from_recordings(recordings_dir: &Path) -> Result<AdaptiveModel, Str
}
}
let pred = logits.iter().enumerate()
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
.unwrap().0;
if pred == *target { correct += 1; }
}
@@ -497,7 +499,7 @@ pub fn train_from_recordings(recordings_dir: &Path) -> Result<AdaptiveModel, Str
}
}
let pred = logits.iter().enumerate()
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
.unwrap().0;
if pred == *target { class_correct[*target] += 1; }
}
@@ -598,11 +598,13 @@ pub fn estimate_persons_from_correlation(frame_history: &VecDeque<Vec<f64>>) ->
}
}
// partial_cmp returns None on NaN; the outer unwrap_or only catches an
// empty iterator, not a comparator panic. Same NaN-panic class as #611.
let (max_var_idx, _) = active.iter().enumerate()
.max_by(|(_, &a), (_, &b)| variances[a].partial_cmp(&variances[b]).unwrap())
.max_by(|(_, &a), (_, &b)| variances[a].partial_cmp(&variances[b]).unwrap_or(std::cmp::Ordering::Equal))
.unwrap_or((0, &0));
let (min_var_idx, _) = active.iter().enumerate()
.min_by(|(_, &a), (_, &b)| variances[a].partial_cmp(&variances[b]).unwrap())
.min_by(|(_, &a), (_, &b)| variances[a].partial_cmp(&variances[b]).unwrap_or(std::cmp::Ordering::Equal))
.unwrap_or((0, &0));
if max_var_idx == min_var_idx { return 1; }
@@ -555,6 +555,93 @@ fn build_node_features(
Some(entries)
}
// ── ADR-044 §5.2: Rolling P95 adaptive feature normalizer ────────────────────
/// Streaming P95 estimator over a fixed-size sliding window.
///
/// Self-calibrates feature normalization to whatever distribution the deployment
/// produces — no hardcoded scale values that can saturate in large rooms or
/// degrade in high-interference environments.
///
/// O(n log n) per query via sorted copy — acceptable at 20 Hz with window=600.
/// Cold-start (len < min_samples) returns `None` so the caller uses the legacy
/// fixed denominator, preserving day-0 behaviour.
pub struct RollingP95 {
buf: std::collections::VecDeque<f64>,
window: usize,
min_samples: usize,
}
impl RollingP95 {
pub fn new(window: usize, min_samples: usize) -> Self {
Self {
buf: std::collections::VecDeque::with_capacity(window),
window,
min_samples,
}
}
pub fn push(&mut self, v: f64) {
if self.buf.len() == self.window {
self.buf.pop_front();
}
self.buf.push_back(v);
}
/// Returns `Some(p95)` once enough samples have accumulated, else `None`.
pub fn current(&self) -> Option<f64> {
if self.buf.len() < self.min_samples {
return None;
}
let mut sorted: Vec<f64> = self.buf.iter().copied().collect();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let idx = ((sorted.len() as f64) * 0.95).ceil() as usize;
Some(sorted[idx.saturating_sub(1).min(sorted.len() - 1)])
}
#[allow(dead_code)]
pub fn len(&self) -> usize {
self.buf.len()
}
}
// ── ADR-044 §5.3: Runtime config persistence ─────────────────────────────────
/// Runtime configuration that persists across server restarts via `data/config.json`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct RuntimeConfig {
/// Divisor for multi-node person-count deduplication (sum / factor).
pub dedup_factor: f64,
}
impl Default for RuntimeConfig {
fn default() -> Self {
Self { dedup_factor: 3.0 }
}
}
/// Load persisted runtime config from `<data_dir>/config.json`.
/// Falls back to [`RuntimeConfig::default`] if the file is absent or malformed.
pub(crate) fn load_runtime_config(data_dir: &std::path::Path) -> RuntimeConfig {
let path = data_dir.join("config.json");
match std::fs::read_to_string(&path) {
Ok(json) => serde_json::from_str(&json).unwrap_or_default(),
Err(_) => RuntimeConfig::default(),
}
}
/// Persist runtime config to `<data_dir>/config.json`.
pub(crate) fn save_runtime_config(data_dir: &std::path::Path, config: &RuntimeConfig) {
let path = data_dir.join("config.json");
if let Ok(json) = serde_json::to_string_pretty(config) {
if let Err(e) = std::fs::write(&path, json) {
warn!("Failed to save runtime config to {}: {e}", path.display());
} else {
info!("Runtime config saved to {}", path.display());
}
}
}
/// Shared application state
struct AppStateInner {
latest_update: Option<SensingUpdate>,
@@ -662,6 +749,21 @@ struct AppStateInner {
multistatic_fuser: MultistaticFuser,
/// SVD-based room field model for eigenvalue person counting (None until calibration).
field_model: Option<FieldModel>,
// ── ADR-044 §5.2: adaptive rolling-p95 normalization ─────────────────────
/// Rolling P95 of `FeatureInfo.variance` over the last ~30 s (600 frames @ 20 Hz).
pub(crate) p95_variance: RollingP95,
/// Rolling P95 of `FeatureInfo.motion_band_power` over the last ~30 s.
pub(crate) p95_motion_band_power: RollingP95,
/// Rolling P95 of `FeatureInfo.spectral_power` over the last ~30 s.
pub(crate) p95_spectral_power: RollingP95,
// ── ADR-044 §5.3: runtime-configurable dedup factor ───────────────────────
/// Divisor for multi-node person-count deduplication (sum / factor).
/// Default 3.0 (one body visible to ~3 nodes on average).
/// Configurable at runtime via `POST /api/v1/config/dedup-factor` and
/// `POST /api/v1/config/ground-truth`. Persisted across restarts.
pub(crate) dedup_factor: f64,
/// Data directory for persisting runtime config (parent of `firmware_dir`).
pub(crate) data_dir: std::path::PathBuf,
}
/// If no ESP32 frame arrives within this duration, source reverts to offline.
@@ -1748,8 +1850,13 @@ async fn windows_wifi_task(state: SharedState, tick_ms: u64) {
let feat_variance = features.variance;
// ADR-044 §5.2: feed raw features into rolling-P95 estimators before scoring.
s.p95_variance.push(features.variance);
s.p95_motion_band_power.push(features.motion_band_power);
s.p95_spectral_power.push(features.spectral_power);
// Multi-person estimation with temporal smoothing (EMA α=0.10).
let raw_score = compute_person_score(&features);
let raw_score = compute_person_score(&*s, &features);
s.smoothed_person_score = s.smoothed_person_score * 0.90 + raw_score * 0.10;
let est_persons = if classification.presence {
let count = s.person_count();
@@ -1887,8 +1994,13 @@ async fn windows_wifi_fallback_tick(state: &SharedState, seq: u32) {
let feat_variance = features.variance;
// ADR-044 §5.2: feed raw features into rolling-P95 estimators before scoring.
s.p95_variance.push(features.variance);
s.p95_motion_band_power.push(features.motion_band_power);
s.p95_spectral_power.push(features.spectral_power);
// Multi-person estimation with temporal smoothing (EMA α=0.10).
let raw_score = compute_person_score(&features);
let raw_score = compute_person_score(&*s, &features);
s.smoothed_person_score = s.smoothed_person_score * 0.90 + raw_score * 0.10;
let est_persons = if classification.presence {
let count = s.person_count();
@@ -2350,13 +2462,19 @@ fn fuse_multi_node_features(
///
/// Returns a raw score (0.0..1.0) that the caller converts to person count
/// after temporal smoothing.
fn compute_person_score(feat: &FeatureInfo) -> f64 {
// Normalize each feature to [0, 1] using ranges calibrated from real
// ESP32 hardware (COM6/COM9 on ruv.net, March 2026).
let var_norm = (feat.variance / 300.0).clamp(0.0, 1.0);
fn compute_person_score(state: &AppStateInner, feat: &FeatureInfo) -> f64 {
// ADR-044 §5.2: adaptive rolling-P95 normalization.
// Legacy fixed denominators (variance/300, motion/250, spectral/500) saturate
// when live ESP32 values exceed those limits — zero dynamic range results.
// Use the P95 of the last ~30 s of history instead, falling back to the legacy
// denominators during cold-start (<60 samples) to preserve day-0 behaviour.
let var_denom = state.p95_variance.current().map(|p| p.max(50.0)).unwrap_or(300.0);
let motion_denom = state.p95_motion_band_power.current().map(|p| p.max(50.0)).unwrap_or(250.0);
let sp_denom = state.p95_spectral_power.current().map(|p| p.max(100.0)).unwrap_or(500.0);
let var_norm = (feat.variance / var_denom).clamp(0.0, 1.0);
let cp_norm = (feat.change_points as f64 / 30.0).clamp(0.0, 1.0);
let motion_norm = (feat.motion_band_power / 250.0).clamp(0.0, 1.0);
let sp_norm = (feat.spectral_power / 500.0).clamp(0.0, 1.0);
let motion_norm = (feat.motion_band_power / motion_denom).clamp(0.0, 1.0);
let sp_norm = (feat.spectral_power / sp_denom).clamp(0.0, 1.0);
var_norm * 0.40 + cp_norm * 0.20 + motion_norm * 0.25 + sp_norm * 0.15
}
@@ -2441,12 +2559,15 @@ fn estimate_persons_from_correlation(frame_history: &VecDeque<Vec<f64>>) -> usiz
}
}
// Source → highest-variance subcarrier, Sink → lowest-variance
// Source → highest-variance subcarrier, Sink → lowest-variance.
// partial_cmp returns None on NaN; the outer unwrap_or only catches an
// empty iterator, not a comparator panic. Same NaN-panic class as #611
// — a single NaN variance frame would kill the sensing-server process.
let (max_var_idx, _) = active.iter().enumerate()
.max_by(|(_, &a), (_, &b)| variances[a].partial_cmp(&variances[b]).unwrap())
.max_by(|(_, &a), (_, &b)| variances[a].partial_cmp(&variances[b]).unwrap_or(std::cmp::Ordering::Equal))
.unwrap_or((0, &0));
let (min_var_idx, _) = active.iter().enumerate()
.min_by(|(_, &a), (_, &b)| variances[a].partial_cmp(&variances[b]).unwrap())
.min_by(|(_, &a), (_, &b)| variances[a].partial_cmp(&variances[b]).unwrap_or(std::cmp::Ordering::Equal))
.unwrap_or((0, &0));
if max_var_idx == min_var_idx {
@@ -3805,8 +3926,9 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
// Aggregate person count: gate on presence first (matching WiFi path).
let now = std::time::Instant::now();
let total_persons = if vitals.presence {
let dedup = s.dedup_factor;
let (fused, fallback_count) = multistatic_bridge::fuse_or_fallback(
&s.multistatic_fuser, &s.node_states,
&s.multistatic_fuser, &s.node_states, dedup,
);
match fused {
Some(ref f) => {
@@ -4091,8 +4213,9 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
// Aggregate person count: gate on presence first (matching WiFi path).
let now = std::time::Instant::now();
let total_persons = if classification.presence {
let dedup = s.dedup_factor;
let (fused, fallback_count) = multistatic_bridge::fuse_or_fallback(
&s.multistatic_fuser, &s.node_states,
&s.multistatic_fuser, &s.node_states, dedup,
);
match fused {
Some(ref f) => {
@@ -4244,8 +4367,13 @@ async fn simulated_data_task(state: SharedState, tick_ms: u64) {
let frame_amplitudes = frame.amplitudes.clone();
let frame_n_sub = frame.n_subcarriers;
// ADR-044 §5.2: feed raw features into rolling-P95 estimators before scoring.
s.p95_variance.push(features.variance);
s.p95_motion_band_power.push(features.motion_band_power);
s.p95_spectral_power.push(features.spectral_power);
// Multi-person estimation with temporal smoothing (EMA α=0.10).
let raw_score = compute_person_score(&features);
let raw_score = compute_person_score(&*s, &features);
s.smoothed_person_score = s.smoothed_person_score * 0.90 + raw_score * 0.10;
let est_persons = if classification.presence {
let count = s.person_count();
@@ -4915,6 +5043,11 @@ async fn main() {
let initial_recordings = scan_recording_files();
info!("Discovered {} model files, {} recording files", initial_models.len(), initial_recordings.len());
// ADR-044 §5.3: load persisted runtime config from the data directory.
let data_dir = std::path::PathBuf::from("data");
let runtime_config = load_runtime_config(&data_dir);
info!("Loaded runtime config: dedup_factor={:.2}", runtime_config.dedup_factor);
let (tx, _) = broadcast::channel::<String>(256);
// ADR-099: parallel broadcast for the per-frame introspection snapshot stream
// consumed by `/ws/introspection`. Same ring size as `tx` (256) — slow
@@ -4996,6 +5129,13 @@ async fn main() {
} else {
None
},
// ADR-044 §5.2: rolling-P95 over ~30 s at 20 Hz; warm-up after 60 samples.
p95_variance: RollingP95::new(600, 60),
p95_motion_band_power: RollingP95::new(600, 60),
p95_spectral_power: RollingP95::new(600, 60),
// ADR-044 §5.3: runtime-configurable dedup factor (persisted).
dedup_factor: runtime_config.dedup_factor,
data_dir: data_dir.clone(),
}));
// Start background tasks based on source
@@ -5147,6 +5287,9 @@ async fn main() {
.route("/api/v1/calibration/start", post(calibration_start))
.route("/api/v1/calibration/stop", post(calibration_stop))
.route("/api/v1/calibration/status", get(calibration_status))
// ADR-044 §5.3: runtime-configurable dedup factor
.route("/api/v1/config/dedup-factor", get(config_get_dedup_factor).post(config_set_dedup_factor))
.route("/api/v1/config/ground-truth", post(config_set_ground_truth))
// Static UI files
.nest_service("/ui", ServeDir::new(&ui_path))
.layer(SetResponseHeaderLayer::overriding(
@@ -5272,3 +5415,131 @@ mod novelty_tests {
assert!(ns.last_novelty_score.is_some());
}
}
// ── ADR-044 §5.3: dedup_factor runtime configuration endpoints ────────────────
/// `GET /api/v1/config/dedup-factor` — read the current dedup factor.
async fn config_get_dedup_factor(
State(state): State<SharedState>,
) -> Json<serde_json::Value> {
let s = state.read().await;
Json(serde_json::json!({
"dedup_factor": s.dedup_factor,
"description": "Divisor for multi-node person count deduplication (sum / factor). Range: 1.010.0."
}))
}
/// `POST /api/v1/config/dedup-factor` — set the dedup factor (clamped 1.010.0).
///
/// Body: `{ "value": <f64> }`
async fn config_set_dedup_factor(
State(state): State<SharedState>,
Json(body): Json<serde_json::Value>,
) -> Json<serde_json::Value> {
let value = body.get("value").and_then(|v| v.as_f64()).unwrap_or(3.0);
let clamped = value.clamp(1.0, 10.0);
let mut s = state.write().await;
s.dedup_factor = clamped;
let data_dir = s.data_dir.clone();
drop(s);
save_runtime_config(&data_dir, &RuntimeConfig { dedup_factor: clamped });
Json(serde_json::json!({
"status": "ok",
"dedup_factor": clamped,
}))
}
/// `POST /api/v1/config/ground-truth` — auto-tune dedup factor from a known person count.
///
/// Derives `dedup_factor = raw_node_sum / ground_truth_count` from the current
/// per-node person counts, clamped to [1.0, 10.0]. Persisted immediately.
///
/// Body: `{ "count": <u64> }`
async fn config_set_ground_truth(
State(state): State<SharedState>,
Json(body): Json<serde_json::Value>,
) -> Json<serde_json::Value> {
let ground_truth = match body.get("count").and_then(|v| v.as_u64()) {
Some(n) if n > 0 => n as usize,
_ => return Json(serde_json::json!({"error": "count must be a positive integer"})),
};
let mut s = state.write().await;
let raw_sum: usize = s.node_states.values()
.filter(|ns| ns.last_frame_time
.map(|t| t.elapsed() < std::time::Duration::from_secs(10))
.unwrap_or(false))
.map(|ns| ns.prev_person_count)
.sum();
let optimal = if raw_sum > 0 {
(raw_sum as f64) / (ground_truth as f64)
} else {
3.0
};
let clamped = optimal.clamp(1.0, 10.0);
s.dedup_factor = clamped;
let data_dir = s.data_dir.clone();
drop(s);
save_runtime_config(&data_dir, &RuntimeConfig { dedup_factor: clamped });
Json(serde_json::json!({
"status": "ok",
"ground_truth": ground_truth,
"raw_sum": raw_sum,
"computed_dedup_factor": clamped,
}))
}
// ── Unit tests: RollingP95 ─────────────────────────────────────────────────────
#[cfg(test)]
mod rolling_p95_tests {
use super::RollingP95;
#[test]
fn cold_start_returns_none() {
let p = RollingP95::new(100, 10);
assert!(p.current().is_none(), "empty buffer must return None");
}
#[test]
fn below_min_samples_returns_none() {
let mut p = RollingP95::new(100, 10);
for i in 1..=9 {
p.push(i as f64);
}
assert!(p.current().is_none(), "fewer than min_samples must return None");
}
#[test]
fn p95_of_ramp_is_near_95() {
let mut p = RollingP95::new(100, 10);
for i in 1..=100 {
p.push(i as f64);
}
let p95 = p.current().expect("should have value after 100 samples");
assert!(
p95 >= 94.0 && p95 <= 96.0,
"P95 of 1..=100 should be ~95, got {p95}"
);
}
#[test]
fn window_slides_evicts_oldest() {
let mut p = RollingP95::new(5, 3);
// Push 1..=5, then 100 — oldest (1) is evicted.
for i in 1..=5 {
p.push(i as f64);
}
p.push(100.0); // evicts 1; buf = [2, 3, 4, 5, 100]
let p95 = p.current().expect("6 pushes, window=5 → 5 samples");
// P95 of [2,3,4,5,100]: idx = ceil(5*0.95)=5 → sorted[4]=100
assert_eq!(p95, 100.0, "largest value should dominate p95 after eviction");
}
#[test]
fn len_reports_buffer_size() {
let mut p = RollingP95::new(10, 5);
assert_eq!(p.len(), 0);
p.push(1.0);
assert_eq!(p.len(), 1);
}
}
@@ -97,6 +97,7 @@ pub fn node_frames_from_states(node_states: &HashMap<u8, NodeState>) -> Vec<Mult
pub fn fuse_or_fallback(
fuser: &MultistaticFuser,
node_states: &HashMap<u8, NodeState>,
dedup_factor: f64,
) -> (Option<FusedSensingFrame>, Option<usize>) {
let frames = node_frames_from_states(node_states);
if frames.is_empty() {
@@ -109,9 +110,11 @@ pub fn fuse_or_fallback(
(Some(fused), None)
}
Err(e) => {
tracing::debug!("Multistatic fusion failed ({e}), using per-node max fallback");
// Use max (not sum) to avoid double-counting when nodes have overlapping coverage.
let max_count: usize = node_states
tracing::debug!("Multistatic fusion failed ({e}), using per-node sum/dedup fallback");
// Sum per-node counts then divide by dedup_factor (assumed average
// visibility per body across nodes). ADR-044 §5.1.
// dedup_factor is runtime-configurable; default 3.0.
let total: usize = node_states
.values()
.filter(|ns| {
ns.last_frame_time
@@ -119,9 +122,9 @@ pub fn fuse_or_fallback(
.unwrap_or(false)
})
.map(|ns| ns.prev_person_count)
.max()
.unwrap_or(0);
(None, Some(max_count))
.sum();
let estimated = ((total as f64) / dedup_factor).ceil() as usize;
(None, Some(estimated))
}
}
}
@@ -257,7 +260,7 @@ mod tests {
fn test_fuse_or_fallback_empty() {
let fuser = MultistaticFuser::new();
let states: HashMap<u8, NodeState> = HashMap::new();
let (fused, count) = fuse_or_fallback(&fuser, &states);
let (fused, count) = fuse_or_fallback(&fuser, &states, 3.0);
assert!(fused.is_none());
assert_eq!(count, Some(0));
}