226 lines
9.6 KiB
JavaScript
226 lines
9.6 KiB
JavaScript
/* money-trace · layered Sankey renderer (SVG, vanilla)
|
|
Columns: 0 sources · 1 accounts · 2 cards/lines · 3 destinations.
|
|
Real flows pop (solid, glow); internal flows recede (dashed, grey).
|
|
Self-transfers (col1↔col1) render as right-bulging recirculation loops. */
|
|
(function () {
|
|
'use strict';
|
|
const SVGNS = 'http://www.w3.org/2000/svg';
|
|
const el = (n, a) => { const e = document.createElementNS(SVGNS, n); for (const k in (a || {})) e.setAttribute(k, a[k]); return e; };
|
|
|
|
const COL_HEAD = ['Income sources', 'My accounts', 'Cards & lines', 'Where it goes'];
|
|
|
|
function dominantCat(links) {
|
|
const m = {};
|
|
for (const l of links) m[l.cat] = (m[l.cat] || 0) + l.value;
|
|
let best = null, bv = -1; for (const k in m) if (m[k] > bv) { bv = m[k]; best = k; }
|
|
return best;
|
|
}
|
|
const catColor = c => ({
|
|
income: '--c-income', inter_in: '--c-person', inter_out: '--c-person',
|
|
expense: '--c-spend', fee: '--c-fee',
|
|
internal_card: '--c-internal', internal_line: '--c-internal', internal_self: '--c-internal'
|
|
})[c] || '--ink-mute';
|
|
const cssv = v => getComputedStyle(document.documentElement).getPropertyValue(v).trim();
|
|
|
|
function render(svg, graph, opts) {
|
|
const W = opts.width, H = opts.height;
|
|
while (svg.firstChild) svg.removeChild(svg.firstChild);
|
|
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
|
|
|
|
const nodes = graph.nodes, links = graph.links;
|
|
if (!nodes.length) { return false; }
|
|
|
|
// layout constants
|
|
const padTop = 40, padBot = 26;
|
|
const nodeW = 15, nodePad = 12;
|
|
const mLeft = 168, mRight = 172;
|
|
const availH = H - padTop - padBot;
|
|
const innerW = W - mLeft - mRight;
|
|
const colX = [mLeft, mLeft + innerW * 0.34, mLeft + innerW * 0.67, mLeft + innerW];
|
|
|
|
// group + sort nodes by column
|
|
const cols = [[], [], [], []];
|
|
for (const n of nodes) cols[n.col].push(n);
|
|
const orderKind = { source: 0, person: 0, account: 0, card: 0, spend: 0, fee: 1 };
|
|
for (const c of cols) c.sort((a, b) => b.value - a.value);
|
|
|
|
// shared value→px scale (binding column fills availH)
|
|
let scale = Infinity;
|
|
cols.forEach(c => {
|
|
if (!c.length) return;
|
|
const sum = c.reduce((s, n) => s + n.value, 0);
|
|
const s = (availH - (c.length - 1) * nodePad) / sum;
|
|
if (s < scale) scale = s;
|
|
});
|
|
if (!isFinite(scale)) scale = 1;
|
|
|
|
// position nodes
|
|
cols.forEach((c, ci) => {
|
|
const total = c.reduce((s, n) => s + n.value * scale, 0) + (c.length - 1) * nodePad;
|
|
let y = padTop + (availH - total) / 2;
|
|
for (const n of c) {
|
|
n._x = colX[ci]; n._w = nodeW; n._y = y; n._h = Math.max(2, n.value * scale);
|
|
n._rcur = n._y; n._lcur = n._y; // edge cursors
|
|
y += n._h + nodePad;
|
|
}
|
|
});
|
|
|
|
// split links
|
|
const fwd = [], self = [];
|
|
for (const l of links) {
|
|
l._s = graph.nodes.find ? null : null;
|
|
}
|
|
const byId = new Map(nodes.map(n => [n.id, n]));
|
|
for (const l of links) {
|
|
const s = byId.get(l.source), t = byId.get(l.target);
|
|
if (!s || !t) continue;
|
|
l._sn = s; l._tn = t;
|
|
if (s.col === t.col) self.push(l); else fwd.push(l);
|
|
}
|
|
|
|
// assign forward endpoints: source right edge, target left edge
|
|
// order each node's links by opposite node center to reduce crossings
|
|
const sortKey = (l, side) => (side === 'src' ? l._tn._y : l._sn._y);
|
|
for (const n of nodes) {
|
|
n._out = fwd.filter(l => l._sn === n).sort((a, b) => sortKey(a, 'src') - sortKey(b, 'src'));
|
|
n._in = fwd.filter(l => l._tn === n).sort((a, b) => sortKey(a, 'tgt') - sortKey(b, 'tgt'));
|
|
}
|
|
for (const n of nodes) {
|
|
for (const l of n._out) { const w = Math.max(1, l.value * scale); l._sx = n._x + n._w; l._sy = n._rcur + w / 2; n._rcur += w; l._w = w; }
|
|
for (const l of n._in) { const w = Math.max(1, l.value * scale); l._tx = n._x; l._ty = n._lcur + w / 2; n._lcur += w; }
|
|
}
|
|
// self links: both endpoints on right edge, bulge right
|
|
for (const n of nodes) {
|
|
n._selfOut = self.filter(l => l._sn === n).sort((a, b) => a._tn._y - b._tn._y);
|
|
n._selfIn = self.filter(l => l._tn === n).sort((a, b) => a._sn._y - b._sn._y);
|
|
}
|
|
for (const n of nodes) {
|
|
for (const l of n._selfOut) { const w = Math.max(1, l.value * scale); l._sx = n._x + n._w; l._sy = n._rcur + w / 2; n._rcur += w; l._w = w; }
|
|
for (const l of n._selfIn) { const w = Math.max(1, l.value * scale); l._tx = n._x + n._w; l._ty = n._rcur + w / 2; n._rcur += w; }
|
|
}
|
|
|
|
// ---- draw ----
|
|
const defs = el('defs');
|
|
svg.appendChild(defs);
|
|
|
|
// label de-collision for outer columns (shift label centres, keep node positions)
|
|
function spread(colArr, minGap) {
|
|
const arr = colArr.slice().sort((a, b) => a._y - b._y);
|
|
let prev = -Infinity;
|
|
for (const n of arr) { let c = n._y + n._h / 2; if (c < prev + minGap) c = prev + minGap; n._lc = c; prev = c; }
|
|
}
|
|
spread(cols[0], 30); spread(cols[3], 30);
|
|
spread(cols[1], 30); spread(cols[2], 30);
|
|
|
|
// column headers
|
|
cols.forEach((c, ci) => {
|
|
if (!c.length) return;
|
|
const x = ci === 3 ? colX[ci] + nodeW : colX[ci];
|
|
const anchor = ci === 0 ? 'start' : (ci === 3 ? 'end' : 'start');
|
|
const tx = ci === 0 ? colX[0] - 0 : (ci === 3 ? colX[3] + nodeW : colX[ci]);
|
|
const th = el('text', { x: tx, y: 22, class: 'colhead', 'text-anchor': ci === 3 ? 'start' : 'start' });
|
|
th.textContent = COL_HEAD[ci];
|
|
svg.appendChild(th);
|
|
});
|
|
|
|
const linkG = el('g'); svg.appendChild(linkG);
|
|
const nodeG = el('g'); svg.appendChild(nodeG);
|
|
|
|
function linkPath(l) {
|
|
if (l._sn.col === l._tn.col) {
|
|
// self loop, bulge right
|
|
const depth = 46 + Math.min(70, l._w * 1.2) + (l._sy % 23);
|
|
const x = l._sx;
|
|
return `M${x},${l._sy} C${x + depth},${l._sy} ${x + depth},${l._ty} ${x},${l._ty}`;
|
|
}
|
|
const sx = l._sx, sy = l._sy, tx = l._tx, ty = l._ty;
|
|
const mx = (sx + tx) / 2;
|
|
return `M${sx},${sy} C${mx},${sy} ${mx},${ty} ${tx},${ty}`;
|
|
}
|
|
|
|
const linkEls = [];
|
|
// draw internal first (under), then real (over)
|
|
const ordered = links.slice().sort((a, b) => (a.kind === 'internal' ? -1 : 1) - (b.kind === 'internal' ? -1 : 1));
|
|
for (const l of ordered) {
|
|
if (l._sx == null || l._tx == null) continue;
|
|
const p = el('path', {
|
|
d: linkPath(l),
|
|
class: 'link ' + (l.kind === 'real' ? 'real glow-real ' : 'internal ') + l.cat,
|
|
'stroke-width': l._w
|
|
});
|
|
p.__link = l;
|
|
linkG.appendChild(p);
|
|
linkEls.push(p);
|
|
}
|
|
|
|
// nodes
|
|
const nodeEls = [];
|
|
for (const n of nodes) {
|
|
const g = el('g'); g.__node = n;
|
|
const dom = dominantCat([...n._out, ...n._in, ...n._selfOut, ...n._selfIn]);
|
|
const col = cssv(catColor(dom)) || cssv('--ink-mute');
|
|
const r = el('rect', { x: n._x, y: n._y, width: n._w, height: n._h, rx: 3, fill: col, class: 'node-rect' });
|
|
r.style.opacity = n.kind === 'account' || n.kind === 'card' ? 0.92 : 0.85;
|
|
g.appendChild(r);
|
|
|
|
// labels
|
|
const cy = n._lc != null ? n._lc : n._y + n._h / 2;
|
|
if (n.col === 0) {
|
|
const t = el('text', { x: n._x - 9, y: cy - 1, class: 'node-label', 'text-anchor': 'end', 'dominant-baseline': 'middle' });
|
|
t.textContent = n.label;
|
|
const v = el('text', { x: n._x - 9, y: cy + 13, class: 'node-val', 'text-anchor': 'end', 'dominant-baseline': 'middle' });
|
|
v.textContent = MTfmt(n.value);
|
|
g.appendChild(t); g.appendChild(v);
|
|
} else if (n.col === 3) {
|
|
const t = el('text', { x: n._x + n._w + 9, y: cy - 1, class: 'node-label', 'dominant-baseline': 'middle' });
|
|
t.textContent = n.label;
|
|
const v = el('text', { x: n._x + n._w + 9, y: cy + 13, class: 'node-val', 'dominant-baseline': 'middle' });
|
|
v.textContent = MTfmt(n.value);
|
|
g.appendChild(t); g.appendChild(v);
|
|
} else {
|
|
// middle: label above node
|
|
const t = el('text', { x: n._x + n._w + 8, y: cy - 5, class: 'node-label', 'dominant-baseline': 'middle' });
|
|
t.textContent = n.label;
|
|
const s = el('text', { x: n._x + n._w + 8, y: cy + 9, class: 'node-sub', 'dominant-baseline': 'middle' });
|
|
s.textContent = n.sub + ' ' + MTfmt(n.value);
|
|
g.appendChild(t); g.appendChild(s);
|
|
}
|
|
nodeG.appendChild(g);
|
|
nodeEls.push(g);
|
|
}
|
|
|
|
// ---- interactions ----
|
|
function setFocus(activeLinks, activeNodes) {
|
|
linkEls.forEach(p => {
|
|
if (!activeLinks || activeLinks.has(p.__link)) p.classList.remove('dim');
|
|
else p.classList.add('dim');
|
|
});
|
|
nodeEls.forEach(g => {
|
|
if (!activeNodes || activeNodes.has(g.__node)) g.classList.remove('dim');
|
|
else g.classList.add('dim');
|
|
});
|
|
}
|
|
function clearFocus() { linkEls.forEach(p => p.classList.remove('dim')); nodeEls.forEach(g => g.classList.remove('dim')); }
|
|
|
|
linkEls.forEach(p => {
|
|
p.addEventListener('mousemove', e => { window.MTtip.link(e, p.__link); });
|
|
p.addEventListener('mouseenter', () => { const s = new Set([p.__link]); setFocus(s, new Set([p.__link._sn, p.__link._tn])); });
|
|
p.addEventListener('mouseleave', () => { window.MTtip.hide(); clearFocus(); });
|
|
});
|
|
nodeEls.forEach(g => {
|
|
const n = g.__node;
|
|
const ls = new Set([...n._out, ...n._in, ...n._selfOut, ...n._selfIn]);
|
|
const ns = new Set([n]); ls.forEach(l => { ns.add(l._sn); ns.add(l._tn); });
|
|
g.addEventListener('mousemove', e => window.MTtip.node(e, n));
|
|
g.addEventListener('mouseenter', () => setFocus(ls, ns));
|
|
g.addEventListener('mouseleave', () => { window.MTtip.hide(); clearFocus(); });
|
|
});
|
|
|
|
return true;
|
|
}
|
|
|
|
function MTfmt(v) { return window.MT.CLPk(v); }
|
|
|
|
window.SankeyView = { render };
|
|
})();
|