kua-money-trace/web/charts.js

235 lines
13 KiB
JavaScript

/* money-trace · chart primitives (vanilla SVG, dark terminal aesthetic)
All charts share the CSS tokens from styles.css. window.Charts. */
(function () {
'use strict';
const NS = 'http://www.w3.org/2000/svg';
const el = (n, a, txt) => { const e = document.createElementNS(NS, n); for (const k in (a || {})) e.setAttribute(k, a[k]); if (txt != null) e.textContent = txt; return e; };
const cssv = v => getComputedStyle(document.documentElement).getPropertyValue(v).trim();
const fmt = v => window.MT.CLPk(v);
const fmtFull = v => window.MT.CLP(v);
function clear(c) { while (c.firstChild) c.removeChild(c.firstChild); }
// ---------- horizontal bar list ----------
// data: [{label, value, color, sub}], opts:{max, money, onHover}
function hbars(container, data, opts) {
opts = opts || {};
clear(container);
const max = opts.max || Math.max(...data.map(d => d.value), 1);
const wrap = document.createElement('div'); wrap.className = 'hbars';
data.forEach(d => {
const row = document.createElement('div'); row.className = 'hbar-row';
const pct = (d.value / max) * 100;
row.innerHTML =
`<div class="hbar-label">${esc(d.label)}${d.sub ? `<span class="hbar-sub">${esc(d.sub)}</span>` : ''}</div>
<div class="hbar-track"><div class="hbar-fill" style="width:${pct.toFixed(1)}%;background:${d.color || cssv('--ink-mute')}"></div></div>
<div class="hbar-val num">${opts.money === false ? d.value : fmt(d.value)}</div>`;
if (opts.onHover) {
row.addEventListener('mousemove', e => opts.onHover(e, d));
row.addEventListener('mouseleave', () => window.MTtip && window.MTtip.hide());
}
wrap.appendChild(row);
});
container.appendChild(wrap);
}
// ---------- grouped monthly bars (income vs spend) ----------
// months:[ym], series:[{key,color,values:{ym:amount}}]
function monthlyBars(container, months, series, opts) {
opts = opts || {};
clear(container);
const W = container.clientWidth || 800, H = opts.height || 240;
const padL = 52, padR = 12, padT = 14, padB = 26;
const innerW = W - padL - padR, innerH = H - padT - padB;
const max = Math.max(1, ...series.flatMap(s => months.map(m => s.values[m] || 0)));
const svg = el('svg', { viewBox: `0 0 ${W} ${H}`, width: '100%', height: H });
// gridlines
const ticks = 4;
for (let i = 0; i <= ticks; i++) {
const y = padT + innerH * (1 - i / ticks);
svg.appendChild(el('line', { x1: padL, y1: y, x2: W - padR, y2: y, stroke: cssv('--line'), 'stroke-width': 1 }));
svg.appendChild(el('text', { x: padL - 8, y: y + 3, 'text-anchor': 'end', class: 'chart-axis' }, fmt(max * i / ticks)));
}
const groupW = innerW / months.length;
const n = series.length, gap = 3, bw = Math.min(22, (groupW * 0.62 - gap * (n - 1)) / n);
months.forEach((m, mi) => {
const gx = padL + groupW * mi + groupW / 2;
const totalW = bw * n + gap * (n - 1);
series.forEach((s, si) => {
const v = s.values[m] || 0;
const h = (v / max) * innerH;
const x = gx - totalW / 2 + si * (bw + gap);
const y = padT + innerH - h;
const r = el('rect', { x, y, width: bw, height: Math.max(0, h), rx: 2, fill: s.color, opacity: 0.92, class: 'mbar' });
r.addEventListener('mousemove', e => window.MTtip && window.MTtip.raw(e, s.label + ' · ' + window.MT.MONTH_LABEL(m), fmtFull(v), s.color));
r.addEventListener('mouseleave', () => window.MTtip && window.MTtip.hide());
svg.appendChild(r);
});
svg.appendChild(el('text', { x: gx, y: H - 8, 'text-anchor': 'middle', class: 'chart-axis' }, window.MT.MONTH_LABEL(m).split(" '")[0]));
});
container.appendChild(svg);
}
// ---------- line / area (cumulative net) ----------
function lineChart(container, months, valuesByMonth, opts) {
opts = opts || {};
clear(container);
const W = container.clientWidth || 800, H = opts.height || 200;
const padL = 56, padR = 14, padT = 14, padB = 26;
const innerW = W - padL - padR, innerH = H - padT - padB;
const vals = months.map(m => valuesByMonth[m] || 0);
let cum = 0; const pts = vals.map((v, i) => { cum += v; return cum; });
const maxV = Math.max(...pts, 0), minV = Math.min(...pts, 0);
const span = (maxV - minV) || 1;
const x = i => padL + (months.length === 1 ? innerW / 2 : innerW * i / (months.length - 1));
const y = v => padT + innerH * (1 - (v - minV) / span);
const svg = el('svg', { viewBox: `0 0 ${W} ${H}`, width: '100%', height: H });
// zero line
if (minV < 0) svg.appendChild(el('line', { x1: padL, y1: y(0), x2: W - padR, y2: y(0), stroke: cssv('--line-2'), 'stroke-dasharray': '3 3' }));
const ticks = 3;
for (let i = 0; i <= ticks; i++) {
const vv = minV + span * i / ticks;
svg.appendChild(el('text', { x: padL - 8, y: y(vv) + 3, 'text-anchor': 'end', class: 'chart-axis' }, fmt(vv)));
}
const col = opts.color || cssv('--c-income');
let dPath = '', aPath = '';
pts.forEach((v, i) => { const cmd = i === 0 ? 'M' : 'L'; dPath += `${cmd}${x(i)},${y(v)} `; });
aPath = dPath + `L${x(pts.length - 1)},${y(minV)} L${x(0)},${y(minV)} Z`;
const gid = 'g' + Math.random().toString(36).slice(2, 7);
const defs = el('defs');
const lg = el('linearGradient', { id: gid, x1: 0, y1: 0, x2: 0, y2: 1 });
lg.appendChild(el('stop', { offset: '0%', 'stop-color': col, 'stop-opacity': 0.28 }));
lg.appendChild(el('stop', { offset: '100%', 'stop-color': col, 'stop-opacity': 0 }));
defs.appendChild(lg); svg.appendChild(defs);
svg.appendChild(el('path', { d: aPath, fill: `url(#${gid})` }));
svg.appendChild(el('path', { d: dPath, fill: 'none', stroke: col, 'stroke-width': 2.4, 'stroke-linejoin': 'round' }));
pts.forEach((v, i) => {
const dot = el('circle', { cx: x(i), cy: y(v), r: 3.4, fill: cssv('--bg'), stroke: col, 'stroke-width': 2 });
dot.addEventListener('mousemove', e => window.MTtip && window.MTtip.raw(e, window.MT.MONTH_LABEL(months[i]), 'running ' + fmtFull(v), col));
dot.addEventListener('mouseleave', () => window.MTtip && window.MTtip.hide());
svg.appendChild(dot);
svg.appendChild(el('text', { x: x(i), y: H - 8, 'text-anchor': 'middle', class: 'chart-axis' }, window.MT.MONTH_LABEL(months[i]).split(" '")[0]));
});
container.appendChild(svg);
}
// ---------- donut ----------
// data:[{label,value,color}]
function donut(container, data, opts) {
opts = opts || {};
clear(container);
const size = opts.size || 200, r = size / 2, ir = r * (opts.inner || 0.62);
const total = data.reduce((a, d) => a + d.value, 0) || 1;
const svg = el('svg', { viewBox: `0 0 ${size} ${size}`, width: size, height: size });
let a0 = -Math.PI / 2;
data.forEach(d => {
const frac = d.value / total, a1 = a0 + frac * Math.PI * 2;
const large = (a1 - a0) > Math.PI ? 1 : 0;
const p = el('path', {
d: `M${r + Math.cos(a0) * r},${r + Math.sin(a0) * r} A${r},${r} 0 ${large} 1 ${r + Math.cos(a1) * r},${r + Math.sin(a1) * r} L${r + Math.cos(a1) * ir},${r + Math.sin(a1) * ir} A${ir},${ir} 0 ${large} 0 ${r + Math.cos(a0) * ir},${r + Math.sin(a0) * ir} Z`,
fill: d.color, opacity: 0.92, class: 'donut-seg'
});
p.addEventListener('mousemove', e => window.MTtip && window.MTtip.raw(e, d.label, fmtFull(d.value) + ' · ' + Math.round(frac * 100) + '%', d.color));
p.addEventListener('mouseleave', () => window.MTtip && window.MTtip.hide());
svg.appendChild(p);
a0 = a1;
});
if (opts.centerTop || opts.centerBot) {
svg.appendChild(el('text', { x: r, y: r - 2, 'text-anchor': 'middle', class: 'donut-center-top' }, opts.centerTop || ''));
svg.appendChild(el('text', { x: r, y: r + 16, 'text-anchor': 'middle', class: 'donut-center-bot' }, opts.centerBot || ''));
}
container.appendChild(svg);
}
const esc = s => String(s == null ? '' : s).replace(/[&<>]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
// ---------- multi-line (balance trajectories over a date domain) ----------
// series:[{label,color,points:[{t:Date|ms, v}]}], opts:{height, money}
function multiLine(container, series, opts) {
opts = opts || {};
clear(container);
const W = container.clientWidth || 800, H = opts.height || 280;
const padL = 60, padR = 16, padT = 16, padB = 28;
const innerW = W - padL - padR, innerH = H - padT - padB;
const all = series.flatMap(s => s.points);
if (!all.length) { container.innerHTML = '<div style="color:var(--ink-dim);padding:40px;text-align:center;font-size:13px;">No balance data</div>'; return; }
const ts = all.map(p => +p.t);
const t0 = Math.min(...ts), t1 = Math.max(...ts);
let vMax = Math.max(...all.map(p => p.v)), vMin = Math.min(...all.map(p => p.v), 0);
const vspan = (vMax - vMin) || 1;
const x = t => padL + (t1 === t0 ? innerW / 2 : innerW * (+t - t0) / (t1 - t0));
const y = v => padT + innerH * (1 - (v - vMin) / vspan);
const svg = el('svg', { viewBox: `0 0 ${W} ${H}`, width: '100%', height: H });
// gridlines + y labels
const ticks = 4;
for (let i = 0; i <= ticks; i++) {
const vv = vMin + vspan * i / ticks, yy = y(vv);
svg.appendChild(el('line', { x1: padL, y1: yy, x2: W - padR, y2: yy, stroke: cssv('--line'), 'stroke-width': 1 }));
svg.appendChild(el('text', { x: padL - 8, y: yy + 3, 'text-anchor': 'end', class: 'chart-axis' }, fmt(vv)));
}
if (vMin < 0) svg.appendChild(el('line', { x1: padL, y1: y(0), x2: W - padR, y2: y(0), stroke: cssv('--c-spend'), 'stroke-opacity': 0.4, 'stroke-dasharray': '4 3' }));
// x labels (month ticks)
const months = [];
let cur = new Date(t0); cur.setDate(1);
while (+cur <= t1) { months.push(new Date(cur)); cur.setMonth(cur.getMonth() + 1); }
const xstep = Math.ceil(months.length / 8);
months.forEach((m, i) => { if (i % xstep === 0) svg.appendChild(el('text', { x: x(+m), y: H - 9, 'text-anchor': 'middle', class: 'chart-axis' }, window.MT.MONTH_LABEL(m.toISOString().slice(0, 7)).split(" '")[0])); });
// lines
series.forEach(s => {
const pts = s.points.slice().sort((a, b) => +a.t - +b.t);
if (!pts.length) return;
let dPath = '';
pts.forEach((p, i) => { dPath += `${i === 0 ? 'M' : 'L'}${x(+p.t)},${y(p.v)} `; });
svg.appendChild(el('path', { d: dPath, fill: 'none', stroke: s.color, 'stroke-width': 2, 'stroke-linejoin': 'round', 'stroke-linecap': 'round', opacity: 0.9 }));
// endpoint dot
const last = pts[pts.length - 1];
svg.appendChild(el('circle', { cx: x(+last.t), cy: y(last.v), r: 3, fill: s.color }));
// hover hit-dots
pts.forEach(p => {
const dot = el('circle', { cx: x(+p.t), cy: y(p.v), r: 7, fill: 'transparent', style: 'cursor:pointer' });
dot.addEventListener('mousemove', e => window.MTtip && window.MTtip.raw(e, s.label, new Date(+p.t).toISOString().slice(0, 10) + ' · ' + fmtFull(p.v), s.color));
dot.addEventListener('mouseleave', () => window.MTtip && window.MTtip.hide());
svg.appendChild(dot);
});
});
container.appendChild(svg);
}
// ---------- heatmap grid ----------
// rows:[{label, cells:[{v, raw, title}]}], colLabels:[], opts:{color, max}
function heatmap(container, rows, colLabels, opts) {
opts = opts || {};
clear(container);
const max = opts.max || Math.max(1, ...rows.flatMap(r => r.cells.map(c => c.v)));
const base = opts.color || cssv('--c-income');
const wrap = document.createElement('div'); wrap.className = 'heatmap';
// header
const head = document.createElement('div'); head.className = 'hm-row hm-head';
head.innerHTML = `<div class="hm-rowlab"></div>` + colLabels.map(c => `<div class="hm-collab">${esc(c)}</div>`).join('');
wrap.appendChild(head);
rows.forEach(r => {
const row = document.createElement('div'); row.className = 'hm-row';
let html = `<div class="hm-rowlab">${esc(r.label)}</div>`;
r.cells.forEach(c => {
const alpha = c.v <= 0 ? 0 : 0.12 + 0.88 * Math.pow(c.v / max, 0.6);
html += `<div class="hm-cell" data-v="${c.v}" title="${esc(c.title || '')}" style="--a:${alpha.toFixed(3)};--hc:${base}"></div>`;
});
row.innerHTML = html;
wrap.appendChild(row);
});
container.appendChild(wrap);
if (opts.onHover) wrap.querySelectorAll('.hm-cell').forEach((cell, i) => { });
// attach hover via cell data
let idx = 0;
rows.forEach(r => r.cells.forEach(c => {
const cell = wrap.querySelectorAll('.hm-cell')[idx++];
if (c.raw && c.raw.length && window.MTtip) {
cell.addEventListener('mousemove', e => window.MTtip.rows(e, c.title, base, c.raw));
cell.addEventListener('mouseleave', () => window.MTtip.hide());
}
}));
}
window.Charts = { hbars, monthlyBars, lineChart, donut, multiLine, heatmap, clear, esc };
})();