mirror of
https://github.com/ruvnet/RuView
synced 2026-06-09 10:13:17 +00:00
49fb2ca9f4
* feat(ui): add keyboard shortcuts, perf monitor, toast system, theme toggle, and WCAG accessibility - Keyboard shortcuts overlay (press ? for help, 1-8 for tabs, T for theme, P for perf) - Real-time performance monitor with FPS, memory, latency sparklines (draggable) - Enhanced toast notification system with stacking, auto-dismiss, progress bars - Dark/light theme toggle with localStorage persistence and system preference detection - WCAG accessibility: skip-to-content link, ARIA roles/attributes on tabs and panels, arrow key navigation in tab bar, focus-visible outlines - ESLint config for UI directory with security and quality rules * feat(ui): add command palette, activity log, data export, fullscreen mode, connection status - Command palette (Ctrl+K / Cmd+K) with fuzzy search across tabs and actions - Activity log panel (L key) with real-time console interception, filters, resizable - Data export utility (E key) for sensor data as JSON/CSV with dialog - Fullscreen mode (F key / F11) for visualization tabs with exit button - Connection status widget in header showing WebSocket state and reconnect * feat(ui): add mobile hamburger nav, PWA support, and 40 unit tests - Mobile hamburger navigation: slide-out drawer replacing tab bar on <768px, swipe-to-close, animated hamburger icon, auto-sync with tab manager - PWA manifest + service worker: installable dashboard, offline shell caching (cache-first for static, network-first for API), auto-cleanup of old caches - 40 unit tests for ToastManager, ThemeToggle, KeyboardShortcuts, PerfMonitor, TabManager - browser-based test runner at ui/tests/unit-tests.html - PWA meta tags: theme-color, apple-mobile-web-app-capable, manifest link - Icon generator page for creating PWA icons (ui/icons/generate.html) * feat(ui): add URL routing, onboarding tour, idle detection, notification center - Hash router: tabs are bookmarkable/shareable via URL (#demo, #sensing, etc.), syncs with TabManager, supports browser back/forward navigation - Onboarding tour: interactive 6-step first-run walkthrough with spotlight highlighting, step indicators, skip/back/next controls, localStorage persistence - Idle detection: pauses health polling and reduces CSS animations after 3 min of inactivity, resumes on user interaction, integrates with Page Visibility API - Notification center: bell icon in header with unread badge, event history panel with mark-read/clear, persists across page views via sessionStorage * feat(ui): add i18n (EN/PL), screenshot tool, settings panel, reduced motion, uptime clock - i18n: English/Polish translations with auto-detection, language selector in header, data-i18n attributes on dashboard elements, localStorage persistence - Screenshot tool (S key): captures active tab to clipboard or downloads PNG, flash effect, canvas rendering with watermark, fallback for tainted canvases - Quick settings panel (gear icon): reduced motion toggle, high contrast mode, compact layout mode, health polling toggle, clear data, reset onboarding - Uptime clock: current time + session duration in header - prefers-reduced-motion: system-level and manual toggle, disables all animations and transitions for vestibular accessibility - High contrast mode: WCAG AAA compliant colors for both light and dark themes - Compact mode: condensed layout for dense information display
149 lines
4.8 KiB
JavaScript
149 lines
4.8 KiB
JavaScript
// Data Export Utility - Export sensor/pose data as JSON or CSV
|
|
|
|
import { sensingService } from '../services/sensing.service.js';
|
|
import { toastManager } from './toast.js';
|
|
|
|
export class DataExport {
|
|
constructor() {
|
|
this.buffer = [];
|
|
this.maxBuffer = 1000;
|
|
this.recording = false;
|
|
this._unsub = null;
|
|
}
|
|
|
|
init() {
|
|
document.addEventListener('export-data', () => this.showExportDialog());
|
|
|
|
// Continuously buffer sensing data when available
|
|
this._unsub = sensingService.onData((data) => {
|
|
if (this.buffer.length >= this.maxBuffer) {
|
|
this.buffer.shift();
|
|
}
|
|
this.buffer.push({
|
|
timestamp: new Date().toISOString(),
|
|
...this.extractFields(data)
|
|
});
|
|
});
|
|
}
|
|
|
|
extractFields(data) {
|
|
// Extract relevant fields from sensing data
|
|
return {
|
|
rssi: data.rssi ?? null,
|
|
variance: data.variance ?? null,
|
|
motion_band: data.motion_band ?? null,
|
|
breathing_band: data.breathing_band ?? null,
|
|
classification: data.classification ?? null,
|
|
person_count: data.person_count ?? data.persons ?? null,
|
|
subcarriers: data.subcarrier_count ?? null,
|
|
source: data.source ?? null
|
|
};
|
|
}
|
|
|
|
showExportDialog() {
|
|
if (this.buffer.length === 0) {
|
|
toastManager.warning('No sensor data to export. Connect to a data source first.');
|
|
return;
|
|
}
|
|
|
|
// Create dialog
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'export-dialog-overlay';
|
|
overlay.innerHTML = `
|
|
<div class="export-dialog" role="dialog" aria-label="Export data" aria-modal="true">
|
|
<h3>Export Sensor Data</h3>
|
|
<p class="export-dialog-info">${this.buffer.length} data points available</p>
|
|
<div class="export-dialog-options">
|
|
<label class="export-option">
|
|
<input type="radio" name="export-format" value="json" checked>
|
|
<span>JSON</span>
|
|
<small>Full data with nested fields</small>
|
|
</label>
|
|
<label class="export-option">
|
|
<input type="radio" name="export-format" value="csv">
|
|
<span>CSV</span>
|
|
<small>Flat table, spreadsheet-ready</small>
|
|
</label>
|
|
</div>
|
|
<div class="export-dialog-range">
|
|
<label>
|
|
Last <input type="number" id="export-count" value="${Math.min(this.buffer.length, 500)}" min="1" max="${this.buffer.length}"> data points
|
|
</label>
|
|
</div>
|
|
<div class="export-dialog-actions">
|
|
<button class="btn btn--secondary export-cancel">Cancel</button>
|
|
<button class="btn btn--primary export-confirm">Export</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
overlay.addEventListener('click', (e) => {
|
|
if (e.target === overlay) overlay.remove();
|
|
});
|
|
overlay.querySelector('.export-cancel').addEventListener('click', () => overlay.remove());
|
|
overlay.querySelector('.export-confirm').addEventListener('click', () => {
|
|
const format = overlay.querySelector('input[name="export-format"]:checked').value;
|
|
const count = parseInt(overlay.querySelector('#export-count').value, 10) || this.buffer.length;
|
|
this.exportData(format, count);
|
|
overlay.remove();
|
|
});
|
|
|
|
document.body.appendChild(overlay);
|
|
overlay.querySelector('.export-confirm').focus();
|
|
}
|
|
|
|
exportData(format, count) {
|
|
const data = this.buffer.slice(-count);
|
|
|
|
let content, filename, mimeType;
|
|
|
|
if (format === 'json') {
|
|
content = JSON.stringify(data, null, 2);
|
|
filename = `ruview-data-${this.timestamp()}.json`;
|
|
mimeType = 'application/json';
|
|
} else {
|
|
content = this.toCSV(data);
|
|
filename = `ruview-data-${this.timestamp()}.csv`;
|
|
mimeType = 'text/csv';
|
|
}
|
|
|
|
this.downloadFile(content, filename, mimeType);
|
|
toastManager.success(`Exported ${data.length} data points as ${format.toUpperCase()}`);
|
|
}
|
|
|
|
toCSV(data) {
|
|
if (data.length === 0) return '';
|
|
const headers = Object.keys(data[0]);
|
|
const rows = data.map(row => headers.map(h => {
|
|
const val = row[h];
|
|
if (val === null || val === undefined) return '';
|
|
if (typeof val === 'string' && (val.includes(',') || val.includes('"'))) {
|
|
return `"${val.replace(/"/g, '""')}"`;
|
|
}
|
|
return String(val);
|
|
}).join(','));
|
|
return [headers.join(','), ...rows].join('\n');
|
|
}
|
|
|
|
downloadFile(content, filename, mimeType) {
|
|
const blob = new Blob([content], { type: mimeType });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
a.style.display = 'none';
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
timestamp() {
|
|
return new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
}
|
|
|
|
dispose() {
|
|
if (this._unsub) this._unsub();
|
|
}
|
|
}
|