424 lines
17 KiB
JavaScript
424 lines
17 KiB
JavaScript
/* ==========================================================================
|
|
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;
|
|
}
|