2026-08-13 16:23:18 +04:00

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;
}