/* 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 = `
`; 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 = '