flowtwin / frontend /js /charts.js
FlowTwin
FlowTwin — AI crowd digital twin for Formula 1 venues
3e2e16c
Raw
History Blame Contribute Delete
3.28 kB
/* Small canvas charts. One job: draw the density trajectory each candidate
* strategy produced, so the comparison table has a shape as well as numbers. */
const SERIES_COLOURS = [
'#ff3222', '#35c8f5', '#12d38a', '#ffa415',
'#a78bfa', '#f472b6', '#94a3b8', '#facc15',
];
export function drawStrategyChart(canvas, result, legendEl, highlightId) {
const ctx = canvas.getContext('2d');
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const rect = canvas.getBoundingClientRect();
if (!rect.width) return;
canvas.width = Math.round(rect.width * dpr);
canvas.height = Math.round(rect.height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const W = rect.width, H = rect.height;
ctx.clearRect(0, 0, W, H);
const strategies = (result.strategies || []).filter(s => s.series?.density?.length);
if (!strategies.length) return;
const pad = { l: 30, r: 8, t: 10, b: 20 };
const maxT = Math.max(...strategies.map(s => s.series.t.at(-1) || 1));
const critical = result.bottleneck_critical_density
?? Math.max(...strategies.flatMap(s => s.series.density)) * 0.85;
const maxD = Math.max(
critical * 1.12,
...strategies.flatMap(s => s.series.density)) * 1.06;
const X = t => pad.l + (t / maxT) * (W - pad.l - pad.r);
const Y = d => H - pad.b - (d / maxD) * (H - pad.t - pad.b);
// grid
ctx.strokeStyle = 'rgba(255,255,255,.055)';
ctx.lineWidth = 1;
ctx.font = '9px ui-monospace, monospace';
ctx.fillStyle = '#64708a';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
const steps = 4;
for (let i = 0; i <= steps; i++) {
const d = (maxD / steps) * i;
const y = Y(d);
ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(W - pad.r, y); ctx.stroke();
ctx.fillText(d.toFixed(1), pad.l - 6, y);
}
// critical threshold
if (result.bottleneck_critical_density) {
ctx.save();
ctx.setLineDash([4, 4]);
ctx.strokeStyle = 'rgba(255,50,34,.55)';
ctx.beginPath();
ctx.moveTo(pad.l, Y(result.bottleneck_critical_density));
ctx.lineTo(W - pad.r, Y(result.bottleneck_critical_density));
ctx.stroke();
ctx.restore();
ctx.textAlign = 'left';
ctx.fillStyle = 'rgba(255,80,66,.8)';
ctx.fillText('critical', pad.l + 4, Y(result.bottleneck_critical_density) - 7);
}
// x axis labels
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillStyle = '#64708a';
for (let i = 0; i <= 4; i++) {
const t = (maxT / 4) * i;
ctx.fillText(`+${Math.round(t)}s`, X(t), H - pad.b + 5);
}
strategies.forEach((s, i) => {
const colour = s.id === 'no_action' ? '#ff3222' : SERIES_COLOURS[(i + 1) % SERIES_COLOURS.length];
const emphasised = highlightId ? s.id === highlightId : (s.recommended || s.id === 'no_action');
ctx.beginPath();
s.series.t.forEach((t, k) => {
const x = X(t), y = Y(s.series.density[k]);
k ? ctx.lineTo(x, y) : ctx.moveTo(x, y);
});
ctx.strokeStyle = colour;
ctx.globalAlpha = emphasised ? 1 : 0.3;
ctx.lineWidth = emphasised ? 2.1 : 1.1;
ctx.lineJoin = 'round';
ctx.stroke();
ctx.globalAlpha = 1;
s._colour = colour;
});
if (legendEl) {
legendEl.innerHTML = strategies.map(s =>
`<span><i style="background:${s._colour}"></i>${s.label}</span>`).join('');
}
}