348 lines
15 KiB
JavaScript
348 lines
15 KiB
JavaScript
/* ============================================================
|
|
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('<br>'));
|
|
}
|
|
|
|
// -------------------- 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]}: <b>${data[s.i]}</b> (${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, `<b>${pts[nearest].label}</b><br>${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 `<div class="chart-legend">${items.map(it =>
|
|
`<span class="legend-item"><span class="legend-dot" style="background:${it.color}"></span>${it.label}</span>`).join('')}</div>`;
|
|
}
|
|
})();
|