235 lines
13 KiB
JavaScript
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 => ({ '&': '&', '<': '<', '>': '>' }[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 };
|
|
})();
|