HR-ATS-Portal/js/charts.js

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>`;
}
})();