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