// Sparkline + TrendChart — hand-rolled SVG (matches design exactly). // Both components read the active SERIES from React context. const SeriesContext = React.createContext(null); window.SeriesContext = SeriesContext; function buildPath(vals, x, y) { return vals.map((v, i) => `${i === 0 ? 'M' : 'L'} ${x(i)} ${y(v)}`).join(' '); } function buildAreaPath(top, bot, x, y) { let d = ''; top.forEach((v, i) => { d += `${i === 0 ? 'M' : 'L'} ${x(i)} ${y(v)} `; }); for (let i = bot.length - 1; i >= 0; i--) d += `L ${x(i)} ${y(bot[i])} `; return d + 'Z'; } function Sparkline({ series, days = 14, width = 320, height = 60, showTarget = false, showLast = false, lastLabel = null, color, padX = 0, padY = 8, targetLabel = null, annotation = null, fullWidth = false, }) { const theme = window.HC_THEME.useHCTheme(); const SERIES = React.useContext(SeriesContext); const s = SERIES && SERIES[series]; if (!s) return null; const c = color || s.color; const ch = theme.chart; const pal = theme.palette; const raw = s.raw.slice(-days); const avg = s.avg.slice(-days); const std = s.std.slice(-days); // Auto-fit Y to the trend + raw points only. The ±1σ band is excluded // from the domain calc so the line uses the full vertical space — any // band edges that extend past the card are clipped softly behind. let allVals = [...avg, ...raw]; if (s.target != null && showTarget) allVals.push(s.target); let min = Math.min(...allVals), max = Math.max(...allVals); const range = max - min || 1; min -= range * 0.08; max += range * 0.08; if (s.invert) { [min, max] = [max, min]; } const x = (i) => padX + (i / (days - 1)) * (width - 2 * padX); const y = (v) => padY + ((max - v) / (max - min)) * (height - 2 * padY); const upper = avg.map((v, i) => v + std[i]); const lower = avg.map((v, i) => v - std[i]); const lastY = y(avg[avg.length - 1]); const clipId = `sp-clip-${series}-${width}-${height}`; return ( {showTarget && s.target != null && ( <> {targetLabel && ( {targetLabel} )} )} {ch.showBand && ( )} {ch.showRaw && ( )} {ch.showRaw && raw.map((v, i) => ( ))} {annotation && ( <> {annotation.label} )} {showLast ? ( <> {lastLabel && ( {lastLabel} )} ) : ch.dotR > 0 && ( )} ); } function TrendChart({ series, width = 358, height = 160, refLine, refLabel, invert = false, yFormat, showVolBand = false, phases = [], annotations = [], range = 'full', windowDays = 60, }) { const theme = window.HC_THEME.useHCTheme(); const pal = theme.palette; const ch = theme.chart; const SERIES = React.useContext(SeriesContext); const s = SERIES && SERIES[series]; if (!s) return null; // Window: 4w shows 28 days, full shows windowDays (typically ~150). const total = s.raw.length; const days = range === '4w' ? Math.min(28, total) : Math.min(windowDays, total); const offset = total - days; // index offset from full-series to window const raw = s.raw.slice(-days); const ewma = s.avg.slice(-days); // 14-day SMA computed across the full series, then sliced. const ma14 = s.raw.map((_, i) => { const start = Math.max(0, i - 13); const slice = s.raw.slice(start, i + 1); return slice.reduce((a, b) => a + b, 0) / slice.length; }).slice(-days); const std = s.std.slice(-days); let allVals = [...raw, ...ewma, ...ma14]; if (showVolBand || ch.showBand) { allVals = allVals.concat( ewma.map((v, i) => v + std[i]), ewma.map((v, i) => v - std[i]), ); } if (refLine != null) allVals.push(refLine); let min = Math.min(...allVals), max = Math.max(...allVals); const r = max - min || 1; min -= r * 0.1; max += r * 0.1; if (invert) [min, max] = [max, min]; const padL = 32, padR = 12, padT = 14, padB = 22; const innerW = width - padL - padR; const innerH = height - padT - padB; const x = (i) => padL + (i / Math.max(1, days - 1)) * innerW; const y = (v) => padT + ((max - v) / (max - min)) * innerH; const ticks = []; for (let t = 0; t < 4; t++) { const frac = t / 3; const v = invert ? min + (max - min) * (1 - frac) : min + (max - min) * frac; ticks.push({ v, y: padT + innerH * (1 - frac) }); } const upper = ewma.map((v, i) => v + std[i]); const lower = ewma.map((v, i) => v - std[i]); return ( {phases.map((p, i) => { const [a, b] = p.range; const a2 = Math.max(0, a - offset); const b2 = Math.max(0, b - offset); if (b2 <= 0 || a2 >= days) return null; return ( ); })} {ticks.map((t, i) => ( ))} {ticks.map((t, i) => ( {yFormat ? yFormat(t.v) : t.v.toFixed(t.v < 10 ? 1 : 0)} ))} {refLine != null && ( <> {refLabel && ( {refLabel} )} )} {(showVolBand || theme.chartKey === 'telemetry') && ( )} {/* Raw area fill + line. Present in signal & telemetry. */} {!showVolBand && theme.chartKey !== 'whisper' && ( <> )} {/* 14-day MA dashed — only on signal & telemetry. */} {theme.chartKey !== 'whisper' && ( )} {/* EWMA — primary trend line. */} {theme.chartKey === 'telemetry' && raw.map((v, i) => ( ))} {/* User-logged event markers from /api/events. */} {annotations.map((a, i) => { const idx = a.idx - offset; if (idx < 0 || idx >= days) return null; return ( {a.label} ); })} {/* X-axis date labels at start / middle / end. Uses the actual date from the days array — the design hardcoded May 10 because it was a mock. */} ); } function DateTicks({ days, offset, x, height, pal }) { const SERIES = React.useContext(SeriesContext); const dates = window.HC_TREND_DATES || null; // injected by TrendsTab const positions = [0, Math.floor(days / 2), days - 1]; return positions.map((i) => { let label = ''; if (dates) { const isoIdx = offset + i; const iso = dates[isoIdx]; if (iso) { const dt = new Date(iso + 'T00:00:00'); label = `${['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'][dt.getMonth()]} ${dt.getDate()}`; } } return ( {label} ); }); } window.Sparkline = Sparkline; window.TrendChart = TrendChart;