Menu
Public repository / 9a35bd9c668fae6e3383420387d1a0b1b4b3f524
AkurAI-Monitor / ui/chart.js
Latest change 811ef9b7c8ba017fa242f008efa90faab07a0454 - Adopt AkurAI-Framework UI layer and add framework-version tracking by Ólafur Búi Ólafsson
(() => {
const SVG_NS = 'http://www.w3.org/2000/svg';
const svgEl = (tag, attrs = {}) => {
const el = document.createElementNS(SVG_NS, tag);
Object.entries(attrs).forEach(([key, value]) => el.setAttribute(key, String(value)));
return el;
};
const token = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim();
const text = (x, y, value, attrs = {}) => {
const el = svgEl('text', { x, y, fill: token('--muted'), 'font-size': 10, 'font-family': 'ui-sans-serif,system-ui,sans-serif', ...attrs });
el.textContent = value;
return el;
};
window.renderTimeSeriesChart = (container, series, opts = {}) => {
const width = Math.max(container.clientWidth || 300, 240);
const height = 180, left = 48, right = 12, top = 10, bottom = 28;
const plotWidth = width - left - right, plotHeight = height - top - bottom;
const points = series.flatMap((item) => item.points);
container.replaceChildren();
if (!points.length) {
container.append(Object.assign(document.createElement('p'), { className: 'chart-empty', textContent: 'No data in range' }));
return;
}
const timestamps = points.map((point) => point.ts);
const values = points.map((point) => point.value);
const minTime = Math.min(...timestamps), maxTime = Math.max(...timestamps);
const minValue = opts.min ?? Math.min(...values);
let maxValue = opts.max ?? Math.max(...values) * 1.1;
if (minValue === maxValue) maxValue += 1;
const x = (value) => left + (maxTime === minTime ? plotWidth / 2 : (value - minTime) / (maxTime - minTime) * plotWidth);
const y = (value) => top + plotHeight - (value - minValue) / (maxValue - minValue) * plotHeight;
const svg = svgEl('svg', { viewBox: `0 0 ${width} ${height}`, class: 'time-series-chart', role: 'img', 'aria-label': opts.label || 'Time series chart' });
for (let index = 0; index <= 4; index++) {
const value = minValue + (maxValue - minValue) * index / 4;
const yy = y(value);
svg.append(svgEl('line', { x1: left, y1: yy, x2: width - right, y2: yy, stroke: token('--border'), 'stroke-width': 1 }), text(left - 6, yy + 3, opts.fmt ? opts.fmt(value) : value.toFixed(0), { 'text-anchor': 'end', 'font-family': 'ui-monospace,monospace' }));
}
const timeLabel = (value) => new Date(value * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
svg.append(text(left, height - 7, timeLabel(minTime)), text(width - right, height - 7, timeLabel(maxTime), { 'text-anchor': 'end' }));
series.forEach((item) => {
if (!item.points.length) return;
const color = item.color.startsWith('--') ? token(item.color) : item.color;
svg.append(svgEl('polyline', { points: item.points.map((point) => `${x(point.ts)},${y(point.value)}`).join(' '), fill: 'none', stroke: color, 'stroke-width': 2, 'stroke-linejoin': 'round', 'stroke-linecap': 'round' }));
});
const crosshair = svgEl('line', { y1: top, y2: top + plotHeight, stroke: token('--muted'), 'stroke-width': 1, 'stroke-dasharray': '3 3', visibility: 'hidden' });
svg.append(crosshair);
const readout = document.createElement('div');
readout.className = 'chart-readout';
container.append(svg, readout);
svg.addEventListener('pointermove', (event) => {
const rect = svg.getBoundingClientRect();
const time = minTime + Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width)) * (maxTime - minTime);
crosshair.setAttribute('x1', x(time)); crosshair.setAttribute('x2', x(time)); crosshair.setAttribute('visibility', 'visible');
readout.textContent = series.map((item) => {
const point = item.points.reduce((nearest, candidate) => Math.abs(candidate.ts - time) < Math.abs(nearest.ts - time) ? candidate : nearest);
return `${item.label}: ${opts.fmt ? opts.fmt(point.value) : point.value}`;
}).join(' · ');
});
svg.addEventListener('pointerleave', () => { crosshair.setAttribute('visibility', 'hidden'); readout.textContent = ''; });
};
})();