157 lines
7.3 KiB
JavaScript
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 };
|
|
})();
|