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>
This commit is contained in:
jedarden 2026-04-06 10:02:29 -04:00
parent e7f4ff3f17
commit c424104582
6 changed files with 2639 additions and 0 deletions

730
dashboard/css/panels.css Normal file
View file

@ -0,0 +1,730 @@
/* ============================================
Panel Framework Styles
============================================ */
/* ----- Overlay Backdrop ----- */
.panel-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0);
z-index: 1000;
transition: background 0.3s ease;
pointer-events: none;
}
.panel-overlay-visible {
background: rgba(0, 0, 0, 0.5);
pointer-events: auto;
}
/* ----- Sidebar Panel ----- */
.panel-sidebar {
position: fixed;
top: 0;
bottom: 0;
width: 360px;
background: #1e1e3a;
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.3);
z-index: 1001;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
flex-direction: column;
max-width: 90vw;
}
.panel-sidebar-right {
right: 0;
transform: translateX(100%);
}
.panel-sidebar-left {
left: 0;
transform: translateX(-100%);
}
.panel-sidebar-visible {
transform: translateX(0);
}
/* Panel Header */
.panel-header {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.panel-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #eee;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.panel-close {
background: none;
border: none;
color: #888;
font-size: 24px;
line-height: 1;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: background 0.2s, color 0.2s;
flex-shrink: 0;
margin-left: 12px;
}
.panel-close:hover {
background: rgba(255, 255, 255, 0.1);
color: #eee;
}
.panel-close:active {
background: rgba(255, 255, 255, 0.15);
}
/* Panel Content */
.panel-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 20px;
}
/* Panel Content Scrollbar Styling */
.panel-content::-webkit-scrollbar {
width: 8px;
}
.panel-content::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
}
.panel-content::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 4px;
}
.panel-content::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
/* ----- Modal Dialog ----- */
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0);
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.3s ease;
pointer-events: none;
}
.modal-backdrop-visible {
background: rgba(0, 0, 0, 0.7);
pointer-events: auto;
}
.modal-container {
background: #1e1e3a;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
width: 600px;
max-width: 90vw;
max-height: 90vh;
display: flex;
flex-direction: column;
transition: opacity 0.3s ease, transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
opacity: 0;
transform: scale(0.95) translateY(-20px);
}
.modal-container-visible {
opacity: 1;
transform: scale(1) translateY(0);
}
/* Modal Header */
.modal-header {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.modal-title {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #eee;
}
.modal-close {
background: none;
border: none;
color: #888;
font-size: 24px;
line-height: 1;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: background 0.2s, color 0.2s;
flex-shrink: 0;
margin-left: 12px;
}
.modal-close:hover {
background: rgba(255, 255, 255, 0.1);
color: #eee;
}
/* Modal Content */
.modal-content {
flex: 1;
overflow-y: auto;
padding: 20px 24px;
min-height: 0;
}
/* Modal Footer */
.modal-footer {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
padding: 16px 24px 20px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.modal-btn {
padding: 10px 20px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
border: none;
transition: background 0.2s, transform 0.1s;
}
.modal-btn:active {
transform: translateY(1px);
}
.modal-btn-cancel {
background: rgba(255, 255, 255, 0.1);
color: #ccc;
}
.modal-btn-cancel:hover {
background: rgba(255, 255, 255, 0.15);
color: #eee;
}
.modal-btn-confirm {
background: #4fc3f7;
color: #1a1a2e;
}
.modal-btn-confirm:hover {
background: #29b6f6;
}
/* ----- Toast Notifications ----- */
#toast-container {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 3000;
display: flex;
flex-direction: column;
gap: 10px;
pointer-events: none;
}
.toast {
background: #2a2a4a;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 8px;
padding: 12px 16px;
min-width: 280px;
max-width: 400px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
gap: 12px;
pointer-events: auto;
transition: opacity 0.3s ease, transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
opacity: 0;
transform: translateY(20px);
}
.toast-visible {
opacity: 1;
transform: translateY(0);
}
.toast-dismissed {
opacity: 0;
transform: translateX(100%);
}
.toast-icon {
font-size: 18px;
flex-shrink: 0;
width: 24px;
text-align: center;
}
.toast-message {
flex: 1;
font-size: 14px;
color: #eee;
line-height: 1.4;
}
.toast-dismiss {
background: none;
border: none;
color: #888;
font-size: 18px;
line-height: 1;
cursor: pointer;
padding: 0 4px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: background 0.2s, color 0.2s;
flex-shrink: 0;
}
.toast-dismiss:hover {
background: rgba(255, 255, 255, 0.1);
color: #eee;
}
/* Toast Type Variants */
.toast-success {
border-color: rgba(102, 187, 106, 0.4);
background: linear-gradient(135deg, rgba(102, 187, 106, 0.2), rgba(76, 175, 80, 0.1));
}
.toast-success .toast-icon {
color: #66bb6a;
}
.toast-info {
border-color: rgba(79, 195, 247, 0.4);
background: linear-gradient(135deg, rgba(79, 195, 247, 0.2), rgba(66, 165, 245, 0.1));
}
.toast-info .toast-icon {
color: #4fc3f7;
}
.toast-warning {
border-color: rgba(255, 167, 38, 0.4);
background: linear-gradient(135deg, rgba(255, 167, 38, 0.2), rgba(255, 152, 0, 0.1));
}
.toast-warning .toast-icon {
color: #ffa726;
}
.toast-error {
border-color: rgba(239, 83, 80, 0.4);
background: linear-gradient(135deg, rgba(239, 83, 80, 0.2), rgba(244, 67, 54, 0.1));
}
.toast-error .toast-icon {
color: #ef5350;
}
/* ----- Mode Toggle Bar ----- */
.mode-toggle-bar {
position: fixed;
top: 40px;
left: 0;
right: 0;
height: 44px;
background: rgba(0, 0, 0, 0.9);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 0 16px;
z-index: 90;
}
.mode-toggle-btn {
background: transparent;
border: none;
color: #888;
font-size: 13px;
font-weight: 500;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s, color 0.2s;
position: relative;
}
.mode-toggle-btn:hover {
background: rgba(255, 255, 255, 0.08);
color: #ccc;
}
.mode-toggle-btn.active {
background: rgba(79, 195, 247, 0.15);
color: #4fc3f7;
}
.mode-toggle-btn.active::after {
content: '';
position: absolute;
bottom: 0;
left: 16px;
right: 16px;
height: 2px;
background: #4fc3f7;
border-radius: 1px 1px 0 0;
}
/* ----- Panel Content Common Styles ----- */
/* Form Groups */
.panel-form-group {
margin-bottom: 16px;
}
.panel-form-group label {
display: block;
font-size: 13px;
color: #aaa;
margin-bottom: 6px;
font-weight: 500;
}
.panel-form-group input[type="text"],
.panel-form-group input[type="number"],
.panel-form-group input[type="password"],
.panel-form-group input[type="email"],
.panel-form-group input[type="url"],
.panel-form-group select,
.panel-form-group textarea {
width: 100%;
padding: 10px 12px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
color: #eee;
font-size: 14px;
font-family: inherit;
transition: border-color 0.2s, box-shadow 0.2s;
box-sizing: border-box;
}
.panel-form-group input:focus,
.panel-form-group select:focus,
.panel-form-group textarea:focus {
outline: none;
border-color: #4fc3f7;
box-shadow: 0 0 0 3px rgba(79, 195, 247, 0.15);
}
.panel-form-group input::placeholder,
.panel-form-group textarea::placeholder {
color: #555;
}
.panel-form-group select {
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23888' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 32px;
}
/* Range Slider */
.panel-form-group input[type="range"] {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 6px;
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
outline: none;
margin: 10px 0;
}
.panel-form-group input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
background: #4fc3f7;
border-radius: 50%;
cursor: pointer;
transition: transform 0.1s;
}
.panel-form-group input[type="range"]::-webkit-slider-thumb:hover {
transform: scale(1.1);
}
.panel-form-group input[type="range"]::-moz-range-thumb {
width: 18px;
height: 18px;
background: #4fc3f7;
border-radius: 50%;
cursor: pointer;
border: none;
}
.panel-form-range-value {
font-size: 13px;
color: #4fc3f7;
font-weight: 500;
text-align: right;
margin-top: -8px;
margin-bottom: 8px;
}
/* Checkbox */
.panel-form-checkbox {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
padding: 6px 0;
}
.panel-form-checkbox input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: #4fc3f7;
cursor: pointer;
flex-shrink: 0;
}
.panel-form-checkbox label {
margin: 0;
cursor: pointer;
flex: 1;
}
/* Section Headers */
.panel-section {
margin-bottom: 20px;
}
.panel-section-header {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 1px;
color: #888;
margin-bottom: 12px;
padding-bottom: 6px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
/* Buttons */
.panel-btn {
padding: 10px 20px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
border: none;
transition: background 0.2s, transform 0.1s;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.panel-btn:active {
transform: translateY(1px);
}
.panel-btn-primary {
background: #4fc3f7;
color: #1a1a2e;
}
.panel-btn-primary:hover {
background: #29b6f6;
}
.panel-btn-secondary {
background: rgba(255, 255, 255, 0.1);
color: #ccc;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.panel-btn-secondary:hover {
background: rgba(255, 255, 255, 0.15);
color: #eee;
}
.panel-btn-danger {
background: rgba(244, 67, 54, 0.2);
color: #ef5350;
border: 1px solid rgba(244, 67, 54, 0.3);
}
.panel-btn-danger:hover {
background: rgba(244, 67, 54, 0.3);
}
.panel-btn-full {
width: 100%;
}
.panel-btn-group {
display: flex;
gap: 10px;
margin-top: 16px;
}
/* Info Cards */
.panel-info-card {
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
padding: 12px 16px;
margin-bottom: 12px;
}
.panel-info-card-title {
font-size: 13px;
font-weight: 600;
color: #eee;
margin-bottom: 4px;
}
.panel-info-card-value {
font-size: 20px;
font-weight: 600;
color: #4fc3f7;
}
.panel-info-card-subtitle {
font-size: 11px;
color: #888;
margin-top: 4px;
}
/* Divider */
.panel-divider {
border: none;
border-top: 1px solid rgba(255, 255, 255, 0.1);
margin: 20px 0;
}
/* Loading State */
.panel-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
color: #888;
}
.panel-loading-spinner {
width: 32px;
height: 32px;
border: 3px solid rgba(79, 195, 247, 0.2);
border-top-color: #4fc3f7;
border-radius: 50%;
animation: panel-spin 0.8s linear infinite;
margin-right: 12px;
}
@keyframes panel-spin {
to { transform: rotate(360deg); }
}
/* Empty State */
.panel-empty {
text-align: center;
padding: 40px 20px;
color: #666;
}
.panel-empty-icon {
font-size: 48px;
margin-bottom: 12px;
opacity: 0.5;
}
.panel-empty-text {
font-size: 14px;
}
/* Responsive */
@media (max-width: 600px) {
.panel-sidebar {
width: 100%;
max-width: 100%;
}
.modal-container {
width: 100vw;
height: 100vh;
max-width: 100vw;
max-height: 100vh;
border-radius: 0;
}
#toast-container {
left: 10px;
right: 10px;
bottom: 10px;
}
.toast {
min-width: 0;
width: 100%;
}
.mode-toggle-bar {
overflow-x: auto;
justify-content: flex-start;
}
.mode-toggle-btn {
padding: 8px 12px;
white-space: nowrap;
}
}

View file

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Spaxel Dashboard</title>
<link rel="stylesheet" href="css/troubleshoot.css">
<link rel="stylesheet" href="css/panels.css">
<style>
* {
margin: 0;
@ -1342,6 +1343,23 @@
background: rgba(79, 195, 247, 0.25);
}
/* Settings button in status bar */
#settings-btn {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.12);
color: #888;
font-size: 12px;
padding: 3px 10px;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s, color 0.2s;
}
#settings-btn:hover {
background: rgba(255, 255, 255, 0.12);
color: #ccc;
}
/* Room editor button */
#room-editor-btn {
background: rgba(255,255,255,0.06);
@ -1989,6 +2007,7 @@
</div>
</div>
<div class="status-item" style="margin-left:auto; gap:6px;">
<button id="settings-btn" onclick="openSettingsPanel && openSettingsPanel()">Settings</button>
<button id="add-node-btn" onclick="SpaxelOnboard && SpaxelOnboard.start()">+ Add Node</button>
<button id="add-virtual-btn" onclick="Placement && Placement.addVirtualNode()">+ Virtual</button>
<button class="view-btn active" id="view-perspective" onclick="Viz3D.setViewPreset('perspective')">3D</button>
@ -2080,6 +2099,14 @@
<script src="js/viz3d.js"></script>
<!-- Node placement, GDOP coverage, room editor -->
<script src="js/placement.js"></script>
<!-- Panel Framework (must load before modules that use it) -->
<script src="js/panels.js"></script>
<!-- State Management -->
<script src="js/state.js"></script>
<!-- Router -->
<script src="js/router.js"></script>
<!-- Settings Panel -->
<script src="js/settings-panel.js"></script>
<!-- Troubleshooting manager (must load before app.js) -->
<script src="js/troubleshoot.js"></script>
<!-- First-time feature tooltips (must load before app.js) -->

606
dashboard/js/panels.js Normal file
View file

@ -0,0 +1,606 @@
/**
* Spaxel Dashboard - Panel Framework
*
* Provides slide-in sidebar, modal overlay, toast notifications,
* and a panel registry for opening panels by name.
*/
(function() {
'use strict';
// ============================================
// Panel Registry
// ============================================
const registeredPanels = new Map();
/**
* Register a panel constructor/creator function
* @param {string} name - Panel identifier
* @param {Function|Object} creator - Function that returns panel config, or panel config object
*/
function registerPanel(name, creator) {
registeredPanels.set(name, creator);
}
/**
* Open a panel by name
* @param {string} name - Panel identifier
* @param {Object} options - Panel options (title, content, onOpen, onClose, etc.)
*/
function openPanel(name, options) {
const creator = registeredPanels.get(name);
if (!creator) {
console.error('[Panels] Unknown panel:', name);
return null;
}
let config;
if (typeof creator === 'function') {
config = creator(options);
} else {
config = { ...creator, ...options };
}
return openSidebar(config);
}
/**
* Open a modal dialog
* @param {Object} options - Modal options (title, content, width, onConfirm, onCancel, etc.)
*/
function openModal(options) {
return createModal(options);
}
// ============================================
// Sidebar Panel
// ============================================
let currentSidebar = null;
let sidebarElement = null;
let sidebarOverlay = null;
const defaultSidebarOptions = {
title: 'Panel',
content: '',
width: '360px',
position: 'right', // 'right' or 'left'
closeOnEscape: true,
closeOnOverlayClick: true,
onOpen: null,
onClose: null,
className: ''
};
/**
* Open a slide-in sidebar panel
* @param {Object} options - Sidebar options
* @returns {Object} Panel control object with close() method
*/
function openSidebar(options) {
// Close existing sidebar first
if (currentSidebar) {
closeSidebar();
}
const config = { ...defaultSidebarOptions, ...options };
const position = config.position;
// Create overlay
sidebarOverlay = document.createElement('div');
sidebarOverlay.className = 'panel-overlay';
sidebarOverlay.addEventListener('click', function(e) {
if (config.closeOnOverlayClick && e.target === sidebarOverlay) {
closeSidebar();
}
});
// Create sidebar panel
sidebarElement = document.createElement('div');
sidebarElement.className = 'panel-sidebar panel-sidebar-' + position + ' ' + config.className;
sidebarElement.style.width = config.width;
// Sidebar header
const header = document.createElement('div');
header.className = 'panel-header';
const title = document.createElement('h2');
title.className = 'panel-title';
title.textContent = config.title;
const closeButton = document.createElement('button');
closeButton.className = 'panel-close';
closeButton.innerHTML = '&times;';
closeButton.setAttribute('aria-label', 'Close');
closeButton.addEventListener('click', closeSidebar);
header.appendChild(title);
header.appendChild(closeButton);
// Sidebar content
const content = document.createElement('div');
content.className = 'panel-content';
if (typeof config.content === 'string') {
content.innerHTML = config.content;
} else if (config.content instanceof HTMLElement) {
content.appendChild(config.content);
} else if (typeof config.content === 'function') {
const result = config.content(content);
if (result instanceof HTMLElement) {
content.innerHTML = '';
content.appendChild(result);
}
}
// Assemble sidebar
sidebarElement.appendChild(header);
sidebarElement.appendChild(content);
// Add to DOM
document.body.appendChild(sidebarOverlay);
document.body.appendChild(sidebarElement);
// Trigger animation
requestAnimationFrame(function() {
sidebarOverlay.classList.add('panel-overlay-visible');
sidebarElement.classList.add('panel-sidebar-visible');
});
// Store panel control
currentSidebar = {
close: closeSidebar,
element: sidebarElement,
config: config
};
// Call onOpen callback
if (config.onOpen) {
config.onOpen(content, currentSidebar);
}
// Handle escape key
if (config.closeOnEscape) {
document.addEventListener('keydown', handleEscape);
}
return currentSidebar;
}
/**
* Close the current sidebar
*/
function closeSidebar() {
if (!currentSidebar) return;
const config = currentSidebar.config;
// Trigger close animation
if (sidebarOverlay) {
sidebarOverlay.classList.remove('panel-overlay-visible');
}
if (sidebarElement) {
sidebarElement.classList.remove('panel-sidebar-visible');
}
// Remove from DOM after animation
setTimeout(function() {
if (sidebarOverlay && sidebarOverlay.parentNode) {
sidebarOverlay.parentNode.removeChild(sidebarOverlay);
}
if (sidebarElement && sidebarElement.parentNode) {
sidebarElement.parentNode.removeChild(sidebarElement);
}
sidebarOverlay = null;
sidebarElement = null;
}, 300);
// Call onClose callback
if (config.onClose) {
config.onClose();
}
// Remove escape handler
document.removeEventListener('keydown', handleEscape);
currentSidebar = null;
}
function handleEscape(e) {
if (e.key === 'Escape' && currentSidebar) {
closeSidebar();
}
}
/**
* Check if a sidebar is currently open
*/
function isSidebarOpen() {
return currentSidebar !== null;
}
// ============================================
// Modal Overlay
// ============================================
let currentModal = null;
let modalElement = null;
let modalBackdrop = null;
const defaultModalOptions = {
title: '',
content: '',
width: '600px',
maxWidth: '90vw',
closeOnEscape: true,
closeOnBackdropClick: true,
showConfirm: false,
showCancel: false,
confirmText: 'OK',
cancelText: 'Cancel',
onOpen: null,
onClose: null,
onConfirm: null,
onCancel: null,
className: ''
};
/**
* Open a modal dialog
* @param {Object} options - Modal options
* @returns {Object} Modal control object with close() method
*/
function createModal(options) {
// Close existing modal first
if (currentModal) {
closeModal();
}
const config = { ...defaultModalOptions, ...options };
// Create backdrop
modalBackdrop = document.createElement('div');
modalBackdrop.className = 'modal-backdrop';
modalBackdrop.addEventListener('click', function(e) {
if (config.closeOnBackdropClick && e.target === modalBackdrop) {
closeModal();
}
});
// Create modal container
modalElement = document.createElement('div');
modalElement.className = 'modal-container ' + config.className;
modalElement.style.width = config.width;
modalElement.style.maxWidth = config.maxWidth;
// Modal header (if title provided)
if (config.title) {
const header = document.createElement('div');
header.className = 'modal-header';
const title = document.createElement('h3');
title.className = 'modal-title';
title.textContent = config.title;
const closeButton = document.createElement('button');
closeButton.className = 'modal-close';
closeButton.innerHTML = '&times;';
closeButton.setAttribute('aria-label', 'Close');
closeButton.addEventListener('click', closeModal);
header.appendChild(title);
header.appendChild(closeButton);
modalElement.appendChild(header);
}
// Modal content
const content = document.createElement('div');
content.className = 'modal-content';
if (typeof config.content === 'string') {
content.innerHTML = config.content;
} else if (config.content instanceof HTMLElement) {
content.appendChild(config.content);
} else if (typeof config.content === 'function') {
const result = config.content(content);
if (result instanceof HTMLElement) {
content.innerHTML = '';
content.appendChild(result);
}
}
modalElement.appendChild(content);
// Modal footer (if buttons requested)
if (config.showConfirm || config.showCancel) {
const footer = document.createElement('div');
footer.className = 'modal-footer';
if (config.showCancel) {
const cancelButton = document.createElement('button');
cancelButton.className = 'modal-btn modal-btn-cancel';
cancelButton.textContent = config.cancelText;
cancelButton.addEventListener('click', function() {
if (config.onCancel) {
config.onCancel();
}
closeModal();
});
footer.appendChild(cancelButton);
}
if (config.showConfirm) {
const confirmButton = document.createElement('button');
confirmButton.className = 'modal-btn modal-btn-confirm';
confirmButton.textContent = config.confirmText;
confirmButton.addEventListener('click', function() {
if (config.onConfirm) {
const result = config.onConfirm();
// If onConfirm returns false, don't close modal
if (result === false) return;
}
closeModal();
});
footer.appendChild(confirmButton);
}
modalElement.appendChild(footer);
}
// Add to DOM
modalBackdrop.appendChild(modalElement);
document.body.appendChild(modalBackdrop);
// Trigger animation
requestAnimationFrame(function() {
modalBackdrop.classList.add('modal-backdrop-visible');
modalElement.classList.add('modal-container-visible');
});
// Store modal control
currentModal = {
close: closeModal,
element: modalElement,
backdrop: modalBackdrop,
config: config
};
// Call onOpen callback
if (config.onOpen) {
config.onOpen(content, currentModal);
}
// Handle escape key
if (config.closeOnEscape) {
document.addEventListener('keydown', handleModalEscape);
}
return currentModal;
}
/**
* Close the current modal
*/
function closeModal() {
if (!currentModal) return;
const config = currentModal.config;
// Trigger close animation
if (modalBackdrop) {
modalBackdrop.classList.remove('modal-backdrop-visible');
}
if (modalElement) {
modalElement.classList.remove('modal-container-visible');
}
// Remove from DOM after animation
setTimeout(function() {
if (modalBackdrop && modalBackdrop.parentNode) {
modalBackdrop.parentNode.removeChild(modalBackdrop);
}
modalBackdrop = null;
modalElement = null;
}, 300);
// Call onClose callback
if (config.onClose) {
config.onClose();
}
// Remove escape handler
document.removeEventListener('keydown', handleModalEscape);
currentModal = null;
}
function handleModalEscape(e) {
if (e.key === 'Escape' && currentModal) {
closeModal();
}
}
/**
* Check if a modal is currently open
*/
function isModalOpen() {
return currentModal !== null;
}
// ============================================
// Toast Notifications
// ============================================
const toastContainer = document.getElementById('toast-container');
if (!toastContainer) {
console.error('[Panels] Toast container element not found');
}
/**
* Show a toast notification
* @param {string} message - Toast message
* @param {Object} options - Toast options (type, duration, icon, etc.)
* @returns {Object} Toast control object with dismiss() method
*/
function showToast(message, options) {
if (!toastContainer) return null;
const config = {
type: 'info', // 'success', 'info', 'warning', 'error'
duration: 5000,
icon: null,
dismissible: true,
...options
};
const toast = document.createElement('div');
toast.className = 'toast toast-' + config.type;
// Icon
let iconHtml = '';
if (config.icon) {
iconHtml = '<span class="toast-icon">' + config.icon + '</span>';
} else {
// Default icons by type
const defaultIcons = {
success: '&#x2713;',
info: '&#x2139;',
warning: '&#x26A0;',
error: '&#x2717;'
};
iconHtml = '<span class="toast-icon">' + (defaultIcons[config.type] || defaultIcons.info) + '</span>';
}
// Dismiss button
let dismissHtml = '';
if (config.dismissible) {
dismissHtml = '<button class="toast-dismiss" aria-label="Dismiss">&times;</button>';
}
toast.innerHTML = iconHtml + '<span class="toast-message">' + escapeHtml(message) + '</span>' + dismissHtml;
toastContainer.appendChild(toast);
// Trigger animation
requestAnimationFrame(function() {
toast.classList.add('toast-visible');
});
// Auto-dismiss after duration
let dismissTimer = null;
if (config.duration > 0) {
dismissTimer = setTimeout(function() {
dismissToast(toast);
}, config.duration);
}
// Handle dismiss button
const dismissBtn = toast.querySelector('.toast-dismiss');
if (dismissBtn) {
dismissBtn.addEventListener('click', function() {
dismissToast(toast);
});
}
// Create toast control object
const toastControl = {
element: toast,
dismiss: function() { dismissToast(toast); }
};
return toastControl;
}
/**
* Dismiss a toast notification
* @param {HTMLElement} toast - Toast element to dismiss
*/
function dismissToast(toast) {
if (!toast || !toast.parentNode) return;
toast.classList.remove('toast-visible');
toast.classList.add('toast-dismissed');
setTimeout(function() {
if (toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}, 300);
}
/**
* Show success toast
*/
function showSuccess(message, options) {
return showToast(message, { ...options, type: 'success' });
}
/**
* Show info toast
*/
function showInfo(message, options) {
return showToast(message, { ...options, type: 'info' });
}
/**
* Show warning toast
*/
function showWarning(message, options) {
return showToast(message, { ...options, type: 'warning' });
}
/**
* Show error toast
*/
function showError(message, options) {
return showToast(message, { ...options, type: 'error' });
}
// ============================================
// Utility Functions
// ============================================
function escapeHtml(text) {
if (!text) return '';
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
// ============================================
// Public API
// ============================================
window.SpaxelPanels = {
// Panel registry
register: registerPanel,
open: openPanel,
// Direct panel opening
openSidebar: openSidebar,
closeSidebar: closeSidebar,
isSidebarOpen: isSidebarOpen,
// Modal
openModal: openModal,
closeModal: closeModal,
isModalOpen: isModalOpen,
// Toasts
showToast: showToast,
showSuccess: showSuccess,
showInfo: showInfo,
showWarning: showWarning,
showError: showError,
// Helper to create content element from HTML string
createContent: function(html) {
const wrapper = document.createElement('div');
wrapper.innerHTML = html;
return wrapper;
}
};
console.log('[Panels] Panel framework initialized');
})();

291
dashboard/js/router.js Normal file
View file

@ -0,0 +1,291 @@
/**
* Spaxel Dashboard - Router
*
* Hash-based routing: #live (default), #timeline, #automations, #settings, #ambient, #replay
* Mode toggle bar in header with active state preserved in localStorage
*/
(function() {
'use strict';
// ============================================
// Configuration
// ============================================
const ROUTES = {
live: {
title: 'Live',
icon: '&#x25A0;',
description: 'Real-time 3D detection view'
},
timeline: {
title: 'Timeline',
icon: '&#x231A;',
description: 'Activity history and events'
},
automations: {
title: 'Automations',
icon: '&#x2699;',
description: 'Spatial triggers and automation rules'
},
settings: {
title: 'Settings',
icon: '&#x2699;',
description: 'System configuration and preferences'
},
ambient: {
title: 'Ambient',
icon: '&#x25C9;',
description: 'Always-on display mode'
},
replay: {
title: 'Replay',
icon: '&#x23F5;',
description: 'Time-travel debugging mode'
}
};
const STORAGE_KEY = 'spaxel_active_mode';
// ============================================
// State
// ============================================
let currentMode = 'live';
let previousMode = null;
let modeChangeHandlers = [];
// ============================================
// Router Core
// ============================================
/**
* Initialize the router
*/
function init() {
// Read saved mode from localStorage
const savedMode = localStorage.getItem(STORAGE_KEY);
const hash = window.location.hash.slice(1) || savedMode || 'live';
// Validate hash against available routes
if (ROUTES[hash]) {
currentMode = hash;
} else {
currentMode = 'live';
}
// Create mode toggle bar
createModeToggleBar();
// Set initial mode
setMode(currentMode, false);
// Listen for hash changes
window.addEventListener('hashchange', onHashChange);
// Listen for popstate (browser back/forward)
window.addEventListener('popstate', onPopState);
console.log('[Router] Initialized with mode:', currentMode);
}
/**
* Create the mode toggle bar in the header
*/
function createModeToggleBar() {
// Check if bar already exists
if (document.getElementById('mode-toggle-bar')) {
return;
}
const statusBar = document.getElementById('status-bar');
if (!statusBar) {
console.error('[Router] Status bar not found');
return;
}
const bar = document.createElement('div');
bar.id = 'mode-toggle-bar';
bar.className = 'mode-toggle-bar';
// Create buttons for each mode
Object.keys(ROUTES).forEach(mode => {
const route = ROUTES[mode];
const btn = document.createElement('button');
btn.className = 'mode-toggle-btn';
btn.dataset.mode = mode;
btn.innerHTML = route.icon + ' ' + route.title;
btn.href = '#' + mode;
btn.addEventListener('click', onModeClick);
bar.appendChild(btn);
});
// Insert bar after status bar
statusBar.parentNode.insertBefore(bar, statusBar.nextSibling);
// Adjust scene-container top position to account for mode bar
const sceneContainer = document.getElementById('scene-container');
if (sceneContainer) {
sceneContainer.style.marginTop = '44px';
}
}
/**
* Handle mode button click
*/
function onModeClick(e) {
const mode = e.currentTarget.dataset.mode;
if (mode !== currentMode) {
setMode(mode);
}
}
/**
* Handle hash change from browser navigation
*/
function onHashChange() {
const hash = window.location.hash.slice(1) || 'live';
if (hash !== currentMode && ROUTES[hash]) {
setMode(hash);
}
}
/**
* Handle popstate (browser back/forward)
*/
function onPopState() {
const hash = window.location.hash.slice(1) || 'live';
if (hash !== currentMode && ROUTES[hash]) {
setMode(hash);
}
}
/**
* Set the current mode
* @param {string} mode - Mode identifier
* @param {boolean} updateHash - Whether to update the URL hash (default: true)
*/
function setMode(mode, updateHash = true) {
if (!ROUTES[mode]) {
console.error('[Router] Unknown mode:', mode);
return;
}
previousMode = currentMode;
currentMode = mode;
// Update hash without triggering hashchange event
if (updateHash) {
history.replaceState({ mode: mode }, '', '#' + mode);
}
// Update active button state
updateActiveButton();
// Save to localStorage
localStorage.setItem(STORAGE_KEY, mode);
// Trigger mode change handlers
notifyModeChange(mode, previousMode);
console.log('[Router] Mode changed:', previousMode, '->', mode);
}
/**
* Update the active state of mode buttons
*/
function updateActiveButton() {
const buttons = document.querySelectorAll('.mode-toggle-btn');
buttons.forEach(btn => {
if (btn.dataset.mode === currentMode) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
}
/**
* Notify all registered mode change handlers
*/
function notifyModeChange(newMode, oldMode) {
modeChangeHandlers.forEach(handler => {
try {
handler(newMode, oldMode);
} catch (e) {
console.error('[Router] Mode change handler error:', e);
}
});
}
// ============================================
// Public API
// ============================================
/**
* Register a handler for mode changes
* @param {Function} handler - Callback(newMode, oldMode)
*/
function onModeChange(handler) {
if (typeof handler === 'function') {
modeChangeHandlers.push(handler);
}
}
/**
* Get the current mode
*/
function getMode() {
return currentMode;
}
/**
* Get the previous mode
*/
function getPreviousMode() {
return previousMode;
}
/**
* Navigate to a specific mode
*/
function navigate(mode) {
if (ROUTES[mode]) {
setMode(mode);
}
}
/**
* Check if a specific mode is active
*/
function isMode(mode) {
return currentMode === mode;
}
/**
* Get all available routes
*/
function getRoutes() {
return { ...ROUTES };
}
// ============================================
// Export
// ============================================
window.SpaxelRouter = {
init: init,
onModeChange: onModeChange,
getMode: getMode,
getPreviousMode: getPreviousMode,
navigate: navigate,
isMode: isMode,
getRoutes: getRoutes
};
// Auto-initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
console.log('[Router] Router module loaded');
})();

View file

@ -0,0 +1,539 @@
/**
* Spaxel Dashboard - Settings Panel
*
* Motion threshold slider, sensing rate override, notification channel config,
* and system info (version, uptime, node count)
*/
(function() {
'use strict';
// ============================================
// Settings State
// ============================================
const settingsState = {
loading: false,
saving: false,
currentSettings: null
};
// ============================================
// Settings API
// ============================================
/**
* Fetch settings from server
*/
function fetchSettings() {
settingsState.loading = true;
renderContent();
return fetch('/api/settings')
.then(function(res) {
if (!res.ok) {
throw new Error('Failed to fetch settings: ' + res.status);
}
return res.json();
})
.then(function(data) {
settingsState.currentSettings = data;
settingsState.loading = false;
renderContent();
return data;
})
.catch(function(err) {
settingsState.loading = false;
console.error('[SettingsPanel] Error fetching settings:', err);
renderContent();
throw err;
});
}
/**
* Save settings to server
* @param {Object} updates - Settings to update (partial)
*/
function saveSettings(updates) {
if (!settingsState.currentSettings) {
return Promise.reject(new Error('No settings loaded'));
}
settingsState.saving = true;
renderContent();
return fetch('/api/settings', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(updates)
})
.then(function(res) {
if (!res.ok) {
throw new Error('Failed to save settings: ' + res.status);
}
return res.json();
})
.then(function(data) {
settingsState.currentSettings = data;
settingsState.saving = false;
renderContent();
SpaxelPanels.showSuccess('Settings saved successfully');
return data;
})
.catch(function(err) {
settingsState.saving = false;
console.error('[SettingsPanel] Error saving settings:', err);
renderContent();
SpaxelPanels.showError('Failed to save settings: ' + err.message);
throw err;
});
}
// ============================================
// Panel Content Rendering
// ============================================
/**
* Render the settings panel content
*/
function renderContent() {
const content = document.getElementById('settings-panel-content');
if (!content) return;
if (settingsState.loading) {
content.innerHTML = renderLoading();
return;
}
const settings = settingsState.currentSettings || {};
content.innerHTML = `
${renderDetectionSettings(settings)}
${renderNotificationSettings(settings)}
${renderSystemInfo()}
`;
// Attach event listeners
attachEventListeners();
}
function renderLoading() {
return `
<div class="panel-loading">
<div class="panel-loading-spinner"></div>
<span>Loading settings...</span>
</div>
`;
}
function renderDetectionSettings(settings) {
const threshold = settings.delta_rms_threshold !== undefined ? settings.delta_rms_threshold : 0.02;
const fusionRate = settings.fusion_rate_hz !== undefined ? settings.fusion_rate_hz : 10;
const gridCell = settings.grid_cell_m !== undefined ? settings.grid_cell_m : 0.2;
const fresnelDecay = settings.fresnel_decay !== undefined ? settings.fresnel_decay : 2.0;
const nSubcarriers = settings.n_subcarriers !== undefined ? settings.n_subcarriers : 16;
const tau = settings.tau_s !== undefined ? settings.tau_s : 30;
return `
<div class="panel-section">
<div class="panel-section-header">Detection Settings</div>
<div class="panel-form-group">
<label for="setting-threshold">Motion Threshold</label>
<input type="range" id="setting-threshold" min="0.005" max="0.1" step="0.005" value="${threshold}">
<div class="panel-form-range-value" id="setting-threshold-value">${(threshold * 1000).toFixed(0)}</div>
<div style="font-size: 11px; color: #888; margin-top: -4px;">
Lower = more sensitive, Higher = less sensitive
</div>
</div>
<div class="panel-form-group">
<label for="setting-fusion-rate">Fusion Rate (Hz)</label>
<select id="setting-fusion-rate">
<option value="5" ${fusionRate === 5 ? 'selected' : ''}>5 Hz (low CPU)</option>
<option value="10" ${fusionRate === 10 ? 'selected' : ''}>10 Hz (default)</option>
<option value="20" ${fusionRate === 20 ? 'selected' : ''}>20 Hz (smooth)</option>
</select>
</div>
<div class="panel-form-group">
<label for="setting-grid-cell">Grid Cell Size (meters)</label>
<input type="range" id="setting-grid-cell" min="0.1" max="0.5" step="0.05" value="${gridCell}">
<div class="panel-form-range-value" id="setting-grid-cell-value">${gridCell.toFixed(2)} m</div>
</div>
<div class="panel-form-group">
<label for="setting-fresnel-decay">Fresnel Weight Decay Rate</label>
<input type="range" id="setting-fresnel-decay" min="1" max="4" step="0.5" value="${fresnelDecay}">
<div class="panel-form-range-value" id="setting-fresnel-decay-value">${fresnelDecay.toFixed(1)}</div>
<div style="font-size: 11px; color: #888; margin-top: -4px;">
1.0 = flat, 2.0 = inverse-square, 4.0 = strong decay
</div>
</div>
<div class="panel-form-group">
<label for="setting-subcarriers">Subcarriers for Detection</label>
<select id="setting-subcarriers">
<option value="8" ${nSubcarriers === 8 ? 'selected' : ''}>8 (fast)</option>
<option value="16" ${nSubcarriers === 16 ? 'selected' : ''}>16 (default)</option>
<option value="32" ${nSubcarriers === 32 ? 'selected' : ''}>32 (accurate)</option>
<option value="64" ${nSubcarriers === 64 ? 'selected' : ''}>64 (all)</option>
</select>
</div>
<div class="panel-form-group">
<label for="setting-tau">Baseline Time Constant (seconds)</label>
<input type="range" id="setting-tau" min="10" max="120" step="5" value="${tau}">
<div class="panel-form-range-value" id="setting-tau-value">${tau} s</div>
<div style="font-size: 11px; color: #888; margin-top: -4px;">
How quickly the baseline adapts to environmental changes
</div>
</div>
<button class="panel-btn panel-btn-primary panel-btn-full" id="save-detection-btn"
${settingsState.saving ? 'disabled' : ''}>
${settingsState.saving ? 'Saving...' : 'Save Detection Settings'}
</button>
</div>
`;
}
function renderNotificationSettings(settings) {
const notificationChannels = settings.notification_channels || {};
const ntfyEnabled = notificationChannels.ntfy && notificationChannels.ntfy.enabled;
const ntfyUrl = notificationChannels.ntfy && notificationChannels.ntfy.config ? (notificationChannels.ntfy.config.url || '') : '';
const ntfyToken = notificationChannels.ntfy && notificationChannels.ntfy.config ? (notificationChannels.ntfy.config.token || '') : '';
const pushoverEnabled = notificationChannels.pushover && notificationChannels.pushover.enabled;
const pushoverToken = notificationChannels.pushover && notificationChannels.pushover.config ? (notificationChannels.pushover.config.user_key || '') : '';
const pushoverApp = notificationChannels.pushover && notificationChannels.pushover.config ? (notificationChannels.pushover.config.app_token || '') : '';
return `
<div class="panel-section">
<div class="panel-section-header">Notification Channels</div>
<div class="panel-form-group">
<label class="panel-form-checkbox">
<input type="checkbox" id="setting-ntfy-enabled" ${ntfyEnabled ? 'checked' : ''}>
<span>Enable Ntfy Notifications</span>
</label>
</div>
<div class="panel-form-group" id="ntfy-settings" style="${ntfyEnabled ? '' : 'display: none;'}">
<label for="setting-ntfy-url">Ntfy Topic URL</label>
<input type="url" id="setting-ntfy-url" placeholder="https://ntfy.sh/my-topic" value="${escapeHtml(ntfyUrl)}">
</div>
<div class="panel-form-group" id="ntfy-token-setting" style="${ntfyEnabled ? '' : 'display: none;'}">
<label for="setting-ntfy-token">Access Token (optional)</label>
<input type="password" id="setting-ntfy-token" placeholder="tk_..." value="${escapeHtml(ntfyToken)}">
</div>
<hr class="panel-divider">
<div class="panel-form-group">
<label class="panel-form-checkbox">
<input type="checkbox" id="setting-pushover-enabled" ${pushoverEnabled ? 'checked' : ''}>
<span>Enable Pushover Notifications</span>
</label>
</div>
<div class="panel-form-group" id="pushover-settings" style="${pushoverEnabled ? '' : 'display: none;'}">
<label for="setting-pushover-token">Pushover User Key</label>
<input type="text" id="setting-pushover-token" placeholder="u..." value="${escapeHtml(pushoverToken)}">
</div>
<div class="panel-form-group" id="pushover-app-setting" style="${pushoverEnabled ? '' : 'display: none;'}">
<label for="setting-pushover-app">Application Token</label>
<input type="text" id="setting-pushover-app" placeholder="a..." value="${escapeHtml(pushoverApp)}">
</div>
<button class="panel-btn panel-btn-primary panel-btn-full" id="save-notification-btn"
${settingsState.saving ? 'disabled' : ''}>
${settingsState.saving ? 'Saving...' : 'Save Notification Settings'}
</button>
<button class="panel-btn panel-btn-secondary panel-btn-full" id="test-notification-btn">
Test Notification
</button>
</div>
`;
}
function renderSystemInfo() {
const system = window.SpaxelState ? window.SpaxelState.system : {};
const version = system.version || 'unknown';
const uptime = system.uptime_s || 0;
const nodesOnline = system.nodes_online || 0;
const nodesTotal = system.nodes_total || 0;
const quality = system.detection_quality || 0;
// Format uptime
const uptimeHours = Math.floor(uptime / 3600);
const uptimeMins = Math.floor((uptime % 3600) / 60);
const uptimeText = uptimeHours > 0
? `${uptimeHours}h ${uptimeMins}m`
: `${uptimeMins}m`;
return `
<div class="panel-section">
<div class="panel-section-header">System Information</div>
<div class="panel-info-card">
<div class="panel-info-card-title">Version</div>
<div class="panel-info-card-value">${escapeHtml(version)}</div>
</div>
<div class="panel-info-card">
<div class="panel-info-card-title">Uptime</div>
<div class="panel-info-card-value">${uptimeText}</div>
</div>
<div class="panel-info-card">
<div class="panel-info-card-title">Detection Quality</div>
<div class="panel-info-card-value">${Math.round(quality)}%</div>
<div class="panel-info-card-subtitle">System-wide confidence</div>
</div>
<div class="panel-info-card">
<div class="panel-info-card-title">Nodes</div>
<div class="panel-info-card-value">${nodesOnline} / ${nodesTotal}</div>
<div class="panel-info-card-subtitle">Online / Total</div>
</div>
</div>
`;
}
/**
* Attach event listeners to the rendered content
*/
function attachEventListeners() {
// Range slider value updates
const thresholdInput = document.getElementById('setting-threshold');
const thresholdValue = document.getElementById('setting-threshold-value');
if (thresholdInput && thresholdValue) {
thresholdInput.addEventListener('input', function() {
thresholdValue.textContent = (parseFloat(this.value) * 1000).toFixed(0);
});
}
const gridCellInput = document.getElementById('setting-grid-cell');
const gridCellValue = document.getElementById('setting-grid-cell-value');
if (gridCellInput && gridCellValue) {
gridCellInput.addEventListener('input', function() {
gridCellValue.textContent = parseFloat(this.value).toFixed(2) + ' m';
});
}
const fresnelDecayInput = document.getElementById('setting-fresnel-decay');
const fresnelDecayValue = document.getElementById('setting-fresnel-decay-value');
if (fresnelDecayInput && fresnelDecayValue) {
fresnelDecayInput.addEventListener('input', function() {
fresnelDecayValue.textContent = parseFloat(this.value).toFixed(1);
});
}
const tauInput = document.getElementById('setting-tau');
const tauValue = document.getElementById('setting-tau-value');
if (tauInput && tauValue) {
tauInput.addEventListener('input', function() {
tauValue.textContent = parseInt(this.value) + ' s';
});
}
// Ntfy toggle
const ntfyEnabled = document.getElementById('setting-ntfy-enabled');
const ntfySettings = document.getElementById('ntfy-settings');
const ntfyTokenSetting = document.getElementById('ntfy-token-setting');
if (ntfyEnabled && ntfySettings && ntfyTokenSetting) {
ntfyEnabled.addEventListener('change', function() {
const visible = this.checked;
ntfySettings.style.display = visible ? '' : 'none';
ntfyTokenSetting.style.display = visible ? '' : 'none';
});
}
// Pushover toggle
const pushoverEnabled = document.getElementById('setting-pushover-enabled');
const pushoverSettings = document.getElementById('pushover-settings');
const pushoverAppSetting = document.getElementById('pushover-app-setting');
if (pushoverEnabled && pushoverSettings && pushoverAppSetting) {
pushoverEnabled.addEventListener('change', function() {
const visible = this.checked;
pushoverSettings.style.display = visible ? '' : 'none';
pushoverAppSetting.style.display = visible ? '' : 'none';
});
}
// Save detection settings
const saveDetectionBtn = document.getElementById('save-detection-btn');
if (saveDetectionBtn) {
saveDetectionBtn.addEventListener('click', saveDetectionSettings);
}
// Save notification settings
const saveNotificationBtn = document.getElementById('save-notification-btn');
if (saveNotificationBtn) {
saveNotificationBtn.addEventListener('click', saveNotificationSettings);
}
// Test notification
const testNotificationBtn = document.getElementById('test-notification-btn');
if (testNotificationBtn) {
testNotificationBtn.addEventListener('click', sendTestNotification);
}
}
/**
* Save detection settings
*/
function saveDetectionSettings() {
const threshold = parseFloat(document.getElementById('setting-threshold').value);
const fusionRate = parseInt(document.getElementById('setting-fusion-rate').value);
const gridCell = parseFloat(document.getElementById('setting-grid-cell').value);
const fresnelDecay = parseFloat(document.getElementById('setting-fresnel-decay').value);
const nSubcarriers = parseInt(document.getElementById('setting-subcarriers').value);
const tau = parseInt(document.getElementById('setting-tau').value);
const updates = {
delta_rms_threshold: threshold,
fusion_rate_hz: fusionRate,
grid_cell_m: gridCell,
fresnel_decay: fresnelDecay,
n_subcarriers: nSubcarriers,
tau_s: tau
};
saveSettings(updates).then(function() {
// Update local state settings
if (window.SpaxelState) {
Object.assign(window.SpaxelState.settings, updates);
}
});
}
/**
* Save notification settings
*/
function saveNotificationSettings() {
const ntfyEnabled = document.getElementById('setting-ntfy-enabled').checked;
const ntfyUrl = document.getElementById('setting-ntfy-url').value;
const ntfyToken = document.getElementById('setting-ntfy-token').value;
const pushoverEnabled = document.getElementById('setting-pushover-enabled').checked;
const pushoverToken = document.getElementById('setting-pushover-token').value;
const pushoverApp = document.getElementById('setting-pushover-app').value;
const updates = {
notification_channels: {
ntfy: {
enabled: ntfyEnabled,
config: {
url: ntfyUrl || null,
token: ntfyToken || null
}
},
pushover: {
enabled: pushoverEnabled,
config: {
user_key: pushoverToken || null,
app_token: pushoverApp || null
}
}
}
};
saveSettings(updates);
}
/**
* Send a test notification
*/
function sendTestNotification() {
SpaxelPanels.showInfo('Sending test notification...');
fetch('/api/notifications/test', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
channel_type: 'all'
})
})
.then(function(res) {
if (!res.ok) {
throw new Error('Failed to send test notification');
}
return res.json();
})
.then(function(data) {
SpaxelPanels.showSuccess('Test notification sent!');
})
.catch(function(err) {
console.error('[SettingsPanel] Error sending test notification:', err);
SpaxelPanels.showError('Failed to send test notification');
});
}
function escapeHtml(text) {
if (!text) return '';
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ============================================
// Panel Registration
// ============================================
/**
* Open the settings panel
*/
function openSettingsPanel() {
// Fetch settings first, then open panel
fetchSettings().then(function() {
SpaxelPanels.openSidebar({
title: 'Settings',
content: '<div id="settings-panel-content"></div>',
width: '400px',
onOpen: function() {
renderContent();
}
});
}).catch(function() {
// Open panel anyway with error state
SpaxelPanels.openSidebar({
title: 'Settings',
content: '<div id="settings-panel-content">' + renderLoading() + '</div>',
width: '400px'
});
});
}
// Register the settings panel
if (window.SpaxelPanels) {
SpaxelPanels.register('settings', openSettingsPanel);
}
// Also register as a global function for direct access
window.openSettingsPanel = openSettingsPanel;
// ============================================
// Router Integration
// ============================================
// Auto-open settings panel when navigating to #settings
if (window.SpaxelRouter) {
SpaxelRouter.onModeChange(function(newMode) {
if (newMode === 'settings') {
openSettingsPanel();
}
});
}
console.log('[SettingsPanel] Settings panel module loaded');
})();

446
dashboard/js/state.js Normal file
View file

@ -0,0 +1,446 @@
/**
* 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');
})();