/* 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 => `${s.label}`).join(''); } }