/* money-trace · Orbit (radial) renderer My accounts + cards sit on an inner ring. Income sources fan in from the left outer arc; destinations fan out to the right outer arc. Internal flows (card payments, line sweeps, self-transfers) bow through the centre, so the "cycles" show up as a grey rosette you can toggle off. */ (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 cssv = v => getComputedStyle(document.documentElement).getPropertyValue(v).trim(); 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'; 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; const cx = W / 2, cy = H / 2; const R1 = Math.min(W, H) * 0.215; // inner ring (my accounts + cards) const R2 = Math.min(W, H) * 0.405; // outer ring (sources + destinations) const byId = new Map(nodes.map(n => [n.id, n])); const maxVal = Math.max(...nodes.map(n => n.value)); const maxLink = Math.max(...links.map(l => l.value)); const rOf = v => 4 + Math.sqrt(v / maxVal) * 22; const wOf = v => Math.max(0.7, (v / maxLink) * 15); // classify nodes into rings const inner = nodes.filter(n => n.col === 1 || n.col === 2); // accounts + cards const sources = nodes.filter(n => n.col === 0); const dests = nodes.filter(n => n.col === 3); inner.sort((a, b) => (a.col - b.col) || (b.value - a.value)); sources.sort((a, b) => b.value - a.value); dests.sort((a, b) => b.value - a.value); // place inner ring: accounts across the upper arc, cards across the lower arc const place = (arr, a0, a1, R) => { const n = arr.length; arr.forEach((node, i) => { const t = n === 1 ? 0.5 : i / (n - 1); const ang = a0 + (a1 - a0) * t; node._a = ang; node._x = cx + Math.cos(ang) * R; node._y = cy + Math.sin(ang) * R; node._R = R; }); }; const accts = inner.filter(n => n.col === 1), cards = inner.filter(n => n.col === 2); const D = Math.PI / 180; // accounts: top arc; cards: bottom arc place(accts, -155 * D, -25 * D, R1); place(cards, 25 * D, 155 * D, R1); // sources: left outer arc; dests: right outer arc place(sources, 130 * D, 230 * D, R2); place(dests, -50 * D, 50 * D, R2); const defs = el('defs'); svg.appendChild(defs); const linkG = el('g'); svg.appendChild(linkG); const nodeG = el('g'); svg.appendChild(nodeG); // rings (guides) [R1, R2].forEach(R => svg.insertBefore(el('circle', { cx, cy, r: R, class: 'orbit-ring' }), linkG)); function path(l) { const s = l._sn, t = l._tn; const internal = l.kind === 'internal'; // control point: internal bows through centre; real arcs gently toward centre let ctrl; if (internal) { const mx = (s._x + t._x) / 2, my = (s._y + t._y) / 2; ctrl = [cx + (mx - cx) * 0.15, cy + (my - cy) * 0.15]; } else { const mx = (s._x + t._x) / 2, my = (s._y + t._y) / 2; ctrl = [cx + (mx - cx) * 0.55, cy + (my - cy) * 0.55]; } return `M${s._x},${s._y} Q${ctrl[0]},${ctrl[1]} ${t._x},${t._y}`; } const linkEls = []; const ordered = links.slice().sort((a, b) => (a.kind === 'internal' ? -1 : 1) - (b.kind === 'internal' ? -1 : 1)); for (const l of ordered) { const s = byId.get(l.source), t = byId.get(l.target); if (!s || !t) continue; l._sn = s; l._tn = t; const p = el('path', { d: path(l), class: 'link ' + (l.kind === 'real' ? 'real glow-real ' : 'internal ') + l.cat, 'stroke-width': wOf(l.value) }); p.__link = l; linkG.appendChild(p); linkEls.push(p); } // centre hub const hub = el('g'); hub.appendChild(el('circle', { cx, cy, r: 46, fill: cssv('--panel-2'), stroke: cssv('--line-2'), 'stroke-width': 1 })); const t1 = el('text', { x: cx, y: cy - 6, class: 'orbit-center-lab', 'text-anchor': 'middle' }); t1.textContent = 'MY MONEY'; const t2 = el('text', { x: cx, y: cy + 13, class: 'orbit-sub', 'text-anchor': 'middle' }); t2.textContent = 'net ' + window.MT.CLPk(graph.totals.net); hub.appendChild(t1); hub.appendChild(t2); nodeG.appendChild(hub); // nodes const nodeEls = []; function dom(n) { const ls = links.filter(l => l._sn === n || l._tn === n); const m = {}; ls.forEach(l => m[l.cat] = (m[l.cat] || 0) + l.value); let b = null, bv = -1; for (const k in m) if (m[k] > bv) { bv = m[k]; b = k; } return b; } for (const n of nodes) { const g = el('g'); g.__node = n; const r = rOf(n.value); const col = cssv(catColor(dom(n))) || cssv('--ink-mute'); const isInner = n.col === 1 || n.col === 2; const c = el('circle', { cx: n._x, cy: n._y, r, fill: isInner ? cssv('--panel-2') : col, stroke: col, 'stroke-width': isInner ? 1.6 : 0 }); c.style.opacity = 0.95; g.appendChild(c); // label: outer nodes -> radially outward; inner -> outward small const outward = Math.atan2(n._y - cy, n._x - cx); const lx = n._x + Math.cos(outward) * (r + 8); const ly = n._y + Math.sin(outward) * (r + 8); const anchor = Math.cos(outward) < -0.2 ? 'end' : (Math.cos(outward) > 0.2 ? 'start' : 'middle'); const lab = el('text', { x: lx, y: ly, class: 'orbit-node-label', 'text-anchor': anchor, 'dominant-baseline': 'middle' }); lab.textContent = n.label.length > 18 ? n.label.slice(0, 17) + '…' : n.label; g.appendChild(lab); if (isInner && n.sub) { const sub = el('text', { x: lx, y: ly + 12, class: 'orbit-sub', 'text-anchor': anchor, 'dominant-baseline': 'middle' }); sub.textContent = n.sub; g.appendChild(sub); } nodeG.appendChild(g); nodeEls.push(g); } // interactions function focus(ls, ns) { linkEls.forEach(p => p.classList.toggle('dim', ls && !ls.has(p.__link))); nodeEls.forEach(g => g.classList.toggle('dim', ns && !ns.has(g.__node))); } const clear = () => { 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', () => focus(new Set([p.__link]), new Set([p.__link._sn, p.__link._tn]))); p.addEventListener('mouseleave', () => { window.MTtip.hide(); clear(); }); }); nodeEls.forEach(g => { const n = g.__node; const ls = new Set(links.filter(l => l._sn === n || l._tn === n)); 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', () => focus(ls, ns)); g.addEventListener('mouseleave', () => { window.MTtip.hide(); clear(); }); }); return true; } window.OrbitView = { render }; })();