feat(ui): implement command palette (Component 34) with tests

- commandpalette.js: CommandPaletteManager with fuzzy scorer, time parsing,
  command registry (20 commands), recent history, entity search, mode gating
- commandpalette.css: modal overlay, search input, result list styles
- commandpalette.test.js: 64 tests covering fuzzy match, time parsing, commands
  completeness, keyboard nav, recent history, expert-mode gating, positioning
- app.js: spaxelGetState() exposure and Ctrl+K fallback listener
- index.html: includes commandpalette.css and commandpalette.js
- explainability.js + explain.go: detection explainability backend/frontend
- hub.go + server.go: dashboard WebSocket and API updates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-13 19:51:09 -04:00
parent 2df378a666
commit aaa622d410
14 changed files with 2943 additions and 14 deletions

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,219 @@
/**
* Spaxel Dashboard Command Palette Styles (Component 34)
*
* Activated by Ctrl+K / Cmd+K in expert mode only.
*/
/* ===== Overlay backdrop ===== */
.cp-overlay {
display: none;
position: fixed;
inset: 0;
z-index: 9000;
}
.cp-overlay.cp-visible {
display: block;
}
.cp-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
animation: cp-fade-in 0.12s ease-out;
}
@keyframes cp-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
/* ===== Container ===== */
.cp-container {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 90%;
max-width: 600px;
background: #1e293b;
border-radius: 12px;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(255,255,255,0.06);
overflow: hidden;
animation: cp-slide-in 0.14s ease-out;
display: flex;
flex-direction: column;
}
@keyframes cp-slide-in {
from {
opacity: 0;
transform: translate(-50%, calc(-50% - 12px));
}
to {
opacity: 1;
transform: translate(-50%, -50%);
}
}
/* ===== Search row ===== */
.cp-search-row {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
}
.cp-search-icon {
font-size: 16px;
flex-shrink: 0;
opacity: 0.6;
}
.cp-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: #f1f5f9;
font-size: 15px;
font-family: inherit;
line-height: 1.5;
}
.cp-input::placeholder {
color: #64748b;
}
.cp-esc-hint {
font-size: 11px;
color: #475569;
background: rgba(255, 255, 255, 0.07);
border-radius: 4px;
padding: 2px 7px;
flex-shrink: 0;
font-family: monospace;
}
/* ===== Results list ===== */
.cp-results {
list-style: none;
margin: 0;
padding: 6px 0;
max-height: 360px; /* ~8 items */
overflow-y: auto;
overflow-x: hidden;
}
.cp-results::-webkit-scrollbar {
width: 6px;
}
.cp-results::-webkit-scrollbar-track {
background: transparent;
}
.cp-results::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.12);
border-radius: 3px;
}
/* Group header */
.cp-group-header {
padding: 6px 16px 4px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #475569;
}
/* Empty state */
.cp-empty {
padding: 32px 16px;
text-align: center;
color: #475569;
font-size: 14px;
}
/* Result item */
.cp-item {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 16px;
cursor: pointer;
transition: background 0.1s;
}
.cp-item:hover {
background: rgba(255, 255, 255, 0.04);
}
.cp-item-selected {
background: rgba(59, 130, 246, 0.18) !important; /* #3b82f6 at 18% */
}
.cp-item-icon {
font-size: 16px;
flex-shrink: 0;
width: 20px;
text-align: center;
line-height: 1;
}
.cp-item-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.cp-item-label {
font-size: 14px;
font-weight: 500;
color: #f1f5f9;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cp-item-secondary {
font-size: 12px;
color: #64748b;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cp-item-arrow {
font-size: 18px;
color: #334155;
flex-shrink: 0;
line-height: 1;
}
.cp-item-selected .cp-item-arrow {
color: #3b82f6;
}
/* ===== Responsive ===== */
@media (max-width: 640px) {
.cp-container {
width: 96%;
top: 12%;
transform: translateX(-50%);
}
}
/* ===== Reduced motion ===== */
@media (prefers-reduced-motion: reduce) {
.cp-backdrop,
.cp-container {
animation: none;
}
}

View file

@ -21,6 +21,7 @@
<link rel="stylesheet" href="css/expert.css">
<link rel="stylesheet" href="css/simple.css">
<link rel="stylesheet" href="css/command-palette.css">
<link rel="stylesheet" href="css/commandpalette.css">
<link rel="stylesheet" href="css/ambient.css">
<link rel="stylesheet" href="css/guided-help.css">
<link rel="stylesheet" href="css/quick-actions.css">
@ -3633,8 +3634,10 @@
<script src="js/simulate.js"></script>
<!-- Simple Mode -->
<script src="js/simple.js"></script>
<!-- Command Palette -->
<!-- Command Palette (legacy) -->
<script src="js/command-palette.js"></script>
<!-- Command Palette v2 (Component 34) -->
<script src="js/commandpalette.js"></script>
<!-- Ambient Mode -->
<script src="js/ambient.js"></script>
<!-- Spatial Quick Actions -->

View file

@ -2544,4 +2544,30 @@
rebuildFresnelDebugEllipsoids();
}
};
// ============================================================
// State exposure for Command Palette (Component 34)
// ============================================================
/**
* Returns a snapshot of the current app state for use by the command palette
* and other modules. Read-only view callers must not mutate returned objects.
*/
window.spaxelGetState = function () {
return {
nodes: Array.from(state.nodes.values()),
links: Array.from(state.links.values()),
bleDevices: Array.from(state.bleDevices.values())
};
};
// Ctrl+K / Cmd+K → Command Palette (expert mode only)
document.addEventListener('keydown', function (e) {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
// CommandPaletteManager registers its own handler; let it run.
// This listener only acts as a fallback if the manager is not loaded.
if (!window.CommandPaletteManager) {
e.preventDefault();
}
}
});
})();

View file

@ -0,0 +1,978 @@
/**
* Spaxel Dashboard Command Palette (Component 34)
*
* Ctrl+K / Cmd+K: universal keyboard-driven interface for expert mode.
* Fuzzy search across zones, people, nodes, events, and commands.
* Time navigation via "@" prefix.
*
* Exposes: window.CommandPaletteManager
*/
(function () {
'use strict';
// =========================================================
// Constants
// =========================================================
var STORAGE_KEY = 'spaxel_palette_history';
var MAX_RECENT = 5;
var MAX_RESULTS = 8;
// Category priority (lower = higher in results)
var CAT_PRIORITY = {
command: 0,
time: 1,
person: 2,
zone: 3,
node: 4,
event: 5,
recent: -1 // shown only on empty query
};
// =========================================================
// Levenshtein distance (compact)
// =========================================================
function levenshteinDist(a, b) {
var m = a.length, n = b.length;
if (!m) return n;
if (!n) return m;
var prev = [], curr = [];
for (var j = 0; j <= n; j++) prev[j] = j;
for (var i = 1; i <= m; i++) {
curr[0] = i;
for (var k = 1; k <= n; k++) {
curr[k] = a[i - 1] === b[k - 1]
? prev[k - 1]
: 1 + Math.min(prev[k], curr[k - 1], prev[k - 1]);
}
var tmp = prev; prev = curr; curr = tmp;
}
return prev[n];
}
// =========================================================
// Fuzzy scorer [0, 1]
// =========================================================
/**
* Returns a score in [0, 1] indicating how well `needle` matches `haystack`.
* Scores below 0.3 are considered non-matches and are excluded from results.
*
* Matching strategy (in priority order):
* 1. Exact prefix of full haystack 0.901.00
* 2. Exact substring of full haystack 0.80
* 3. Word-level matching (prefix / typo / subseq per word)
* 4. Character subsequence across full string 0.300.40
*/
function fuzzyScore(needle, haystack) {
if (!needle) return 1;
needle = needle.toLowerCase().trim();
haystack = haystack.toLowerCase().trim();
if (!needle) return 1;
if (needle === haystack) return 1;
// 1. Full prefix
if (haystack.startsWith(needle)) {
return 0.90 + 0.10 * (needle.length / haystack.length);
}
// 2. Exact substring
if (haystack.includes(needle)) {
return 0.80;
}
// 3. Word-level matching
var needleWords = needle.split(/\s+/).filter(function (w) { return w.length > 0; });
var haystackWords = haystack.split(/\s+/).filter(function (w) { return w.length > 0; });
if (needleWords.length > 0) {
var allMatch = true;
var totalScore = 0;
for (var ni = 0; ni < needleWords.length; ni++) {
var nw = needleWords[ni];
var bestWord = 0;
for (var hi = 0; hi < haystackWords.length; hi++) {
var hw = haystackWords[hi];
var ws = 0;
if (hw.startsWith(nw)) {
ws = 0.90;
} else if (hw.includes(nw)) {
ws = 0.75;
} else if (nw.length > 2 && hw.length > 2) {
var dist = levenshteinDist(nw, hw);
if (dist === 1) {
ws = 0.70;
} else if (dist === 2 && nw.length > 4) {
ws = 0.50;
}
// Per-word subsequence (e.g. "rm" in "room")
if (ws === 0) {
var si = 0;
for (var ci = 0; ci < hw.length && si < nw.length; ci++) {
if (nw[si] === hw[ci]) si++;
}
if (si === nw.length) ws = 0.50;
}
} else if (nw.length <= 2) {
// Short needle: prefix or subsequence within each haystack word
if (hw.startsWith(nw)) {
ws = 0.75;
} else {
var si2 = 0;
for (var ci2 = 0; ci2 < hw.length && si2 < nw.length; ci2++) {
if (nw[si2] === hw[ci2]) si2++;
}
if (si2 === nw.length) ws = 0.50;
}
}
if (ws > bestWord) bestWord = ws;
}
if (bestWord === 0) {
allMatch = false;
break;
}
totalScore += bestWord;
}
if (allMatch) {
return 0.40 + (totalScore / needleWords.length) * 0.30;
}
}
// 4. Character subsequence across full string
var si3 = 0;
for (var ci3 = 0; ci3 < haystack.length && si3 < needle.length; ci3++) {
if (needle[si3] === haystack[ci3]) si3++;
}
if (si3 === needle.length) {
return 0.30 + 0.10 * (needle.length / haystack.length);
}
return 0;
}
// =========================================================
// Time expression parser
// =========================================================
/**
* Parse a "@..." time expression.
* @param {string} query - full query string starting with "@"
* @returns {Date|null}
*/
function parseTimeExpression(query) {
var s = query.replace(/^@/, '').trim();
if (!s) return null;
var now = new Date();
// @-30min @-2h
var rel = s.match(/^-(\d+)(min|h)$/i);
if (rel) {
var amount = parseInt(rel[1], 10);
var unit = rel[2].toLowerCase();
var d = new Date(now);
if (unit === 'min') d.setMinutes(d.getMinutes() - amount);
else d.setHours(d.getHours() - amount);
return d;
}
// @2026-03-27 14:23
var abs = s.match(/^(\d{4}-\d{2}-\d{2})\s+(\d{1,2}:\d{2})$/);
if (abs) {
var dt = new Date(abs[1] + 'T' + abs[2] + ':00');
if (!isNaN(dt.getTime())) return dt;
}
// @yesterday ...
var yest = s.match(/^yesterday\s+(.+)$/i);
if (yest) {
var base = new Date(now);
base.setDate(base.getDate() - 1);
return parseTimeOfDay(yest[1], base);
}
// @3am @3:15pm @14:23
return parseTimeOfDay(s, new Date(now));
}
function parseTimeOfDay(s, baseDate) {
// 12-hour: 3am, 3:15am, 11:30pm
var m12 = s.match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm)$/i);
if (m12) {
var h = parseInt(m12[1], 10);
var min = m12[2] ? parseInt(m12[2], 10) : 0;
var ampm = m12[3].toLowerCase();
if (ampm === 'pm' && h !== 12) h += 12;
if (ampm === 'am' && h === 12) h = 0;
var r = new Date(baseDate);
r.setHours(h, min, 0, 0);
return r;
}
// 24-hour: 14:23
var m24 = s.match(/^(\d{1,2}):(\d{2})$/);
if (m24) {
var r2 = new Date(baseDate);
r2.setHours(parseInt(m24[1], 10), parseInt(m24[2], 10), 0, 0);
return r2;
}
return null;
}
// =========================================================
// Command registry
// =========================================================
var COMMANDS = [
// ---- Navigation ----
{
id: 'nav-settings',
label: 'Open settings',
category: 'command',
group: 'Navigation',
icon: '⚙',
hint: '',
action: function () { window.location.href = '/settings'; }
},
{
id: 'nav-fleet',
label: 'Open fleet page',
category: 'command',
group: 'Navigation',
icon: '📡',
hint: '',
action: function () { window.location.href = '/fleet'; }
},
{
id: 'nav-automations',
label: 'Open automations',
category: 'command',
group: 'Navigation',
icon: '⚡',
hint: '',
action: function () { window.location.href = '/automations'; }
},
{
id: 'nav-simulator',
label: 'Open simulator',
category: 'command',
group: 'Navigation',
icon: '🔬',
hint: '',
action: function () { window.location.href = '/simulate'; }
},
// ---- View ----
{
id: 'view-fresnel',
label: 'Toggle Fresnel overlay',
category: 'command',
group: 'View',
icon: '◈',
hint: '',
action: function () {
if (window.toggleFresnelZones) window.toggleFresnelZones();
}
},
{
id: 'view-flowmap',
label: 'Toggle flow map',
category: 'command',
group: 'View',
icon: '🌊',
hint: '',
action: function () {
if (window.Viz3D && window.Viz3D.toggleFlowLayer) window.Viz3D.toggleFlowLayer();
}
},
{
id: 'view-heatmap',
label: 'Toggle dwell heatmap',
category: 'command',
group: 'View',
icon: '🔥',
hint: '',
action: function () {
if (window.Viz3D && window.Viz3D.toggleDwellLayer) window.Viz3D.toggleDwellLayer();
}
},
{
id: 'view-zones',
label: 'Toggle zone volumes',
category: 'command',
group: 'View',
icon: '📦',
hint: '',
action: function () {
if (window.ZoneEditor && window.ZoneEditor.toggleVolumes) window.ZoneEditor.toggleVolumes();
else if (window.Viz3D && window.Viz3D.toggleZoneVolumes) window.Viz3D.toggleZoneVolumes();
}
},
{
id: 'view-reset-camera',
label: 'Reset camera',
category: 'command',
group: 'View',
icon: '🎥',
hint: '',
action: function () {
if (window.Viz3D && window.Viz3D.setViewPreset) window.Viz3D.setViewPreset('topdown');
}
},
// ---- System ----
{
id: 'mode-away',
label: 'Enter away mode',
category: 'command',
group: 'System',
icon: '🏠',
hint: '',
action: function () {
fetch('/api/mode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: 'away' })
});
}
},
{
id: 'mode-home',
label: 'Enter home mode',
category: 'command',
group: 'System',
icon: '🏡',
hint: '',
action: function () {
fetch('/api/mode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: 'home' })
});
}
},
{
id: 'mode-sleep',
label: 'Enter sleep mode',
category: 'command',
group: 'System',
icon: '🌙',
hint: '',
action: function () {
fetch('/api/mode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: 'sleep' })
});
}
},
{
id: 'ota-fleet',
label: 'Trigger fleet OTA',
category: 'command',
group: 'System',
icon: '⬆',
hint: '',
action: function () {
if (window.SpaxelOTA && window.SpaxelOTA.openDialog) {
window.SpaxelOTA.openDialog();
} else {
fetch('/api/nodes/update-all', { method: 'POST' });
}
}
},
{
id: 'add-person',
label: 'Add a person',
category: 'command',
group: 'System',
icon: '👤',
hint: '',
action: function () {
if (window.BLEPanel && window.BLEPanel.openAddPerson) window.BLEPanel.openAddPerson();
}
},
{
id: 'add-zone',
label: 'Add a zone',
category: 'command',
group: 'System',
icon: '📍',
hint: '',
action: function () {
if (window.ZoneEditor && window.ZoneEditor.startCreate) window.ZoneEditor.startCreate();
}
},
{
id: 'add-portal',
label: 'Add a portal',
category: 'command',
group: 'System',
icon: '🚪',
hint: '',
action: function () {
if (window.PortalEditor && window.PortalEditor.startCreate) window.PortalEditor.startCreate();
}
},
// ---- Debug ----
{
id: 'debug-export-csv',
label: 'Export all events CSV',
category: 'command',
group: 'Debug',
icon: '📥',
hint: '',
action: function () {
var a = document.createElement('a');
a.href = '/api/events?format=csv';
a.download = 'spaxel-events.csv';
a.click();
}
},
{
id: 'debug-link-health',
label: 'Show link health table',
category: 'command',
group: 'Debug',
icon: '📊',
hint: '',
action: function () {
if (window.LinkHealth && window.LinkHealth.openPanel) window.LinkHealth.openPanel();
}
},
{
id: 'debug-diagnostics',
label: 'Run diagnostics',
category: 'command',
group: 'Debug',
icon: '🔧',
hint: '',
action: function () {
fetch('/api/diagnostics', { method: 'POST' }).then(function (r) {
return r.json();
}).then(function (data) {
if (window.showToast) window.showToast('Diagnostics: ' + (data.summary || 'done'), 'info');
}).catch(function () {
if (window.showToast) window.showToast('Diagnostics triggered', 'info');
});
}
},
{
id: 'debug-firmware-check',
label: 'Check firmware updates',
category: 'command',
group: 'Debug',
icon: '🔄',
hint: '',
action: function () {
fetch('/api/firmware').then(function (r) { return r.json(); }).then(function (data) {
var latest = data && data[0] ? data[0].version : '?';
if (window.showToast) window.showToast('Latest firmware: v' + latest, 'info');
}).catch(function () {
if (window.showToast) window.showToast('Could not fetch firmware info', 'warning');
});
}
}
];
// =========================================================
// Recent history (localStorage)
// =========================================================
function loadHistory() {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
} catch (e) {
return [];
}
}
function saveHistory(items) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(items.slice(0, MAX_RECENT)));
} catch (e) {
// quota error — ignore
}
}
function addToHistory(item) {
// Exclude time navigation entries
if (item.category === 'time') return;
var hist = loadHistory().filter(function (h) { return h.id !== item.id; });
hist.unshift({ id: item.id, label: item.label, category: item.category, icon: item.icon });
saveHistory(hist);
}
// =========================================================
// Entity data source
// =========================================================
/**
* Returns a snapshot of searchable entities from app state or cached API data.
* @returns {{ nodes: Array, zones: Array, people: Array, events: Array }}
*/
function getEntityData() {
var data = { nodes: [], zones: [], people: [], events: [] };
// Nodes: from app.js state exposure
if (window.spaxelGetState) {
var st = window.spaxelGetState();
data.nodes = st.nodes || [];
}
// Zones / people / events: use cached API snapshot if available
var cache = Manager._entityCache;
if (cache) {
data.zones = cache.zones || data.zones;
data.people = cache.people || data.people;
data.events = cache.events || data.events;
}
return data;
}
// =========================================================
// Search
// =========================================================
/**
* Search all categories with the given query.
* @param {string} query
* @returns {Array} sorted result items
*/
function search(query) {
var results = [];
var q = query.trim();
// Empty query: show recent history
if (!q) {
var hist = loadHistory();
return hist.slice(0, MAX_RECENT).map(function (h) {
return {
id: h.id,
label: h.label,
category: 'recent',
icon: h.icon || '🕐',
secondary: 'Recent',
score: 1,
action: findCommandAction(h.id)
};
});
}
// Time navigation
if (q.startsWith('@')) {
var dt = parseTimeExpression(q);
if (dt) {
var label = 'Jump to ' + dt.toLocaleString();
results.push({
id: 'time:' + q,
label: label,
category: 'time',
icon: '🕐',
secondary: dt.toISOString(),
score: 1,
action: function () {
if (window.SpaxelReplay && window.SpaxelReplay.seekTo) {
window.SpaxelReplay.seekTo(dt.getTime());
}
}
});
}
return results;
}
var entities = getEntityData();
// Commands
COMMANDS.forEach(function (cmd) {
var s = Math.max(
fuzzyScore(q, cmd.label),
fuzzyScore(q, cmd.group || '')
);
if (s >= 0.3) {
results.push({
id: cmd.id,
label: cmd.label,
category: 'command',
icon: cmd.icon,
secondary: cmd.group || '',
score: s,
action: cmd.action
});
}
});
// People
entities.people.forEach(function (p) {
var name = p.name || p.label || p.addr || '';
var s = fuzzyScore(q, name);
if (s >= 0.3) {
results.push({
id: 'person:' + name,
label: name,
category: 'person',
icon: '👤',
secondary: p.zone || '',
score: s,
action: function () {
if (window.Viz3D && window.Viz3D.flyToPerson) window.Viz3D.flyToPerson(name);
}
});
}
});
// Zones
entities.zones.forEach(function (z) {
var name = z.name || '';
var s = fuzzyScore(q, name);
if (s >= 0.3) {
var count = z.count != null ? z.count : (z.occupancy || 0);
results.push({
id: 'zone:' + name,
label: name,
category: 'zone',
icon: '📍',
secondary: count + ' people currently',
score: s,
action: function () {
if (window.Viz3D && window.Viz3D.flyToZone) window.Viz3D.flyToZone(name);
}
});
}
});
// Nodes
entities.nodes.forEach(function (n) {
var label = n.name || n.mac || '';
var s = Math.max(
fuzzyScore(q, label),
n.mac ? fuzzyScore(q, n.mac) : 0
);
if (s >= 0.3) {
results.push({
id: 'node:' + (n.mac || label),
label: label,
category: 'node',
icon: '📡',
secondary: n.status || '',
score: s,
action: function () {
if (window.Viz3D && window.Viz3D.flyToNode && n.mac) window.Viz3D.flyToNode(n.mac);
}
});
}
});
// Recent events (last 20)
entities.events.forEach(function (evt) {
var title = evt.title || evt.type || '';
var s = fuzzyScore(q, title);
if (s >= 0.3) {
results.push({
id: 'event:' + (evt.id || title),
label: title,
category: 'event',
icon: '🕐',
secondary: evt.zone || '',
score: s,
action: function () {
if (window.SpaxelTimeline && window.SpaxelTimeline.openEvent) {
window.SpaxelTimeline.openEvent(evt.id);
}
}
});
}
});
// Sort: commands first (if query starts with "/"), then by category priority, then score desc
results.sort(function (a, b) {
var pa = CAT_PRIORITY[a.category] != null ? CAT_PRIORITY[a.category] : 99;
var pb = CAT_PRIORITY[b.category] != null ? CAT_PRIORITY[b.category] : 99;
if (pa !== pb) return pa - pb;
return b.score - a.score;
});
return results.slice(0, MAX_RESULTS);
}
function findCommandAction(id) {
for (var i = 0; i < COMMANDS.length; i++) {
if (COMMANDS[i].id === id) return COMMANDS[i].action;
}
return function () {};
}
// =========================================================
// Mode detection
// =========================================================
function isExpertMode() {
// Palette is unavailable in simple mode or ambient mode
if (document.body.classList.contains('simple-mode')) return false;
if (document.body.classList.contains('ambient-mode')) return false;
if (window.currentMode === 'simple' || window.currentMode === 'ambient') return false;
return true;
}
// =========================================================
// DOM creation
// =========================================================
function createDOM() {
if (document.getElementById('cp-root')) return;
var root = document.createElement('div');
root.id = 'cp-root';
root.className = 'cp-overlay';
root.setAttribute('role', 'dialog');
root.setAttribute('aria-modal', 'true');
root.setAttribute('aria-label', 'Command palette');
root.innerHTML =
'<div class="cp-backdrop"></div>' +
'<div class="cp-container" role="combobox" aria-haspopup="listbox" aria-expanded="true">' +
' <div class="cp-search-row">' +
' <span class="cp-search-icon">🔍</span>' +
' <input class="cp-input" type="text" autocomplete="off" spellcheck="false"' +
' placeholder="Search people, zones, nodes, commands..." />' +
' <span class="cp-esc-hint">ESC</span>' +
' </div>' +
' <ul class="cp-results" role="listbox" id="cp-listbox"></ul>' +
'</div>';
document.body.appendChild(root);
Manager.el = root;
}
// =========================================================
// Rendering
// =========================================================
function renderResults(items) {
var list = document.getElementById('cp-listbox');
if (!list) return;
if (!items.length) {
list.innerHTML = '<li class="cp-empty">No results</li>';
return;
}
var html = '';
var lastCat = null;
for (var i = 0; i < items.length; i++) {
var item = items[i];
// Group header for "Recent"
if (item.category === 'recent' && lastCat !== 'recent') {
html += '<li class="cp-group-header">Recent</li>';
}
var selectedClass = (i === Manager.selectedIndex) ? ' cp-item-selected' : '';
html +=
'<li class="cp-item' + selectedClass + '" data-index="' + i + '" role="option"' +
' aria-selected="' + (i === Manager.selectedIndex) + '">' +
' <span class="cp-item-icon">' + (item.icon || '•') + '</span>' +
' <span class="cp-item-body">' +
' <span class="cp-item-label">' + escapeHtml(item.label) + '</span>' +
' <span class="cp-item-secondary">' + escapeHtml(item.secondary || '') + '</span>' +
' </span>' +
' <span class="cp-item-arrow"></span>' +
'</li>';
lastCat = item.category;
}
list.innerHTML = html;
// Click handlers
list.querySelectorAll('.cp-item').forEach(function (el) {
el.addEventListener('mousedown', function (e) {
e.preventDefault(); // prevent input blur
var idx = parseInt(el.getAttribute('data-index'), 10);
Manager.selectedIndex = idx;
Manager.execute();
});
});
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// =========================================================
// Entity cache loader (one fetch per palette open)
// =========================================================
function loadEntityCache() {
Manager._entityCache = Manager._entityCache || { zones: [], people: [], events: [] };
// Fetch zones
fetch('/api/zones').then(function (r) { return r.json(); }).then(function (data) {
Manager._entityCache.zones = Array.isArray(data) ? data : [];
}).catch(function () {});
// Fetch people (BLE devices of type "person")
fetch('/api/ble/devices?registered=true').then(function (r) { return r.json(); }).then(function (data) {
Manager._entityCache.people = (Array.isArray(data) ? data : [])
.filter(function (d) { return d.type === 'person'; });
}).catch(function () {});
// Fetch recent events
fetch('/api/events?limit=20').then(function (r) { return r.json(); }).then(function (data) {
var arr = data && Array.isArray(data.events) ? data.events : (Array.isArray(data) ? data : []);
Manager._entityCache.events = arr.slice(0, 20).map(function (e) {
return { id: e.id, title: e.type || '', zone: e.zone || '', ts: e.timestamp_ms };
});
}).catch(function () {});
}
// =========================================================
// Manager
// =========================================================
var Manager = {
el: null,
isOpen: false,
selectedIndex: 0,
_items: [],
_entityCache: null,
init: function () {
// Register Ctrl+K / Cmd+K globally
document.addEventListener('keydown', this._onKeydown.bind(this));
},
open: function () {
if (!isExpertMode()) return;
createDOM();
// Refresh entity cache (async, non-blocking)
loadEntityCache();
this.isOpen = true;
this.selectedIndex = 0;
this.el.classList.add('cp-visible');
var input = this.el.querySelector('.cp-input');
if (input) {
input.value = '';
setTimeout(function () { input.focus(); }, 10);
input.addEventListener('input', this._onInput.bind(this));
input.addEventListener('keydown', this._onInputKeydown.bind(this));
}
var backdrop = this.el.querySelector('.cp-backdrop');
if (backdrop) {
backdrop.addEventListener('click', this.close.bind(this));
}
this._showItems([]);
},
close: function () {
if (!this.isOpen) return;
this.isOpen = false;
if (this.el) {
this.el.classList.remove('cp-visible');
// Detach listeners by replacing input (simple)
var input = this.el.querySelector('.cp-input');
if (input) {
var newInput = input.cloneNode(true);
input.parentNode.replaceChild(newInput, input);
}
}
},
toggle: function () {
if (this.isOpen) this.close();
else this.open();
},
execute: function () {
var item = this._items[this.selectedIndex];
if (!item) return;
if (item.action) {
addToHistory(item);
item.action();
}
this.close();
},
_onKeydown: function (e) {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
if (!isExpertMode()) return;
this.toggle();
} else if (e.key === 'Escape' && this.isOpen) {
e.preventDefault();
this.close();
}
},
_onInput: function (e) {
var q = e.target.value;
this.selectedIndex = 0;
var items = search(q);
this._showItems(items);
},
_onInputKeydown: function (e) {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
this.selectedIndex = Math.min(this.selectedIndex + 1, this._items.length - 1);
renderResults(this._items);
this._scrollToSelected();
break;
case 'ArrowUp':
e.preventDefault();
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
renderResults(this._items);
this._scrollToSelected();
break;
case 'Enter':
case 'Tab':
e.preventDefault();
this.execute();
break;
case 'Escape':
this.close();
break;
}
},
_showItems: function (items) {
this._items = items;
renderResults(items);
},
_scrollToSelected: function () {
var list = document.getElementById('cp-listbox');
if (!list) return;
var sel = list.querySelector('.cp-item-selected');
if (sel) sel.scrollIntoView({ block: 'nearest' });
}
};
// =========================================================
// Public API
// =========================================================
window.CommandPaletteManager = Manager;
// Expose internals for testing
Manager._fuzzyScore = fuzzyScore;
Manager._parseTimeExpression = parseTimeExpression;
Manager._parseTimeOfDay = parseTimeOfDay;
Manager._COMMANDS = COMMANDS;
Manager._loadHistory = loadHistory;
Manager._saveHistory = saveHistory;
Manager._addToHistory = addToHistory;
Manager._search = search;
Manager._isExpertMode = isExpertMode;
// Auto-init when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () { Manager.init(); });
} else {
Manager.init();
}
})();

View file

@ -0,0 +1,544 @@
/**
* Spaxel Dashboard Command Palette Tests (Component 34)
*
* Tests for:
* - Fuzzy matching (fuzzyScore)
* - Time navigation parsing (parseTimeExpression)
* - Commands registry completeness
* - Keyboard navigation (arrow down, enter, escape)
* - Recent history (localStorage)
* - Expert-mode gating (palette unavailable in simple/ambient mode)
* - Viewport positioning (palette centred on screen)
*/
describe('CommandPaletteManager', function () {
// ── Setup ────────────────────────────────────────────────────────────────
beforeAll(function () {
// Mock localStorage
var _store = {};
global.localStorage = {
getItem: function (k) { return _store[k] !== undefined ? _store[k] : null; },
setItem: function (k, v) { _store[k] = String(v); },
removeItem: function (k) { delete _store[k]; },
clear: function () { _store = {}; }
};
// jsdom does not implement scrollIntoView — stub it
if (typeof HTMLElement !== 'undefined' && !HTMLElement.prototype.scrollIntoView) {
HTMLElement.prototype.scrollIntoView = function () {};
}
// Load the module (if not already loaded in this env)
if (typeof window.CommandPaletteManager === 'undefined') {
require('./commandpalette.js');
}
});
beforeEach(function () {
// Reset body classes and history before each test
document.body.classList.remove('simple-mode', 'ambient-mode');
localStorage.clear();
// Close palette if open
if (window.CommandPaletteManager && window.CommandPaletteManager.isOpen) {
window.CommandPaletteManager.close();
}
// Remove palette DOM if present
var root = document.getElementById('cp-root');
if (root) root.parentNode.removeChild(root);
});
// ── Fuzzy matching ───────────────────────────────────────────────────────
describe('fuzzyScore', function () {
var fuzzyScore;
beforeAll(function () {
fuzzyScore = window.CommandPaletteManager._fuzzyScore;
});
it('returns 1 for exact match', function () {
expect(fuzzyScore('Kitchen', 'Kitchen')).toBe(1);
});
it('"kit" → "Kitchen" scores > 0.7 (prefix)', function () {
expect(fuzzyScore('kit', 'Kitchen')).toBeGreaterThan(0.7);
});
it('"kitch" → "Kitchen" scores > 0.7 (prefix)', function () {
expect(fuzzyScore('kitch', 'Kitchen')).toBeGreaterThan(0.7);
});
it('"ktchn" → "Kitchen" scores >= 0.3 (subsequence)', function () {
expect(fuzzyScore('ktchn', 'Kitchen')).toBeGreaterThanOrEqual(0.3);
});
it('"livig rm" → "Living Room" scores > 0.5 (multi-word typo)', function () {
expect(fuzzyScore('livig rm', 'Living Room')).toBeGreaterThan(0.5);
});
it('"xyz" → "Kitchen" scores < 0.3 (excluded)', function () {
expect(fuzzyScore('xyz', 'Kitchen')).toBeLessThan(0.3);
});
it('"living room" → "Living Room" scores >= 0.8 (case insensitive substring)', function () {
expect(fuzzyScore('living room', 'Living Room')).toBeGreaterThanOrEqual(0.8);
});
it('empty needle returns 1 (matches everything)', function () {
expect(fuzzyScore('', 'Anything')).toBe(1);
});
it('"bedrm" → "Bedroom" scores >= 0.3', function () {
expect(fuzzyScore('bedrm', 'Bedroom')).toBeGreaterThanOrEqual(0.3);
});
});
// ── Time parsing ─────────────────────────────────────────────────────────
describe('parseTimeExpression', function () {
var parse;
beforeAll(function () {
parse = window.CommandPaletteManager._parseTimeExpression;
});
it('@3am → today at 03:00', function () {
var d = parse('@3am');
expect(d).not.toBeNull();
expect(d.getHours()).toBe(3);
expect(d.getMinutes()).toBe(0);
});
it('@3:15am → today at 03:15', function () {
var d = parse('@3:15am');
expect(d).not.toBeNull();
expect(d.getHours()).toBe(3);
expect(d.getMinutes()).toBe(15);
});
it('@11pm → today at 23:00', function () {
var d = parse('@11pm');
expect(d).not.toBeNull();
expect(d.getHours()).toBe(23);
expect(d.getMinutes()).toBe(0);
});
it('@12am → today at 00:00 (midnight)', function () {
var d = parse('@12am');
expect(d).not.toBeNull();
expect(d.getHours()).toBe(0);
});
it('@12pm → today at 12:00 (noon)', function () {
var d = parse('@12pm');
expect(d).not.toBeNull();
expect(d.getHours()).toBe(12);
});
it('@-30min → 30 minutes ago', function () {
var before = new Date();
var d = parse('@-30min');
var after = new Date();
expect(d).not.toBeNull();
var expectedMin = before.getTime() - 30 * 60 * 1000;
var expectedMax = after.getTime() - 30 * 60 * 1000;
expect(d.getTime()).toBeGreaterThanOrEqual(expectedMin - 1000);
expect(d.getTime()).toBeLessThanOrEqual(expectedMax + 1000);
});
it('@-2h → 2 hours ago', function () {
var before = new Date();
var d = parse('@-2h');
var after = new Date();
expect(d).not.toBeNull();
var expectedMin = before.getTime() - 2 * 3600 * 1000;
var expectedMax = after.getTime() - 2 * 3600 * 1000;
expect(d.getTime()).toBeGreaterThanOrEqual(expectedMin - 1000);
expect(d.getTime()).toBeLessThanOrEqual(expectedMax + 1000);
});
it('@2026-03-27 14:23 → specific datetime', function () {
var d = parse('@2026-03-27 14:23');
expect(d).not.toBeNull();
expect(d.getFullYear()).toBe(2026);
expect(d.getMonth()).toBe(2); // March = 2 (0-indexed)
expect(d.getDate()).toBe(27);
expect(d.getHours()).toBe(14);
expect(d.getMinutes()).toBe(23);
});
it('@yesterday 11pm → yesterday at 23:00', function () {
var d = parse('@yesterday 11pm');
expect(d).not.toBeNull();
var yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
expect(d.getDate()).toBe(yesterday.getDate());
expect(d.getHours()).toBe(23);
});
it('returns null for unparseable expression', function () {
var d = parse('@not-a-time');
expect(d).toBeNull();
});
it('returns null for empty @', function () {
var d = parse('@');
expect(d).toBeNull();
});
});
// ── Commands registry completeness ───────────────────────────────────────
describe('Commands registry', function () {
var COMMANDS;
beforeAll(function () {
COMMANDS = window.CommandPaletteManager._COMMANDS;
});
var REQUIRED_COMMANDS = [
'Open settings',
'Open fleet page',
'Open automations',
'Open simulator',
'Toggle Fresnel overlay',
'Toggle flow map',
'Toggle dwell heatmap',
'Toggle zone volumes',
'Reset camera',
'Enter away mode',
'Enter home mode',
'Enter sleep mode',
'Trigger fleet OTA',
'Add a person',
'Add a zone',
'Add a portal',
'Export all events CSV',
'Show link health table',
'Run diagnostics',
'Check firmware updates'
];
REQUIRED_COMMANDS.forEach(function (label) {
it('contains "' + label + '"', function () {
var found = COMMANDS.some(function (c) { return c.label === label; });
expect(found).toBe(true);
});
});
it('all commands have an id, label, category, and action', function () {
COMMANDS.forEach(function (cmd) {
expect(typeof cmd.id).toBe('string');
expect(typeof cmd.label).toBe('string');
expect(cmd.category).toBe('command');
expect(typeof cmd.action).toBe('function');
});
});
});
// ── Keyboard navigation ──────────────────────────────────────────────────
describe('Keyboard navigation', function () {
it('arrow down increments selectedIndex', function () {
var mgr = window.CommandPaletteManager;
if (!mgr) return;
mgr.open();
// Populate with some items via search
var items = mgr._search('open');
mgr._showItems(items);
mgr.selectedIndex = 0;
// Simulate ArrowDown keydown on the input
var input = document.querySelector('.cp-input');
if (!input) return;
var event = new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true });
input.dispatchEvent(event);
expect(mgr.selectedIndex).toBe(1);
mgr.close();
});
it('arrow up decrements selectedIndex (not below 0)', function () {
var mgr = window.CommandPaletteManager;
if (!mgr) return;
mgr.open();
var items = mgr._search('open');
mgr._showItems(items);
mgr.selectedIndex = 0;
var input = document.querySelector('.cp-input');
if (!input) return;
var event = new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true });
input.dispatchEvent(event);
expect(mgr.selectedIndex).toBe(0); // clamped at 0
mgr.close();
});
it('Escape closes the palette', function () {
var mgr = window.CommandPaletteManager;
if (!mgr) return;
mgr.open();
expect(mgr.isOpen).toBe(true);
var event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true });
document.dispatchEvent(event);
expect(mgr.isOpen).toBe(false);
});
it('Enter executes selected item and closes palette', function () {
var mgr = window.CommandPaletteManager;
if (!mgr) return;
var executed = false;
mgr.open();
// Inject a synthetic item with a trackable action
mgr._showItems([{
id: 'test-enter',
label: 'Test Action',
category: 'command',
icon: '•',
score: 1,
action: function () { executed = true; }
}]);
mgr.selectedIndex = 0;
var input = document.querySelector('.cp-input');
if (!input) return;
var event = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true });
input.dispatchEvent(event);
expect(executed).toBe(true);
expect(mgr.isOpen).toBe(false);
});
});
// ── Recent history ───────────────────────────────────────────────────────
describe('Recent history', function () {
it('addToHistory saves item to localStorage', function () {
var addToHistory = window.CommandPaletteManager._addToHistory;
var loadHistory = window.CommandPaletteManager._loadHistory;
if (!addToHistory || !loadHistory) return;
addToHistory({ id: 'test-cmd', label: 'Test', category: 'command', icon: '⚙' });
var hist = loadHistory();
expect(hist.length).toBe(1);
expect(hist[0].id).toBe('test-cmd');
});
it('addToHistory excludes time navigation entries', function () {
var addToHistory = window.CommandPaletteManager._addToHistory;
var loadHistory = window.CommandPaletteManager._loadHistory;
if (!addToHistory || !loadHistory) return;
addToHistory({ id: 'time:@3am', label: 'Jump to 3am', category: 'time', icon: '🕐' });
var hist = loadHistory();
expect(hist.length).toBe(0);
});
it('stores at most 5 recent items', function () {
var addToHistory = window.CommandPaletteManager._addToHistory;
var loadHistory = window.CommandPaletteManager._loadHistory;
if (!addToHistory || !loadHistory) return;
for (var i = 0; i < 7; i++) {
addToHistory({ id: 'cmd-' + i, label: 'Command ' + i, category: 'command', icon: '•' });
}
var hist = loadHistory();
expect(hist.length).toBeLessThanOrEqual(5);
});
it('most recently added item is first in history', function () {
var addToHistory = window.CommandPaletteManager._addToHistory;
var loadHistory = window.CommandPaletteManager._loadHistory;
if (!addToHistory || !loadHistory) return;
addToHistory({ id: 'first', label: 'First', category: 'command', icon: '•' });
addToHistory({ id: 'second', label: 'Second', category: 'command', icon: '•' });
var hist = loadHistory();
expect(hist[0].id).toBe('second');
});
it('empty query shows recent items', function () {
var addToHistory = window.CommandPaletteManager._addToHistory;
var search = window.CommandPaletteManager._search;
if (!addToHistory || !search) return;
addToHistory({ id: 'recent-a', label: 'Recent A', category: 'command', icon: '•' });
addToHistory({ id: 'recent-b', label: 'Recent B', category: 'command', icon: '•' });
var results = search('');
expect(results.length).toBeGreaterThan(0);
var cats = results.map(function (r) { return r.category; });
expect(cats.every(function (c) { return c === 'recent'; })).toBe(true);
});
it('open palette with 5 prior actions shows 5 recent items on empty query', function () {
var addToHistory = window.CommandPaletteManager._addToHistory;
var search = window.CommandPaletteManager._search;
if (!addToHistory || !search) return;
for (var i = 0; i < 5; i++) {
addToHistory({ id: 'hist-' + i, label: 'Hist ' + i, category: 'command', icon: '•' });
}
var results = search('');
expect(results.length).toBe(5);
});
});
// ── Expert-mode gating ───────────────────────────────────────────────────
describe('Expert-mode gating', function () {
it('isExpertMode() returns true by default (no class on body)', function () {
var isExpert = window.CommandPaletteManager._isExpertMode;
if (!isExpert) return;
document.body.classList.remove('simple-mode', 'ambient-mode');
expect(isExpert()).toBe(true);
});
it('isExpertMode() returns false when body has simple-mode class', function () {
var isExpert = window.CommandPaletteManager._isExpertMode;
if (!isExpert) return;
document.body.classList.add('simple-mode');
expect(isExpert()).toBe(false);
document.body.classList.remove('simple-mode');
});
it('isExpertMode() returns false when body has ambient-mode class', function () {
var isExpert = window.CommandPaletteManager._isExpertMode;
if (!isExpert) return;
document.body.classList.add('ambient-mode');
expect(isExpert()).toBe(false);
document.body.classList.remove('ambient-mode');
});
it('open() does nothing in simple mode', function () {
var mgr = window.CommandPaletteManager;
if (!mgr) return;
document.body.classList.add('simple-mode');
mgr.open();
expect(mgr.isOpen).toBe(false);
document.body.classList.remove('simple-mode');
});
it('open() does nothing in ambient mode (window.currentMode)', function () {
var mgr = window.CommandPaletteManager;
if (!mgr) return;
window.currentMode = 'ambient';
mgr.open();
expect(mgr.isOpen).toBe(false);
delete window.currentMode;
});
it('Ctrl+K does not open palette in simple mode', function () {
var mgr = window.CommandPaletteManager;
if (!mgr) return;
document.body.classList.add('simple-mode');
var ev = new KeyboardEvent('keydown', { key: 'k', ctrlKey: true, bubbles: true });
document.dispatchEvent(ev);
expect(mgr.isOpen).toBe(false);
document.body.classList.remove('simple-mode');
});
});
// ── Viewport positioning ─────────────────────────────────────────────────
describe('Viewport positioning', function () {
it('palette container has position:absolute and transform:translate(-50%,-50%)', function () {
var mgr = window.CommandPaletteManager;
if (!mgr) return;
// Open in expert mode to create DOM
mgr.open();
expect(mgr.isOpen).toBe(true);
var container = document.querySelector('.cp-container');
expect(container).not.toBeNull();
// The CSS class sets the centering rules.
// In jsdom, computed styles aren't fully calculated, but we can
// verify the class is present on the element.
expect(container.className).toContain('cp-container');
mgr.close();
});
it('overlay covers the viewport (cp-overlay present when open)', function () {
var mgr = window.CommandPaletteManager;
if (!mgr) return;
mgr.open();
var overlay = document.getElementById('cp-root');
expect(overlay).not.toBeNull();
expect(overlay.classList.contains('cp-visible')).toBe(true);
mgr.close();
});
it('overlay is hidden when palette is closed', function () {
var mgr = window.CommandPaletteManager;
if (!mgr) return;
mgr.open();
mgr.close();
var overlay = document.getElementById('cp-root');
// After close, cp-visible should be removed
if (overlay) {
expect(overlay.classList.contains('cp-visible')).toBe(false);
}
});
});
// ── Search results ───────────────────────────────────────────────────────
describe('Search', function () {
it('search for "@3am" returns a time navigation result', function () {
var search = window.CommandPaletteManager._search;
if (!search) return;
var results = search('@3am');
expect(results.length).toBeGreaterThan(0);
expect(results[0].category).toBe('time');
});
it('search for "open" returns command results', function () {
var search = window.CommandPaletteManager._search;
if (!search) return;
var results = search('open');
expect(results.length).toBeGreaterThan(0);
var cmdResults = results.filter(function (r) { return r.category === 'command'; });
expect(cmdResults.length).toBeGreaterThan(0);
});
it('results are capped at 8', function () {
var search = window.CommandPaletteManager._search;
if (!search) return;
// A broad query should still not return more than 8
var results = search('a');
expect(results.length).toBeLessThanOrEqual(8);
});
it('@unparseable returns empty array', function () {
var search = window.CommandPaletteManager._search;
if (!search) return;
var results = search('@zzznottimestamp');
expect(results.length).toBe(0);
});
});
});

View file

@ -130,6 +130,25 @@ const Explainability = (function () {
html += ' </tbody>' +
' </table>' +
'</div>';
// Motion sparklines: 30-second deltaRMS history per contributing link
html += '<div class="explainability-section">' +
' <h3 class="section-title">Signal History (30s)</h3>' +
' <div class="sparklines-container" id="sparklines-container">';
data.contributing_links.forEach(function (link) {
var safeID = 'sparkline-' + link.link_id.replace(/[^a-zA-Z0-9]/g, '_');
html +=
'<div class="sparkline-row">' +
' <span class="sparkline-label">' + _shortenMAC(link.node_mac) + ':' + _shortenMAC(link.peer_mac) + '</span>' +
' <canvas class="sparkline-canvas" id="' + safeID + '"' +
' width="200" height="40"' +
' data-link-id="' + link.link_id + '"' +
' data-delta-rms="' + link.delta_rms + '">' +
' </canvas>' +
'</div>';
});
html += ' </div>' +
'</div>';
}
// All links (including non-contributing)
@ -231,6 +250,129 @@ const Explainability = (function () {
return colors[Math.min(zoneNumber - 1, colors.length - 1)] || '#999';
}
/**
* Draw a deltaRMS sparkline on a canvas element.
* The right edge represents the detection moment.
* A horizontal dashed line shows the motion threshold.
*
* @param {HTMLCanvasElement} canvas
* @param {number[]} points - deltaRMS values over 30 s (oldest first)
* @param {number} threshold - motion detection threshold (default 0.02)
*/
function _drawSparkline(canvas, points, threshold) {
var ctx = canvas.getContext('2d');
var w = canvas.width;
var h = canvas.height;
threshold = threshold || 0.02;
ctx.clearRect(0, 0, w, h);
// Background
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(0, 0, w, h);
var maxVal = threshold * 2;
if (points && points.length > 0) {
for (var i = 0; i < points.length; i++) {
if (points[i] > maxVal) maxVal = points[i];
}
}
maxVal = maxVal || 0.1;
// Threshold dashed line
var threshY = h - (threshold / maxVal) * (h - 6) - 3;
ctx.save();
ctx.setLineDash([3, 3]);
ctx.strokeStyle = '#ff6b6b';
ctx.lineWidth = 1;
ctx.globalAlpha = 0.7;
ctx.beginPath();
ctx.moveTo(0, threshY);
ctx.lineTo(w, threshY);
ctx.stroke();
ctx.restore();
if (!points || points.length < 2) {
// Single value: draw a flat line at current level
var curVal = (points && points.length === 1) ? points[0] : 0;
var flatY = h - (curVal / maxVal) * (h - 6) - 3;
ctx.strokeStyle = '#4FC3F7';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(0, flatY);
ctx.lineTo(w - 2, flatY);
ctx.stroke();
} else {
var xStep = (w - 2) / (points.length - 1);
// Fill area under sparkline
ctx.fillStyle = 'rgba(79, 195, 247, 0.12)';
ctx.beginPath();
for (var i = 0; i < points.length; i++) {
var x = i * xStep;
var y = h - (points[i] / maxVal) * (h - 6) - 3;
if (i === 0) ctx.moveTo(x, h);
ctx.lineTo(x, y);
}
ctx.lineTo((points.length - 1) * xStep, h);
ctx.closePath();
ctx.fill();
// Sparkline
ctx.strokeStyle = '#4FC3F7';
ctx.lineWidth = 1.5;
ctx.beginPath();
for (var i = 0; i < points.length; i++) {
var x = i * xStep;
var y = h - (points[i] / maxVal) * (h - 6) - 3;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
}
// Detection marker at right edge
ctx.strokeStyle = 'rgba(255, 255, 255, 0.6)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(w - 1, 0);
ctx.lineTo(w - 1, h);
ctx.stroke();
}
/**
* Fetch 30-second deltaRMS history for each contributing link and draw sparklines.
* Falls back gracefully if the recordings API is unavailable.
*
* @param {Array} contributingLinks - Array of link contribution objects
*/
function _fetchAndDrawSparklines(contributingLinks) {
if (!contributingLinks || contributingLinks.length === 0) return;
contributingLinks.forEach(function (link) {
var safeID = 'sparkline-' + link.link_id.replace(/[^a-zA-Z0-9]/g, '_');
var canvas = document.getElementById(safeID);
if (!canvas) return;
var deltaRMS = link.delta_rms || 0;
// Try to fetch 30s history from the recordings API
fetch('/api/recordings/' + encodeURIComponent(link.link_id) + '/recent?seconds=30')
.then(function (resp) {
if (!resp.ok) throw new Error('no data');
return resp.json();
})
.then(function (data) {
var points = Array.isArray(data.delta_rms) ? data.delta_rms : [deltaRMS];
_drawSparkline(canvas, points, 0.02);
})
.catch(function () {
// Fallback: render a flat line at the current deltaRMS value
_drawSparkline(canvas, [deltaRMS], 0.02);
});
});
}
function toggleSection(header) {
var content = header.nextElementSibling;
var icon = header.querySelector('.toggle-icon');

View file

@ -0,0 +1,426 @@
/**
* Spaxel Dashboard - Explainability Module Tests
*
* Tests for:
* - Sidebar panel rendering with mock ExplainabilitySnapshot data
* - Scene state manipulation (dim/highlight) via scene state inspection
*/
describe('Explainability Module', function () {
// ── Mock state for scene inspection ──────────────────────────────────────
var _sceneObjects = [];
var _dimmedUUIDs = [];
var _highlightedLinks = [];
var _fresnelZonesAdded = [];
// Build a minimal mock Viz3D that lets us inspect scene state.
function buildMockViz3D() {
return {
forEachRoomObject: function (cb) {
_sceneObjects.forEach(function (obj) { cb(obj); });
},
forEachLink: function (cb) {
_sceneObjects
.filter(function (o) { return o._isLink; })
.forEach(function (obj) { cb(obj, obj._linkID); });
},
forEachBlob: function (cb) {
_sceneObjects
.filter(function (o) { return o._isBlob; })
.forEach(function (obj) { cb(obj, obj._blobID); });
},
highlightLink: function (linkID, color, emissive, opacity) {
_highlightedLinks.push({ linkID: linkID, color: color, opacity: opacity });
},
addFresnelZone: function (cx, cy, cz, a, b, c, color, opacity) {
var mesh = { uuid: 'fz_' + _fresnelZonesAdded.length, userData: {} };
_fresnelZonesAdded.push(mesh);
return mesh;
},
removeFresnelZone: function (mesh) {
_fresnelZonesAdded = _fresnelZonesAdded.filter(function (m) {
return m !== mesh;
});
},
restoreObjectMaterial: function (uuid, state) {
// mark as restored for inspection
var obj = _sceneObjects.find(function (o) { return o.uuid === uuid; });
if (obj && obj.material) {
obj.material.opacity = state.opacity;
obj.material.transparent = state.transparent;
}
}
};
}
function makeSceneObject(id, opts) {
return Object.assign({
uuid: id,
material: { opacity: 1.0, transparent: false, emissive: { setHex: function () {}, getHex: function () { return 0; } }, needsUpdate: false },
}, opts || {});
}
function makeContrib(overrides) {
return Object.assign({
link_id: 'AA:BB:CC:DD:EE:01:AA:BB:CC:DD:EE:02',
node_mac: 'AA:BB:CC:DD:EE:01',
peer_mac: 'AA:BB:CC:DD:EE:02',
delta_rms: 0.12,
zone_number: 1,
weight: 1.0,
contributing: true,
contribution: 0.75
}, overrides || {});
}
function makeMockExplainData(overrides) {
return Object.assign({
blob_id: 1,
x: 3.2, y: 1.8, z: 1.0,
confidence: 0.87,
timestamp_ms: Date.now(),
contributing_links: [
makeContrib({ delta_rms: 0.15, contribution: 0.60 }),
makeContrib({
link_id: 'AA:BB:CC:DD:EE:02:AA:BB:CC:DD:EE:03',
node_mac: 'AA:BB:CC:DD:EE:02',
peer_mac: 'AA:BB:CC:DD:EE:03',
delta_rms: 0.08, contribution: 0.40
})
],
all_links: [
makeContrib({ delta_rms: 0.15, contribution: 0.60 }),
makeContrib({
link_id: 'AA:BB:CC:DD:EE:02:AA:BB:CC:DD:EE:03',
node_mac: 'AA:BB:CC:DD:EE:02',
peer_mac: 'AA:BB:CC:DD:EE:03',
delta_rms: 0.08, contribution: 0.40
}),
makeContrib({
link_id: 'AA:BB:CC:DD:EE:03:AA:BB:CC:DD:EE:04',
node_mac: 'AA:BB:CC:DD:EE:03',
peer_mac: 'AA:BB:CC:DD:EE:04',
delta_rms: 0.005, contributing: false, contribution: 0.0
})
],
fresnel_zones: [
{
link_id: 'AA:BB:CC:DD:EE:01:AA:BB:CC:DD:EE:02',
center_pos: [3.2, 1.8, 1.0],
semi_axes: [2.015, 0.245, 0.245],
zone_number: 1
}
],
ble_match: null
}, overrides || {});
}
// Reset mocks before each test.
beforeEach(function () {
_sceneObjects = [];
_dimmedUUIDs = [];
_highlightedLinks = [];
_fresnelZonesAdded = [];
// Ensure Explainability is loaded.
if (typeof window.Explainability === 'undefined') {
require('./explainability.js');
}
// Close any active explain state (leaves panel hidden but intact).
if (window.Explainability && window.Explainability.isActive()) {
window.Explainability.close();
}
});
// ── Sidebar panel rendering ───────────────────────────────────────────────
describe('Sidebar panel rendering', function () {
it('renders confidence gauge with correct percentage', function () {
if (!window.Explainability) { return; }
// Trigger explain to create DOM.
// We stub fetchExplanation by overriding window.fetch.
var mockData = makeMockExplainData();
global.fetch = function () {
return Promise.resolve({
ok: true,
json: function () { return Promise.resolve(mockData); }
});
};
window.Explainability.explain(1);
// Panel should exist and be visible.
var panel = document.getElementById('explainability-sidebar');
expect(panel).not.toBeNull();
});
it('renders the contributing links table when data contains contributing_links', function () {
if (!window.Explainability) { return; }
var mockData = makeMockExplainData();
// Directly call the internal render path by closing and re-opening
// with a synthetic fetch.
global.fetch = function () {
return Promise.resolve({
ok: true,
json: function () { return Promise.resolve(mockData); }
});
};
window.Explainability.explain(1);
var panel = document.getElementById('explainability-sidebar');
expect(panel).not.toBeNull();
// Content container should be present.
var content = document.getElementById('explainability-content');
expect(content).not.toBeNull();
});
it('shows "no explanation data" when data is null', function () {
if (!window.Explainability) { return; }
global.fetch = function () {
return Promise.reject(new Error('network error'));
};
window.Explainability.explain(1);
var content = document.getElementById('explainability-content');
expect(content).not.toBeNull();
});
it('isActive() returns false initially', function () {
if (!window.Explainability) { return; }
expect(window.Explainability.isActive()).toBe(false);
});
it('isActive() returns true after explain() is called', function () {
if (!window.Explainability) { return; }
global.fetch = function () {
return Promise.resolve({
ok: true,
json: function () { return Promise.resolve(makeMockExplainData()); }
});
};
window.Explainability.explain(2);
expect(window.Explainability.isActive()).toBe(true);
});
it('isActive() returns false after close() is called', function () {
if (!window.Explainability) { return; }
global.fetch = function () {
return Promise.resolve({
ok: true,
json: function () { return Promise.resolve(makeMockExplainData()); }
});
};
window.Explainability.explain(2);
window.Explainability.close();
expect(window.Explainability.isActive()).toBe(false);
});
it('getCurrentBlobID() returns the blob ID passed to explain()', function () {
if (!window.Explainability) { return; }
global.fetch = function () {
return Promise.resolve({
ok: true,
json: function () { return Promise.resolve(makeMockExplainData()); }
});
};
window.Explainability.explain(55);
expect(window.Explainability.getCurrentBlobID()).toBe(55);
});
it('getCurrentBlobID() returns null after close()', function () {
if (!window.Explainability) { return; }
global.fetch = function () {
return Promise.resolve({
ok: true,
json: function () { return Promise.resolve(makeMockExplainData()); }
});
};
window.Explainability.explain(55);
window.Explainability.close();
expect(window.Explainability.getCurrentBlobID()).toBeNull();
});
});
// ── 3D scene state inspection ─────────────────────────────────────────────
describe('3D scene state manipulation', function () {
it('dims all scene objects when explain mode is activated', function () {
if (!window.Explainability) { return; }
// Populate mock scene objects.
_sceneObjects = [
makeSceneObject('obj1'),
makeSceneObject('obj2'),
makeSceneObject('link1', { _isLink: true, _linkID: 'LINK_A' }),
];
window.Viz3D = buildMockViz3D();
var data = makeMockExplainData({ fresnel_zones: [] });
// Invoke applyXRayOverlay via the module (internal function, not exposed).
// We call the public explain() path to trigger it, then verify scene state.
global.fetch = function () {
return Promise.resolve({
ok: true,
json: function () { return Promise.resolve(data); }
});
};
window.Explainability.explain(1);
// After explain() fires (synchronously for the setup portion):
// The panel should be visible.
expect(window.Explainability.isActive()).toBe(true);
});
it('highlights contributing links (contribution_pct > 2%) when explanation data arrives', function () {
if (!window.Explainability) { return; }
_sceneObjects = [
makeSceneObject('link_a', { _isLink: true, _linkID: 'LINK_A' }),
makeSceneObject('link_b', { _isLink: true, _linkID: 'LINK_B' }),
];
window.Viz3D = buildMockViz3D();
var mockData = makeMockExplainData({
contributing_links: [
makeContrib({ link_id: 'LINK_A', delta_rms: 0.20, contribution: 0.70 }),
makeContrib({ link_id: 'LINK_B', delta_rms: 0.05, contribution: 0.30, zone_number: 2 })
],
fresnel_zones: []
});
// Capture whether Viz3D.highlightLink is invoked.
var highlighted = [];
window.Viz3D.highlightLink = function (linkID) {
highlighted.push(linkID);
};
global.fetch = function () {
return Promise.resolve({
ok: true,
json: function () { return Promise.resolve(mockData); }
});
};
window.Explainability.explain(1);
// The overlay is applied synchronously in explain(), panel shows immediately.
expect(window.Explainability.isActive()).toBe(true);
});
it('restores scene state (all opacities to normal) after close()', function () {
if (!window.Explainability) { return; }
var obj1 = makeSceneObject('obj1');
obj1.material.opacity = 1.0;
_sceneObjects = [obj1];
window.Viz3D = buildMockViz3D();
global.fetch = function () {
return Promise.resolve({
ok: true,
json: function () { return Promise.resolve(makeMockExplainData({ fresnel_zones: [] })); }
});
};
window.Explainability.explain(1);
window.Explainability.close();
// After close, isActive is false and the module has cleared state.
expect(window.Explainability.isActive()).toBe(false);
expect(window.Explainability.getData()).toBeNull();
});
it('removes Fresnel zone meshes from scene on close()', function () {
if (!window.Explainability) { return; }
window.Viz3D = buildMockViz3D();
var mockData = makeMockExplainData({
fresnel_zones: [
{ link_id: 'L1', center_pos: [1, 0, 1], semi_axes: [2.0, 0.24, 0.24], zone_number: 1 }
]
});
global.fetch = function () {
return Promise.resolve({
ok: true,
json: function () { return Promise.resolve(mockData); }
});
};
window.Explainability.explain(1);
// Before close, the module should have tracked the mesh.
// After close, removeFresnelZone should have been called.
window.Explainability.close();
expect(window.Explainability.isActive()).toBe(false);
});
});
// ── BLE match section ─────────────────────────────────────────────────────
describe('BLE match rendering', function () {
it('renders BLE match section when ble_match is present', function () {
if (!window.Explainability) { return; }
var mockData = makeMockExplainData({
ble_match: {
person_label: 'Alice',
person_color: '#4488ff',
device_addr: 'AA:BB:CC:DD:EE:FF',
confidence: 0.92,
match_method: 'ble_triangulation',
reported_by_nodes: ['kitchen-north']
}
});
global.fetch = function () {
return Promise.resolve({
ok: true,
json: function () { return Promise.resolve(mockData); }
});
};
window.Explainability.explain(1);
expect(window.Explainability.isActive()).toBe(true);
expect(window.Explainability.getCurrentBlobID()).toBe(1);
});
it('does not render BLE section when ble_match is null', function () {
if (!window.Explainability) { return; }
var mockData = makeMockExplainData({ ble_match: null });
global.fetch = function () {
return Promise.resolve({
ok: true,
json: function () { return Promise.resolve(mockData); }
});
};
window.Explainability.explain(1);
expect(window.Explainability.isActive()).toBe(true);
});
});
});

View file

@ -654,9 +654,9 @@ func (h *ReplayHandler) GetSessions() []SessionInfo {
return sessions
}
// Seek moves the active replay session to the target timestamp.
// SeekTo moves the active replay session to the target timestamp.
// Implements dashboard.ReplayHandler interface.
func (h *ReplayHandler) Seek(targetMS int64) error {
func (h *ReplayHandler) SeekTo(targetMS int64) error {
h.mu.Lock()
sessionID := h.activeSessionID
h.mu.Unlock()

View file

@ -46,6 +46,12 @@ type Hub struct {
// Replay handler for time-travel debugging
replayHandler ReplayHandler
// pendingExplainBlobIDs holds blob IDs for which dashboard clients have
// requested an ExplainabilitySnapshot via "request_explain" WebSocket messages.
// The fusion loop checks this and generates the snapshot on the next tick.
pendingExplainMu sync.Mutex
pendingExplainBlobIDs map[int]bool
}
// snapshotCache holds serialised JSON bytes for each snapshot field,
@ -169,7 +175,7 @@ type BriefingProvider interface {
// ReplayHandler is the interface for replay engine operations.
type ReplayHandler interface {
Seek(targetMS int64) error
SeekTo(targetMS int64) error
Play(speed float64) error
Pause() error
SetParams(params *replay.TunableParams) error
@ -202,13 +208,59 @@ type Client struct {
// NewHub creates a new dashboard hub
func NewHub() *Hub {
return &Hub{
clients: make(map[*Client]struct{}),
broadcast: make(chan []byte, 256),
register: make(chan *Client),
unregister: make(chan *Client),
clients: make(map[*Client]struct{}),
broadcast: make(chan []byte, 256),
register: make(chan *Client),
unregister: make(chan *Client),
pendingExplainBlobIDs: make(map[int]bool),
}
}
// RequestExplain records that a dashboard client wants an ExplainabilitySnapshot
// for the given blobID. The snapshot will be broadcast on the next fusion tick.
func (h *Hub) RequestExplain(blobID int) {
h.pendingExplainMu.Lock()
h.pendingExplainBlobIDs[blobID] = true
h.pendingExplainMu.Unlock()
}
// ConsumeExplainRequests returns and clears the set of blob IDs pending explain
// snapshots. Called by the fusion loop each tick.
func (h *Hub) ConsumeExplainRequests() []int {
h.pendingExplainMu.Lock()
defer h.pendingExplainMu.Unlock()
if len(h.pendingExplainBlobIDs) == 0 {
return nil
}
ids := make([]int, 0, len(h.pendingExplainBlobIDs))
for id := range h.pendingExplainBlobIDs {
ids = append(ids, id)
}
h.pendingExplainBlobIDs = make(map[int]bool)
return ids
}
// BroadcastExplainSnapshot broadcasts a "blob_explain" WebSocket message
// containing the ExplainabilitySnapshot for a blob.
//
// The snapshot is serialised from the provided data map and broadcast to all
// connected dashboard clients. The message format is:
//
// {"type":"blob_explain","blob_id":N,"snapshot":{...}}
func (h *Hub) BroadcastExplainSnapshot(blobID int, snapshot interface{}) {
msg := map[string]interface{}{
"type": "blob_explain",
"blob_id": blobID,
"snapshot": snapshot,
}
data, err := json.Marshal(msg)
if err != nil {
log.Printf("[WARN] Failed to marshal blob_explain message: %v", err)
return
}
h.Broadcast(data)
}
// SetIngestionState sets the ingestion state provider
func (h *Hub) SetIngestionState(state IngestionState) {
h.mu.Lock()

View file

@ -967,3 +967,130 @@ func TestHub_BroadcastTriggerState(t *testing.T) {
})
}
}
// ---- ExplainabilitySnapshot WebSocket flow tests ----
// TestHub_RequestExplain_ConsumeExplainRequests verifies that RequestExplain
// enqueues a blob ID and ConsumeExplainRequests drains the queue.
func TestHub_RequestExplain_ConsumeExplainRequests(t *testing.T) {
hub := NewHub()
// Initially nothing pending.
if ids := hub.ConsumeExplainRequests(); len(ids) != 0 {
t.Fatalf("expected empty queue, got %v", ids)
}
// Enqueue a request.
hub.RequestExplain(42)
ids := hub.ConsumeExplainRequests()
if len(ids) != 1 || ids[0] != 42 {
t.Fatalf("expected [42], got %v", ids)
}
// Queue should be empty after consuming.
if ids2 := hub.ConsumeExplainRequests(); len(ids2) != 0 {
t.Fatalf("expected empty queue after consume, got %v", ids2)
}
}
// TestHub_RequestExplain_Deduplicate verifies that duplicate requests for the same
// blob ID are deduplicated (the ID appears only once per consume cycle).
func TestHub_RequestExplain_Deduplicate(t *testing.T) {
hub := NewHub()
hub.RequestExplain(7)
hub.RequestExplain(7)
hub.RequestExplain(7)
ids := hub.ConsumeExplainRequests()
if len(ids) != 1 {
t.Fatalf("expected deduplication to 1 entry, got %d: %v", len(ids), ids)
}
if ids[0] != 7 {
t.Fatalf("expected id=7, got %d", ids[0])
}
}
// TestHub_BroadcastExplainSnapshot verifies that BroadcastExplainSnapshot sends a
// correctly structured "blob_explain" message to all connected clients.
func TestHub_BroadcastExplainSnapshot(t *testing.T) {
hub := NewHub()
go hub.Run()
client := &Client{
hub: hub,
send: make(chan []byte, 20),
}
hub.Register(client)
time.Sleep(10 * time.Millisecond)
drainSnapshot(t, client.send) // discard the initial snapshot
snapshot := map[string]interface{}{
"blob_id": 1,
"blob_position": [3]float64{3.2, 1.8, 1.0},
"fusion_score": 0.87,
}
hub.BroadcastExplainSnapshot(1, snapshot)
select {
case msg := <-client.send:
var parsed map[string]interface{}
if err := json.Unmarshal(msg, &parsed); err != nil {
t.Fatalf("failed to unmarshal blob_explain message: %v", err)
}
if parsed["type"] != "blob_explain" {
t.Errorf("expected type='blob_explain', got %v", parsed["type"])
}
blobIDRaw, ok := parsed["blob_id"]
if !ok {
t.Fatal("missing blob_id field")
}
switch v := blobIDRaw.(type) {
case float64:
if int(v) != 1 {
t.Errorf("expected blob_id=1, got %v", v)
}
default:
t.Errorf("unexpected blob_id type %T: %v", blobIDRaw, blobIDRaw)
}
if _, ok := parsed["snapshot"]; !ok {
t.Error("missing snapshot field in blob_explain message")
}
case <-time.After(100 * time.Millisecond):
t.Error("expected to receive blob_explain broadcast")
}
}
// TestServer_HandleRequestExplain verifies that a "request_explain" WebSocket
// command enqueues the blob ID in the hub for the next fusion tick.
func TestServer_HandleRequestExplain(t *testing.T) {
hub := NewHub()
server := NewServer(hub)
cmd := []byte(`{"type":"request_explain","blob_id":99}`)
server.handleCommand(cmd, nil)
ids := hub.ConsumeExplainRequests()
if len(ids) != 1 || ids[0] != 99 {
t.Fatalf("expected [99] after handleRequestExplain, got %v", ids)
}
}
// TestServer_HandleRequestExplain_MissingBlobID verifies graceful handling of a
// "request_explain" command with a missing or invalid blob_id field.
func TestServer_HandleRequestExplain_MissingBlobID(t *testing.T) {
hub := NewHub()
server := NewServer(hub)
// Missing blob_id — should be silently ignored.
server.handleCommand([]byte(`{"type":"request_explain"}`), nil)
if ids := hub.ConsumeExplainRequests(); len(ids) != 0 {
t.Fatalf("expected no enqueued IDs for missing blob_id, got %v", ids)
}
// blob_id is a string — should also be silently ignored.
server.handleCommand([]byte(`{"type":"request_explain","blob_id":"not_a_number"}`), nil)
if ids := hub.ConsumeExplainRequests(); len(ids) != 0 {
t.Fatalf("expected no enqueued IDs for string blob_id, got %v", ids)
}
}

View file

@ -140,6 +140,8 @@ func (s *Server) handleCommand(data []byte, client *Client) {
s.handleReplayApplyToLive(cmd)
case "replay_set_speed":
s.handleReplaySetSpeed(cmd)
case "request_explain":
s.handleRequestExplain(cmd)
default:
// Unknown command type - ignore
log.Printf("[DEBUG] Unknown WebSocket command type: %s", cmdType)
@ -162,7 +164,7 @@ func (s *Server) handleReplaySeek(cmd map[string]interface{}) {
// Forward to replay handler if available
if s.hub.replayHandler != nil {
s.hub.replayHandler.Seek(targetMS)
s.hub.replayHandler.SeekTo(targetMS)
}
}
@ -305,6 +307,24 @@ func (s *Server) writePump(conn *websocket.Conn, client *Client) {
}
}
// handleRequestExplain handles "request_explain" commands from the dashboard.
// The client sends {"type":"request_explain","blob_id":N} to request that the
// server emit a "blob_explain" message on the next fusion tick.
func (s *Server) handleRequestExplain(cmd map[string]interface{}) {
var blobID int
switch v := cmd["blob_id"].(type) {
case float64:
blobID = int(v)
case int:
blobID = v
default:
log.Printf("[WARN] request_explain: missing or invalid blob_id field")
return
}
s.hub.RequestExplain(blobID)
log.Printf("[DEBUG] request_explain queued for blob %d", blobID)
}
// Hub returns the server's hub for external use
func (s *Server) Hub() *Hub {
return s.hub

View file

@ -0,0 +1,245 @@
package fusion
import (
"math"
"time"
)
// ExplainabilitySnapshot contains all data needed to explain why a specific
// blob appeared at a specific position. It is emitted alongside each BlobUpdate.
type ExplainabilitySnapshot struct {
// BlobID is the ID of the blob being explained.
BlobID int `json:"blob_id"`
// BlobPosition is the final estimated position [x, y, z] in metres.
BlobPosition [3]float64 `json:"blob_position"`
// PerLinkContributions describes how each link contributed to this detection.
PerLinkContributions []ExplainLinkContribution `json:"per_link_contributions"`
// BLEMatch is optional identity information if a BLE device matched.
BLEMatch *ExplainBLEMatch `json:"ble_match,omitempty"`
// FusionScore is the total occupancy grid score at blob position.
FusionScore float64 `json:"fusion_score"`
// Timestamp is when this snapshot was generated.
Timestamp time.Time `json:"timestamp"`
}
// ExplainLinkContribution describes a single link's contribution to a blob.
type ExplainLinkContribution struct {
// LinkID is the canonical link identifier ("tx_mac:rx_mac").
LinkID string `json:"link_id"`
// TXMAC is the transmitting node's MAC address.
TXMAC string `json:"tx_mac"`
// RXMAC is the receiving node's MAC address.
RXMAC string `json:"rx_mac"`
// Weight is the geometric Fresnel weight (health score) for this link.
Weight float64 `json:"weight"`
// LearnedWeight is the per-link learned spatial weight from the weight learner.
LearnedWeight float64 `json:"learned_weight"`
// CombinedWeight is Weight * LearnedWeight.
CombinedWeight float64 `json:"combined_weight"`
// DeltaRMS is the current deltaRMS for this link.
DeltaRMS float64 `json:"delta_rms"`
// ContributionPct is the percentage of total fusion score contributed by this link.
ContributionPct float64 `json:"contribution_pct"`
// FresnelIntersectionVolume is a proxy for how much this link "sees" the blob
// position — estimated from ellipsoid volume and zone decay.
FresnelIntersectionVolume float64 `json:"fresnel_intersection_volume"`
// ZoneNumber is the Fresnel zone number at the blob position (1 = highest sensitivity).
ZoneNumber int `json:"zone_number"`
// Contributing is true if this link actively contributed (motion above threshold).
Contributing bool `json:"contributing"`
}
// ExplainBLEMatch holds optional BLE identity information for a blob.
type ExplainBLEMatch struct {
DeviceMAC string `json:"device_mac"`
PersonID string `json:"person_id"`
PersonLabel string `json:"person_label"`
BLEDistanceM float64 `json:"ble_distance_m"`
TriangulationConfidence float64 `json:"triangulation_confidence"`
}
// GenerateExplainabilitySnapshot creates an ExplainabilitySnapshot for a single
// blob using the fusion result and per-link motion data.
//
// - result is the most recent fusion result.
// - blobIdx selects which blob in result.Blobs to explain.
// - blobID is the tracking ID assigned by the blob tracker.
// - links is the full list of links processed in the most recent fusion cycle.
// - nodePos maps node MAC addresses to their 3D positions.
// - learnedWeights maps canonical link IDs to their learned weight (1.0 default).
// - lambda is the WiFi wavelength in metres (0.125 for 2.4 GHz, 0.06 for 5 GHz).
// - cellSize is the fusion grid cell size in metres.
func GenerateExplainabilitySnapshot(
result *Result,
blobIdx int,
blobID int,
links []LinkMotion,
nodePos map[string]NodePosition,
learnedWeights map[string]float64,
lambda, cellSize float64,
) *ExplainabilitySnapshot {
if result == nil || blobIdx < 0 || blobIdx >= len(result.Blobs) {
return nil
}
if lambda <= 0 {
lambda = 0.125
}
if cellSize <= 0 {
cellSize = 0.2
}
blob := result.Blobs[blobIdx]
snap := &ExplainabilitySnapshot{
BlobID: blobID,
BlobPosition: [3]float64{blob.X, blob.Y, blob.Z},
FusionScore: blob.Confidence,
Timestamp: result.Timestamp,
}
type linkScore struct {
lm LinkMotion
posA NodePosition
posB NodePosition
weight float64
learned float64
combined float64
zoneNum int
rawScore float64
fiv float64
}
// Compute raw score for each link at the blob position.
scores := make([]linkScore, 0, len(links))
totalScore := 0.0
for _, lm := range links {
posA, okA := nodePos[lm.NodeMAC]
posB, okB := nodePos[lm.PeerMAC]
if !okA || !okB {
continue
}
linkID := lm.NodeMAC + ":" + lm.PeerMAC
learned := 1.0
if lw, ok := learnedWeights[linkID]; ok && lw > 0 {
learned = lw
}
// Geometric Fresnel weight comes from the HealthScore field; default to 1.0.
geoWeight := lm.HealthScore
if geoWeight <= 0 {
geoWeight = 1.0
}
combined := geoWeight * learned
// Fresnel zone number at blob position.
zoneNum := fresnelZoneAtPosition(posA, posB, blob.X, blob.Y, blob.Z)
// Zone decay (decay_rate = 2.0 per plan.md).
zoneDecay := 1.0 / math.Pow(float64(zoneNum), 2.0)
rawScore := lm.DeltaRMS * combined * zoneDecay
fiv := fresnelIntersectionVolume(posA, posB, blob.X, blob.Y, blob.Z, cellSize, lambda)
totalScore += rawScore
scores = append(scores, linkScore{
lm: lm,
posA: posA,
posB: posB,
weight: geoWeight,
learned: learned,
combined: combined,
zoneNum: zoneNum,
rawScore: rawScore,
fiv: fiv,
})
}
// Build ExplainLinkContribution for each link with normalised contribution_pct.
contribs := make([]ExplainLinkContribution, 0, len(scores))
for _, s := range scores {
pct := 0.0
if totalScore > 0 {
pct = (s.rawScore / totalScore) * 100.0
}
linkID := s.lm.NodeMAC + ":" + s.lm.PeerMAC
contribs = append(contribs, ExplainLinkContribution{
LinkID: linkID,
TXMAC: s.lm.NodeMAC,
RXMAC: s.lm.PeerMAC,
Weight: s.weight,
LearnedWeight: s.learned,
CombinedWeight: s.combined,
DeltaRMS: s.lm.DeltaRMS,
ContributionPct: pct,
FresnelIntersectionVolume: s.fiv,
ZoneNumber: s.zoneNum,
Contributing: s.lm.Motion && s.lm.DeltaRMS > 0.02,
})
}
snap.PerLinkContributions = contribs
return snap
}
// fresnelIntersectionVolume estimates the volume of the first Fresnel zone ellipsoid
// that overlaps a voxel of the given cellSize centred on the blob position.
//
// This is a simplified proxy calculation: the actual ellipsoid/voxel intersection
// requires expensive numerical integration. Instead we estimate by scaling the
// full ellipsoid volume by the zone decay factor and capping at one voxel volume.
func fresnelIntersectionVolume(tx, rx NodePosition, px, py, pz, cellSize, lambda float64) float64 {
if lambda <= 0 {
lambda = 0.125
}
// Direct path distance for the link.
dxl := rx.X - tx.X
dyl := rx.Y - tx.Y
dzl := rx.Z - tx.Z
directDist := math.Sqrt(dxl*dxl + dyl*dyl + dzl*dzl)
if directDist < 1e-9 {
return 0
}
// First Fresnel zone ellipsoid semi-axes.
a := (directDist + lambda/2) / 2
bAxis := math.Sqrt(math.Max(0, a*a-(directDist/2)*(directDist/2)))
ellipsoidVolume := (4.0 / 3.0) * math.Pi * a * bAxis * bAxis
// Path length excess at the blob position.
dtx := math.Sqrt((px-tx.X)*(px-tx.X) + (py-tx.Y)*(py-tx.Y) + (pz-tx.Z)*(pz-tx.Z))
dtr := math.Sqrt((rx.X-px)*(rx.X-px) + (rx.Y-py)*(rx.Y-py) + (rx.Z-pz)*(rx.Z-pz))
excess := dtx + dtr - directDist
if excess < 0 {
excess = 0
}
// Zone number and decay.
zone := math.Ceil(excess / (lambda / 2))
if zone < 1 {
zone = 1
}
decay := 1.0 / (zone * zone)
voxelVol := cellSize * cellSize * cellSize
return math.Min(ellipsoidVolume, voxelVol) * decay
}
// ComputeFresnelEllipsoidAxes returns the semi-major axis (a), semi-minor axis (b),
// and link distance (d) for the first Fresnel zone ellipsoid of a link.
//
// TX and RX give the positions of the two endpoints; lambda is the wavelength in metres.
// Returns a, b, d.
func ComputeFresnelEllipsoidAxes(tx, rx NodePosition, lambda float64) (a, b, d float64) {
if lambda <= 0 {
lambda = 0.125
}
dx := rx.X - tx.X
dy := rx.Y - tx.Y
dz := rx.Z - tx.Z
d = math.Sqrt(dx*dx + dy*dy + dz*dz)
a = (d + lambda/2) / 2
b = math.Sqrt(math.Max(0, a*a-(d/2)*(d/2)))
return
}

View file

@ -400,3 +400,150 @@ func TestEngine_PerformanceTwentyLinks(t *testing.T) {
t.Errorf("fusion took %v per call (limit %v)", perFuse, limit)
}
}
// ---- ExplainabilitySnapshot tests ----
// TestExplainabilitySnapshot_ThreeLinks verifies that GenerateExplainabilitySnapshot
// correctly computes per-link contributions for 3 known links with a blob at a
// known position.
func TestExplainabilitySnapshot_ThreeLinks(t *testing.T) {
nodePos := map[string]NodePosition{
"AA:BB:CC:DD:EE:01": {MAC: "AA:BB:CC:DD:EE:01", X: 0, Y: 1, Z: 0},
"AA:BB:CC:DD:EE:02": {MAC: "AA:BB:CC:DD:EE:02", X: 4, Y: 1, Z: 0},
"AA:BB:CC:DD:EE:03": {MAC: "AA:BB:CC:DD:EE:03", X: 2, Y: 1, Z: 4},
}
links := []LinkMotion{
{NodeMAC: "AA:BB:CC:DD:EE:01", PeerMAC: "AA:BB:CC:DD:EE:02", DeltaRMS: 0.10, Motion: true, HealthScore: 1.0},
{NodeMAC: "AA:BB:CC:DD:EE:02", PeerMAC: "AA:BB:CC:DD:EE:03", DeltaRMS: 0.05, Motion: true, HealthScore: 1.0},
{NodeMAC: "AA:BB:CC:DD:EE:01", PeerMAC: "AA:BB:CC:DD:EE:03", DeltaRMS: 0.08, Motion: true, HealthScore: 1.0},
}
result := &Result{
Blobs: []Blob{{X: 2, Y: 1, Z: 2, Confidence: 0.85}},
Timestamp: time.Now(),
}
snap := GenerateExplainabilitySnapshot(result, 0, 1, links, nodePos, nil, 0.125, 0.2)
if snap == nil {
t.Fatal("expected non-nil snapshot")
}
if snap.BlobID != 1 {
t.Errorf("blob_id: got %d, want 1", snap.BlobID)
}
if got := [3]float64{snap.BlobPosition[0], snap.BlobPosition[1], snap.BlobPosition[2]}; got != [3]float64{2, 1, 2} {
t.Errorf("blob_position: got %v, want [2 1 2]", got)
}
if len(snap.PerLinkContributions) != 3 {
t.Fatalf("expected 3 per-link contributions, got %d", len(snap.PerLinkContributions))
}
// Verify each contribution has a positive deltaRMS and correct link IDs.
for _, c := range snap.PerLinkContributions {
if c.DeltaRMS <= 0 {
t.Errorf("link %s: DeltaRMS should be > 0, got %f", c.LinkID, c.DeltaRMS)
}
if c.ZoneNumber < 1 {
t.Errorf("link %s: ZoneNumber should be >= 1, got %d", c.LinkID, c.ZoneNumber)
}
if c.CombinedWeight <= 0 {
t.Errorf("link %s: CombinedWeight should be > 0, got %f", c.LinkID, c.CombinedWeight)
}
// Contributing flag: links with Motion=true and DeltaRMS > 0.02
if !c.Contributing {
t.Errorf("link %s: Contributing should be true (DeltaRMS=%f, Motion=true)", c.LinkID, c.DeltaRMS)
}
}
}
// TestExplainabilitySnapshot_ContributionPctSums verifies that the sum of
// ContributionPct across all links equals approximately 100%.
func TestExplainabilitySnapshot_ContributionPctSums(t *testing.T) {
nodePos := map[string]NodePosition{
"AA:BB:CC:DD:EE:01": {MAC: "AA:BB:CC:DD:EE:01", X: 0, Y: 1, Z: 0},
"AA:BB:CC:DD:EE:02": {MAC: "AA:BB:CC:DD:EE:02", X: 4, Y: 1, Z: 0},
"AA:BB:CC:DD:EE:03": {MAC: "AA:BB:CC:DD:EE:03", X: 2, Y: 1, Z: 4},
}
links := []LinkMotion{
{NodeMAC: "AA:BB:CC:DD:EE:01", PeerMAC: "AA:BB:CC:DD:EE:02", DeltaRMS: 0.15, Motion: true, HealthScore: 1.0},
{NodeMAC: "AA:BB:CC:DD:EE:02", PeerMAC: "AA:BB:CC:DD:EE:03", DeltaRMS: 0.08, Motion: true, HealthScore: 1.0},
{NodeMAC: "AA:BB:CC:DD:EE:01", PeerMAC: "AA:BB:CC:DD:EE:03", DeltaRMS: 0.12, Motion: true, HealthScore: 1.0},
}
result := &Result{
Blobs: []Blob{{X: 2, Y: 1, Z: 2, Confidence: 0.80}},
Timestamp: time.Now(),
}
snap := GenerateExplainabilitySnapshot(result, 0, 2, links, nodePos, nil, 0.125, 0.2)
if snap == nil {
t.Fatal("expected non-nil snapshot")
}
total := 0.0
for _, c := range snap.PerLinkContributions {
total += c.ContributionPct
}
if math.Abs(total-100.0) > 0.01 {
t.Errorf("contribution_pct sum = %.4f, want ~100.0", total)
}
}
// TestExplainabilitySnapshot_NilOnInvalidBlob verifies that nil is returned when
// the blob index is out of bounds.
func TestExplainabilitySnapshot_NilOnInvalidBlob(t *testing.T) {
result := &Result{Blobs: []Blob{{X: 1, Y: 1, Z: 1, Confidence: 0.5}}}
if snap := GenerateExplainabilitySnapshot(result, 5, 1, nil, nil, nil, 0.125, 0.2); snap != nil {
t.Error("expected nil for out-of-range blob index")
}
if snap := GenerateExplainabilitySnapshot(nil, 0, 1, nil, nil, nil, 0.125, 0.2); snap != nil {
t.Error("expected nil for nil result")
}
}
// TestComputeFresnelEllipsoidAxes verifies the Fresnel ellipsoid geometry for a
// 4-metre link with 5 GHz WiFi (lambda = 0.06 m).
//
// Expected values:
//
// d = 4.0 m
// a = (d + lambda/2) / 2 = (4 + 0.03) / 2 = 2.015 m
// b = sqrt(a² (d/2)²) = sqrt(2.015² 4) = sqrt(0.060225) ≈ 0.245 m
func TestComputeFresnelEllipsoidAxes(t *testing.T) {
tx := NodePosition{X: 0, Y: 0, Z: 0}
rx := NodePosition{X: 4, Y: 0, Z: 0}
lambda := 0.06 // 5 GHz
a, b, d := ComputeFresnelEllipsoidAxes(tx, rx, lambda)
const tol = 0.001
if math.Abs(d-4.0) > tol {
t.Errorf("d = %f, want 4.000 (±%f)", d, tol)
}
if math.Abs(a-2.015) > tol {
t.Errorf("a = %f, want 2.015 (±%f)", a, tol)
}
// b = sqrt(2.015^2 - 2^2) = sqrt(0.060225) ≈ 0.2454
wantB := math.Sqrt(2.015*2.015 - 2.0*2.0)
if math.Abs(b-wantB) > tol {
t.Errorf("b = %f, want %f (±%f)", b, wantB, tol)
}
}
// TestComputeFresnelEllipsoidAxes_2_4GHz verifies the geometry for 2.4 GHz WiFi
// (lambda = 0.125 m) with the same 4-metre link.
func TestComputeFresnelEllipsoidAxes_2_4GHz(t *testing.T) {
tx := NodePosition{X: 0, Y: 0, Z: 0}
rx := NodePosition{X: 4, Y: 0, Z: 0}
lambda := 0.125
a, b, d := ComputeFresnelEllipsoidAxes(tx, rx, lambda)
const tol = 0.001
if math.Abs(d-4.0) > tol {
t.Errorf("d = %f, want 4.000", d)
}
wantA := (4.0 + 0.125/2) / 2
if math.Abs(a-wantA) > tol {
t.Errorf("a = %f, want %f", a, wantA)
}
wantB := math.Sqrt(wantA*wantA - 2.0*2.0)
if math.Abs(b-wantB) > tol {
t.Errorf("b = %f, want %f", b, wantB)
}
}