spaxel/dashboard/js/state.js
jedarden c424104582 feat: build dashboard panel/modal/sidebar UI framework
Implemented a comprehensive panel framework for the Spaxel dashboard to
support Phase 6-9 UI work (automation builder, timeline, explainability,
settings, notifications, presence predictions).

- Panel System (dashboard/js/panels.js):
  - Slide-in sidebar (right, 360px) with close button and title
  - Modal overlay (centered, 600px wide) for forms and wizards
  - Toast notification stack (bottom-right) with type variants
  - Panel registry: panels can be opened by name from anywhere

- Route/Mode Navigation (dashboard/js/router.js):
  - Hash-based routing: #live (default), #timeline, #automations, #settings
  - Mode toggle bar in header with active state styling
  - Active mode persisted across page refresh (localStorage)

- State Management (dashboard/js/state.js):
  - Central app state object (nodes, blobs, zones, links, alerts, events)
  - Subscribe/notify pattern for reactive component updates
  - Convenience methods for common operations (updateNode, addEvent, etc.)

- Settings Panel (dashboard/js/settings-panel.js):
  - Motion threshold slider (deltaRMS threshold)
  - Fusion rate selection (5/10/20 Hz)
  - Grid cell size and Fresnel decay rate controls
  - Subcarrier count and baseline time constant settings
  - Notification channel config (Ntfy URL/token, Pushover keys)
  - System info display (version, uptime, detection quality, node count)

- Updated index.html with:
  - CSS/JS includes for panel framework
  - Settings button in status bar

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 10:04:40 -04:00

446 lines
13 KiB
JavaScript

/**
* Spaxel Dashboard - State Management
*
* Central app state object with subscribe/notify pattern.
* Separate from WebSocket message parsing.
*/
(function() {
'use strict';
// ============================================
// Central Application State
// ============================================
const appState = {
// Node data
nodes: {}, // Map of MAC -> { mac, name, pos_x, pos_y, pos_z, role, firmware_version, status, rssi, uptime_s, last_seen, virtual }
// Blobs (detected people)
blobs: {}, // Map of blob_id -> { id, x, y, z, confidence, vx, vy, vz, posture, person, ble_device, trails }
// Zones
zones: {}, // Map of zone_id -> { id, name, x, y, z, w, d, h, zone_type, occupancy, people:[] }
// Links (node-to-node connections)
links: {}, // Map of link_id -> { id, node_mac, peer_mac, delta_rms, snr, phase_stability, quality, weight }
// Alerts
alerts: [], // Array of active alerts { id, type, severity, title, message, timestamp_ms, acknowledged }
// Events (for timeline)
events: [], // Array of recent events { id, timestamp_ms, type, zone, person, blob_id, detail_json, severity }
// BLE devices
ble_devices: {}, // Map of addr -> { addr, label, type, color, icon, auto_rotate, first_seen, last_seen, last_rssi }
// Triggers (automation)
triggers: {}, // Map of trigger_id -> { id, name, shape_json, condition, condition_params_json, time_constraint_json, actions_json, enabled, last_fired }
// Portals
portals: {}, // Map of portal_id -> { id, name, zone_a_id, zone_b_id, points_json }
// System info
system: {
version: null,
uptime_s: 0,
detection_quality: 0,
confidence: 0,
security_mode: false,
nodes_online: 0,
nodes_total: 0
},
// Predictions
predictions: [], // Array of { person, zone, probability, horizon_min }
// Connection state
connection: {
connected: false,
connecting: false,
last_disconnect_time: null
},
// Settings
settings: {
delta_rms_threshold: 0.02,
fusion_rate_hz: 10,
grid_cell_m: 0.2,
fresnel_decay: 2.0,
n_subcarriers: 16,
tau_s: 30,
breathing_sensitivity: 0.5
}
};
// ============================================
// Subscriber Registry
// ============================================
const subscribers = new Map();
/**
* Subscribe to state changes
* @param {string} key - State key to watch (or '*' for all changes)
* @param {Function} callback - Callback(newValue, oldValue, key)
* @returns {Function} Unsubscribe function
*/
function subscribe(key, callback) {
if (typeof callback !== 'function') {
console.error('[State] Callback must be a function');
return function() {};
}
if (!subscribers.has(key)) {
subscribers.set(key, []);
}
const callbacks = subscribers.get(key);
callbacks.push(callback);
// Return unsubscribe function
return function unsubscribe() {
const callbacks = subscribers.get(key);
if (callbacks) {
const index = callbacks.indexOf(callback);
if (index !== -1) {
callbacks.splice(index, 1);
}
}
};
}
/**
* Notify subscribers of a state change
* @param {string} key - State key that changed
* @param {*} newValue - New value
* @param {*} oldValue - Previous value
*/
function notify(key, newValue, oldValue) {
// Notify key-specific subscribers
if (subscribers.has(key)) {
const callbacks = subscribers.get(key);
callbacks.forEach(callback => {
try {
callback(newValue, oldValue, key);
} catch (e) {
console.error('[State] Subscriber error for key', key, ':', e);
}
});
}
// Notify wildcard subscribers
if (subscribers.has('*')) {
const callbacks = subscribers.get('*');
callbacks.forEach(callback => {
try {
callback(newValue, oldValue, key);
} catch (e) {
console.error('[State] Wildcard subscriber error:', e);
}
});
}
}
// ============================================
// State Getters/Setters
// ============================================
/**
* Get a value from state
* @param {string} key - Dot-notation key (e.g., 'system.version')
* @returns {*} Value or undefined if not found
*/
function get(key) {
const parts = key.split('.');
let value = appState;
for (let i = 0; i < parts.length; i++) {
if (value && typeof value === 'object' && parts[i] in value) {
value = value[parts[i]];
} else {
return undefined;
}
}
return value;
}
/**
* Set a value in state and notify subscribers
* @param {string} key - Dot-notation key
* @param {*} value - New value
*/
function set(key, value) {
const oldValue = get(key);
const parts = key.split('.');
let obj = appState;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
if (!(part in obj) || typeof obj[part] !== 'object') {
obj[part] = {};
}
obj = obj[part];
}
const lastPart = parts[parts.length - 1];
obj[lastPart] = value;
notify(key, value, oldValue);
}
/**
* Update a nested object in state
* @param {string} key - Dot-notation key to the object
* @param {Object} updates - Object with keys to update
*/
function update(key, updates) {
const obj = get(key);
if (!obj || typeof obj !== 'object') {
console.error('[State] Cannot update non-object at key:', key);
return;
}
const oldObj = { ...obj };
Object.assign(obj, updates);
notify(key, obj, oldObj);
}
/**
* Get the entire state (use carefully - for debugging)
*/
function getState() {
return appState;
}
/**
* Reset state to initial values
* @param {string} key - Optional dot-notation key to reset (resets all if omitted)
*/
function reset(key) {
if (key) {
const parts = key.split('.');
let obj = appState;
for (let i = 0; i < parts.length - 1; i++) {
obj = obj[parts[i]];
if (!obj) return;
}
const lastPart = parts[parts.length - 1];
const oldValue = obj[lastPart];
// Reset to appropriate default based on type
if (Array.isArray(oldValue)) {
obj[lastPart] = [];
} else if (typeof oldValue === 'object' && oldValue !== null) {
obj[lastPart] = {};
} else {
obj[lastPart] = null;
}
notify(key, obj[lastPart], oldValue);
} else {
// Reset entire state (not commonly used)
Object.keys(appState).forEach(k => {
if (Array.isArray(appState[k])) {
appState[k] = [];
} else if (typeof appState[k] === 'object' && appState[k] !== null) {
appState[k] = {};
} else {
appState[k] = null;
}
});
notify('*', null, null);
}
}
// ============================================
// Convenience Methods for Common State
// ============================================
/**
* Update a node's state
*/
function updateNode(mac, updates) {
if (!appState.nodes[mac]) {
appState.nodes[mac] = { mac: mac };
}
Object.assign(appState.nodes[mac], updates);
notify('nodes.' + mac, appState.nodes[mac], null);
notify('nodes', appState.nodes, null);
}
/**
* Remove a node
*/
function removeNode(mac) {
const oldValue = appState.nodes[mac];
delete appState.nodes[mac];
notify('nodes.' + mac, null, oldValue);
notify('nodes', appState.nodes, null);
}
/**
* Update a blob's state
*/
function updateBlob(id, updates) {
if (!appState.blobs[id]) {
appState.blobs[id] = { id: id };
}
Object.assign(appState.blobs[id], updates);
notify('blobs.' + id, appState.blobs[id], null);
notify('blobs', appState.blobs, null);
}
/**
* Remove a blob
*/
function removeBlob(id) {
const oldValue = appState.blobs[id];
delete appState.blobs[id];
notify('blobs.' + id, null, oldValue);
notify('blobs', appState.blobs, null);
}
/**
* Add an event to the timeline
*/
function addEvent(event) {
if (!event.id) {
event.id = 'evt_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
}
if (!event.timestamp_ms) {
event.timestamp_ms = Date.now();
}
appState.events.unshift(event);
// Keep only last 1000 events in memory
if (appState.events.length > 1000) {
appState.events = appState.events.slice(0, 1000);
}
notify('events', appState.events, null);
}
/**
* Add an alert
*/
function addAlert(alert) {
if (!alert.id) {
alert.id = 'alert_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
}
if (!alert.timestamp_ms) {
alert.timestamp_ms = Date.now();
}
if (!alert.acknowledged) {
alert.acknowledged = false;
}
appState.alerts.push(alert);
notify('alerts', appState.alerts, null);
return alert.id;
}
/**
* Acknowledge an alert
*/
function acknowledgeAlert(alertId) {
const alert = appState.alerts.find(a => a.id === alertId);
if (alert) {
alert.acknowledged = true;
notify('alerts', appState.alerts, null);
}
}
/**
* Remove an alert
*/
function removeAlert(alertId) {
const index = appState.alerts.findIndex(a => a.id === alertId);
if (index !== -1) {
const removed = appState.alerts.splice(index, 1)[0];
notify('alerts', appState.alerts, null);
return removed;
}
}
/**
* Update connection state
*/
function setConnectionState(state) {
const oldConnected = appState.connection.connected;
if (state.connected !== undefined) {
appState.connection.connected = state.connected;
}
if (state.connecting !== undefined) {
appState.connection.connecting = state.connecting;
}
if (state.last_disconnect_time !== undefined) {
appState.connection.last_disconnect_time = state.last_disconnect_time;
}
notify('connection', appState.connection, { connected: oldConnected });
// Track disconnect time for stale detection
if (state.connected === false && oldConnected === true) {
appState.connection.last_disconnect_time = Date.now();
}
}
/**
* Update system info
*/
function updateSystem(updates) {
const oldSystem = { ...appState.system };
Object.assign(appState.system, updates);
notify('system', appState.system, oldSystem);
}
// ============================================
// Public API
// ============================================
window.SpaxelState = {
// Core methods
get: get,
set: set,
update: update,
getState: getState,
reset: reset,
// Subscription
subscribe: subscribe,
// Convenience methods
updateNode: updateNode,
removeNode: removeNode,
updateBlob: updateBlob,
removeBlob: removeBlob,
addEvent: addEvent,
addAlert: addAlert,
acknowledgeAlert: acknowledgeAlert,
removeAlert: removeAlert,
setConnectionState: setConnectionState,
updateSystem: updateSystem,
// Direct access to state (read-only preferred)
nodes: appState.nodes,
blobs: appState.blobs,
zones: appState.zones,
links: appState.links,
alerts: appState.alerts,
events: appState.events,
ble_devices: appState.ble_devices,
triggers: appState.triggers,
portals: appState.portals,
system: appState.system,
predictions: appState.predictions,
connection: appState.connection,
settings: appState.settings
};
console.log('[State] State management initialized');
})();