// Data Analysis Page Component — a subpage of Live Data (#data-analysis). // // Where Live Data shows each measurement on its own gauge and its own history // popup, this overlays two to four of them on one timeline: indoor vs outdoor // temperature, indoor vs fridge, CO2 vs TVOC. The measurement list, the // presets, the series colors and the labels all come from // window.liveDataConfig; the chart itself is javascript/comparison_chart.js. // Nothing here knows what a sensor is called or what a history window means — // those stay in the config and in history_chart.js's VIEWS respectively. // // Styled in the Console Light design system, consumed from the shared // window.DesignSystem module (javascript/design_system.js — see // docs/design_style_guide.md). const LiveDataAnalysisPage = () => { const { Box, useTheme } = MaterialUI; const CONFIG = window.liveDataConfig; const DS = window.DesignSystem; // Two lines is the minimum that is a comparison at all; past four the axes // and the legend stop being readable. const MIN_SERIES = 2; const MAX_SERIES = 4; const CHART_CONTAINER_ID = 'comparison_chart'; // Dark mode flag, read from the MUI theme App is currently rendering with // rather than from window.chartConfig (see the same note in LiveDataPage). const darkMode = useTheme().palette.mode === 'dark'; const T = React.useMemo(() => DS.tokens(darkMode), [darkMode]); // The opening comparison is the first preset, so the page has something on // it before anything is clicked. const [selectedKeys, setSelectedKeys] = React.useState( () => CONFIG.comparisonPresets[0].keys.slice() ); const [activePreset, setActivePreset] = React.useState( () => CONFIG.comparisonPresets[0].id ); // History window, defaulting to the widest (3 days): this page is for // reading a shape over time, where Live Data's popup opens on the most // recent 10 minutes. const [selectedView, setSelectedView] = React.useState('view1'); const [liveUpdatesEnabled, setLiveUpdatesEnabled] = React.useState(true); // Cosmetic "updated Ns ago" ticker: counts 1..9 then resets while live const [elapsed, setElapsed] = React.useState(0); // The selected measurements, always in config order so the legend, the // checkbox list and the axis order agree. const selected = React.useMemo( () => CONFIG.measurements.filter(m => selectedKeys.indexOf(m.key) !== -1), [selectedKeys] ); // Resolved series colors, from the chart module rather than from the config // map directly: it is the module that breaks ties when a custom selection // lands two measurements on the same hue, so asking it is what keeps the // swatch beside a checkbox the same color as the line on the chart. const seriesColors = React.useMemo( () => (window.ComparisonChart ? window.ComparisonChart.colorsFor(selectedKeys) : {}), [selectedKeys] ); // ---- interactions ------------------------------------------------------- // Hash navigation rather than a callback prop: app.jsx's hashchange listener // already routes on it. It does not scroll the way handlePageChange does, // so that part is explicit. const navigate = (route) => { window.location.hash = route; window.scrollTo(0, 0); }; const applyPreset = (preset) => { setSelectedKeys(preset.keys.slice()); setActivePreset(preset.id); }; // Editing the list by hand means the selection is no longer a preset, so no // preset shows as active. A click that would breach either limit changes // nothing at all — including the preset, which is still the thing on screen. const toggleMeasurement = (key) => { const on = selectedKeys.indexOf(key) !== -1; if (on && selectedKeys.length <= MIN_SERIES) return; if (!on && selectedKeys.length >= MAX_SERIES) return; const next = on ? selectedKeys.filter(k => k !== key) : CONFIG.measurements .filter(m => selectedKeys.indexOf(m.key) !== -1 || m.key === key) .map(m => m.key); setSelectedKeys(next); setActivePreset(null); }; // ---- effects ------------------------------------------------------------ // Sync liveUpdatesEnabled to window.chartConfig.pollingEnabled, which is // what the chart module's poll interval reads React.useEffect(() => { if (window.chartConfig) { window.chartConfig.pollingEnabled = liveUpdatesEnabled; } }, [liveUpdatesEnabled]); // Cosmetic status ticker: increment 1..9 each second while live, reset when // paused or re-enabled React.useEffect(() => { if (!liveUpdatesEnabled) { setElapsed(0); return; } setElapsed(0); const id = setInterval(() => { setElapsed(prev => (prev >= 9 ? 0 : prev + 1)); }, 1000); return () => clearInterval(id); }, [liveUpdatesEnabled]); // Build the chart, and rebuild it whenever the selection, the window or the // theme changes — the same recreate-on-dark-mode pattern the history charts // use, since Highcharts bakes its colors in at construction. The container // is always mounted here (unlike the history popup), so this runs on mount // and the cleanup covers unmount. React.useEffect(() => { const chart = window.ComparisonChart; if (!chart) { console.warn('ComparisonChart module not available'); return; } chart.createChart(CHART_CONTAINER_ID, selectedKeys, selectedView); return () => chart.destroy(); }, [selectedKeys, selectedView, darkMode]); // ---- derived view data -------------------------------------------------- const statusDot = liveUpdatesEnabled ? T.statusOk : T.textMuted; const statusText = liveUpdatesEnabled ? 'Updated ' + (elapsed === 0 ? 'just now' : elapsed + 's ago') : 'Updates paused'; const liveOptions = [ { value: 'live', label: 'Live', active: liveUpdatesEnabled, onClick: () => setLiveUpdatesEnabled(true) }, { value: 'paused', label: 'Paused', active: !liveUpdatesEnabled, onClick: () => setLiveUpdatesEnabled(false) } ]; // The four windows come from history_chart.js, which owns the mapping from // each view to its time window and sample period const windowOptions = window.HistoryChart.VIEW_ORDER.map(view => ({ value: view, label: window.HistoryChart.VIEWS[view].label, active: selectedView === view, onClick: () => setSelectedView(view) })); // Name the comparison by what the selected measurements have in common, so // the title says the interesting half rather than repeating the shared one: // same quantity in different places -> 'Indoor vs Outdoor Temperature'; // same place, different quantities -> 'Indoor CO₂ vs TVOC'; neither -> // the full labels joined. const comparisonTitle = () => { const parts = selected.map(m => ({ prefix: CONFIG.comparisonPrefix(m), label: m.label })); if (parts.length === 0) return 'Comparison'; if (parts.every(p => p.label === parts[0].label)) { return parts.map(p => p.prefix).join(' vs ') + ' ' + parts[0].label; } if (parts.every(p => p.prefix === parts[0].prefix)) { return parts[0].prefix + ' ' + parts.map(p => p.label).join(' vs '); } return parts.map(p => p.prefix + ' ' + p.label).join(' vs '); }; // Highcharts container style. Same overrides LiveDataPage uses: without them // the chart's own container sets a minimum width and the card stops being // able to shrink. const chartBoxSx = { width: '100%', minWidth: 0, overflow: 'hidden', '& .highcharts-container': { width: '100% !important', minWidth: '0 !important', maxWidth: '100% !important' }, '& .highcharts-container svg': { width: '100% !important', minWidth: '0 !important', maxWidth: '100% !important' } }; // Card heading over a hairline: the presets and measurements cards share it const cardHeadingSx = { borderBottom: DS.hairline(T), paddingBottom: '8px', marginBottom: '10px' }; const presetBtnSx = (active) => ({ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px', width: '100%', padding: '7px 10px', textAlign: 'left', cursor: 'pointer', borderRadius: 0, lineHeight: 1.2, fontFamily: DS.MONO, fontSize: '10.5px', letterSpacing: '0.1em', textTransform: 'uppercase', fontWeight: active ? 700 : 400, border: `1px solid ${active ? T.accent : T.divider}`, background: active ? T.accent : T.card, color: active ? T.onAccent : T.textStrong, transition: `background-color ${DS.metrics.transition}`, '&:hover': { background: active ? T.accent : T.selectedRow }, '&:focus-visible': { outline: `2px solid ${T.accent}`, outlineOffset: '-2px' } }); // 12px square, never a round MUI checkbox — nothing in this system is round // except the gauges. const checkboxSx = (on) => (on ? { width: '12px', height: '12px', flex: 'none', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', background: T.accent, color: T.onAccent, fontFamily: DS.MONO, fontSize: '9px', lineHeight: 1 } : { width: '12px', height: '12px', flex: 'none', display: 'inline-block', border: `1px solid ${T.divider}`, background: T.card }); return ( {/* Header */} navigate('live-data')} sx={{ ...DS.secondaryBtnSx(T), flex: 'none' }} > Back to live data {/* Top divider */} {/* Control bar */} Updates {/* Body: controls beside the chart, stacking on a narrow content column. The query container is PageShell's inner box, so this asks about the width the content actually has rather than the viewport's. */} {/* Left column: what to compare */} {/* Presets */} Presets {CONFIG.comparisonPresets.map(preset => { const active = activePreset === preset.id; return ( applyPreset(preset)} aria-pressed={active} sx={presetBtnSx(active)} > {preset.label} {active ? '●' : '○'} ); })} {/* Measurements */} Measurements {/* Not sectionMetaSx: that helper drops itself on a narrow content column, which is right for a section header's device list but wrong for a count inside a 260px card. */} {selectedKeys.length} of {CONFIG.measurements.length} {CONFIG.measurements.map(m => { const on = selectedKeys.indexOf(m.key) !== -1; // A row that cannot currently be turned on reads as // unavailable; a checked row at the two-series floor stays // fully lit, because it is showing, not blocked. const full = !on && selectedKeys.length >= MAX_SERIES; const blocked = full || (on && selectedKeys.length <= MIN_SERIES); return ( toggleMeasurement(m.key)} aria-pressed={on} aria-disabled={blocked || undefined} sx={{ display: 'flex', alignItems: 'center', gap: '9px', width: '100%', padding: '5px 0', border: 'none', background: 'transparent', textAlign: 'left', cursor: blocked ? 'default' : 'pointer', '&:focus-visible': { outline: `2px solid ${T.accent}`, outlineOffset: '-2px' } }} > {on ? '✓' : ''} {CONFIG.comparisonLabel(m)} {on && ( )} ); })} {/* Right column: the comparison */} {selected.length} series } /> History window {/* Chart will be rendered here by comparison_chart.js */} ); }; // Make component available globally window.LiveDataAnalysisPage = LiveDataAnalysisPage;