/* ============================================================ charts.js — Lightweight Canvas chart engine (no libraries) Exposes global `Charts` with: line, bar, groupedBar, doughnut, area, horizontalBar, sparkline ============================================================ */ (function () { 'use strict'; function css(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); } /* Series colours live in CSS (--c1..--c8) so light and dark each get a set tuned to their surface. Read live rather than cached: App.setTheme re-renders the view, and every draw call then picks up the new theme. */ const PALETTE_FALLBACK = ['#004d43', '#0f9d76', '#5b60e8', '#6f8f14', '#0e7490', '#8a5a00', '#a8327d', '#3f6d64']; function palette() { const out = []; for (let i = 1; i <= 8; i++) { const v = css('--c' + i); if (v) out.push(v); } return out.length ? out : PALETTE_FALLBACK; } /* Canvas needs a literal font string; mirror the CSS stack so charts match the rest of the UI (and any licensed Neue Montreal install). */ function FONT(px, weight) { const fam = css('--font') || 'Inter, sans-serif'; return (weight ? weight + ' ' : '') + px + 'px ' + fam; } function themeColors() { return { grid: css('--border') || '#dbe8e2', text: css('--text-3') || '#54726c', surface: css('--bg-elev') || '#fff' }; } // shared floating tooltip let tip; function tooltip() { if (!tip) { tip = document.createElement('div'); tip.className = 'chart-tooltip'; document.body.appendChild(tip); } return tip; } function showTip(x, y, html) { const t = tooltip(); t.innerHTML = html; t.style.left = (x + 12) + 'px'; t.style.top = (y - 12) + 'px'; t.style.opacity = '1'; } function hideTip() { if (tip) tip.style.opacity = '0'; } function setup(canvas) { const dpr = window.devicePixelRatio || 1; const rect = canvas.getBoundingClientRect(); const w = rect.width || canvas.clientWidth || 600; const h = parseInt(canvas.getAttribute('height')) || 260; canvas.width = w * dpr; canvas.height = h * dpr; canvas.style.height = h + 'px'; const ctx = canvas.getContext('2d'); ctx.scale(dpr, dpr); ctx.clearRect(0, 0, w, h); return { ctx, w, h }; } function niceMax(v) { if (v <= 0) return 10; const pow = Math.pow(10, Math.floor(Math.log10(v))); const n = v / pow; let m = n <= 1 ? 1 : n <= 2 ? 2 : n <= 5 ? 5 : 10; return m * pow; } function drawGridY(ctx, w, h, pad, max, tc, fmt) { ctx.font = FONT(11); ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; const steps = 4; for (let i = 0; i <= steps; i++) { const val = (max / steps) * i; const y = h - pad.b - (val / max) * (h - pad.t - pad.b); ctx.strokeStyle = tc.grid; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(w - pad.r, y); ctx.stroke(); ctx.fillStyle = tc.text; ctx.fillText(fmt ? fmt(val) : Math.round(val), pad.l - 8, y); } } function animate(draw) { const start = performance.now(), dur = 700; function frame(now) { // The rAF timestamp is the frame's start time and can predate the // performance.now() taken just above, so (now - start) may be < 0. // Unclamped that yields negative progress -> negative bar geometry -> // arcTo throws -> the loop dies and the chart is left half-drawn. let t = Math.min(1, Math.max(0, (now - start) / dur)); t = 1 - Math.pow(1 - t, 3); // easeOutCubic draw(t); if (t < 1) requestAnimationFrame(frame); } requestAnimationFrame(frame); } // -------------------- LINE / AREA -------------------- function line(canvas, opts) { const { labels, datasets, area, yFmt } = opts; const tc = themeColors(); const pad = { t: 16, r: 16, b: 28, l: 44 }; const allVals = datasets.flatMap(d => d.data); const max = niceMax(Math.max(...allVals, 1) * 1.1); const points = []; function render(prog) { const { ctx, w, h } = setup(canvas); drawGridY(ctx, w, h, pad, max, tc, yFmt); const plotW = w - pad.l - pad.r, plotH = h - pad.t - pad.b; const stepX = plotW / (labels.length - 1 || 1); points.length = 0; // x labels ctx.fillStyle = tc.text; ctx.font = FONT(11); ctx.textAlign = 'center'; ctx.textBaseline = 'top'; labels.forEach((l, i) => ctx.fillText(l, pad.l + stepX * i, h - pad.b + 8)); datasets.forEach((ds, di) => { const pal = palette(); const color = ds.color || pal[di % pal.length]; const pts = ds.data.map((v, i) => ({ x: pad.l + stepX * i, y: h - pad.b - (v * prog / max) * plotH, v })); if (di === 0) pts.forEach((p, i) => points.push({ ...p, label: labels[i] })); if (area) { const grad = ctx.createLinearGradient(0, pad.t, 0, h - pad.b); grad.addColorStop(0, color + '55'); grad.addColorStop(1, color + '00'); ctx.beginPath(); ctx.moveTo(pts[0].x, h - pad.b); pts.forEach(p => ctx.lineTo(p.x, p.y)); ctx.lineTo(pts[pts.length - 1].x, h - pad.b); ctx.closePath(); ctx.fillStyle = grad; ctx.fill(); } ctx.beginPath(); ctx.lineWidth = 2.5; ctx.strokeStyle = color; ctx.lineJoin = 'round'; pts.forEach((p, i) => i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)); ctx.stroke(); if (prog === 1) pts.forEach(p => { ctx.beginPath(); ctx.arc(p.x, p.y, 3.5, 0, 7); ctx.fillStyle = tc.surface; ctx.fill(); ctx.lineWidth = 2.5; ctx.strokeStyle = color; ctx.stroke(); }); }); } animate(render); attachHover(canvas, () => points, (p, i) => datasets.map(ds => `${ds.label}: ${yFmt ? yFmt(ds.data[i]) : ds.data[i]}`).join('
')); } // -------------------- BAR -------------------- function bar(canvas, opts) { const { labels, data, colors, yFmt, horizontal } = opts; if (horizontal) return horizontalBar(canvas, opts); const tc = themeColors(); const pad = { t: 16, r: 16, b: 30, l: 44 }; const max = niceMax(Math.max(...data, 1) * 1.1); const bars = []; function render(prog) { const { ctx, w, h } = setup(canvas); drawGridY(ctx, w, h, pad, max, tc, yFmt); const plotW = w - pad.l - pad.r, plotH = h - pad.t - pad.b; const slot = plotW / data.length, bw = Math.min(46, slot * 0.6); bars.length = 0; ctx.font = FONT(11); ctx.textAlign = 'center'; ctx.textBaseline = 'top'; data.forEach((v, i) => { const x = pad.l + slot * i + slot / 2; const bh = (v * prog / max) * plotH; const y = h - pad.b - bh; // One series = one colour. Cycling the palette here made colour look // meaningful when the category is already named on the axis; callers // that genuinely encode category in colour pass `colors` explicitly. const pal = palette(); const color = (colors && colors[i]) || pal[0]; roundRect(ctx, x - bw / 2, y, bw, bh, 5); ctx.fillStyle = color; ctx.fill(); ctx.fillStyle = tc.text; ctx.fillText(labels[i].length > 9 ? labels[i].slice(0, 8) + '…' : labels[i], x, h - pad.b + 8); bars.push({ x: x - bw / 2, y, w: bw, h: bh, cx: x, cy: y, label: labels[i], v }); }); } animate(render); attachHoverRect(canvas, () => bars, (b) => `${b.label}: ${yFmt ? yFmt(b.v) : b.v}`); } // -------------------- GROUPED BAR -------------------- function groupedBar(canvas, opts) { const { labels, datasets, yFmt } = opts; const tc = themeColors(); const pad = { t: 16, r: 16, b: 30, l: 44 }; const max = niceMax(Math.max(...datasets.flatMap(d => d.data), 1) * 1.1); const bars = []; function render(prog) { const { ctx, w, h } = setup(canvas); drawGridY(ctx, w, h, pad, max, tc, yFmt); const plotW = w - pad.l - pad.r, plotH = h - pad.t - pad.b; const slot = plotW / labels.length; const bw = Math.min(18, (slot * 0.7) / datasets.length); const groupW = bw * datasets.length + (datasets.length - 1) * 3; bars.length = 0; ctx.font = FONT(11); ctx.textAlign = 'center'; ctx.textBaseline = 'top'; labels.forEach((lbl, i) => { const gx = pad.l + slot * i + slot / 2 - groupW / 2; datasets.forEach((ds, di) => { const v = ds.data[i]; const bh = (v * prog / max) * plotH; const x = gx + di * (bw + 3); const y = h - pad.b - bh; roundRect(ctx, x, y, bw, bh, 4); ctx.fillStyle = ds.color || palette()[di]; ctx.fill(); bars.push({ x, y, w: bw, h: bh, label: lbl + ' · ' + ds.label, v }); }); ctx.fillStyle = tc.text; ctx.fillText(lbl.length > 9 ? lbl.slice(0, 8) + '…' : lbl, pad.l + slot * i + slot / 2, h - pad.b + 8); }); } animate(render); attachHoverRect(canvas, () => bars, (b) => `${b.label}: ${yFmt ? yFmt(b.v) : b.v}`); } // -------------------- HORIZONTAL BAR -------------------- function horizontalBar(canvas, opts) { const { labels, data, colors, yFmt } = opts; const tc = themeColors(); const pad = { t: 8, r: 40, b: 8, l: 108 }; const max = niceMax(Math.max(...data, 1)); const bars = []; function render(prog) { const { ctx, w, h } = setup(canvas); const plotW = w - pad.l - pad.r, plotH = h - pad.t - pad.b; const slot = plotH / data.length, bh = Math.min(22, slot * 0.6); bars.length = 0; ctx.font = FONT(12); data.forEach((v, i) => { const y = pad.t + slot * i + slot / 2; const bwd = (v * prog / max) * plotW; const pal3 = palette(); const color = (colors && colors[i]) || pal3[0]; ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; ctx.fillStyle = tc.text; ctx.fillText(labels[i].length > 13 ? labels[i].slice(0, 12) + '…' : labels[i], pad.l - 10, y); roundRect(ctx, pad.l, y - bh / 2, bwd, bh, 4); ctx.fillStyle = color; ctx.fill(); ctx.textAlign = 'left'; ctx.fillStyle = css('--text-2') || '#4a625c'; ctx.font = FONT(12,600); if (prog === 1) ctx.fillText(yFmt ? yFmt(v) : v, pad.l + bwd + 8, y); ctx.font = FONT(12); bars.push({ x: pad.l, y: y - bh / 2, w: Math.max(bwd, 60), h: bh, label: labels[i], v }); }); } animate(render); attachHoverRect(canvas, () => bars, (b) => `${b.label}: ${yFmt ? yFmt(b.v) : b.v}`); } // -------------------- DOUGHNUT / PIE -------------------- function doughnut(canvas, opts) { const { data, labels, colors, centerLabel, centerValue, pie } = opts; const tc = themeColors(); const total = data.reduce((a, b) => a + b, 0) || 1; const segs = []; function render(prog) { const { ctx, w, h } = setup(canvas); const cx = w / 2, cy = h / 2, r = Math.min(w, h) / 2 - 10; const inner = pie ? 0 : r * 0.62; let a0 = -Math.PI / 2; segs.length = 0; data.forEach((v, i) => { const a1 = a0 + (v / total) * Math.PI * 2 * prog; ctx.beginPath(); ctx.moveTo(cx, cy); ctx.arc(cx, cy, r, a0, a1); ctx.closePath(); const pal2 = palette(); ctx.fillStyle = (colors && colors[i]) || pal2[i % pal2.length]; ctx.fill(); segs.push({ a0, a1, i }); a0 = a1; }); if (!pie) { ctx.beginPath(); ctx.arc(cx, cy, inner, 0, 7); ctx.fillStyle = tc.surface; ctx.fill(); if (centerValue !== undefined) { ctx.fillStyle = css('--text') || '#10231f'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.font = FONT(26,700); ctx.fillText(centerValue, cx, cy - 6); ctx.font = FONT(12); ctx.fillStyle = tc.text; ctx.fillText(centerLabel || '', cx, cy + 16); } } canvas._segs = segs; canvas._geo = { cx, cy, r, inner }; } animate(render); canvas.onmousemove = (e) => { const rect = canvas.getBoundingClientRect(); const mx = e.clientX - rect.left, my = e.clientY - rect.top; const g = canvas._geo; if (!g) return; const dx = mx - g.cx, dy = my - g.cy, dist = Math.hypot(dx, dy); if (dist > g.r || dist < g.inner) { hideTip(); canvas.style.cursor = 'default'; return; } let ang = Math.atan2(dy, dx); if (ang < -Math.PI / 2) ang += Math.PI * 2; const s = (canvas._segs || []).find(sg => ang >= sg.a0 && ang <= sg.a1); if (s) { canvas.style.cursor = 'pointer'; const pct = Math.round(data[s.i] / total * 100); showTip(e.clientX, e.clientY, `${labels[s.i]}: ${data[s.i]} (${pct}%)`); } else hideTip(); }; canvas.onmouseleave = hideTip; } // -------------------- SPARKLINE -------------------- function sparkline(canvas, data, color) { const { ctx, w, h } = setup(canvas); const max = Math.max(...data), min = Math.min(...data), rng = max - min || 1; const stepX = w / (data.length - 1); color = color || palette()[0]; const grad = ctx.createLinearGradient(0, 0, 0, h); grad.addColorStop(0, color + '40'); grad.addColorStop(1, color + '00'); const pts = data.map((v, i) => ({ x: i * stepX, y: h - 4 - ((v - min) / rng) * (h - 8) })); ctx.beginPath(); ctx.moveTo(0, h); pts.forEach(p => ctx.lineTo(p.x, p.y)); ctx.lineTo(w, h); ctx.closePath(); ctx.fillStyle = grad; ctx.fill(); ctx.beginPath(); ctx.strokeStyle = color; ctx.lineWidth = 2; ctx.lineJoin = 'round'; pts.forEach((p, i) => i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)); ctx.stroke(); } // -------------------- helpers -------------------- function roundRect(ctx, x, y, w, h, r) { if (h < 0) { y += h; h = -h; } if (w < 0) { x += w; w = -w; } // arcTo rejects a negative radius by throwing, which would abort the // whole render pass — clamp so a degenerate rect is just skipped. r = Math.max(0, Math.min(r, w / 2, h / 2)); ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); } function attachHover(canvas, getPoints, fmt) { canvas.onmousemove = (e) => { const rect = canvas.getBoundingClientRect(); const mx = e.clientX - rect.left; const pts = getPoints(); if (!pts.length) return; let nearest = 0, min = Infinity; pts.forEach((p, i) => { const d = Math.abs(p.x - mx); if (d < min) { min = d; nearest = i; } }); if (min < 40) { canvas.style.cursor = 'pointer'; showTip(e.clientX, e.clientY, `${pts[nearest].label}
${fmt(pts[nearest], nearest)}`); } else hideTip(); }; canvas.onmouseleave = hideTip; } function attachHoverRect(canvas, getBars, fmt) { canvas.onmousemove = (e) => { const rect = canvas.getBoundingClientRect(); const mx = e.clientX - rect.left, my = e.clientY - rect.top; const b = getBars().find(b => mx >= b.x - 3 && mx <= b.x + b.w + 3 && my >= b.y - 3 && my <= b.y + b.h + 3); if (b) { canvas.style.cursor = 'pointer'; showTip(e.clientX, e.clientY, fmt(b)); } else hideTip(); }; canvas.onmouseleave = hideTip; } window.Charts = { line, bar, groupedBar, doughnut, horizontalBar, sparkline, legend, token: css }; // Live getter: each read reflects the active theme's --c1..--c8. Object.defineProperty(window.Charts, 'PALETTE', { get: palette, enumerable: true }); function legend(items) { return `
${items.map(it => `${it.label}`).join('')}
`; } })();