306 lines
15 KiB
HTML
306 lines
15 KiB
HTML
<!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 => ({'&':'&','<':'<','>':'>'}[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>
|