/* ========================================================================== SVG charts. Mark specs follow the house data-viz rules: 2px lines with round joins, area washes at 10% opacity, >=8px end markers carrying a 2px surface ring, solid hairline gridlines, selective direct labels (endpoint only), a legend whenever there are two or more series, and a crosshair + tooltip on hover. Text always wears text tokens, never the series colour. ========================================================================== */ import { el, clear } from './ui.js'; import { seriesColor, cssVar } from './core.js'; const NS = 'http://www.w3.org/2000/svg'; function svgEl(tag, attrs = {}) { const node = document.createElementNS(NS, tag); for (const [key, value] of Object.entries(attrs)) { if (value !== null && value !== undefined) node.setAttribute(key, value); } return node; } function niceTicks(min, max, count = 4) { if (!Number.isFinite(min) || !Number.isFinite(max)) return [0, 1]; if (min === max) { min -= 0.5; max += 0.5; } const span = max - min; const raw = span / count; const magnitude = Math.pow(10, Math.floor(Math.log10(raw))); const normalised = raw / magnitude; const step = (normalised >= 5 ? 10 : normalised >= 2 ? 5 : normalised >= 1 ? 2 : 1) * magnitude; const ticks = []; for (let t = Math.ceil(min / step) * step; t <= max + step * 0.001; t += step) { ticks.push(Number(t.toFixed(10))); } return ticks.length >= 2 ? ticks : [min, max]; } const fmt = (v, precision) => Number(v).toLocaleString(undefined, { minimumFractionDigits: precision, maximumFractionDigits: precision, }); /* -- Sparkline ------------------------------------------------------------ */ /** * A bare trend line for a stat tile. No axes, no legend - the tile's label and * value carry the meaning; this only shows shape. */ export function sparkline(points, { width = 200, height = 30, colorIndex = 0, fill = true } = {}) { const svg = svgEl('svg', { class: 'chart', viewBox: `0 0 ${width} ${height}`, preserveAspectRatio: 'none', height, }); svg.style.width = '100%'; const values = (points || []).map((p) => (Array.isArray(p) ? p[1] : p)).filter(Number.isFinite); if (values.length < 2) return svg; const min = Math.min(...values); const max = Math.max(...values); const span = (max - min) || 1; const pad = 3; const x = (i) => (i / (values.length - 1)) * width; const y = (v) => height - pad - ((v - min) / span) * (height - pad * 2); const line = values.map((v, i) => `${i ? 'L' : 'M'}${x(i).toFixed(2)},${y(v).toFixed(2)}`).join(''); const color = seriesColor(colorIndex); if (fill) { svg.appendChild(svgEl('path', { class: 'series-area', d: `${line}L${width},${height}L0,${height}Z`, fill: color, })); } svg.appendChild(svgEl('path', { class: 'series-line', d: line, stroke: color })); svg.appendChild(svgEl('circle', { class: 'end-dot', cx: width, cy: y(values.at(-1)), r: 2.5, fill: color, })); return svg; } /* -- Time series ---------------------------------------------------------- */ /** * series: [{ key, label, points: [[ts, value], ...], colorIndex, unit }] * Returns a container element with an .update(series) method. */ export function timeSeries(series, { height = 200, precision = 2, unit = '', yMin = null, yMax = null, zeroLine = false, showLegend = null, directLabel = true, } = {}) { const host = el('div', { style: { position: 'relative' } }); const svg = svgEl('svg', { class: 'chart', height, preserveAspectRatio: 'none' }); svg.style.width = '100%'; host.appendChild(svg); const tip = el('div.chart-tip'); document.body.appendChild(tip); let currentSeries = series; let hover = null; const cleanup = new MutationObserver(() => { if (!host.isConnected) { tip.remove(); cleanup.disconnect(); } }); cleanup.observe(document.body, { childList: true, subtree: true }); function render() { const rect = host.getBoundingClientRect(); const width = Math.max(220, rect.width || 480); const padL = 44, padR = directLabel ? 52 : 12, padT = 10, padB = 22; const plotW = width - padL - padR; const plotH = height - padT - padB; clear(svg); svg.setAttribute('viewBox', `0 0 ${width} ${height}`); const active = currentSeries.filter((s) => (s.points || []).length >= 2); if (!active.length) { svg.appendChild(svgEl('text', { x: width / 2, y: height / 2, 'text-anchor': 'middle', class: 'tick-text', })).textContent = 'Waiting for data…'; return; } const allValues = active.flatMap((s) => s.points.map((p) => p[1])).filter(Number.isFinite); const allTimes = active.flatMap((s) => s.points.map((p) => p[0])); let lo = yMin !== null ? yMin : Math.min(...allValues); let hi = yMax !== null ? yMax : Math.max(...allValues); if (lo === hi) { lo -= 0.5; hi += 0.5; } const headroom = (hi - lo) * 0.12; if (yMin === null) lo -= headroom; if (yMax === null) hi += headroom; if (zeroLine) { lo = Math.min(lo, 0); hi = Math.max(hi, 0); } const t0 = Math.min(...allTimes), t1 = Math.max(...allTimes); const tSpan = (t1 - t0) || 1; const X = (t) => padL + ((t - t0) / tSpan) * plotW; const Y = (v) => padT + plotH - ((v - lo) / (hi - lo)) * plotH; // Gridlines - solid hairlines, one step off the surface, recessive. for (const tick of niceTicks(lo, hi, 4)) { if (tick < lo || tick > hi) continue; const y = Y(tick); svg.appendChild(svgEl('line', { class: 'grid-line', x1: padL, x2: padL + plotW, y1: y, y2: y })); const label = svgEl('text', { class: 'tick-text', x: padL - 7, y: y + 3.5, 'text-anchor': 'end' }); label.textContent = fmt(tick, precision); svg.appendChild(label); } if (zeroLine && lo < 0 && hi > 0) { svg.appendChild(svgEl('line', { class: 'axis-line', x1: padL, x2: padL + plotW, y1: Y(0), y2: Y(0) })); } svg.appendChild(svgEl('line', { class: 'axis-line', x1: padL, x2: padL + plotW, y1: padT + plotH, y2: padT + plotH, })); const surface = cssVar('--surface') || '#141619'; // When series converge their end-labels overlap. Nudging them apart would // detach each label from its line and read as noise, so drop direct labels // for this render and let the legend and tooltip carry identity instead. const endYs = active.map((s) => Y(s.points.at(-1)[1])).sort((a, b) => a - b); const labelsCollide = endYs.some((y, i) => i > 0 && Math.abs(y - endYs[i - 1]) < 13); const showEndLabels = directLabel && !labelsCollide; active.forEach((s, i) => { const color = seriesColor(s.colorIndex ?? i); const path = s.points .map((p, idx) => `${idx ? 'L' : 'M'}${X(p[0]).toFixed(2)},${Y(p[1]).toFixed(2)}`) .join(''); if (active.length === 1) { svg.appendChild(svgEl('path', { class: 'series-area', fill: color, d: `${path}L${X(t1)},${padT + plotH}L${X(t0)},${padT + plotH}Z`, })); } svg.appendChild(svgEl('path', { class: 'series-line', d: path, stroke: color })); // End marker: >=8px with a 2px surface ring so overlaps stay legible. const last = s.points.at(-1); svg.appendChild(svgEl('circle', { class: 'end-dot', cx: X(last[0]), cy: Y(last[1]), r: 4, fill: color, stroke: surface, })); // Direct-label the endpoint only - never a number on every point. if (showEndLabels) { const label = svgEl('text', { class: 'end-label', x: X(last[0]) + 9, y: Y(last[1]) + 3.5, }); label.textContent = `${fmt(last[1], precision)}${s.unit ?? unit}`; svg.appendChild(label); } }); if (hover !== null) { const x = padL + hover * plotW; svg.appendChild(svgEl('line', { class: 'crosshair', x1: x, x2: x, y1: padT, y2: padT + plotH })); } // Hover layer sits above everything and is the full plot height, so the hit // target is far bigger than the marks. const overlay = svgEl('rect', { x: padL, y: padT, width: plotW, height: plotH, fill: 'transparent', style: 'cursor:crosshair', }); overlay.addEventListener('pointermove', (event) => { const bounds = svg.getBoundingClientRect(); const scale = width / bounds.width; const px = (event.clientX - bounds.left) * scale; hover = Math.max(0, Math.min(1, (px - padL) / plotW)); const t = t0 + hover * tSpan; const rows = active.map((s, i) => { let best = s.points[0], bestGap = Infinity; for (const p of s.points) { const gap = Math.abs(p[0] - t); if (gap < bestGap) { bestGap = gap; best = p; } } return { label: s.label, value: best[1], unit: s.unit ?? unit, color: seriesColor(s.colorIndex ?? i) }; }); tip.innerHTML = ''; tip.appendChild(el('div.tip-time', { text: new Date(t * 1000).toLocaleTimeString([], { hour12: false }) })); for (const row of rows) { tip.appendChild(el('div.tip-row', {}, el('span', { style: { display: 'inline-flex', alignItems: 'center', gap: '6px' } }, el('span', { style: { width: '10px', height: '2.5px', borderRadius: '2px', background: row.color } }), el('span', { text: row.label, style: { color: 'var(--text-2)' } }), ), el('b', { text: `${fmt(row.value, precision)}${row.unit}` }), )); } tip.dataset.show = 'true'; const tipBox = tip.getBoundingClientRect(); tip.style.left = `${Math.min(window.innerWidth - tipBox.width - 10, event.clientX + 14)}px`; tip.style.top = `${Math.max(10, event.clientY - tipBox.height - 12)}px`; render(); }); overlay.addEventListener('pointerleave', () => { hover = null; tip.dataset.show = 'false'; render(); }); svg.appendChild(overlay); } // A legend is always present for two or more series - identity never rests on // colour alone. One series needs none; the card title already names it. const legendVisible = showLegend ?? (series.length >= 2); if (legendVisible) { const legend = el('div.legend'); series.forEach((s, i) => { legend.appendChild(el('div.legend-item', {}, el('span.legend-key', { style: { background: seriesColor(s.colorIndex ?? i) } }), el('span', { text: s.label }), )); }); host.appendChild(legend); } host.update = (next) => { currentSeries = next; render(); }; host.redraw = render; requestAnimationFrame(render); const observer = new ResizeObserver(() => render()); observer.observe(host); return host; } /* -- Horizontal bars ------------------------------------------------------ */ /** * rows: [{ label, value, min, max, tone }] * Used for joint positions - a diverging bar around a zero centre reads better * than a table of numbers when you are looking for the joint that is off. */ export function bars(rows, { height = 20, precision = 2, unit = '', diverging = true } = {}) { const host = el('div', { style: { display: 'flex', flexDirection: 'column', gap: '4px' } }); for (const row of rows) { const min = row.min ?? -1; const max = row.max ?? 1; const span = (max - min) || 1; const value = Math.max(min, Math.min(max, row.value ?? 0)); const zeroFrac = diverging && min < 0 && max > 0 ? (0 - min) / span : 0; const valueFrac = (value - min) / span; const left = Math.min(zeroFrac, valueFrac) * 100; const width = Math.abs(valueFrac - zeroFrac) * 100; const track = el('div', { style: { position: 'relative', height: `${height - 8}px`, borderRadius: '3px', background: 'var(--surface-3)', flex: '1', minWidth: '60px', overflow: 'hidden', }, }, // 4px rounded data-end, square where it meets the baseline. el('div', { style: { position: 'absolute', left: `${left}%`, width: `${Math.max(width, 0.6)}%`, top: '0', bottom: '0', background: row.tone === 'critical' ? 'var(--critical)' : row.tone === 'warning' ? 'var(--warning)' : seriesColor(row.colorIndex ?? 0), borderRadius: valueFrac >= zeroFrac ? '0 4px 4px 0' : '4px 0 0 4px', }, }), zeroFrac > 0 ? el('div', { style: { position: 'absolute', left: `${zeroFrac * 100}%`, top: '0', bottom: '0', width: '1px', background: 'var(--axis)', }, }) : null, ); host.appendChild(el('div', { style: { display: 'flex', alignItems: 'center', gap: '10px' } }, el('span', { text: row.label, style: { fontSize: '11.5px', color: 'var(--text-2)', minWidth: '118px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }, }), track, el('span', { text: `${Number(row.value ?? 0).toFixed(precision)}${unit}`, style: { fontSize: '11px', fontFamily: 'var(--mono)', color: 'var(--text-2)', minWidth: '58px', textAlign: 'right', fontVariantNumeric: 'tabular-nums' }, }), )); } return host; } /* -- Attitude indicator --------------------------------------------------- */ /** Artificial horizon from IMU roll and pitch - the one place a dial beats a number. */ export function attitude(roll = 0, pitch = 0, { size = 150 } = {}) { const svg = svgEl('svg', { class: 'chart', viewBox: '0 0 200 200', width: size, height: size }); const clipId = `att-clip-${Math.random().toString(36).slice(2, 8)}`; const defs = svgEl('defs'); const clip = svgEl('clipPath', { id: clipId }); clip.appendChild(svgEl('circle', { cx: 100, cy: 100, r: 84 })); defs.appendChild(clip); svg.appendChild(defs); const group = svgEl('g', { 'clip-path': `url(#${clipId})` }); const pitchPx = Math.max(-70, Math.min(70, pitch * (180 / Math.PI) * 2)); const inner = svgEl('g', { transform: `rotate(${-roll * (180 / Math.PI)} 100 100) translate(0 ${pitchPx})`, }); inner.appendChild(svgEl('rect', { x: -60, y: -80, width: 320, height: 180, fill: 'var(--surface-3)' })); inner.appendChild(svgEl('rect', { x: -60, y: 100, width: 320, height: 200, fill: 'var(--surface-hi)' })); inner.appendChild(svgEl('line', { x1: -60, x2: 260, y1: 100, y2: 100, stroke: seriesColor(0), 'stroke-width': 2 })); for (const offset of [-40, -20, 20, 40]) { const wide = Math.abs(offset) === 40; inner.appendChild(svgEl('line', { x1: 100 - (wide ? 26 : 16), x2: 100 + (wide ? 26 : 16), y1: 100 + offset, y2: 100 + offset, stroke: 'var(--text-3)', 'stroke-width': 1, })); } group.appendChild(inner); svg.appendChild(group); svg.appendChild(svgEl('circle', { cx: 100, cy: 100, r: 84, fill: 'none', stroke: 'var(--border)', 'stroke-width': 1.5 })); // Fixed aircraft reference. svg.appendChild(svgEl('path', { d: 'M62 100 h22 l8 8 l8 -8 h22', fill: 'none', stroke: 'var(--text)', 'stroke-width': 2.5, 'stroke-linejoin': 'round', 'stroke-linecap': 'round', })); svg.appendChild(svgEl('path', { d: 'M100 16 l-7 12 h14 z', fill: 'var(--text-2)', })); return svg; } /* -- Compass / heading ---------------------------------------------------- */ export function compass(yaw = 0, { size = 150 } = {}) { const svg = svgEl('svg', { class: 'chart', viewBox: '0 0 200 200', width: size, height: size }); svg.appendChild(svgEl('circle', { cx: 100, cy: 100, r: 84, fill: 'var(--surface-2)', stroke: 'var(--border)', 'stroke-width': 1.5 })); const dial = svgEl('g', { transform: `rotate(${-yaw * (180 / Math.PI)} 100 100)` }); for (let deg = 0; deg < 360; deg += 15) { const major = deg % 45 === 0; const rad = (deg - 90) * Math.PI / 180; const r1 = major ? 66 : 74; dial.appendChild(svgEl('line', { x1: 100 + Math.cos(rad) * r1, y1: 100 + Math.sin(rad) * r1, x2: 100 + Math.cos(rad) * 80, y2: 100 + Math.sin(rad) * 80, stroke: major ? 'var(--text-3)' : 'var(--border)', 'stroke-width': major ? 1.5 : 1, })); } for (const [label, deg] of [['N', 0], ['E', 90], ['S', 180], ['W', 270]]) { const rad = (deg - 90) * Math.PI / 180; const text = svgEl('text', { x: 100 + Math.cos(rad) * 52, y: 100 + Math.sin(rad) * 52 + 4, 'text-anchor': 'middle', class: 'tick-text', style: 'font-size:11px;font-weight:600', }); text.textContent = label; dial.appendChild(text); } svg.appendChild(dial); svg.appendChild(svgEl('path', { d: 'M100 34 L110 100 L100 92 L90 100 Z', fill: seriesColor(0), })); svg.appendChild(svgEl('circle', { cx: 100, cy: 100, r: 4, fill: 'var(--text-2)' })); const heading = svgEl('text', { x: 100, y: 132, 'text-anchor': 'middle', class: 'tick-text', style: 'font-size:15px;font-weight:600;fill:var(--text)', }); heading.textContent = `${(((yaw * 180 / Math.PI) % 360 + 360) % 360).toFixed(0)}°`; svg.appendChild(heading); return svg; }