kua-money-trace/web/money-trace.html

306 lines
15 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Money Trace</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div id="loading">reconstructing money flow…</div>
<div id="app" style="display:none;">
<header class="bar">
<div class="brand">
<div class="mark">money<b>·</b>trace</div>
<div class="sub">725 txns · 12 banks · 10 months</div>
</div>
<div class="stats">
<div class="stat income"><div class="lab"><i style="background:var(--c-income)"></i>Real income</div><div class="val num" id="s-in"></div></div>
<div class="stat spend"><div class="lab"><i style="background:var(--c-spend)"></i>Real spending</div><div class="val num" id="s-out"></div></div>
<div class="stat internal"><div class="lab"><i style="background:var(--c-internal)"></i>Internal shuffled</div><div class="val num" id="s-int"></div></div>
<div class="stat net"><div class="lab"><i style="background:var(--ink)"></i>Net real</div><div class="val num" id="s-net"></div></div>
<a href="dashboard.html" style="text-decoration:none; align-self:center; margin-left:8px;">
<div class="seg"><button style="color:var(--ink-mute)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;"><rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/></svg>
Dashboard
</button></div>
</a>
</div>
</header>
<div class="controls">
<div class="seg" id="viewSeg">
<button data-v="sankey" class="on">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 5h6c4 0 4 7 9 7h3M3 12h4c5 0 4 7 11 7h3M3 19h7"/></svg>
Flow
</button>
<button data-v="orbit">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><ellipse cx="12" cy="12" rx="9" ry="4"/><ellipse cx="12" cy="12" rx="4" ry="9"/></svg>
Orbit
</button>
</div>
<div class="toggle" id="internalToggle"><div class="track"></div><div><div class="lbl">Hide internal flows</div></div></div>
<div class="scrub">
<button class="play" id="playBtn" title="Play through months">
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</button>
<div class="track-wrap">
<div class="months"><span id="mLabel">All months</span><b id="mRange"></b></div>
<div class="bars" id="bars"></div>
</div>
</div>
<div class="dropdown" id="bankDrop">
<button class="btn">Banks <span class="cnt" id="bankCnt">all 12</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
</button>
<div class="menu" id="bankMenu"></div>
</div>
</div>
<div class="view" id="view">
<svg id="svg"></svg>
<div class="empty" id="empty" style="display:none;">No flows for this filter.</div>
<div class="legend" id="legend" style="position:absolute;left:20px;bottom:14px;">
<div class="item"><i style="background:var(--c-income)"></i>Income</div>
<div class="item"><i style="background:var(--c-spend)"></i>Spending</div>
<div class="item"><i style="background:var(--c-person)"></i>To/from a person</div>
<div class="item"><i style="background:var(--c-fee)"></i>Fees</div>
<div class="item dash"><i></i>Internal cycle</div>
</div>
</div>
</div>
<div id="tip"></div>
<div id="tweaks-root"></div>
<script src="engine.js"></script>
<script src="sankey-view.js"></script>
<script src="orbit-view.js"></script>
<script>
(function () {
'use strict';
const $ = s => document.querySelector(s);
const state = { view: 'sankey', hideInternal: false, months: null, banks: null };
let META = null, GRAPH = null, playTimer = null, playIdx = -1;
// ---------- tooltip ----------
const tip = $('#tip');
const cssv = v => getComputedStyle(document.documentElement).getPropertyValue(v).trim();
const catC = 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 place(e) {
const pad = 16, w = tip.offsetWidth, h = tip.offsetHeight;
let x = e.clientX + pad, y = e.clientY + pad;
if (x + w > innerWidth - 8) x = e.clientX - w - pad;
if (y + h > innerHeight - 8) y = e.clientY - h - pad;
tip.style.left = x + 'px'; tip.style.top = y + 'px';
}
function rows(txns) {
const top = txns.slice().sort((a,b)=>b.amount-a.amount).slice(0,6);
let html = '<div class="t-rows">';
for (const t of top) html += `<div class="t-row"><span class="d">${t.date.slice(5).replace('-','/')}</span><span class="desc">${esc(window.MT.cleanDesc(t.description) || t.counterparty || '—')}</span><span class="a">${window.MT.CLP(t.amount)}</span></div>`;
html += '</div>';
if (txns.length > 6) html += `<div class="t-more">+${txns.length - 6} more transactions</div>`;
return html;
}
const esc = s => String(s).replace(/[&<>]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
window.MTtip = {
node(e, n) {
const ls = [...(n._out||[]), ...(n._in||[]), ...(n._selfOut||[]), ...(n._selfIn||[])];
const all = ls.length ? ls : GRAPH.links.filter(l => l._sn === n || l._tn === n);
const txns = []; all.forEach(l => l.txns.forEach(t => txns.push(t)));
const col = cssv('--ink');
tip.innerHTML = `<div class="t-head"><span class="t-dot" style="background:${col}"></span><span class="t-title">${esc(n.label)}</span></div>
<div class="t-amt num">${window.MT.CLP(n.value)}</div>
<div class="t-meta">${n.sub} · in ${window.MT.CLPk(n.inSum||0)} · out ${window.MT.CLPk(n.outSum||0)} · ${txns.length} txns</div>${rows(txns)}`;
tip.classList.add('show'); place(e);
},
link(e, l) {
const col = cssv(catC(l.cat));
tip.innerHTML = `<div class="t-head"><span class="t-dot" style="background:${col}"></span><span class="t-title">${esc(l._sn.label)}${esc(l._tn.label)}</span></div>
<div class="t-amt num" style="color:${col}">${window.MT.CLP(l.value)}</div>
<div class="t-meta">${window.MT.CAT[l.cat].label} · ${l.txns.length} txns</div>${rows(l.txns)}`;
tip.classList.add('show'); place(e);
},
hide() { tip.classList.remove('show'); }
};
// ---------- render ----------
function rebuild() {
GRAPH = window.MT.build(state);
// stats
$('#s-in').innerHTML = fmtBig(GRAPH.totals.realIn);
$('#s-out').innerHTML = fmtBig(GRAPH.totals.realOut);
$('#s-int').innerHTML = fmtBig(GRAPH.totals.internal);
const net = GRAPH.totals.net;
$('#s-net').innerHTML = (net < 0 ? '' : '') + fmtBig(Math.abs(net));
render();
}
function fmtBig(v) {
if (v >= 1e6) return '$' + (v/1e6).toLocaleString('es-CL',{minimumFractionDigits:1,maximumFractionDigits:1}) + '<small>M</small>';
if (v >= 1e3) return '$' + Math.round(v/1e3).toLocaleString('es-CL') + '<small>k</small>';
return window.MT.CLP(v);
}
function render() {
if (!GRAPH) return;
const view = $('#view'), svg = $('#svg');
const w = view.clientWidth, h = view.clientHeight;
const ok = (state.view === 'sankey' ? window.SankeyView : window.OrbitView).render(svg, GRAPH, { width: w, height: h });
$('#empty').style.display = ok ? 'none' : 'grid';
}
// ---------- controls ----------
$('#viewSeg').addEventListener('click', e => {
const b = e.target.closest('button'); if (!b) return;
state.view = b.dataset.v;
[...$('#viewSeg').children].forEach(x => x.classList.toggle('on', x === b));
render();
});
const itog = $('#internalToggle');
itog.addEventListener('click', () => { state.hideInternal = !state.hideInternal; itog.classList.toggle('on', state.hideInternal); rebuild(); });
// month bars
function buildBars() {
const counts = {}; window.MT.tx.forEach(t => counts[t.ym] = (counts[t.ym]||0)+1);
const months = window.MT.months;
const max = Math.max(...months.map(m => counts[m]||0));
const bars = $('#bars'); bars.innerHTML = '';
months.forEach(m => {
const b = document.createElement('div');
b.className = 'b'; b.style.height = (8 + (counts[m]||0)/max*22) + 'px';
b.title = window.MT.MONTH_LABEL(m) + ' · ' + (counts[m]||0) + ' txns';
b.dataset.m = m;
b.addEventListener('click', () => selectMonth(m));
bars.appendChild(b);
});
$('#mRange').textContent = window.MT.MONTH_LABEL(months[0]) + ' ' + window.MT.MONTH_LABEL(months[months.length-1]);
}
function selectMonth(m) {
stopPlay();
if (state.months && state.months.length === 1 && state.months[0] === m) { state.months = null; }
else state.months = [m];
syncBars(); rebuild();
}
function syncBars() {
const sel = state.months;
[...$('#bars').children].forEach(b => b.classList.toggle('active', sel ? sel.includes(b.dataset.m) : false));
$('#mLabel').textContent = sel && sel.length === 1 ? window.MT.MONTH_LABEL(sel[0]) : 'All months';
}
$('#playBtn').addEventListener('click', () => { playTimer ? stopPlay() : startPlay(); });
function startPlay() {
const months = window.MT.months;
playIdx = state.months && state.months.length === 1 ? months.indexOf(state.months[0]) : -1;
$('#playBtn').innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="5" width="4" height="14"/><rect x="14" y="5" width="4" height="14"/></svg>';
playTimer = setInterval(() => {
playIdx++;
if (playIdx >= months.length) { stopPlay(); state.months = null; syncBars(); rebuild(); return; }
state.months = [months[playIdx]]; syncBars(); rebuild();
}, 1100);
}
function stopPlay() {
if (playTimer) clearInterval(playTimer); playTimer = null;
$('#playBtn').innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>';
}
// bank dropdown
function buildBankMenu() {
const counts = {}; window.MT.tx.forEach(t => counts[t.bank] = (counts[t.bank]||0)+1);
const menu = $('#bankMenu'); menu.innerHTML = '';
const all = document.createElement('div'); all.className = 'all'; all.textContent = 'Select all';
all.addEventListener('click', () => { state.banks = null; refreshBankMenu(); rebuild(); });
menu.appendChild(all);
menu.appendChild(Object.assign(document.createElement('div'), { className: 'sep' }));
window.MT.banks.forEach(bk => {
const row = document.createElement('div'); row.className = 'row'; row.dataset.b = bk;
row.innerHTML = `<span class="ck"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M5 12l5 5 9-11"/></svg></span><span>${bk}</span><span class="n">${counts[bk]||0}</span>`;
row.addEventListener('click', () => {
const cur = state.banks || window.MT.banks.slice();
const i = cur.indexOf(bk);
if (i >= 0) cur.splice(i, 1); else cur.push(bk);
state.banks = cur.length === window.MT.banks.length ? null : cur.slice();
refreshBankMenu(); rebuild();
});
menu.appendChild(row);
});
refreshBankMenu();
}
function refreshBankMenu() {
const sel = state.banks;
[...$('#bankMenu').querySelectorAll('.row')].forEach(r => r.classList.toggle('on', sel ? sel.includes(r.dataset.b) : true));
$('#bankCnt').textContent = sel ? sel.length + ' / 12' : 'all 12';
}
$('#bankDrop > .btn').addEventListener('click', e => { e.stopPropagation(); $('#bankDrop').classList.toggle('open'); });
document.addEventListener('click', () => $('#bankDrop').classList.remove('open'));
$('#bankMenu').addEventListener('click', e => e.stopPropagation());
// resize
let rt; new ResizeObserver(() => { clearTimeout(rt); rt = setTimeout(render, 80); }).observe($('#view'));
window.MTapply = render; // re-render hook for Tweaks
// ---------- boot ----------
window.MT.load().then(meta => {
META = meta;
document.querySelector('.brand .sub').textContent = `${window.MT.tx.length} txns · ${window.MT.banks.length} banks · ${window.MT.months.length} months`;
buildBars(); syncBars(); buildBankMenu();
rebuild();
$('#loading').style.display = 'none';
$('#app').style.display = 'flex';
setTimeout(render, 60);
}).catch(err => { $('#loading').textContent = 'failed to load ledger.json — ' + err.message; });
})();
</script>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<script type="text/babel" src="tweaks-panel.jsx"></script>
<script type="text/babel">
const TWEAK_DEFAULTS = {
palette: ['#2ee6a6', '#ff5f73', '#b98cff', '#ffc24b'],
glow: 1,
linkOpacity: 0.62,
bg: '#090b0f'
};
function applyTweaks(t) {
const r = document.documentElement.style;
r.setProperty('--c-income', t.palette[0]);
r.setProperty('--c-spend', t.palette[1]);
r.setProperty('--c-person', t.palette[2]);
r.setProperty('--c-fee', t.palette[3]);
r.setProperty('--glow', t.glow);
r.setProperty('--link-op', t.linkOpacity);
r.setProperty('--bg', t.bg);
if (window.MTapply) window.MTapply();
}
function TweakApp() {
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
React.useEffect(() => { applyTweaks(t); }, [t]);
return (
<TweaksPanel title="Tweaks">
<TweakSection label="Flow palette" hint="income · spending · people · fees" />
<TweakColor label="Colors" value={t.palette} onChange={v => setTweak('palette', v)}
options={[
['#2ee6a6', '#ff5f73', '#b98cff', '#ffc24b'],
['#34d399', '#fb7185', '#a78bfa', '#fbbf24'],
['#00e5a0', '#ff4d6d', '#8c9eff', '#ffd45a'],
['#7CFFB2', '#FF8FA3', '#C9A6FF', '#FFE08A']
]} />
<TweakSection label="Atmosphere" />
<TweakSlider label="Glow" value={t.glow} min={0} max={2} step={0.1} onChange={v => setTweak('glow', v)} />
<TweakSlider label="Flow opacity" value={t.linkOpacity} min={0.3} max={1} step={0.05} onChange={v => setTweak('linkOpacity', v)} />
<TweakColor label="Background" value={t.bg} onChange={v => setTweak('bg', v)}
options={['#090b0f', '#0c0a0e', '#0a0d0c', '#0d0d0d']} />
</TweaksPanel>
);
}
ReactDOM.createRoot(document.getElementById('tweaks-root')).render(<TweakApp />);
</script>
</body>
</html>