kua-money-trace/web/sankey-view.js

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