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

157 lines
7.3 KiB
JavaScript

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