Import Claude Design dashboard: 12-tab finance terminal
Full handoff from claude.ai/design bundle (4ESEfttPcJJzpoqjJxZozQ): - dashboard.html: 12-tab shell (Overview, Spending, Income, People, Cards, Cycles, Balances, Rhythm, Platforms, Banks, Ledger, Quality) - engine.js: data normalisation, categorisation, Sankey graph builder - charts.js: reusable SVG primitives (bars, donuts, heatmaps, multiline) - dashboard.js + dashboard2.js: all tab renderers - styles.css + dashboard.css: Space Grotesk / IBM Plex Mono dark terminal - sankey-view.js + orbit-view.js: Sankey and radial cycle views - money-trace.html: Sankey / Orbit visualisation shell - tweaks-panel.jsx: React palette/glow tweaks panel
This commit is contained in:
parent
97ca5d6c10
commit
a2cb7d3700
|
|
@ -0,0 +1,234 @@
|
||||||
|
/* money-trace · chart primitives (vanilla SVG, dark terminal aesthetic)
|
||||||
|
All charts share the CSS tokens from styles.css. window.Charts. */
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
const NS = 'http://www.w3.org/2000/svg';
|
||||||
|
const el = (n, a, txt) => { const e = document.createElementNS(NS, n); for (const k in (a || {})) e.setAttribute(k, a[k]); if (txt != null) e.textContent = txt; return e; };
|
||||||
|
const cssv = v => getComputedStyle(document.documentElement).getPropertyValue(v).trim();
|
||||||
|
const fmt = v => window.MT.CLPk(v);
|
||||||
|
const fmtFull = v => window.MT.CLP(v);
|
||||||
|
|
||||||
|
function clear(c) { while (c.firstChild) c.removeChild(c.firstChild); }
|
||||||
|
|
||||||
|
// ---------- horizontal bar list ----------
|
||||||
|
// data: [{label, value, color, sub}], opts:{max, money, onHover}
|
||||||
|
function hbars(container, data, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
clear(container);
|
||||||
|
const max = opts.max || Math.max(...data.map(d => d.value), 1);
|
||||||
|
const wrap = document.createElement('div'); wrap.className = 'hbars';
|
||||||
|
data.forEach(d => {
|
||||||
|
const row = document.createElement('div'); row.className = 'hbar-row';
|
||||||
|
const pct = (d.value / max) * 100;
|
||||||
|
row.innerHTML =
|
||||||
|
`<div class="hbar-label">${esc(d.label)}${d.sub ? `<span class="hbar-sub">${esc(d.sub)}</span>` : ''}</div>
|
||||||
|
<div class="hbar-track"><div class="hbar-fill" style="width:${pct.toFixed(1)}%;background:${d.color || cssv('--ink-mute')}"></div></div>
|
||||||
|
<div class="hbar-val num">${opts.money === false ? d.value : fmt(d.value)}</div>`;
|
||||||
|
if (opts.onHover) {
|
||||||
|
row.addEventListener('mousemove', e => opts.onHover(e, d));
|
||||||
|
row.addEventListener('mouseleave', () => window.MTtip && window.MTtip.hide());
|
||||||
|
}
|
||||||
|
wrap.appendChild(row);
|
||||||
|
});
|
||||||
|
container.appendChild(wrap);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- grouped monthly bars (income vs spend) ----------
|
||||||
|
// months:[ym], series:[{key,color,values:{ym:amount}}]
|
||||||
|
function monthlyBars(container, months, series, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
clear(container);
|
||||||
|
const W = container.clientWidth || 800, H = opts.height || 240;
|
||||||
|
const padL = 52, padR = 12, padT = 14, padB = 26;
|
||||||
|
const innerW = W - padL - padR, innerH = H - padT - padB;
|
||||||
|
const max = Math.max(1, ...series.flatMap(s => months.map(m => s.values[m] || 0)));
|
||||||
|
const svg = el('svg', { viewBox: `0 0 ${W} ${H}`, width: '100%', height: H });
|
||||||
|
// gridlines
|
||||||
|
const ticks = 4;
|
||||||
|
for (let i = 0; i <= ticks; i++) {
|
||||||
|
const y = padT + innerH * (1 - i / ticks);
|
||||||
|
svg.appendChild(el('line', { x1: padL, y1: y, x2: W - padR, y2: y, stroke: cssv('--line'), 'stroke-width': 1 }));
|
||||||
|
svg.appendChild(el('text', { x: padL - 8, y: y + 3, 'text-anchor': 'end', class: 'chart-axis' }, fmt(max * i / ticks)));
|
||||||
|
}
|
||||||
|
const groupW = innerW / months.length;
|
||||||
|
const n = series.length, gap = 3, bw = Math.min(22, (groupW * 0.62 - gap * (n - 1)) / n);
|
||||||
|
months.forEach((m, mi) => {
|
||||||
|
const gx = padL + groupW * mi + groupW / 2;
|
||||||
|
const totalW = bw * n + gap * (n - 1);
|
||||||
|
series.forEach((s, si) => {
|
||||||
|
const v = s.values[m] || 0;
|
||||||
|
const h = (v / max) * innerH;
|
||||||
|
const x = gx - totalW / 2 + si * (bw + gap);
|
||||||
|
const y = padT + innerH - h;
|
||||||
|
const r = el('rect', { x, y, width: bw, height: Math.max(0, h), rx: 2, fill: s.color, opacity: 0.92, class: 'mbar' });
|
||||||
|
r.addEventListener('mousemove', e => window.MTtip && window.MTtip.raw(e, s.label + ' · ' + window.MT.MONTH_LABEL(m), fmtFull(v), s.color));
|
||||||
|
r.addEventListener('mouseleave', () => window.MTtip && window.MTtip.hide());
|
||||||
|
svg.appendChild(r);
|
||||||
|
});
|
||||||
|
svg.appendChild(el('text', { x: gx, y: H - 8, 'text-anchor': 'middle', class: 'chart-axis' }, window.MT.MONTH_LABEL(m).split(" '")[0]));
|
||||||
|
});
|
||||||
|
container.appendChild(svg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- line / area (cumulative net) ----------
|
||||||
|
function lineChart(container, months, valuesByMonth, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
clear(container);
|
||||||
|
const W = container.clientWidth || 800, H = opts.height || 200;
|
||||||
|
const padL = 56, padR = 14, padT = 14, padB = 26;
|
||||||
|
const innerW = W - padL - padR, innerH = H - padT - padB;
|
||||||
|
const vals = months.map(m => valuesByMonth[m] || 0);
|
||||||
|
let cum = 0; const pts = vals.map((v, i) => { cum += v; return cum; });
|
||||||
|
const maxV = Math.max(...pts, 0), minV = Math.min(...pts, 0);
|
||||||
|
const span = (maxV - minV) || 1;
|
||||||
|
const x = i => padL + (months.length === 1 ? innerW / 2 : innerW * i / (months.length - 1));
|
||||||
|
const y = v => padT + innerH * (1 - (v - minV) / span);
|
||||||
|
const svg = el('svg', { viewBox: `0 0 ${W} ${H}`, width: '100%', height: H });
|
||||||
|
// zero line
|
||||||
|
if (minV < 0) svg.appendChild(el('line', { x1: padL, y1: y(0), x2: W - padR, y2: y(0), stroke: cssv('--line-2'), 'stroke-dasharray': '3 3' }));
|
||||||
|
const ticks = 3;
|
||||||
|
for (let i = 0; i <= ticks; i++) {
|
||||||
|
const vv = minV + span * i / ticks;
|
||||||
|
svg.appendChild(el('text', { x: padL - 8, y: y(vv) + 3, 'text-anchor': 'end', class: 'chart-axis' }, fmt(vv)));
|
||||||
|
}
|
||||||
|
const col = opts.color || cssv('--c-income');
|
||||||
|
let dPath = '', aPath = '';
|
||||||
|
pts.forEach((v, i) => { const cmd = i === 0 ? 'M' : 'L'; dPath += `${cmd}${x(i)},${y(v)} `; });
|
||||||
|
aPath = dPath + `L${x(pts.length - 1)},${y(minV)} L${x(0)},${y(minV)} Z`;
|
||||||
|
const gid = 'g' + Math.random().toString(36).slice(2, 7);
|
||||||
|
const defs = el('defs');
|
||||||
|
const lg = el('linearGradient', { id: gid, x1: 0, y1: 0, x2: 0, y2: 1 });
|
||||||
|
lg.appendChild(el('stop', { offset: '0%', 'stop-color': col, 'stop-opacity': 0.28 }));
|
||||||
|
lg.appendChild(el('stop', { offset: '100%', 'stop-color': col, 'stop-opacity': 0 }));
|
||||||
|
defs.appendChild(lg); svg.appendChild(defs);
|
||||||
|
svg.appendChild(el('path', { d: aPath, fill: `url(#${gid})` }));
|
||||||
|
svg.appendChild(el('path', { d: dPath, fill: 'none', stroke: col, 'stroke-width': 2.4, 'stroke-linejoin': 'round' }));
|
||||||
|
pts.forEach((v, i) => {
|
||||||
|
const dot = el('circle', { cx: x(i), cy: y(v), r: 3.4, fill: cssv('--bg'), stroke: col, 'stroke-width': 2 });
|
||||||
|
dot.addEventListener('mousemove', e => window.MTtip && window.MTtip.raw(e, window.MT.MONTH_LABEL(months[i]), 'running ' + fmtFull(v), col));
|
||||||
|
dot.addEventListener('mouseleave', () => window.MTtip && window.MTtip.hide());
|
||||||
|
svg.appendChild(dot);
|
||||||
|
svg.appendChild(el('text', { x: x(i), y: H - 8, 'text-anchor': 'middle', class: 'chart-axis' }, window.MT.MONTH_LABEL(months[i]).split(" '")[0]));
|
||||||
|
});
|
||||||
|
container.appendChild(svg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- donut ----------
|
||||||
|
// data:[{label,value,color}]
|
||||||
|
function donut(container, data, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
clear(container);
|
||||||
|
const size = opts.size || 200, r = size / 2, ir = r * (opts.inner || 0.62);
|
||||||
|
const total = data.reduce((a, d) => a + d.value, 0) || 1;
|
||||||
|
const svg = el('svg', { viewBox: `0 0 ${size} ${size}`, width: size, height: size });
|
||||||
|
let a0 = -Math.PI / 2;
|
||||||
|
data.forEach(d => {
|
||||||
|
const frac = d.value / total, a1 = a0 + frac * Math.PI * 2;
|
||||||
|
const large = (a1 - a0) > Math.PI ? 1 : 0;
|
||||||
|
const p = el('path', {
|
||||||
|
d: `M${r + Math.cos(a0) * r},${r + Math.sin(a0) * r} A${r},${r} 0 ${large} 1 ${r + Math.cos(a1) * r},${r + Math.sin(a1) * r} L${r + Math.cos(a1) * ir},${r + Math.sin(a1) * ir} A${ir},${ir} 0 ${large} 0 ${r + Math.cos(a0) * ir},${r + Math.sin(a0) * ir} Z`,
|
||||||
|
fill: d.color, opacity: 0.92, class: 'donut-seg'
|
||||||
|
});
|
||||||
|
p.addEventListener('mousemove', e => window.MTtip && window.MTtip.raw(e, d.label, fmtFull(d.value) + ' · ' + Math.round(frac * 100) + '%', d.color));
|
||||||
|
p.addEventListener('mouseleave', () => window.MTtip && window.MTtip.hide());
|
||||||
|
svg.appendChild(p);
|
||||||
|
a0 = a1;
|
||||||
|
});
|
||||||
|
if (opts.centerTop || opts.centerBot) {
|
||||||
|
svg.appendChild(el('text', { x: r, y: r - 2, 'text-anchor': 'middle', class: 'donut-center-top' }, opts.centerTop || ''));
|
||||||
|
svg.appendChild(el('text', { x: r, y: r + 16, 'text-anchor': 'middle', class: 'donut-center-bot' }, opts.centerBot || ''));
|
||||||
|
}
|
||||||
|
container.appendChild(svg);
|
||||||
|
}
|
||||||
|
|
||||||
|
const esc = s => String(s == null ? '' : s).replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c]));
|
||||||
|
|
||||||
|
// ---------- multi-line (balance trajectories over a date domain) ----------
|
||||||
|
// series:[{label,color,points:[{t:Date|ms, v}]}], opts:{height, money}
|
||||||
|
function multiLine(container, series, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
clear(container);
|
||||||
|
const W = container.clientWidth || 800, H = opts.height || 280;
|
||||||
|
const padL = 60, padR = 16, padT = 16, padB = 28;
|
||||||
|
const innerW = W - padL - padR, innerH = H - padT - padB;
|
||||||
|
const all = series.flatMap(s => s.points);
|
||||||
|
if (!all.length) { container.innerHTML = '<div style="color:var(--ink-dim);padding:40px;text-align:center;font-size:13px;">No balance data</div>'; return; }
|
||||||
|
const ts = all.map(p => +p.t);
|
||||||
|
const t0 = Math.min(...ts), t1 = Math.max(...ts);
|
||||||
|
let vMax = Math.max(...all.map(p => p.v)), vMin = Math.min(...all.map(p => p.v), 0);
|
||||||
|
const vspan = (vMax - vMin) || 1;
|
||||||
|
const x = t => padL + (t1 === t0 ? innerW / 2 : innerW * (+t - t0) / (t1 - t0));
|
||||||
|
const y = v => padT + innerH * (1 - (v - vMin) / vspan);
|
||||||
|
const svg = el('svg', { viewBox: `0 0 ${W} ${H}`, width: '100%', height: H });
|
||||||
|
// gridlines + y labels
|
||||||
|
const ticks = 4;
|
||||||
|
for (let i = 0; i <= ticks; i++) {
|
||||||
|
const vv = vMin + vspan * i / ticks, yy = y(vv);
|
||||||
|
svg.appendChild(el('line', { x1: padL, y1: yy, x2: W - padR, y2: yy, stroke: cssv('--line'), 'stroke-width': 1 }));
|
||||||
|
svg.appendChild(el('text', { x: padL - 8, y: yy + 3, 'text-anchor': 'end', class: 'chart-axis' }, fmt(vv)));
|
||||||
|
}
|
||||||
|
if (vMin < 0) svg.appendChild(el('line', { x1: padL, y1: y(0), x2: W - padR, y2: y(0), stroke: cssv('--c-spend'), 'stroke-opacity': 0.4, 'stroke-dasharray': '4 3' }));
|
||||||
|
// x labels (month ticks)
|
||||||
|
const months = [];
|
||||||
|
let cur = new Date(t0); cur.setDate(1);
|
||||||
|
while (+cur <= t1) { months.push(new Date(cur)); cur.setMonth(cur.getMonth() + 1); }
|
||||||
|
const xstep = Math.ceil(months.length / 8);
|
||||||
|
months.forEach((m, i) => { if (i % xstep === 0) svg.appendChild(el('text', { x: x(+m), y: H - 9, 'text-anchor': 'middle', class: 'chart-axis' }, window.MT.MONTH_LABEL(m.toISOString().slice(0, 7)).split(" '")[0])); });
|
||||||
|
// lines
|
||||||
|
series.forEach(s => {
|
||||||
|
const pts = s.points.slice().sort((a, b) => +a.t - +b.t);
|
||||||
|
if (!pts.length) return;
|
||||||
|
let dPath = '';
|
||||||
|
pts.forEach((p, i) => { dPath += `${i === 0 ? 'M' : 'L'}${x(+p.t)},${y(p.v)} `; });
|
||||||
|
svg.appendChild(el('path', { d: dPath, fill: 'none', stroke: s.color, 'stroke-width': 2, 'stroke-linejoin': 'round', 'stroke-linecap': 'round', opacity: 0.9 }));
|
||||||
|
// endpoint dot
|
||||||
|
const last = pts[pts.length - 1];
|
||||||
|
svg.appendChild(el('circle', { cx: x(+last.t), cy: y(last.v), r: 3, fill: s.color }));
|
||||||
|
// hover hit-dots
|
||||||
|
pts.forEach(p => {
|
||||||
|
const dot = el('circle', { cx: x(+p.t), cy: y(p.v), r: 7, fill: 'transparent', style: 'cursor:pointer' });
|
||||||
|
dot.addEventListener('mousemove', e => window.MTtip && window.MTtip.raw(e, s.label, new Date(+p.t).toISOString().slice(0, 10) + ' · ' + fmtFull(p.v), s.color));
|
||||||
|
dot.addEventListener('mouseleave', () => window.MTtip && window.MTtip.hide());
|
||||||
|
svg.appendChild(dot);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
container.appendChild(svg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- heatmap grid ----------
|
||||||
|
// rows:[{label, cells:[{v, raw, title}]}], colLabels:[], opts:{color, max}
|
||||||
|
function heatmap(container, rows, colLabels, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
clear(container);
|
||||||
|
const max = opts.max || Math.max(1, ...rows.flatMap(r => r.cells.map(c => c.v)));
|
||||||
|
const base = opts.color || cssv('--c-income');
|
||||||
|
const wrap = document.createElement('div'); wrap.className = 'heatmap';
|
||||||
|
// header
|
||||||
|
const head = document.createElement('div'); head.className = 'hm-row hm-head';
|
||||||
|
head.innerHTML = `<div class="hm-rowlab"></div>` + colLabels.map(c => `<div class="hm-collab">${esc(c)}</div>`).join('');
|
||||||
|
wrap.appendChild(head);
|
||||||
|
rows.forEach(r => {
|
||||||
|
const row = document.createElement('div'); row.className = 'hm-row';
|
||||||
|
let html = `<div class="hm-rowlab">${esc(r.label)}</div>`;
|
||||||
|
r.cells.forEach(c => {
|
||||||
|
const alpha = c.v <= 0 ? 0 : 0.12 + 0.88 * Math.pow(c.v / max, 0.6);
|
||||||
|
html += `<div class="hm-cell" data-v="${c.v}" title="${esc(c.title || '')}" style="--a:${alpha.toFixed(3)};--hc:${base}"></div>`;
|
||||||
|
});
|
||||||
|
row.innerHTML = html;
|
||||||
|
wrap.appendChild(row);
|
||||||
|
});
|
||||||
|
container.appendChild(wrap);
|
||||||
|
if (opts.onHover) wrap.querySelectorAll('.hm-cell').forEach((cell, i) => { });
|
||||||
|
// attach hover via cell data
|
||||||
|
let idx = 0;
|
||||||
|
rows.forEach(r => r.cells.forEach(c => {
|
||||||
|
const cell = wrap.querySelectorAll('.hm-cell')[idx++];
|
||||||
|
if (c.raw && c.raw.length && window.MTtip) {
|
||||||
|
cell.addEventListener('mousemove', e => window.MTtip.rows(e, c.title, base, c.raw));
|
||||||
|
cell.addEventListener('mouseleave', () => window.MTtip.hide());
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Charts = { hbars, monthlyBars, lineChart, donut, multiLine, heatmap, clear, esc };
|
||||||
|
})();
|
||||||
|
|
@ -0,0 +1,195 @@
|
||||||
|
/* money-trace · dashboard layout & charts */
|
||||||
|
.dash-body { overflow-y: auto; overflow-x: hidden; }
|
||||||
|
|
||||||
|
/* tab nav */
|
||||||
|
.tabs {
|
||||||
|
display: flex; gap: 2px; padding: 0 22px; border-bottom: 1px solid var(--line);
|
||||||
|
background: var(--panel); position: sticky; top: 0; z-index: 20;
|
||||||
|
overflow-x: auto; scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
.tabs::-webkit-scrollbar { height: 4px; }
|
||||||
|
.tabs::-webkit-scrollbar-thumb { background: var(--line-2); border-radius: 2px; }
|
||||||
|
.tab {
|
||||||
|
font-family: var(--font-ui); font-size: 13.5px; color: var(--ink-mute);
|
||||||
|
background: transparent; border: 0; border-bottom: 2px solid transparent;
|
||||||
|
padding: 15px 18px 13px; cursor: pointer; display: flex; align-items: center; gap: 9px;
|
||||||
|
transition: .16s; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.tab:hover { color: var(--ink); }
|
||||||
|
.tab.on { color: var(--ink); border-bottom-color: var(--c-income); }
|
||||||
|
.tab svg { width: 16px; height: 16px; opacity: .85; }
|
||||||
|
.tab .tnum { font-family: var(--font-num); font-size: 10px; color: var(--ink-dim); }
|
||||||
|
|
||||||
|
/* panels */
|
||||||
|
.panel-wrap { padding: 22px; max-width: 1320px; margin: 0 auto; }
|
||||||
|
.panel { display: none; animation: fade .22s ease; }
|
||||||
|
.panel.on { display: block; }
|
||||||
|
@keyframes fade { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
|
||||||
|
|
||||||
|
.panel-head { margin-bottom: 18px; }
|
||||||
|
.panel-head h2 { font-size: 22px; font-weight: 600; margin: 0 0 5px; letter-spacing: -0.01em; }
|
||||||
|
.panel-head p { font-size: 13.5px; color: var(--ink-mute); margin: 0; max-width: 640px; line-height: 1.5; }
|
||||||
|
.panel-head p b { color: var(--ink); font-weight: 500; }
|
||||||
|
|
||||||
|
/* grid */
|
||||||
|
.grid { display: grid; gap: 16px; }
|
||||||
|
.g-2 { grid-template-columns: 1fr 1fr; }
|
||||||
|
.g-3 { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
.g-1-2 { grid-template-columns: 320px 1fr; }
|
||||||
|
.g-2-1 { grid-template-columns: 1fr 340px; }
|
||||||
|
@media (max-width: 1080px) { .g-2, .g-3, .g-1-2, .g-2-1 { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
/* card */
|
||||||
|
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 18px; }
|
||||||
|
.card.tall { min-height: 280px; }
|
||||||
|
.card h3 { font-size: 12px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--ink-mute); margin: 0 0 16px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
|
||||||
|
.card h3 .pill { margin-left: auto; font-family: var(--font-num); font-size: 11px; color: var(--ink-dim); text-transform: none; letter-spacing: 0; }
|
||||||
|
.card .chart-host { width: 100%; }
|
||||||
|
|
||||||
|
/* KPI cards */
|
||||||
|
.kpi { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 18px 20px; position: relative; overflow: hidden; }
|
||||||
|
.kpi::before { content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--accent, var(--ink-mute)); }
|
||||||
|
.kpi .k-lab { font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--ink-mute); margin-bottom: 10px; }
|
||||||
|
.kpi .k-val { font-size: 30px; font-weight: 500; line-height: 1; font-family: var(--font-num); letter-spacing: -0.01em; }
|
||||||
|
.kpi .k-val small { font-size: 15px; color: var(--ink-dim); }
|
||||||
|
.kpi .k-sub { font-size: 11.5px; color: var(--ink-dim); margin-top: 8px; }
|
||||||
|
.kpi .k-sub b { font-family: var(--font-num); color: var(--ink-mute); font-weight: 500; }
|
||||||
|
|
||||||
|
/* hbars */
|
||||||
|
.hbars { display: flex; flex-direction: column; gap: 11px; }
|
||||||
|
.hbar-row { display: grid; grid-template-columns: 168px 1fr 96px; align-items: center; gap: 14px; cursor: default; }
|
||||||
|
.hbar-label { font-size: 13px; color: var(--ink); display: flex; flex-direction: column; gap: 2px; overflow: hidden; }
|
||||||
|
.hbar-label .hbar-sub { font-size: 10.5px; color: var(--ink-dim); font-family: var(--font-num); }
|
||||||
|
.hbar-track { height: 22px; background: var(--bg); border-radius: 5px; overflow: hidden; }
|
||||||
|
.hbar-fill { height: 100%; border-radius: 5px; opacity: 0.9; transition: width .5s cubic-bezier(.3,1,.4,1); }
|
||||||
|
.hbar-val { text-align: right; font-size: 13px; color: var(--ink); }
|
||||||
|
.hbar-row:hover .hbar-fill { opacity: 1; }
|
||||||
|
|
||||||
|
/* legend chips */
|
||||||
|
.chips { display: flex; flex-wrap: wrap; gap: 8px 16px; margin-top: 14px; }
|
||||||
|
.chip { display: flex; align-items: center; gap: 7px; font-size: 12px; color: var(--ink-mute); }
|
||||||
|
.chip i { width: 10px; height: 10px; border-radius: 3px; }
|
||||||
|
.chip b { color: var(--ink); font-family: var(--font-num); font-weight: 500; }
|
||||||
|
|
||||||
|
/* chart axis text */
|
||||||
|
.chart-axis { fill: var(--ink-dim); font-size: 10px; font-family: var(--font-num); }
|
||||||
|
.donut-center-top { fill: var(--ink); font-size: 20px; font-family: var(--font-num); font-weight: 500; }
|
||||||
|
.donut-center-bot { fill: var(--ink-dim); font-size: 10.5px; letter-spacing: 0.08em; text-transform: uppercase; font-family: var(--font-ui); }
|
||||||
|
.donut-wrap { display: flex; align-items: center; gap: 22px; }
|
||||||
|
|
||||||
|
/* big callout */
|
||||||
|
.callout { display: flex; align-items: baseline; gap: 14px; padding: 4px 0; }
|
||||||
|
.callout .big { font-size: 54px; font-weight: 600; font-family: var(--font-num); line-height: 1; letter-spacing: -0.02em; }
|
||||||
|
.callout .big.mult { color: var(--c-fee); }
|
||||||
|
.callout .ctxt { font-size: 13px; color: var(--ink-mute); line-height: 1.5; }
|
||||||
|
.callout .ctxt b { color: var(--ink); }
|
||||||
|
|
||||||
|
/* bank table */
|
||||||
|
.btable { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||||
|
.btable th { text-align: right; font-size: 10.5px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-dim); font-weight: 600; padding: 0 14px 12px; border-bottom: 1px solid var(--line); }
|
||||||
|
.btable th:first-child { text-align: left; }
|
||||||
|
.btable td { padding: 13px 14px; border-bottom: 1px solid var(--line); text-align: right; font-family: var(--font-num); color: var(--ink); }
|
||||||
|
.btable td:first-child { text-align: left; font-family: var(--font-ui); }
|
||||||
|
.btable tr:hover td { background: rgba(255,255,255,0.02); }
|
||||||
|
.btable .bk-name { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.btable .bk-dot { width: 9px; height: 9px; border-radius: 3px; flex: none; }
|
||||||
|
.btable .bk-meta { font-size: 10.5px; color: var(--ink-dim); font-family: var(--font-num); }
|
||||||
|
.btable .minibar { display: inline-flex; height: 7px; border-radius: 3px; overflow: hidden; width: 120px; background: var(--bg); vertical-align: middle; }
|
||||||
|
.btable .minibar i { height: 100%; display: block; }
|
||||||
|
.btable td.muted { color: var(--ink-dim); }
|
||||||
|
|
||||||
|
/* insight strip */
|
||||||
|
.insight { display: flex; gap: 12px; align-items: flex-start; background: var(--panel-2); border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px; font-size: 13px; color: var(--ink-mute); line-height: 1.55; }
|
||||||
|
.insight .i-ic { width: 26px; height: 26px; border-radius: 7px; display: grid; place-items: center; flex: none; background: var(--bg); color: var(--c-income); }
|
||||||
|
.insight b { color: var(--ink); font-weight: 500; }
|
||||||
|
.insight .num { color: var(--ink); }
|
||||||
|
|
||||||
|
/* month mini selector for panels */
|
||||||
|
.mini-months { display: flex; gap: 3px; align-items: center; }
|
||||||
|
.mini-months .mm { font-family: var(--font-num); font-size: 11px; color: var(--ink-dim); padding: 4px 8px; border-radius: 6px; cursor: pointer; }
|
||||||
|
.mini-months .mm:hover { color: var(--ink); background: var(--bg); }
|
||||||
|
.mini-months .mm.on { color: var(--bg); background: var(--c-income); }
|
||||||
|
|
||||||
|
/* top merchants list */
|
||||||
|
.mlist { display: flex; flex-direction: column; }
|
||||||
|
.mrow { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--line); }
|
||||||
|
.mrow:last-child { border-bottom: 0; }
|
||||||
|
.mrow .m-rank { font-family: var(--font-num); font-size: 11px; color: var(--ink-dim); width: 20px; }
|
||||||
|
.mrow .m-name { flex: 1; font-size: 13px; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.mrow .m-cat { font-size: 10.5px; color: var(--ink-dim); margin-left: 6px; }
|
||||||
|
.mrow .m-amt { font-family: var(--font-num); font-size: 13px; color: var(--ink); }
|
||||||
|
.mrow .m-cnt { font-family: var(--font-num); font-size: 10.5px; color: var(--ink-dim); width: 48px; text-align: right; }
|
||||||
|
|
||||||
|
/* ---- heatmap ---- */
|
||||||
|
.heatmap { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.hm-row { display: grid; grid-template-columns: 64px repeat(var(--cols, 31), 1fr); gap: 4px; align-items: center; }
|
||||||
|
.hm-head { margin-bottom: 2px; }
|
||||||
|
.hm-rowlab { font-size: 11.5px; color: var(--ink-mute); text-align: right; padding-right: 8px; font-family: var(--font-ui); }
|
||||||
|
.hm-collab { font-size: 9px; color: var(--ink-dim); text-align: center; font-family: var(--font-num); }
|
||||||
|
.hm-cell { aspect-ratio: 1; border-radius: 3px; background: color-mix(in oklab, var(--hc) calc(var(--a) * 100%), var(--bg)); border: 1px solid var(--line); transition: transform .1s; min-height: 14px; }
|
||||||
|
.hm-cell[data-v="0"] { background: var(--bg); }
|
||||||
|
.hm-cell:hover { transform: scale(1.18); border-color: var(--line-2); cursor: default; }
|
||||||
|
.hm-scale { display: flex; align-items: center; gap: 8px; font-size: 10.5px; color: var(--ink-dim); margin-top: 12px; justify-content: flex-end; }
|
||||||
|
.hm-scale .sw { display: flex; gap: 3px; }
|
||||||
|
.hm-scale .sw i { width: 14px; height: 14px; border-radius: 3px; }
|
||||||
|
|
||||||
|
/* ---- account picker chips ---- */
|
||||||
|
.acct-chips { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; }
|
||||||
|
.acct-chip { display: flex; align-items: center; gap: 8px; padding: 7px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--panel); cursor: pointer; font-size: 12.5px; color: var(--ink-mute); transition: .14s; }
|
||||||
|
.acct-chip:hover { border-color: var(--line-2); color: var(--ink); }
|
||||||
|
.acct-chip.on { color: var(--ink); background: var(--panel-2); }
|
||||||
|
.acct-chip i { width: 9px; height: 9px; border-radius: 3px; }
|
||||||
|
.acct-chip .ac-end { font-family: var(--font-num); font-size: 11px; color: var(--ink-dim); }
|
||||||
|
|
||||||
|
/* ---- ledger explorer table ---- */
|
||||||
|
.lx-controls { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; margin-bottom: 16px; }
|
||||||
|
.lx-search { flex: 1; min-width: 220px; position: relative; }
|
||||||
|
.lx-search input { width: 100%; background: var(--bg); border: 1px solid var(--line); border-radius: 9px; padding: 11px 14px 11px 38px; color: var(--ink); font-family: var(--font-ui); font-size: 13.5px; }
|
||||||
|
.lx-search input:focus { outline: none; border-color: var(--line-2); }
|
||||||
|
.lx-search svg { position: absolute; left: 12px; top: 11px; width: 16px; height: 16px; color: var(--ink-dim); }
|
||||||
|
.lx-select { background: var(--bg); border: 1px solid var(--line); border-radius: 9px; padding: 11px 13px; color: var(--ink); font-family: var(--font-ui); font-size: 13px; cursor: pointer; }
|
||||||
|
.lx-count { font-size: 12px; color: var(--ink-dim); font-family: var(--font-num); white-space: nowrap; }
|
||||||
|
.lx-table-wrap { border: 1px solid var(--line); border-radius: 12px; overflow: hidden; }
|
||||||
|
.lx-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
|
||||||
|
.lx-table thead { position: sticky; top: 0; background: var(--panel-2); z-index: 2; }
|
||||||
|
.lx-table th { text-align: left; font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-dim); font-weight: 600; padding: 11px 14px; border-bottom: 1px solid var(--line); cursor: pointer; user-select: none; white-space: nowrap; }
|
||||||
|
.lx-table th:hover { color: var(--ink-mute); }
|
||||||
|
.lx-table th.r, .lx-table td.r { text-align: right; }
|
||||||
|
.lx-table td { padding: 10px 14px; border-bottom: 1px solid var(--line); color: var(--ink-mute); }
|
||||||
|
.lx-table tr:last-child td { border-bottom: 0; }
|
||||||
|
.lx-table tr:hover td { background: rgba(255,255,255,0.02); }
|
||||||
|
.lx-table .lx-date { font-family: var(--font-num); font-size: 11.5px; color: var(--ink-dim); white-space: nowrap; }
|
||||||
|
.lx-table .lx-desc { color: var(--ink); max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.lx-table .lx-amt { font-family: var(--font-num); white-space: nowrap; }
|
||||||
|
.lx-table .lx-amt.cr { color: var(--c-income); }
|
||||||
|
.lx-table .lx-amt.db { color: var(--ink); }
|
||||||
|
.lx-flow-tag { display: inline-flex; align-items: center; gap: 5px; font-size: 10.5px; padding: 2px 8px; border-radius: 20px; border: 1px solid var(--line-2); color: var(--ink-mute); white-space: nowrap; }
|
||||||
|
.lx-flow-tag i { width: 6px; height: 6px; border-radius: 50%; }
|
||||||
|
.lx-pdf { color: var(--ink-dim); font-family: var(--font-num); font-size: 10.5px; }
|
||||||
|
.lx-more { text-align: center; padding: 14px; color: var(--ink-dim); font-size: 12px; cursor: pointer; }
|
||||||
|
.lx-more:hover { color: var(--ink); background: rgba(255,255,255,0.02); }
|
||||||
|
|
||||||
|
/* ---- coverage grid (data quality) ---- */
|
||||||
|
.cov-grid { overflow-x: auto; }
|
||||||
|
.cov-row { display: grid; grid-template-columns: 190px repeat(var(--cols, 15), 1fr); gap: 3px; align-items: center; margin-bottom: 3px; }
|
||||||
|
.cov-head .cov-cell { background: transparent; }
|
||||||
|
.cov-rowlab { font-size: 11.5px; color: var(--ink); display: flex; align-items: center; gap: 8px; overflow: hidden; }
|
||||||
|
.cov-rowlab .cov-dot { width: 8px; height: 8px; border-radius: 3px; flex: none; }
|
||||||
|
.cov-rowlab .cov-meta { font-size: 9.5px; color: var(--ink-dim); font-family: var(--font-num); }
|
||||||
|
.cov-collab { font-size: 9px; color: var(--ink-dim); text-align: center; font-family: var(--font-num); writing-mode: vertical-rl; height: 36px; }
|
||||||
|
.cov-cell { height: 22px; border-radius: 3px; background: var(--bg); border: 1px solid var(--line); position: relative; }
|
||||||
|
.cov-cell.has { background: color-mix(in oklab, var(--c-income) 30%, var(--bg)); border-color: color-mix(in oklab, var(--c-income) 45%, var(--bg)); }
|
||||||
|
.cov-cell.has:hover { background: color-mix(in oklab, var(--c-income) 55%, var(--bg)); }
|
||||||
|
|
||||||
|
/* ---- mini stat row ---- */
|
||||||
|
.qstats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 16px; }
|
||||||
|
.qstat { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 15px 16px; }
|
||||||
|
.qstat .qv { font-family: var(--font-num); font-size: 24px; color: var(--ink); }
|
||||||
|
.qstat .ql { font-size: 11px; color: var(--ink-mute); margin-top: 5px; }
|
||||||
|
.qbar { height: 8px; border-radius: 4px; background: var(--bg); overflow: hidden; margin-top: 10px; display: flex; }
|
||||||
|
.qbar i { height: 100%; }
|
||||||
|
@media (max-width: 1080px) { .qstats { grid-template-columns: 1fr 1fr; } }
|
||||||
|
|
||||||
|
/* net pill */
|
||||||
|
.net-pos { color: var(--c-income); }
|
||||||
|
.net-neg { color: var(--c-spend); }
|
||||||
|
|
@ -1,637 +1,190 @@
|
||||||
<!doctype html>
|
<!DOCTYPE html>
|
||||||
<html lang="es">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>money-trace · dashboard</title>
|
<title>Money Trace · Dashboard</title>
|
||||||
<style>
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
:root {
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
--bg: #0d0d12;
|
<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" />
|
||||||
--surface: #14141c;
|
<link rel="stylesheet" href="styles.css" />
|
||||||
--raised: #1c1c28;
|
<link rel="stylesheet" href="dashboard.css" />
|
||||||
--border: #242433;
|
|
||||||
--border2: #2e2e44;
|
|
||||||
--txt: #e2e2f0;
|
|
||||||
--sub: #7070a0;
|
|
||||||
--dim: #4a4a70;
|
|
||||||
--in: #4ade9a;
|
|
||||||
--in-dim: #1a3d2a;
|
|
||||||
--out: #f06a6a;
|
|
||||||
--out-dim: #3d1a1a;
|
|
||||||
--acc: #a78bfa;
|
|
||||||
--acc-dim: #2a1f4a;
|
|
||||||
--gold: #f0b84a;
|
|
||||||
--gold-dim:#3d2e0f;
|
|
||||||
--blue: #60b0f0;
|
|
||||||
--sidebar: 56px;
|
|
||||||
--topbar: 52px;
|
|
||||||
}
|
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0 }
|
|
||||||
body { background: var(--bg); color: var(--txt); font: 13px/1.5 ui-sans-serif, -apple-system, Segoe UI, sans-serif; height: 100vh; display: flex; overflow: hidden }
|
|
||||||
|
|
||||||
/* ── sidebar ──────────────────────────────── */
|
|
||||||
nav {
|
|
||||||
width: var(--sidebar); height: 100vh; background: var(--surface);
|
|
||||||
border-right: 1px solid var(--border); display: flex; flex-direction: column;
|
|
||||||
align-items: center; padding: 12px 0; gap: 4px; flex-shrink: 0; z-index: 10;
|
|
||||||
}
|
|
||||||
.logo { width: 32px; height: 32px; border-radius: 9px; background: var(--acc-dim);
|
|
||||||
display: flex; align-items: center; justify-content: center; margin-bottom: 12px;
|
|
||||||
font-size: 15px; font-weight: 800; color: var(--acc); letter-spacing: -1px }
|
|
||||||
.nav-btn { width: 36px; height: 36px; border-radius: 8px; border: none; background: transparent;
|
|
||||||
color: var(--dim); cursor: pointer; display: flex; align-items: center; justify-content: center;
|
|
||||||
font-size: 16px; transition: background .15s, color .15s }
|
|
||||||
.nav-btn:hover { background: var(--raised); color: var(--txt) }
|
|
||||||
.nav-btn.active { background: var(--acc-dim); color: var(--acc) }
|
|
||||||
.nav-sep { width: 24px; height: 1px; background: var(--border); margin: 4px 0 }
|
|
||||||
.nav-bot { margin-top: auto }
|
|
||||||
|
|
||||||
/* ── main frame ───────────────────────────── */
|
|
||||||
.frame { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0 }
|
|
||||||
|
|
||||||
/* ── topbar ───────────────────────────────── */
|
|
||||||
.topbar {
|
|
||||||
height: var(--topbar); background: var(--surface); border-bottom: 1px solid var(--border);
|
|
||||||
display: flex; align-items: center; padding: 0 20px; gap: 14px; flex-shrink: 0
|
|
||||||
}
|
|
||||||
.topbar h1 { font-size: 13px; font-weight: 600; color: var(--txt); letter-spacing: .2px }
|
|
||||||
.topbar h1 span { color: var(--sub); font-weight: 400 }
|
|
||||||
.status-pill { display: flex; align-items: center; gap: 5px; background: var(--raised);
|
|
||||||
border: 1px solid var(--border); border-radius: 20px; padding: 3px 10px 3px 6px; font-size: 11px; color: var(--sub) }
|
|
||||||
.dot { width: 6px; height: 6px; border-radius: 50%; background: var(--dim) }
|
|
||||||
.dot.live { background: var(--in); box-shadow: 0 0 6px var(--in) }
|
|
||||||
.dot.err { background: var(--out) }
|
|
||||||
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px }
|
|
||||||
.api-input { background: var(--raised); border: 1px solid var(--border); color: var(--txt);
|
|
||||||
padding: 5px 10px; border-radius: 8px; font-size: 12px; outline: none; width: 190px }
|
|
||||||
.api-input:focus { border-color: var(--acc) }
|
|
||||||
.icon-btn { background: var(--raised); border: 1px solid var(--border); color: var(--sub);
|
|
||||||
padding: 5px 10px; border-radius: 8px; font-size: 12px; cursor: pointer }
|
|
||||||
.icon-btn:hover { color: var(--txt); border-color: var(--border2) }
|
|
||||||
|
|
||||||
/* ── scroll area ──────────────────────────── */
|
|
||||||
.scroll { flex: 1; overflow-y: auto; overflow-x: hidden; padding: 20px }
|
|
||||||
.scroll::-webkit-scrollbar { width: 5px }
|
|
||||||
.scroll::-webkit-scrollbar-track { background: transparent }
|
|
||||||
.scroll::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 3px }
|
|
||||||
|
|
||||||
/* ── error bar ────────────────────────────── */
|
|
||||||
#err { display: none; background: var(--out-dim); border: 1px solid #5a2020; color: #f09090;
|
|
||||||
border-radius: 8px; padding: 9px 14px; margin-bottom: 16px; font-size: 12px }
|
|
||||||
|
|
||||||
/* ── KPI row ──────────────────────────────── */
|
|
||||||
.kpi-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; margin-bottom: 18px }
|
|
||||||
.kpi { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 16px 18px; position: relative; overflow: hidden }
|
|
||||||
.kpi::before { content: ''; position: absolute; inset: 0; opacity: .06; border-radius: 12px }
|
|
||||||
.kpi.green::before { background: var(--in) } .kpi.green .kpi-val { color: var(--in) }
|
|
||||||
.kpi.red::before { background: var(--out) } .kpi.red .kpi-val { color: var(--out) }
|
|
||||||
.kpi.purple::before{ background: var(--acc) } .kpi.purple .kpi-val { color: var(--acc) }
|
|
||||||
.kpi.gold::before { background: var(--gold) } .kpi.gold .kpi-val { color: var(--gold) }
|
|
||||||
.kpi-label { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: .7px; color: var(--sub); margin-bottom: 6px }
|
|
||||||
.kpi-val { font-size: 26px; font-weight: 700; font-variant-numeric: tabular-nums; line-height: 1 }
|
|
||||||
.kpi-sub { font-size: 11px; color: var(--dim); margin-top: 4px }
|
|
||||||
|
|
||||||
/* ── two-col body ─────────────────────────── */
|
|
||||||
.body-grid { display: grid; grid-template-columns: 1fr 340px; gap: 14px; margin-bottom: 14px }
|
|
||||||
@media (max-width: 960px) { .body-grid { grid-template-columns: 1fr } }
|
|
||||||
|
|
||||||
/* ── panel ────────────────────────────────── */
|
|
||||||
.panel { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; overflow: hidden }
|
|
||||||
.panel-head { padding: 13px 16px; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between }
|
|
||||||
.panel-title { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .7px; color: var(--sub) }
|
|
||||||
.panel-count { font-size: 11px; color: var(--dim) }
|
|
||||||
|
|
||||||
/* ── type chart ───────────────────────────── */
|
|
||||||
.chart-wrap { padding: 16px; display: flex; gap: 20px; align-items: center }
|
|
||||||
.donut-wrap { flex-shrink: 0; position: relative; width: 120px; height: 120px }
|
|
||||||
.donut-wrap svg { overflow: visible }
|
|
||||||
.donut-center { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; pointer-events: none }
|
|
||||||
.donut-center .dc-val { font-size: 20px; font-weight: 700; color: var(--txt) }
|
|
||||||
.donut-center .dc-sub { font-size: 10px; color: var(--sub) }
|
|
||||||
.donut-legend { flex: 1; display: flex; flex-direction: column; gap: 7px; min-width: 0 }
|
|
||||||
.legend-row { display: flex; align-items: center; gap: 8px }
|
|
||||||
.legend-dot { width: 8px; height: 8px; border-radius: 2px; flex-shrink: 0 }
|
|
||||||
.legend-label { font-size: 12px; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
|
|
||||||
.legend-amt { font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; color: var(--sub); white-space: nowrap }
|
|
||||||
.legend-pct { font-size: 10px; color: var(--dim); width: 32px; text-align: right; flex-shrink: 0 }
|
|
||||||
|
|
||||||
/* ── entity cards ─────────────────────────── */
|
|
||||||
.entity-list { padding: 8px 0 }
|
|
||||||
.entity-row { padding: 10px 16px; display: flex; align-items: center; gap: 11px; border-bottom: 1px solid var(--border) }
|
|
||||||
.entity-row:last-child { border-bottom: 0 }
|
|
||||||
.avatar { width: 32px; height: 32px; border-radius: 9px; background: var(--acc-dim);
|
|
||||||
display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; color: var(--acc); flex-shrink: 0 }
|
|
||||||
.avatar.company { background: var(--gold-dim); color: var(--gold) }
|
|
||||||
.avatar.vendor { background: var(--raised); color: var(--sub) }
|
|
||||||
.entity-info { flex: 1; min-width: 0 }
|
|
||||||
.entity-name { font-size: 13px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
|
|
||||||
.entity-meta { font-size: 11px; color: var(--sub); margin-top: 1px }
|
|
||||||
.entity-accts { margin-top: 5px; display: flex; flex-direction: column; gap: 3px }
|
|
||||||
.acct-chip { font-size: 11px; color: var(--dim); display: flex; align-items: center; gap: 5px }
|
|
||||||
.acct-chip::before { content: ''; width: 3px; height: 3px; border-radius: 50%; background: var(--border2); flex-shrink: 0 }
|
|
||||||
.entity-net { font-size: 13px; font-weight: 700; font-variant-numeric: tabular-nums; flex-shrink: 0 }
|
|
||||||
|
|
||||||
/* ── movements ────────────────────────────── */
|
|
||||||
.mov-controls { padding: 12px 16px; border-bottom: 1px solid var(--border); display: flex; gap: 8px; flex-wrap: wrap; align-items: center }
|
|
||||||
.ctrl-input { background: var(--raised); border: 1px solid var(--border); color: var(--txt);
|
|
||||||
padding: 6px 10px; border-radius: 8px; font-size: 12px; outline: none }
|
|
||||||
.ctrl-input:focus { border-color: var(--acc) }
|
|
||||||
.ctrl-input::placeholder { color: var(--dim) }
|
|
||||||
.seg { display: flex; border: 1px solid var(--border); border-radius: 8px; overflow: hidden }
|
|
||||||
.seg button { background: transparent; border: 0; color: var(--sub); padding: 6px 11px; cursor: pointer; font-size: 12px }
|
|
||||||
.seg button.on { background: var(--raised); color: var(--txt) }
|
|
||||||
|
|
||||||
/* ── movement feed ────────────────────────── */
|
|
||||||
.mov-feed { display: flex; flex-direction: column }
|
|
||||||
.mov-item { display: flex; align-items: flex-start; gap: 12px; padding: 12px 16px;
|
|
||||||
border-bottom: 1px solid var(--border); cursor: pointer; transition: background .1s }
|
|
||||||
.mov-item:last-child { border-bottom: 0 }
|
|
||||||
.mov-item:hover { background: var(--raised) }
|
|
||||||
.mov-item.selected { background: #1e1e35 }
|
|
||||||
.mov-icon { width: 34px; height: 34px; border-radius: 9px; display: flex; align-items: center;
|
|
||||||
justify-content: center; font-size: 14px; flex-shrink: 0; margin-top: 1px }
|
|
||||||
.mov-icon.in { background: var(--in-dim); color: var(--in) }
|
|
||||||
.mov-icon.out { background: var(--out-dim); color: var(--out) }
|
|
||||||
.mov-body { flex: 1; min-width: 0 }
|
|
||||||
.mov-top { display: flex; align-items: baseline; gap: 8px }
|
|
||||||
.mov-desc { font-size: 13px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0 }
|
|
||||||
.mov-amt { font-size: 13px; font-weight: 700; font-variant-numeric: tabular-nums; flex-shrink: 0 }
|
|
||||||
.mov-amt.in { color: var(--in) }
|
|
||||||
.mov-amt.out { color: var(--out) }
|
|
||||||
.mov-bot { display: flex; align-items: center; gap: 8px; margin-top: 3px }
|
|
||||||
.mov-date { font-size: 11px; color: var(--dim) }
|
|
||||||
.mov-cpty { font-size: 11px; color: var(--sub); overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
|
|
||||||
.etype-chip { font-size: 10px; padding: 1px 6px; border-radius: 4px; white-space: nowrap; flex-shrink: 0 }
|
|
||||||
.tree-trigger { flex-shrink: 0; background: var(--raised); border: 1px solid var(--border);
|
|
||||||
color: var(--dim); padding: 4px 9px; border-radius: 6px; font-size: 11px; cursor: pointer;
|
|
||||||
margin-top: 1px; transition: border-color .15s, color .15s }
|
|
||||||
.tree-trigger:hover { border-color: var(--acc); color: var(--acc) }
|
|
||||||
|
|
||||||
/* ── empty / loading ──────────────────────── */
|
|
||||||
.empty { color: var(--sub); padding: 28px; text-align: center; font-size: 12px }
|
|
||||||
.spin { display: inline-block; width: 11px; height: 11px; border: 2px solid var(--border2);
|
|
||||||
border-top-color: var(--acc); border-radius: 50%; animation: sp .6s linear infinite; vertical-align: middle; margin-right: 5px }
|
|
||||||
@keyframes sp { to { transform: rotate(360deg) } }
|
|
||||||
|
|
||||||
/* ── tree drawer ──────────────────────────── */
|
|
||||||
.drawer { position: fixed; top: 0; right: 0; bottom: 0; width: 400px; max-width: 90vw;
|
|
||||||
background: var(--surface); border-left: 1px solid var(--border);
|
|
||||||
display: flex; flex-direction: column; z-index: 100;
|
|
||||||
transform: translateX(100%); transition: transform .22s cubic-bezier(.4,0,.2,1) }
|
|
||||||
.drawer.open { transform: translateX(0) }
|
|
||||||
.drawer-head { padding: 14px 16px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 10px; flex-shrink: 0 }
|
|
||||||
.drawer-head h3 { font-size: 13px; font-weight: 600; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
|
|
||||||
.close-btn { background: var(--raised); border: 1px solid var(--border); color: var(--sub);
|
|
||||||
width: 28px; height: 28px; border-radius: 7px; cursor: pointer; font-size: 13px; display: flex; align-items: center; justify-content: center }
|
|
||||||
.close-btn:hover { color: var(--txt) }
|
|
||||||
.drawer-tabs { display: flex; border-bottom: 1px solid var(--border); flex-shrink: 0 }
|
|
||||||
.drawer-tabs button { flex: 1; background: transparent; border: 0; border-bottom: 2px solid transparent;
|
|
||||||
color: var(--sub); padding: 9px; font-size: 12px; cursor: pointer }
|
|
||||||
.drawer-tabs button.on { color: var(--txt); border-bottom-color: var(--acc) }
|
|
||||||
.drawer-body { flex: 1; overflow-y: auto; padding: 14px; font: 12px/1.6 ui-monospace, monospace }
|
|
||||||
|
|
||||||
/* ── tree nodes ───────────────────────────── */
|
|
||||||
.tn { margin: 3px 0 }
|
|
||||||
.tn-label { display: flex; align-items: flex-start; gap: 6px; padding: 3px 6px; border-radius: 6px }
|
|
||||||
.tn-label:hover { background: var(--raised) }
|
|
||||||
.tn-kind { font-size: 9px; background: var(--raised); border: 1px solid var(--border); padding: 1px 5px; border-radius: 4px; color: var(--dim); flex-shrink: 0; margin-top: 2px }
|
|
||||||
.tn-id { color: var(--dim); font-size: 10px }
|
|
||||||
.tn-desc { color: var(--txt) }
|
|
||||||
.tn-amt { color: var(--acc); font-size: 11px; white-space: nowrap; flex-shrink: 0 }
|
|
||||||
.tn-date { color: var(--dim); font-size: 10px; flex-shrink: 0 }
|
|
||||||
.tl-label { color: var(--sub); font-size: 10px; padding: 1px 6px 1px 20px }
|
|
||||||
.tl-label .tl-type { color: var(--blue) }
|
|
||||||
.tl-label .tl-meth { color: var(--dim) }
|
|
||||||
.tc { border-left: 1px solid var(--border); margin-left: 12px; padding-left: 10px }
|
|
||||||
.t-cycle { color: var(--gold); font-size: 11px; font-style: italic }
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div id="loading">reconstructing money flow…</div>
|
||||||
|
|
||||||
<!-- sidebar -->
|
<div id="app" style="display:none; flex-direction:column; height:100vh;">
|
||||||
<nav>
|
<header class="bar">
|
||||||
<div class="logo">₿</div>
|
<div class="brand">
|
||||||
<button class="nav-btn active" title="Dashboard">⊞</button>
|
<div class="mark">money<b>·</b>trace</div>
|
||||||
<a href="index.html" style="text-decoration:none"><button class="nav-btn" title="Cartolas">≡</button></a>
|
<div class="sub">725 txns · 12 banks · 10 months</div>
|
||||||
<div class="nav-sep"></div>
|
|
||||||
<div class="nav-bot">
|
|
||||||
<div class="dot" id="nav-dot" title="Estado del servidor"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
<div class="stats">
|
||||||
|
<a href="money-trace.html" style="text-decoration:none; align-self:center; margin-right:6px;">
|
||||||
|
<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;"><path d="M3 5h6c4 0 4 7 9 7h3M3 12h4c5 0 4 7 11 7h3M3 19h7"/></svg>
|
||||||
|
Flow view
|
||||||
|
</button></div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<!-- main -->
|
<nav class="tabs" id="tabs">
|
||||||
<div class="frame">
|
<button class="tab on" data-tab="overview">
|
||||||
<!-- topbar -->
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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>
|
||||||
<div class="topbar">
|
Overview
|
||||||
<h1>money‑trace <span id="sub-label">· cargando…</span></h1>
|
</button>
|
||||||
<div class="status-pill">
|
<button class="tab" data-tab="spending">
|
||||||
<div class="dot" id="status-dot"></div>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 3v18h18"/><path d="M7 14l3-4 3 3 5-7"/></svg>
|
||||||
<span id="status-txt">conectando</span>
|
Where it goes
|
||||||
</div>
|
</button>
|
||||||
<div class="topbar-right">
|
<button class="tab" data-tab="income">
|
||||||
<input class="api-input" id="api-base" value="http://localhost:3910" placeholder="http://localhost:3910">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 19V5M5 12l7-7 7 7"/></svg>
|
||||||
<button class="icon-btn" id="reload-btn">↺ Recargar</button>
|
Where it comes from
|
||||||
</div>
|
</button>
|
||||||
</div>
|
<button class="tab" data-tab="people">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="9" cy="8" r="3"/><path d="M3 20c0-3.3 2.7-6 6-6s6 2.7 6 6"/><path d="M16 5.5a3 3 0 0 1 0 5.5M21 20c0-2.5-1.5-4.6-3.7-5.5"/></svg>
|
||||||
|
People
|
||||||
|
</button>
|
||||||
|
<button class="tab" data-tab="cards">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="5" width="20" height="14" rx="2"/><path d="M2 10h20"/></svg>
|
||||||
|
Cards & credit
|
||||||
|
</button>
|
||||||
|
<button class="tab" data-tab="cycles">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 3v5h-5"/></svg>
|
||||||
|
The cycles
|
||||||
|
</button>
|
||||||
|
<button class="tab" data-tab="balances">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 3v18h18"/><path d="M6 14l4-5 3 3 5-7"/></svg>
|
||||||
|
Balances
|
||||||
|
</button>
|
||||||
|
<button class="tab" data-tab="rhythm">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="17" rx="2"/><path d="M3 9h18M8 2v4M16 2v4"/></svg>
|
||||||
|
Rhythm
|
||||||
|
</button>
|
||||||
|
<button class="tab" data-tab="platforms">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="6" cy="6" r="3"/><circle cx="18" cy="6" r="3"/><circle cx="12" cy="18" r="3"/><path d="M6 9v3a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V9M12 14v1"/></svg>
|
||||||
|
Platforms
|
||||||
|
</button>
|
||||||
|
<button class="tab" data-tab="banks">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 21h18M4 10h16M5 10V7l7-4 7 4v3M8 10v8M12 10v8M16 10v8"/></svg>
|
||||||
|
Banks
|
||||||
|
</button>
|
||||||
|
<button class="tab" data-tab="ledger">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16v16H4z"/><path d="M4 9h16M4 14h16M9 4v16"/></svg>
|
||||||
|
Ledger
|
||||||
|
</button>
|
||||||
|
<button class="tab" data-tab="quality">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 12l2 2 4-4"/><path d="M12 3l8 4v5c0 4.4-3.1 8.3-8 9-4.9-.7-8-4.6-8-9V7z"/></svg>
|
||||||
|
Data quality
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<!-- scroll area -->
|
<div class="view dash-body" style="flex:1;">
|
||||||
<div class="scroll">
|
<div class="panel-wrap">
|
||||||
<div id="err"></div>
|
<div class="panel on" id="panel-overview">
|
||||||
|
<div class="panel-head"><h2>The big picture</h2><p id="ov-intro">Across these statements, real money came in and went out — but the ledger logged far more movement than that. Here's the real story versus the noise.</p></div>
|
||||||
<!-- KPIs -->
|
<div id="host-overview"></div>
|
||||||
<div class="kpi-row" id="kpi-row">
|
|
||||||
<div class="kpi green"><div class="kpi-label">Ingresos reales</div><div class="kpi-val" id="k-income">–</div><div class="kpi-sub" id="k-income-c">– movimientos</div></div>
|
|
||||||
<div class="kpi red"> <div class="kpi-label">Egresos reales</div> <div class="kpi-val" id="k-expense">–</div><div class="kpi-sub" id="k-expense-c">– movimientos</div></div>
|
|
||||||
<div class="kpi purple"><div class="kpi-label">Neto</div> <div class="kpi-val" id="k-net">–</div> <div class="kpi-sub">ingresos − egresos</div></div>
|
|
||||||
<div class="kpi"> <div class="kpi-label">Movimientos</div> <div class="kpi-val" id="k-mov">–</div> <div class="kpi-sub" id="k-mov-sub">– cuentas</div></div>
|
|
||||||
<div class="kpi"> <div class="kpi-label">Links de trazab.</div><div class="kpi-val" id="k-links">–</div><div class="kpi-sub" id="k-links-sub">– documentos</div></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="panel" id="panel-spending">
|
||||||
<!-- body grid -->
|
<div class="panel-head"><h2>What am I spending my money on, at the end of the day?</h2><p>Every purchase and fee, grouped by what it actually was. Merchant names aren't in the raw statements, so categories are inferred from transaction descriptions.</p></div>
|
||||||
<div class="body-grid">
|
<div id="host-spending"></div>
|
||||||
|
|
||||||
<!-- left: type donut + movements -->
|
|
||||||
<div style="display:flex;flex-direction:column;gap:14px">
|
|
||||||
|
|
||||||
<div class="panel">
|
|
||||||
<div class="panel-head">
|
|
||||||
<div class="panel-title">Flujo por tipo económico</div>
|
|
||||||
<div class="panel-count" id="type-cur">CLP</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="chart-wrap" id="chart-wrap">
|
<div class="panel" id="panel-income">
|
||||||
<div class="empty"><span class="spin"></span>cargando…</div>
|
<div class="panel-head"><h2>Where does the money come from?</h2><p>Real income only — salary, business deposits, and transfers in from other people. Internal moves are excluded.</p></div>
|
||||||
|
<div id="host-income"></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel" id="panel-people">
|
||||||
|
<div class="panel-head"><h2>Who do you exchange money with?</h2><p>Every named payer and person, with money in versus money out and your net position with each. Counterparties are resolved from transaction descriptions.</p></div>
|
||||||
|
<div id="host-people"></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel" id="panel-cards">
|
||||||
|
<div class="panel-head"><h2>What is credit costing you?</h2><p>Fees, interest, and taxes charged on your cards and credit lines — plus what you paid back to your own cards.</p></div>
|
||||||
|
<div id="host-cards"></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel" id="panel-cycles">
|
||||||
|
<div class="panel-head"><h2>Money that just moves in circles</h2><p>The bulk of your statement volume is internal: paying your own cards, sweeping credit lines, and shuffling cash between accounts. None of it is real income or spending.</p></div>
|
||||||
|
<div id="host-cycles"></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel" id="panel-balances">
|
||||||
|
<div class="panel-head"><h2>How much did you actually have?</h2><p>Running account balances over time, wherever the statements reported them. This turns the flow of money into the level of money you held.</p></div>
|
||||||
|
<div id="host-balances"></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel" id="panel-rhythm">
|
||||||
|
<div class="panel-head"><h2>When do you spend?</h2><p>The cadence of your spending — by day of the month and by day of the week. Each cell and bar is real purchase activity.</p></div>
|
||||||
|
<div id="host-rhythm"></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel" id="panel-platforms">
|
||||||
|
<div class="panel-head"><h2>Which payment rails carry your money?</h2><p>The processors and wallets your money flows through — MercadoPago, MACH, Servipag, PayU and more — detected from transaction descriptions and platform tags.</p></div>
|
||||||
|
<div id="host-platforms"></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel" id="panel-banks">
|
||||||
|
<div class="panel-head"><h2>12 banks, side by side</h2><p>How activity splits across the banks you reconstructed — and which ones hold real money flow versus pure internal cycling.</p></div>
|
||||||
|
<div id="host-banks"></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel" id="panel-ledger">
|
||||||
|
<div class="panel-head"><h2>The full ledger</h2><p>All 725 reconstructed transactions. Search, filter by type or bank, and sort any column.</p></div>
|
||||||
|
<div id="host-ledger"></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel" id="panel-quality">
|
||||||
|
<div class="panel-head"><h2>How complete is this picture?</h2><p>What was reconstructed cleanly versus inferred or missing — statement coverage, balance availability, and categorization confidence.</p></div>
|
||||||
|
<div id="host-quality"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel">
|
|
||||||
<div class="panel-head">
|
|
||||||
<div class="panel-title">Movimientos</div>
|
|
||||||
<div style="display:flex;gap:6px">
|
|
||||||
<div class="seg" id="dir-seg">
|
|
||||||
<button data-d="all" class="on">Todo</button>
|
|
||||||
<button data-d="in">▲</button>
|
|
||||||
<button data-d="out">▼</button>
|
|
||||||
</div>
|
|
||||||
<select id="etype-sel" class="ctrl-input" style="padding:5px 8px">
|
|
||||||
<option value="">Todos los tipos</option>
|
|
||||||
</select>
|
|
||||||
<input class="ctrl-input" id="mov-q" placeholder="Buscar…" style="width:150px">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mov-feed" id="mov-feed">
|
|
||||||
<div class="empty"><span class="spin"></span>cargando…</div>
|
|
||||||
</div>
|
|
||||||
<div id="mov-empty" class="empty" hidden>Sin movimientos para este filtro.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- right: entities -->
|
|
||||||
<div class="panel" style="align-self:start">
|
|
||||||
<div class="panel-head">
|
|
||||||
<div class="panel-title">Entidades</div>
|
|
||||||
<div class="panel-count" id="ent-count">–</div>
|
|
||||||
</div>
|
|
||||||
<div class="entity-list" id="entity-list">
|
|
||||||
<div class="empty"><span class="spin"></span>cargando…</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div><!-- /body-grid -->
|
|
||||||
</div><!-- /scroll -->
|
|
||||||
</div><!-- /frame -->
|
|
||||||
|
|
||||||
<!-- tree drawer -->
|
|
||||||
<div class="drawer" id="drawer">
|
|
||||||
<div class="drawer-head">
|
|
||||||
<h3 id="drawer-title">Árbol de trazabilidad</h3>
|
|
||||||
<button class="close-btn" id="drawer-close">✕</button>
|
|
||||||
</div>
|
|
||||||
<div class="drawer-tabs">
|
|
||||||
<button class="on" id="tab-orig" onclick="switchTab('orig')">← Origen del dinero</button>
|
|
||||||
<button id="tab-dest" onclick="switchTab('dest')">Destino →</button>
|
|
||||||
</div>
|
|
||||||
<div class="drawer-body" id="drawer-body">
|
|
||||||
<div class="empty">Selecciona un movimiento.</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="tip"></div>
|
||||||
|
<div id="tweaks-root"></div>
|
||||||
|
|
||||||
|
<script src="engine.js"></script>
|
||||||
|
<script src="charts.js"></script>
|
||||||
<script>
|
<script>
|
||||||
// ── state ─────────────────────────────────────────────────────
|
/* point the renderers at the host divs inside each panel */
|
||||||
let MOVS=[], ENTS=[], ACCS=[], SUM=null;
|
window.addEventListener('DOMContentLoaded', () => {
|
||||||
let byId={}, entById={}, accById={};
|
document.querySelectorAll('.panel').forEach(p => {
|
||||||
let selId=null, activeTab='orig';
|
const tab = p.id.replace('panel-', '');
|
||||||
|
const host = document.getElementById('host-' + tab);
|
||||||
// ── helpers ────────────────────────────────────────────────────
|
if (host) p._host = host;
|
||||||
const api = () => (document.getElementById('api-base').value||'http://localhost:3910').replace(/\/$/,'');
|
|
||||||
const fmt = (n, cur='CLP') => n==null ? '–' : (cur!=='CLP'?cur+' ':'')+Math.abs(n).toLocaleString('es-CL');
|
|
||||||
const esc = s => String(s||'').replace(/[&<>"]/g, c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
|
||||||
|
|
||||||
// economic type metadata: [label, color, isIncome, isExpense]
|
|
||||||
const ET = {
|
|
||||||
pure_income: ['Ingreso puro', '#4ade9a', true, false],
|
|
||||||
operating_income: ['Ingreso oper.', '#4ade9a', true, false],
|
|
||||||
real_expense: ['Gasto real', '#f06a6a', false, true ],
|
|
||||||
card_charge: ['Cargo tarjeta', '#f06a6a', false, true ],
|
|
||||||
card_payment: ['Pago tarjeta', '#7070a0', false, false],
|
|
||||||
wallet_funding: ['Carga wallet', '#7070a0', false, false],
|
|
||||||
foreign_account_funding: ['Fondeo ext.', '#7070a0', false, false],
|
|
||||||
internal_transfer: ['Transf. interna', '#7070a0', false, false],
|
|
||||||
reimbursement: ['Reembolso', '#60d0a0', true, false],
|
|
||||||
refund: ['Refund', '#60d0a0', true, false],
|
|
||||||
partner_loan: ['Préstamo socio', '#a78bfa', false, false],
|
|
||||||
partner_withdrawal: ['Retiro socio', '#a78bfa', false, false],
|
|
||||||
adjustment: ['Ajuste', '#4a4a70', false, false],
|
|
||||||
non_accounting: ['No contable', '#2a2a44', false, false],
|
|
||||||
unknown: ['Desconocido', '#2a2a44', false, false],
|
|
||||||
};
|
|
||||||
const etLabel = t => ET[t]?.[0] || t;
|
|
||||||
const etColor = t => ET[t]?.[1] || '#4a4a70';
|
|
||||||
const isInc = t => ET[t]?.[2] || false;
|
|
||||||
const isExp = t => ET[t]?.[3] || false;
|
|
||||||
|
|
||||||
function setStatus(ok) {
|
|
||||||
const dot = document.getElementById('status-dot');
|
|
||||||
const ndot = document.getElementById('nav-dot');
|
|
||||||
const txt = document.getElementById('status-txt');
|
|
||||||
dot.className = ndot.className = 'dot ' + (ok ? 'live' : 'err');
|
|
||||||
txt.textContent = ok ? 'conectado' : 'error';
|
|
||||||
}
|
|
||||||
|
|
||||||
function showErr(msg) {
|
|
||||||
const el = document.getElementById('err');
|
|
||||||
el.textContent = msg; el.style.display = 'block';
|
|
||||||
}
|
|
||||||
function clearErr() { document.getElementById('err').style.display = 'none'; }
|
|
||||||
|
|
||||||
// ── load ────────────────────────────────────────────────────────
|
|
||||||
async function load() {
|
|
||||||
clearErr();
|
|
||||||
try {
|
|
||||||
const [sum, er, ar, mr] = await Promise.all([
|
|
||||||
fetch(api()+'/summary').then(r=>{ if(!r.ok) throw new Error(r.status); return r.json() }),
|
|
||||||
fetch(api()+'/entities').then(r=>r.json()),
|
|
||||||
fetch(api()+'/accounts').then(r=>r.json()),
|
|
||||||
fetch(api()+'/movements').then(r=>r.json()),
|
|
||||||
]);
|
|
||||||
setStatus(true);
|
|
||||||
SUM = sum;
|
|
||||||
ENTS = er.entities || [];
|
|
||||||
ACCS = ar.accounts || [];
|
|
||||||
MOVS = (mr.movements || []).slice().sort((a,b) => (b.date||'').localeCompare(a.date||''));
|
|
||||||
accById = Object.fromEntries(ACCS.map(a=>[a.id,a]));
|
|
||||||
entById = Object.fromEntries(ENTS.map(e=>[e.id,e]));
|
|
||||||
renderAll();
|
|
||||||
} catch(e) {
|
|
||||||
setStatus(false);
|
|
||||||
showErr('No se pudo conectar: '+e.message+' — ¿está corriendo npm run serve?');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderAll() {
|
|
||||||
renderKPIs();
|
|
||||||
renderChart();
|
|
||||||
renderEntities();
|
|
||||||
renderMovements();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── KPIs ────────────────────────────────────────────────────────
|
|
||||||
function renderKPIs() {
|
|
||||||
const c = SUM.counts;
|
|
||||||
const bt = SUM.movementAmountByType || {};
|
|
||||||
let inc=0, incN=0, exp=0, expN=0;
|
|
||||||
for (const m of MOVS) {
|
|
||||||
if (isInc(m.economicType)) { inc += m.amount?.value||0; incN++ }
|
|
||||||
if (isExp(m.economicType)) { exp += m.amount?.value||0; expN++ }
|
|
||||||
}
|
|
||||||
const net = inc - exp;
|
|
||||||
document.getElementById('k-income').textContent = fmt(inc);
|
|
||||||
document.getElementById('k-income-c').textContent = incN + ' movimientos';
|
|
||||||
document.getElementById('k-expense').textContent = fmt(exp);
|
|
||||||
document.getElementById('k-expense-c').textContent = expN + ' movimientos';
|
|
||||||
const nel = document.getElementById('k-net');
|
|
||||||
nel.textContent = (net >= 0 ? '+' : '-') + fmt(Math.abs(net));
|
|
||||||
nel.style.color = net >= 0 ? 'var(--in)' : 'var(--out)';
|
|
||||||
document.getElementById('k-mov').textContent = c.movements;
|
|
||||||
document.getElementById('k-mov-sub').textContent = c.accounts + ' cuentas · ' + c.entities + ' entidades';
|
|
||||||
document.getElementById('k-links').textContent = c.links;
|
|
||||||
document.getElementById('k-links-sub').textContent = c.documents + ' documentos · ' + c.events + ' eventos';
|
|
||||||
document.getElementById('sub-label').textContent = '· ' + c.entities + ' entidades · ' + c.movements + ' movimientos';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── donut chart ─────────────────────────────────────────────────
|
|
||||||
function renderChart() {
|
|
||||||
const bt = SUM.movementAmountByType || {};
|
|
||||||
const entries = Object.entries(bt).filter(([,v])=>v>0).sort((a,b)=>b[1]-a[1]);
|
|
||||||
const total = entries.reduce((s,[,v])=>s+v, 0);
|
|
||||||
if (!total) { document.getElementById('chart-wrap').innerHTML='<div class="empty">Sin datos.</div>'; return; }
|
|
||||||
|
|
||||||
// populate filter
|
|
||||||
const sel = document.getElementById('etype-sel');
|
|
||||||
while (sel.options.length > 1) sel.remove(1);
|
|
||||||
entries.forEach(([t]) => sel.add(new Option(etLabel(t), t)));
|
|
||||||
|
|
||||||
// SVG donut
|
|
||||||
const R=52, r=34, cx=60, cy=60, TAU=2*Math.PI;
|
|
||||||
let angle = -Math.PI/2;
|
|
||||||
const segments = entries.map(([t,v]) => {
|
|
||||||
const pct = v/total, sweep = pct*TAU;
|
|
||||||
const x1=cx+R*Math.cos(angle), y1=cy+R*Math.sin(angle);
|
|
||||||
angle += sweep;
|
|
||||||
const x2=cx+R*Math.cos(angle), y2=cy+R*Math.sin(angle);
|
|
||||||
const lf = sweep > Math.PI ? 1 : 0;
|
|
||||||
const path = `M${cx},${cy} L${x1},${y1} A${R},${R},0,${lf},1,${x2},${y2} Z`;
|
|
||||||
return { t, v, pct, path, color: etColor(t) };
|
|
||||||
});
|
});
|
||||||
// inner hole mask
|
|
||||||
const svgH = `<svg width="120" height="120" viewBox="0 0 120 120">
|
|
||||||
${segments.map(s=>`<path d="${esc(s.path)}" fill="${s.color}" opacity=".85"/>`).join('')}
|
|
||||||
<circle cx="60" cy="60" r="${r}" fill="var(--surface)"/>
|
|
||||||
</svg>`;
|
|
||||||
|
|
||||||
const legend = segments.slice(0,6).map(s => {
|
|
||||||
const pct = Math.round(s.pct*100);
|
|
||||||
return `<div class="legend-row">
|
|
||||||
<div class="legend-dot" style="background:${s.color}"></div>
|
|
||||||
<div class="legend-label">${esc(etLabel(s.t))}</div>
|
|
||||||
<div class="legend-amt">${fmt(s.v)}</div>
|
|
||||||
<div class="legend-pct">${pct}%</div>
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
document.getElementById('chart-wrap').innerHTML = `
|
|
||||||
<div class="donut-wrap">
|
|
||||||
${svgH}
|
|
||||||
<div class="donut-center">
|
|
||||||
<div class="dc-val">${entries.length}</div>
|
|
||||||
<div class="dc-sub">tipos</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="donut-legend">${legend}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── entities ────────────────────────────────────────────────────
|
|
||||||
function renderEntities() {
|
|
||||||
const acctsByEnt = {};
|
|
||||||
for (const a of ACCS) {
|
|
||||||
if (!acctsByEnt[a.ownerEntityId]) acctsByEnt[a.ownerEntityId] = [];
|
|
||||||
acctsByEnt[a.ownerEntityId].push(a);
|
|
||||||
}
|
|
||||||
const hints = SUM.movementAmountByEntityHint || {};
|
|
||||||
document.getElementById('ent-count').textContent = ENTS.length + ' entidades';
|
|
||||||
|
|
||||||
const html = ENTS.map(e => {
|
|
||||||
const init = (e.name||e.id).split(/\s+/).slice(0,2).map(w=>w[0]).join('').toUpperCase();
|
|
||||||
const cls = e.kind==='company' ? 'company' : e.kind==='vendor' ? 'vendor' : '';
|
|
||||||
const accts = acctsByEnt[e.id] || [];
|
|
||||||
const net = hints[e.id];
|
|
||||||
const netHtml = net!=null
|
|
||||||
? `<div class="entity-net ${net>=0?'':'out'}" style="color:${net>=0?'var(--in)':'var(--out)'}">${net>=0?'+':''}${fmt(net)}</div>` : '';
|
|
||||||
const acctHtml = accts.map(a=>`<div class="acct-chip">${esc(a.label||a.id)}</div>`).join('');
|
|
||||||
return `<div class="entity-row">
|
|
||||||
<div class="avatar ${cls}">${esc(init)}</div>
|
|
||||||
<div class="entity-info">
|
|
||||||
<div class="entity-name">${esc(e.name||e.id)}</div>
|
|
||||||
<div class="entity-meta">${esc(e.kind||'')}${e.rut?' · '+e.rut:''}</div>
|
|
||||||
${accts.length?'<div class="entity-accts">'+acctHtml+'</div>':''}
|
|
||||||
</div>
|
|
||||||
${netHtml}
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
|
||||||
document.getElementById('entity-list').innerHTML = html || '<div class="empty">Sin entidades.</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── movements ────────────────────────────────────────────────────
|
|
||||||
function currentMovs() {
|
|
||||||
const q = document.getElementById('mov-q').value.toLowerCase().trim();
|
|
||||||
const dir = document.querySelector('#dir-seg .on').dataset.d;
|
|
||||||
const et = document.getElementById('etype-sel').value;
|
|
||||||
return MOVS.filter(m => {
|
|
||||||
if (dir!=='all' && m.direction!==dir) return false;
|
|
||||||
if (et && m.economicType!==et) return false;
|
|
||||||
if (q) {
|
|
||||||
const hay = ((m.description||'')+' '+(m.counterparty||'')+' '+m.id).toLowerCase();
|
|
||||||
if (!hay.includes(q)) return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderMovements() {
|
|
||||||
const movs = currentMovs();
|
|
||||||
const feed = document.getElementById('mov-feed');
|
|
||||||
document.getElementById('mov-empty').hidden = movs.length > 0;
|
|
||||||
feed.innerHTML = movs.map(m => {
|
|
||||||
const cls = m.direction==='in'?'in':'out';
|
|
||||||
const icon = m.direction==='in' ? '↑' : '↓';
|
|
||||||
const color = etColor(m.economicType);
|
|
||||||
const sel = m.id===selId ? ' selected' : '';
|
|
||||||
const acct = accById[m.accountId];
|
|
||||||
return `<div class="mov-item${sel}" data-id="${esc(m.id)}">
|
|
||||||
<div class="mov-icon ${cls}">${icon}</div>
|
|
||||||
<div class="mov-body">
|
|
||||||
<div class="mov-top">
|
|
||||||
<div class="mov-desc">${esc(m.description||m.id)}</div>
|
|
||||||
<div class="mov-amt ${cls}">${cls==='in'?'+':'-'}${fmt(m.amount?.value, m.amount?.currency)}</div>
|
|
||||||
</div>
|
|
||||||
<div class="mov-bot">
|
|
||||||
<span class="mov-date">${m.date||''}</span>
|
|
||||||
${m.counterparty?`<span class="mov-cpty">→ ${esc(m.counterparty)}</span>`:''}
|
|
||||||
<span class="etype-chip" style="background:${color}22;color:${color}">${esc(etLabel(m.economicType))}</span>
|
|
||||||
${acct?`<span class="mov-date">${esc(acct.label||acct.id)}</span>`:''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button class="tree-trigger" data-id="${esc(m.id)}">árbol ↗</button>
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
feed.querySelectorAll('.mov-item').forEach(el => el.addEventListener('click', e => {
|
|
||||||
if (e.target.classList.contains('tree-trigger')) return;
|
|
||||||
openDrawer(el.dataset.id);
|
|
||||||
}));
|
|
||||||
feed.querySelectorAll('.tree-trigger').forEach(b => b.addEventListener('click', e => {
|
|
||||||
e.stopPropagation(); openDrawer(b.dataset.id);
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('mov-q').addEventListener('input', renderMovements);
|
|
||||||
document.getElementById('etype-sel').addEventListener('change', renderMovements);
|
|
||||||
document.querySelectorAll('#dir-seg button').forEach(b => b.addEventListener('click', () => {
|
|
||||||
document.querySelectorAll('#dir-seg button').forEach(x=>x.classList.remove('on'));
|
|
||||||
b.classList.add('on'); renderMovements();
|
|
||||||
}));
|
|
||||||
|
|
||||||
// ── drawer ───────────────────────────────────────────────────────
|
|
||||||
async function openDrawer(movId) {
|
|
||||||
selId = movId;
|
|
||||||
document.querySelectorAll('.mov-item').forEach(el => el.classList.toggle('selected', el.dataset.id===movId));
|
|
||||||
const m = MOVS.find(x=>x.id===movId);
|
|
||||||
document.getElementById('drawer-title').textContent = m?.description || movId;
|
|
||||||
document.getElementById('drawer').classList.add('open');
|
|
||||||
await loadTree(movId, activeTab);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadTree(movId, tab) {
|
|
||||||
const body = document.getElementById('drawer-body');
|
|
||||||
body.innerHTML = '<div class="empty"><span class="spin"></span>cargando árbol…</div>';
|
|
||||||
try {
|
|
||||||
const path = tab==='orig'
|
|
||||||
? `/nodes/${encodeURIComponent(movId)}/origin-tree`
|
|
||||||
: `/nodes/${encodeURIComponent(movId)}/destination-tree`;
|
|
||||||
const tree = await fetch(api()+path).then(r=>r.json());
|
|
||||||
body.innerHTML = renderTree(tree, tab==='orig');
|
|
||||||
} catch(e) {
|
|
||||||
body.innerHTML = `<div class="empty">Error: ${esc(e.message)}</div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function switchTab(tab) {
|
|
||||||
activeTab = tab;
|
|
||||||
document.getElementById('tab-orig').classList.toggle('on', tab==='orig');
|
|
||||||
document.getElementById('tab-dest').classList.toggle('on', tab==='dest');
|
|
||||||
if (selId) loadTree(selId, tab);
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('drawer-close').addEventListener('click', () => {
|
|
||||||
document.getElementById('drawer').classList.remove('open');
|
|
||||||
selId = null;
|
|
||||||
document.querySelectorAll('.mov-item.selected').forEach(el=>el.classList.remove('selected'));
|
|
||||||
});
|
});
|
||||||
|
</script>
|
||||||
|
<script src="dashboard.js"></script>
|
||||||
|
<script src="dashboard2.js"></script>
|
||||||
|
|
||||||
// ── tree renderer ────────────────────────────────────────────────
|
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||||
function renderTree(branch, isOrig) {
|
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||||
const lines = [];
|
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||||
function visit(b) {
|
<script type="text/babel" src="tweaks-panel.jsx"></script>
|
||||||
const n = b.node || {};
|
<script type="text/babel">
|
||||||
const label = n.description || n.subject || n.label || n.name || n.issuerName || n.id || '?';
|
const TWEAK_DEFAULTS = { palette: ['#2ee6a6', '#ff5f73', '#b98cff', '#ffc24b'], bg: '#090b0f' };
|
||||||
const amtH = n.amount ? ` <span class="tn-amt">${fmt(n.amount.value,n.amount.currency)}</span>` : '';
|
function applyTweaks(t) {
|
||||||
const dtH = (n.date||n.documentDate) ? ` <span class="tn-date">${n.date||n.documentDate}</span>` : '';
|
const r = document.documentElement.style;
|
||||||
const kindH = n.kind ? `<span class="tn-kind">${esc(n.kind)}</span>` : '';
|
r.setProperty('--c-income', t.palette[0]); r.setProperty('--c-spend', t.palette[1]);
|
||||||
if (b.cycle) { lines.push(`<div class="t-cycle">↩ ciclo: ${esc(label)}</div>`); return }
|
r.setProperty('--c-person', t.palette[2]); r.setProperty('--c-fee', t.palette[3]);
|
||||||
if (b.truncated) { lines.push(`<div class="t-cycle">… truncado</div>`); return }
|
r.setProperty('--bg', t.bg);
|
||||||
lines.push(`<div class="tn"><div class="tn-label">${kindH}<span class="tn-id">${esc(n.id||'')}</span>${dtH} <span class="tn-desc">${esc(label)}</span>${amtH}</div>`);
|
if (window.MTapply) window.MTapply();
|
||||||
const edges = isOrig ? (b.incoming||[]) : (b.outgoing||[]);
|
|
||||||
if (edges.length) {
|
|
||||||
lines.push('<div class="tc">');
|
|
||||||
for (const e of edges) {
|
|
||||||
const lk = e.link || {};
|
|
||||||
const la = lk.amount ? ' '+fmt(lk.amount.value,lk.amount.currency) : '';
|
|
||||||
const lm = lk.method ? ` <span class="tl-meth">(${esc(lk.method)})</span>` : '';
|
|
||||||
lines.push(`<div class="tl-label"><span class="tl-type">${esc(lk.type||'')}</span>${la}${lm}</div>`);
|
|
||||||
visit(isOrig ? e.source : e.target);
|
|
||||||
}
|
|
||||||
lines.push('</div>');
|
|
||||||
}
|
|
||||||
lines.push('</div>');
|
|
||||||
}
|
|
||||||
visit(branch);
|
|
||||||
return lines.join('');
|
|
||||||
}
|
}
|
||||||
|
function TweakApp() {
|
||||||
// ── boot ─────────────────────────────────────────────────────────
|
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
|
||||||
document.getElementById('reload-btn').addEventListener('click', load);
|
React.useEffect(() => { applyTweaks(t); }, [t]);
|
||||||
document.getElementById('api-base').addEventListener('keydown', e => { if(e.key==='Enter') load() });
|
return (
|
||||||
load();
|
<TweaksPanel title="Tweaks">
|
||||||
|
<TweakSection label="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="Background" />
|
||||||
|
<TweakColor label="Canvas" value={t.bg} onChange={v => setTweak('bg', v)}
|
||||||
|
options={['#090b0f', '#0c0a0e', '#0a0d0c', '#0d0d0d']} />
|
||||||
|
</TweaksPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ReactDOM.createRoot(document.getElementById('tweaks-root')).render(<TweakApp />);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,327 @@
|
||||||
|
/* money-trace · dashboard controller + 5 perspective tabs */
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
const $ = s => document.querySelector(s);
|
||||||
|
const C = window.Charts;
|
||||||
|
const col = {
|
||||||
|
income: () => cssv('--c-income'), spend: () => cssv('--c-spend'),
|
||||||
|
person: () => cssv('--c-person'), fee: () => cssv('--c-fee'),
|
||||||
|
internal: () => cssv('--c-internal'), ink: () => cssv('--ink'), mute: () => cssv('--ink-mute')
|
||||||
|
};
|
||||||
|
const cssv = v => getComputedStyle(document.documentElement).getPropertyValue(v).trim();
|
||||||
|
const esc = C.esc;
|
||||||
|
|
||||||
|
// ---------- tooltip ----------
|
||||||
|
const tip = $('#tip');
|
||||||
|
function place(e) {
|
||||||
|
const w = tip.offsetWidth, h = tip.offsetHeight, pad = 16;
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
window.MTtip = {
|
||||||
|
raw(e, title, sub, c) {
|
||||||
|
tip.innerHTML = `<div class="t-head"><span class="t-dot" style="background:${c || col.mute()}"></span><span class="t-title">${esc(title)}</span></div><div class="t-meta" style="margin:0">${esc(sub)}</div>`;
|
||||||
|
tip.classList.add('show'); place(e);
|
||||||
|
},
|
||||||
|
rows(e, title, c, txns) {
|
||||||
|
const top = txns.slice().sort((a, b) => b.amount - a.amount).slice(0, 6);
|
||||||
|
let html = `<div class="t-head"><span class="t-dot" style="background:${c}"></span><span class="t-title">${esc(title)}</span></div>
|
||||||
|
<div class="t-amt num" style="color:${c}">${window.MT.CLP(txns.reduce((a, t) => a + t.amount, 0))}</div>
|
||||||
|
<div class="t-meta">${txns.length} transactions</div><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</div>`;
|
||||||
|
tip.innerHTML = html; tip.classList.add('show'); place(e);
|
||||||
|
},
|
||||||
|
hide() { tip.classList.remove('show'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- aggregation ----------
|
||||||
|
const TX = () => window.MT.tx;
|
||||||
|
const sum = arr => arr.reduce((a, t) => a + t.amount, 0);
|
||||||
|
const groupSum = (arr, keyFn) => {
|
||||||
|
const m = {};
|
||||||
|
for (const t of arr) { const k = keyFn(t) || '—'; (m[k] = m[k] || { value: 0, txns: [] }); m[k].value += t.amount; m[k].txns.push(t); }
|
||||||
|
return m;
|
||||||
|
};
|
||||||
|
const sortEntries = m => Object.entries(m).sort((a, b) => b[1].value - a[1].value);
|
||||||
|
|
||||||
|
const isExpense = t => t.flow_type === 'expense';
|
||||||
|
const isFee = t => t.flow_type === 'fee';
|
||||||
|
const isIncome = t => t.flow_type === 'income';
|
||||||
|
const isInterIn = t => t.flow_type === 'inter_person' && t.direction === 'credit';
|
||||||
|
const isInterOut = t => t.flow_type === 'inter_person' && t.direction === 'debit';
|
||||||
|
const isInternal = t => ['self_transfer', 'credit_line', 'card_payment'].includes(t.flow_type);
|
||||||
|
|
||||||
|
// ---------- shared totals ----------
|
||||||
|
let TOT;
|
||||||
|
function computeTotals() {
|
||||||
|
const tx = TX();
|
||||||
|
const realIn = sum(tx.filter(t => isIncome(t) || isInterIn(t)));
|
||||||
|
const realOut = sum(tx.filter(t => isExpense(t) || isFee(t) || isInterOut(t)));
|
||||||
|
const internal = sum(tx.filter(isInternal));
|
||||||
|
TOT = { realIn, realOut, internal, net: realIn - realOut, gross: realIn + realOut + internal };
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmtBig = v => {
|
||||||
|
const sign = v < 0 ? '−' : ''; const a = Math.abs(v);
|
||||||
|
if (a >= 1e6) return sign + '$' + (a / 1e6).toLocaleString('es-CL', { minimumFractionDigits: 1, maximumFractionDigits: 1 }) + '<small>M</small>';
|
||||||
|
if (a >= 1e3) return sign + '$' + Math.round(a / 1e3).toLocaleString('es-CL') + '<small>k</small>';
|
||||||
|
return sign + window.MT.CLP(a);
|
||||||
|
};
|
||||||
|
const kpi = (lab, val, sub, accent) =>
|
||||||
|
`<div class="kpi" style="--accent:${accent}"><div class="k-lab">${lab}</div><div class="k-val">${val}</div>${sub ? `<div class="k-sub">${sub}</div>` : ''}</div>`;
|
||||||
|
|
||||||
|
// ============================================================ TAB 1: OVERVIEW
|
||||||
|
function renderOverview(host) {
|
||||||
|
const tx = TX();
|
||||||
|
const months = window.MT.months;
|
||||||
|
const introEl = document.getElementById('ov-intro');
|
||||||
|
if (introEl) introEl.innerHTML = `Across <b>${months.length} months</b>, <b>${window.MT.CLPk(TOT.realIn)}</b> came in and <b>${window.MT.CLPk(TOT.realOut)}</b> went out — but your statements logged <b>${window.MT.CLPk(TOT.gross)}</b> of total movement. Here's the real story versus the noise.`;
|
||||||
|
const inByM = {}, outByM = {}, netByM = {};
|
||||||
|
months.forEach(m => {
|
||||||
|
inByM[m] = sum(tx.filter(t => t.ym === m && (isIncome(t) || isInterIn(t))));
|
||||||
|
outByM[m] = sum(tx.filter(t => t.ym === m && (isExpense(t) || isFee(t) || isInterOut(t))));
|
||||||
|
netByM[m] = inByM[m] - outByM[m];
|
||||||
|
});
|
||||||
|
const mult = TOT.gross / (TOT.realIn + TOT.realOut);
|
||||||
|
|
||||||
|
host.innerHTML = `
|
||||||
|
<div class="grid g-3" style="margin-bottom:16px;">
|
||||||
|
${kpi('Real income', fmtBig(TOT.realIn), '93 deposits & transfers in', col.income())}
|
||||||
|
${kpi('Real spending', fmtBig(TOT.realOut), 'purchases · fees · people', col.spend())}
|
||||||
|
${kpi('Net real', (TOT.net < 0 ? '−' : '') + fmtBig(Math.abs(TOT.net)), 'income minus spending', col.ink())}
|
||||||
|
</div>
|
||||||
|
<div class="grid g-2-1">
|
||||||
|
<div class="card tall"><h3>Income vs spending by month<span class="pill">monthly, CLP</span></h3><div class="chart-host" id="ov-bars"></div>
|
||||||
|
<div class="chips"><span class="chip"><i style="background:${col.income()}"></i>Money in</span><span class="chip"><i style="background:${col.spend()}"></i>Money out</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="card tall"><h3>Real vs internal</h3>
|
||||||
|
<div class="donut-wrap"><div id="ov-donut"></div>
|
||||||
|
<div class="chips" style="flex-direction:column;gap:10px;margin:0;">
|
||||||
|
<span class="chip"><i style="background:${col.income()}"></i>Real money <b>${window.MT.CLPk(TOT.realIn + TOT.realOut)}</b></span>
|
||||||
|
<span class="chip"><i style="background:${col.internal()}"></i>Internal shuffle <b>${window.MT.CLPk(TOT.internal)}</b></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="insight" style="margin-top:18px;"><div class="i-ic">↻</div><div>For every <b>$1</b> of real money, <span class="num">$${mult.toFixed(1)}</span> moved between your own accounts. Most of the statement volume is noise.</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="margin-top:16px;"><h3>Cumulative net position<span class="pill">running income − spending</span></h3><div class="chart-host" id="ov-line"></div></div>
|
||||||
|
`;
|
||||||
|
C.monthlyBars($('#ov-bars'), months, [
|
||||||
|
{ key: 'in', label: 'Money in', color: col.income(), values: inByM },
|
||||||
|
{ key: 'out', label: 'Money out', color: col.spend(), values: outByM }
|
||||||
|
], { height: 230 });
|
||||||
|
C.donut($('#ov-donut'), [
|
||||||
|
{ label: 'Real money', value: TOT.realIn + TOT.realOut, color: col.income() },
|
||||||
|
{ label: 'Internal shuffle', value: TOT.internal, color: col.internal() }
|
||||||
|
], { size: 176, centerTop: 'x' + mult.toFixed(1), centerBot: 'multiplier' });
|
||||||
|
C.lineChart($('#ov-line'), months, netByM, { height: 200, color: col.income() });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================ TAB 2: SPENDING
|
||||||
|
function renderSpending(host) {
|
||||||
|
const tx = TX();
|
||||||
|
const spend = tx.filter(t => isExpense(t) || isFee(t));
|
||||||
|
const total = sum(spend);
|
||||||
|
const byCat = groupSum(spend, t => isFee(t) ? 'Fees & interest' : window.MT.spendCategory(t.description));
|
||||||
|
const cats = sortEntries(byCat);
|
||||||
|
const palette = ['#ff5f73', '#ff7a6b', '#ff9460', '#ffae57', '#ffc24b', '#d98cff', '#8c9eff', '#6fd0ff', '#5fd0a0', '#7a8699'];
|
||||||
|
const catColor = i => i < palette.length ? palette[i] : col.mute();
|
||||||
|
const months = window.MT.months;
|
||||||
|
const spendByM = {}; months.forEach(m => spendByM[m] = sum(spend.filter(t => t.ym === m)));
|
||||||
|
// top merchants
|
||||||
|
const byMerch = groupSum(tx.filter(isExpense), t => window.MT.cleanDesc(t.description) || 'Unknown');
|
||||||
|
const merch = sortEntries(byMerch).slice(0, 10);
|
||||||
|
const biggestCat = cats[0];
|
||||||
|
|
||||||
|
host.innerHTML = `
|
||||||
|
<div class="grid g-3" style="margin-bottom:16px;">
|
||||||
|
${kpi('Total spending', fmtBig(total), `${spend.length} purchases & fees`, col.spend())}
|
||||||
|
${kpi('Biggest category', biggestCat[0], `${window.MT.CLPk(biggestCat[1].value)} · ${Math.round(biggestCat[1].value / total * 100)}% of spend`, palette[0])}
|
||||||
|
${kpi('Avg / month', fmtBig(total / months.length), 'across ' + months.length + ' months', col.fee())}
|
||||||
|
</div>
|
||||||
|
<div class="grid g-2-1">
|
||||||
|
<div class="card"><h3>Where it actually goes<span class="pill">by category</span></h3><div id="sp-cats"></div></div>
|
||||||
|
<div class="card"><h3>Top merchants<span class="pill">single payees</span></h3><div class="mlist" id="sp-merch"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="margin-top:16px;"><h3>Spending by month<span class="pill">CLP / month</span></h3><div class="chart-host" id="sp-month"></div></div>
|
||||||
|
`;
|
||||||
|
C.hbars($('#sp-cats'), cats.map(([label, d], i) => ({
|
||||||
|
label, value: d.value, color: catColor(i), sub: d.txns.length + ' txns · ' + Math.round(d.value / total * 100) + '%',
|
||||||
|
_txns: d.txns, _c: catColor(i)
|
||||||
|
})), { max: cats[0][1].value, onHover: (e, d) => window.MTtip.rows(e, d.label, d._c, d._txns) });
|
||||||
|
const ml = $('#sp-merch');
|
||||||
|
merch.forEach(([name, d], i) => {
|
||||||
|
const cat = window.MT.spendCategory(d.txns[0].description);
|
||||||
|
const row = document.createElement('div'); row.className = 'mrow';
|
||||||
|
row.innerHTML = `<span class="m-rank">${i + 1}</span><span class="m-name">${esc(name)}<span class="m-cat">${esc(cat)}</span></span><span class="m-cnt">${d.txns.length}×</span><span class="m-amt">${window.MT.CLPk(d.value)}</span>`;
|
||||||
|
row.addEventListener('mousemove', e => window.MTtip.rows(e, name, col.spend(), d.txns));
|
||||||
|
row.addEventListener('mouseleave', () => window.MTtip.hide());
|
||||||
|
ml.appendChild(row);
|
||||||
|
});
|
||||||
|
C.monthlyBars($('#sp-month'), months, [{ key: 'sp', label: 'Spending', color: col.spend(), values: spendByM }], { height: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================ TAB 3: INCOME
|
||||||
|
function renderIncome(host) {
|
||||||
|
const tx = TX();
|
||||||
|
const inc = tx.filter(t => isIncome(t) || isInterIn(t));
|
||||||
|
const total = sum(inc);
|
||||||
|
const bySrc = groupSum(inc, t => isIncome(t) ? window.MT.normIncome(t.counterparty) : window.MT.normPerson(t.counterparty));
|
||||||
|
const srcs = sortEntries(bySrc);
|
||||||
|
const palette = ['#2ee6a6', '#3ad6b0', '#46c6ba', '#52b6c4', '#5ea6ce', '#b98cff', '#8c9eff', '#7a8699'];
|
||||||
|
const months = window.MT.months;
|
||||||
|
const incByM = {}; months.forEach(m => incByM[m] = sum(inc.filter(t => t.ym === m)));
|
||||||
|
const top3 = srcs.slice(0, 3).reduce((a, [, d]) => a + d.value, 0);
|
||||||
|
const conc = Math.round(top3 / total * 100);
|
||||||
|
|
||||||
|
host.innerHTML = `
|
||||||
|
<div class="grid g-3" style="margin-bottom:16px;">
|
||||||
|
${kpi('Total income', fmtBig(total), `${inc.length} deposits & transfers in`, col.income())}
|
||||||
|
${kpi('Top 3 sources', conc + '%', 'of all income comes from 3 payers', col.income())}
|
||||||
|
${kpi('Avg / month', fmtBig(total / months.length), 'across ' + months.length + ' months', col.person())}
|
||||||
|
</div>
|
||||||
|
<div class="grid g-2-1">
|
||||||
|
<div class="card"><h3>Where it comes from<span class="pill">by source</span></h3><div id="in-src"></div></div>
|
||||||
|
<div class="card"><h3>Concentration</h3>
|
||||||
|
<div class="donut-wrap"><div id="in-donut"></div></div>
|
||||||
|
<div class="chips" id="in-chips" style="margin-top:18px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="margin-top:16px;"><h3>Income by month<span class="pill">CLP / month</span></h3><div class="chart-host" id="in-month"></div></div>
|
||||||
|
`;
|
||||||
|
C.hbars($('#in-src'), srcs.map(([label, d], i) => ({
|
||||||
|
label, value: d.value, color: i < palette.length ? palette[i] : col.mute(),
|
||||||
|
sub: d.txns.length + ' deposits · ' + Math.round(d.value / total * 100) + '%',
|
||||||
|
_txns: d.txns, _c: i < palette.length ? palette[i] : col.mute()
|
||||||
|
})), { max: srcs[0][1].value, onHover: (e, d) => window.MTtip.rows(e, d.label, d._c, d._txns) });
|
||||||
|
// donut top 5 + other
|
||||||
|
const top5 = srcs.slice(0, 5);
|
||||||
|
const otherV = total - top5.reduce((a, [, d]) => a + d.value, 0);
|
||||||
|
const donutData = top5.map(([label, d], i) => ({ label, value: d.value, color: palette[i] }));
|
||||||
|
if (otherV > 0) donutData.push({ label: 'Other sources', value: otherV, color: col.mute() });
|
||||||
|
C.donut($('#in-donut'), donutData, { size: 176, centerTop: window.MT.CLPk(total).replace('$', '$'), centerBot: 'total in' });
|
||||||
|
$('#in-chips').innerHTML = donutData.map(d => `<span class="chip"><i style="background:${d.color}"></i>${esc(d.label)} <b>${Math.round(d.value / total * 100)}%</b></span>`).join('');
|
||||||
|
C.monthlyBars($('#in-month'), months, [{ key: 'in', label: 'Income', color: col.income(), values: incByM }], { height: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================ TAB 4: CYCLES
|
||||||
|
function renderCycles(host) {
|
||||||
|
const tx = TX();
|
||||||
|
const internal = tx.filter(isInternal);
|
||||||
|
const total = sum(internal);
|
||||||
|
const byType = {
|
||||||
|
'Card payments': { value: sum(tx.filter(t => t.flow_type === 'card_payment')), c: tx.filter(t => t.flow_type === 'card_payment'), color: '#6f7b90', label: 'Account → its own credit card' },
|
||||||
|
'Credit-line sweeps': { value: sum(tx.filter(t => t.flow_type === 'credit_line')), c: tx.filter(t => t.flow_type === 'credit_line'), color: '#566173', label: 'Cuenta corriente ↔ línea de crédito' },
|
||||||
|
'Self-transfers': { value: sum(tx.filter(t => t.flow_type === 'self_transfer')), c: tx.filter(t => t.flow_type === 'self_transfer'), color: '#7d899e', label: 'Between your own accounts' }
|
||||||
|
};
|
||||||
|
const mult = TOT.gross / (TOT.realIn + TOT.realOut);
|
||||||
|
const byBank = groupSum(internal, t => t.bank);
|
||||||
|
const banks = sortEntries(byBank).slice(0, 8);
|
||||||
|
|
||||||
|
host.innerHTML = `
|
||||||
|
<div class="card" style="margin-bottom:16px;">
|
||||||
|
<div class="callout"><div class="big mult">×${mult.toFixed(1)}</div><div class="ctxt">Your statements show <b>${window.MT.CLPk(TOT.gross)}</b> of gross movement, but only <b>${window.MT.CLPk(TOT.realIn + TOT.realOut)}</b> is real money in or out.<br/><b>${window.MT.CLPk(total)}</b> is internal shuffling — money that never left your perimeter.</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="grid g-2">
|
||||||
|
<div class="card"><h3>What the cycling is<span class="pill">by type</span></h3><div id="cy-types"></div>
|
||||||
|
<div class="insight" style="margin-top:16px;"><div class="i-ic">i</div><div>These flows net to roughly zero — they're you paying your own cards, sweeping credit lines, and moving cash between accounts.</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="card"><h3>Which banks cycle most<span class="pill">internal volume</span></h3><div id="cy-banks"></div></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
const typeArr = Object.entries(byType);
|
||||||
|
C.hbars($('#cy-types'), typeArr.map(([label, d]) => ({
|
||||||
|
label, value: d.value, color: d.color, sub: d.label + ' · ' + d.c.length + ' txns', _txns: d.c, _c: d.color
|
||||||
|
})), { max: Math.max(...typeArr.map(([, d]) => d.value)), onHover: (e, d) => window.MTtip.rows(e, d.label, d._c, d._txns) });
|
||||||
|
C.hbars($('#cy-banks'), banks.map(([label, d]) => ({
|
||||||
|
label, value: d.value, color: col.internal(), sub: d.txns.length + ' txns', _txns: d.txns, _c: col.internal()
|
||||||
|
})), { max: banks[0][1].value, onHover: (e, d) => window.MTtip.rows(e, d.label, d._c, d._txns) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================ TAB 5: BANKS
|
||||||
|
function renderBanks(host) {
|
||||||
|
const tx = TX();
|
||||||
|
const raw = window.MT.raw;
|
||||||
|
const banks = window.MT.banks.map(bk => {
|
||||||
|
const bt = tx.filter(t => t.bank === bk);
|
||||||
|
const realIn = sum(bt.filter(t => isIncome(t) || isInterIn(t)));
|
||||||
|
const realOut = sum(bt.filter(t => isExpense(t) || isFee(t) || isInterOut(t)));
|
||||||
|
const internal = sum(bt.filter(isInternal));
|
||||||
|
const vol = realIn + realOut + internal;
|
||||||
|
// docs from raw
|
||||||
|
let docs = 0; for (const s of raw.statements) if (window.MT.bankName(s.bank) === bk) docs++;
|
||||||
|
return { bk, txns: bt.length, docs, realIn, realOut, internal, vol, real: realIn + realOut };
|
||||||
|
}).sort((a, b) => b.vol - a.vol);
|
||||||
|
const maxVol = Math.max(...banks.map(b => b.vol));
|
||||||
|
const totalReal = banks.reduce((a, b) => a + b.real, 0);
|
||||||
|
const totalInt = banks.reduce((a, b) => a + b.internal, 0);
|
||||||
|
|
||||||
|
host.innerHTML = `
|
||||||
|
<div class="grid g-3" style="margin-bottom:16px;">
|
||||||
|
${kpi('Banks reconstructed', '12', `${window.MT.tx.length} txns · ${raw.statements.length} statements`, col.income())}
|
||||||
|
${kpi('Most active', banks[0].bk, `${window.MT.CLPk(banks[0].vol)} total volume`, col.income())}
|
||||||
|
${kpi('Real vs internal', Math.round(totalReal / (totalReal + totalInt) * 100) + '%', 'of all volume is real money', col.fee())}
|
||||||
|
</div>
|
||||||
|
<div class="card"><h3>Per-bank breakdown<span class="pill">sorted by volume</span></h3>
|
||||||
|
<table class="btable"><thead><tr>
|
||||||
|
<th>Bank</th><th>Txns</th><th>Real in</th><th>Real out</th><th>Internal</th><th>Volume</th><th style="width:150px;">Real vs internal</th>
|
||||||
|
</tr></thead><tbody id="bk-rows"></tbody></table>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
const tb = $('#bk-rows');
|
||||||
|
banks.forEach(b => {
|
||||||
|
const realPct = b.vol ? b.real / b.vol * 100 : 0;
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td><div class="bk-name"><span class="bk-dot" style="background:${b.real >= b.internal ? col.income() : col.internal()}"></span><div>${esc(b.bk)}<div class="bk-meta">${b.docs} statements</div></div></div></td>
|
||||||
|
<td>${b.txns}</td>
|
||||||
|
<td style="color:${col.income()}">${b.realIn ? window.MT.CLPk(b.realIn) : '<span class="muted">—</span>'}</td>
|
||||||
|
<td style="color:${col.spend()}">${b.realOut ? window.MT.CLPk(b.realOut) : '<span class="muted">—</span>'}</td>
|
||||||
|
<td class="muted">${b.internal ? window.MT.CLPk(b.internal) : '—'}</td>
|
||||||
|
<td>${window.MT.CLPk(b.vol)}</td>
|
||||||
|
<td><span class="minibar" title="${Math.round(realPct)}% real"><i style="width:${realPct}%;background:${col.income()}"></i><i style="width:${100 - realPct}%;background:${col.internal()}"></i></span></td>`;
|
||||||
|
tb.appendChild(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- tab wiring ----------
|
||||||
|
const RENDERERS = { overview: renderOverview, spending: renderSpending, income: renderIncome, cycles: renderCycles, banks: renderBanks };
|
||||||
|
const rendered = {};
|
||||||
|
|
||||||
|
// shared API for the extra-tabs module (dashboard2.js)
|
||||||
|
window.MTdash = {
|
||||||
|
TX, sum, groupSum, sortEntries, col, cssv, esc, fmtBig, kpi,
|
||||||
|
isExpense, isFee, isIncome, isInterIn, isInterOut, isInternal,
|
||||||
|
get TOT() { return TOT; },
|
||||||
|
register(name, fn) { RENDERERS[name] = fn; }
|
||||||
|
};
|
||||||
|
function show(tab) {
|
||||||
|
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('on', t.dataset.tab === tab));
|
||||||
|
document.querySelectorAll('.panel').forEach(p => p.classList.toggle('on', p.id === 'panel-' + tab));
|
||||||
|
const host = $('#host-' + tab);
|
||||||
|
// always re-render to pick up resize/tweak changes
|
||||||
|
RENDERERS[tab](host);
|
||||||
|
rendered[tab] = true;
|
||||||
|
localStorage.setItem('mt-dash-tab', tab);
|
||||||
|
}
|
||||||
|
document.querySelectorAll('.tab').forEach(t => t.addEventListener('click', () => show(t.dataset.tab)));
|
||||||
|
|
||||||
|
let rt; addEventListener('resize', () => { clearTimeout(rt); rt = setTimeout(() => { const cur = document.querySelector('.tab.on'); if (cur && TOT) RENDERERS[cur.dataset.tab]($('#host-' + cur.dataset.tab)); }, 160); });
|
||||||
|
|
||||||
|
// expose for tweaks re-render (no-op until data is loaded)
|
||||||
|
window.MTapply = () => { if (!TOT) return; const cur = document.querySelector('.tab.on'); if (cur) RENDERERS[cur.dataset.tab]($('#host-' + cur.dataset.tab)); };
|
||||||
|
|
||||||
|
// ---------- boot ----------
|
||||||
|
window.MT.load().then(() => {
|
||||||
|
computeTotals();
|
||||||
|
$('.brand .sub').textContent = `${window.MT.tx.length} txns · ${window.MT.banks.length} banks · ${window.MT.months.length} months`;
|
||||||
|
const start = localStorage.getItem('mt-dash-tab') || 'overview';
|
||||||
|
show(RENDERERS[start] ? start : 'overview');
|
||||||
|
$('#loading').style.display = 'none';
|
||||||
|
$('#app').style.display = 'flex';
|
||||||
|
}).catch(err => { $('#loading').textContent = 'failed to load ledger.json — ' + err.message; });
|
||||||
|
})();
|
||||||
|
|
@ -0,0 +1,435 @@
|
||||||
|
/* money-trace · dashboard part 2 — extended perspective tabs
|
||||||
|
Balances · People · Cards & credit · Rhythm · Ledger explorer · Data quality
|
||||||
|
Registers into the controller via window.MTdash.register(). */
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
const D = window.MTdash, C = window.Charts, MT = window.MT;
|
||||||
|
const { TX, sum, groupSum, sortEntries, col, cssv, esc, fmtBig, kpi } = D;
|
||||||
|
const { isExpense, isFee, isIncome, isInterIn, isInterOut, isInternal } = D;
|
||||||
|
const $ = s => document.querySelector(s);
|
||||||
|
const CLPk = MT.CLPk, CLP = MT.CLP;
|
||||||
|
const ms = ymd => +new Date(ymd + 'T00:00:00');
|
||||||
|
|
||||||
|
// palette for many-series
|
||||||
|
const SERIES_COLORS = ['#2ee6a6', '#6fd0ff', '#b98cff', '#ffc24b', '#ff8f6b', '#5fd0a0', '#8c9eff', '#ff5f73', '#d0d6e0', '#46c6ba'];
|
||||||
|
|
||||||
|
// ============================================================ BALANCES & RUNWAY
|
||||||
|
function renderBalances(host) {
|
||||||
|
const tx = TX();
|
||||||
|
// group transactions that carry a balance, by account
|
||||||
|
const acctMap = {};
|
||||||
|
for (const t of tx) {
|
||||||
|
if (t.balance == null || t.doc_type === 'tarjeta_credito') continue;
|
||||||
|
const key = t.bank + '·' + (t.last4 || t.doc_type) + '·' + t.doc_type;
|
||||||
|
(acctMap[key] = acctMap[key] || { bank: t.bank, last4: t.last4, doc_type: t.doc_type, pts: [] });
|
||||||
|
acctMap[key].pts.push({ t: ms(t.date), v: t.balance, tx: t });
|
||||||
|
}
|
||||||
|
// keep accounts with >=4 points, sort by point count
|
||||||
|
let accts = Object.entries(acctMap).map(([k, a]) => ({ key: k, ...a }))
|
||||||
|
.filter(a => a.pts.length >= 4)
|
||||||
|
.sort((a, b) => b.pts.length - a.pts.length);
|
||||||
|
accts.forEach((a, i) => { a.color = SERIES_COLORS[i % SERIES_COLORS.length]; a.pts.sort((p, q) => p.t - q.t); a.last = a.pts[a.pts.length - 1].v; a.min = Math.min(...a.pts.map(p => p.v)); });
|
||||||
|
|
||||||
|
// default selection: top 4
|
||||||
|
if (!host._sel) host._sel = new Set(accts.slice(0, 4).map(a => a.key));
|
||||||
|
const sel = host._sel;
|
||||||
|
const lowest = accts.slice().sort((a, b) => a.min - b.min)[0];
|
||||||
|
const liquid = accts.filter(a => a.doc_type !== 'linea_credito').reduce((s, a) => s + a.last, 0);
|
||||||
|
|
||||||
|
host.innerHTML = `
|
||||||
|
<div class="grid g-3" style="margin-bottom:16px;">
|
||||||
|
${kpi('Accounts with balances', accts.length, 'of 17 reconstructed accounts', col.income())}
|
||||||
|
${kpi('Latest liquid total', fmtBig(liquid), 'sum of last-known cash balances', col.income())}
|
||||||
|
${kpi('Lowest point', fmtBig(lowest ? lowest.min : 0), lowest ? `${esc(lowest.bank)} ··${lowest.last4 || ''}` : '', lowest && lowest.min < 0 ? col.spend() : col.fee())}
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>Balance over time<span class="pill">running balance, where statements report it</span></h3>
|
||||||
|
<div class="acct-chips" id="bal-chips"></div>
|
||||||
|
<div class="chart-host" id="bal-chart"></div>
|
||||||
|
</div>
|
||||||
|
<div class="insight" style="margin-top:16px;"><div class="i-ic">i</div><div>Credit cards don't report a running balance in these statements, so they're excluded here. Coverage varies by account — lines connect only the points each statement actually logged.</div></div>
|
||||||
|
`;
|
||||||
|
const chips = $('#bal-chips');
|
||||||
|
accts.forEach(a => {
|
||||||
|
const c = document.createElement('div'); c.className = 'acct-chip' + (sel.has(a.key) ? ' on' : '');
|
||||||
|
c.innerHTML = `<i style="background:${a.color}"></i>${esc(a.bank)}<span class="ac-end">${MT.acctShort(a.doc_type)}${a.last4 ? ' ··' + a.last4 : ''}</span>`;
|
||||||
|
c.addEventListener('click', () => { sel.has(a.key) ? sel.delete(a.key) : sel.add(a.key); renderBalances(host); });
|
||||||
|
chips.appendChild(c);
|
||||||
|
});
|
||||||
|
const series = accts.filter(a => sel.has(a.key)).map(a => ({
|
||||||
|
label: a.bank + ' ' + MT.acctShort(a.doc_type) + (a.last4 ? ' ··' + a.last4 : ''),
|
||||||
|
color: a.color, points: a.pts
|
||||||
|
}));
|
||||||
|
C.multiLine($('#bal-chart'), series, { height: 320 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================ PEOPLE
|
||||||
|
function renderPeople(host) {
|
||||||
|
const tx = TX();
|
||||||
|
// build per-person ledger from income (in), inter_person (in/out)
|
||||||
|
const ppl = {};
|
||||||
|
function add(name, t, dir) {
|
||||||
|
const k = name;
|
||||||
|
(ppl[k] = ppl[k] || { name, in: 0, out: 0, inTx: [], outTx: [], dates: [] });
|
||||||
|
if (dir === 'in') { ppl[k].in += t.amount; ppl[k].inTx.push(t); }
|
||||||
|
else { ppl[k].out += t.amount; ppl[k].outTx.push(t); }
|
||||||
|
ppl[k].dates.push(t.date);
|
||||||
|
}
|
||||||
|
for (const t of tx) {
|
||||||
|
if (isIncome(t)) add(MT.normIncome(t.counterparty), t, 'in');
|
||||||
|
else if (isInterIn(t)) add(MT.normPerson(t.counterparty), t, 'in');
|
||||||
|
else if (isInterOut(t)) add(MT.normPerson(t.counterparty), t, 'out');
|
||||||
|
}
|
||||||
|
let people = Object.values(ppl).map(p => {
|
||||||
|
p.net = p.in - p.out; p.gross = p.in + p.out; p.n = p.inTx.length + p.outTx.length;
|
||||||
|
p.dates.sort(); p.first = p.dates[0]; p.last = p.dates[p.dates.length - 1];
|
||||||
|
return p;
|
||||||
|
}).filter(p => p.name && p.name !== '—').sort((a, b) => b.gross - a.gross);
|
||||||
|
|
||||||
|
const payers = people.filter(p => p.net > 0).slice(0, 1);
|
||||||
|
const namedIncome = people.reduce((s, p) => s + p.in, 0);
|
||||||
|
|
||||||
|
host.innerHTML = `
|
||||||
|
<div class="grid g-3" style="margin-bottom:16px;">
|
||||||
|
${kpi('People & payers', people.length, 'distinct counterparties with a name', col.person())}
|
||||||
|
${kpi('Biggest net source', payers.length ? esc(payers[0].name) : '—', payers.length ? '+' + CLPk(payers[0].net) + ' net to you' : '', col.income())}
|
||||||
|
${kpi('Named-source income', fmtBig(namedIncome), 'total in from identified people', col.income())}
|
||||||
|
</div>
|
||||||
|
<div class="card"><h3>Your money relationships<span class="pill">money in · money out · net</span></h3>
|
||||||
|
<table class="btable"><thead><tr>
|
||||||
|
<th>Person / payer</th><th>Txns</th><th>Money in</th><th>Money out</th><th>Net</th><th style="width:170px;">In vs out</th><th>Last seen</th>
|
||||||
|
</tr></thead><tbody id="ppl-rows"></tbody></table>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
const tb = $('#ppl-rows');
|
||||||
|
const maxGross = Math.max(...people.map(p => p.gross), 1);
|
||||||
|
people.forEach(p => {
|
||||||
|
const inPct = p.gross ? p.in / p.gross * 100 : 0;
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td><div class="bk-name"><span class="bk-dot" style="background:${p.net >= 0 ? col.income() : col.spend()}"></span>${esc(p.name)}</div></td>
|
||||||
|
<td>${p.n}</td>
|
||||||
|
<td style="color:${col.income()}">${p.in ? CLPk(p.in) : '<span class="muted">—</span>'}</td>
|
||||||
|
<td style="color:${col.spend()}">${p.out ? CLPk(p.out) : '<span class="muted">—</span>'}</td>
|
||||||
|
<td class="${p.net >= 0 ? 'net-pos' : 'net-neg'}">${p.net >= 0 ? '+' : '−'}${CLPk(Math.abs(p.net))}</td>
|
||||||
|
<td><span class="minibar" style="width:${10 + p.gross / maxGross * 140}px"><i style="width:${inPct}%;background:${col.income()}"></i><i style="width:${100 - inPct}%;background:${col.spend()}"></i></span></td>
|
||||||
|
<td class="muted" style="font-size:11px;">${p.last ? p.last.slice(0, 7) : ''}</td>`;
|
||||||
|
tr.addEventListener('mousemove', e => window.MTtip.rows(e, p.name, p.net >= 0 ? col.income() : col.spend(), [...p.inTx, ...p.outTx]));
|
||||||
|
tr.addEventListener('mouseleave', () => window.MTtip.hide());
|
||||||
|
tb.appendChild(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================ CARDS & CREDIT
|
||||||
|
function renderCards(host) {
|
||||||
|
const tx = TX();
|
||||||
|
const fees = tx.filter(isFee);
|
||||||
|
const feeTotal = sum(fees);
|
||||||
|
// classify fee descriptions
|
||||||
|
const feeCat = d => {
|
||||||
|
const u = (d || '').toUpperCase();
|
||||||
|
if (/INTERES|MORA|ROTATIV/.test(u)) return 'Interest';
|
||||||
|
if (/IMPUESTO|DL 3475|DECRETO/.test(u)) return 'Taxes & stamp';
|
||||||
|
if (/COBRANZA/.test(u)) return 'Collection charges';
|
||||||
|
if (/COMIS|MANTENCION|MANTENC|ADMIN|SERVICIO|CARGO/.test(u)) return 'Commissions & maintenance';
|
||||||
|
return 'Other charges';
|
||||||
|
};
|
||||||
|
const byFee = groupSum(fees, t => feeCat(t.description));
|
||||||
|
const feeArr = sortEntries(byFee);
|
||||||
|
const FEE_C = { 'Interest': '#ff5f73', 'Taxes & stamp': '#ffc24b', 'Collection charges': '#ff8f6b', 'Commissions & maintenance': '#b98cff', 'Other charges': '#7a8699' };
|
||||||
|
|
||||||
|
// credit-line activity: draws (debit on cuenta = borrowing) vs repayments
|
||||||
|
const lineTx = tx.filter(t => t.flow_type === 'credit_line');
|
||||||
|
const cardPay = tx.filter(t => t.flow_type === 'card_payment');
|
||||||
|
const cardPayTotal = sum(cardPay);
|
||||||
|
const months = MT.months;
|
||||||
|
const feeByM = {}; months.forEach(m => feeByM[m] = sum(fees.filter(t => t.ym === m)));
|
||||||
|
|
||||||
|
// which cards/lines exist
|
||||||
|
const cardAccts = {};
|
||||||
|
for (const t of tx) {
|
||||||
|
if (!MT.isCardType(t.doc_type)) continue;
|
||||||
|
const k = t.bank + ' · ' + MT.acctShort(t.doc_type);
|
||||||
|
(cardAccts[k] = cardAccts[k] || { bank: t.bank, type: t.doc_type, fees: 0, spend: 0, n: 0 });
|
||||||
|
cardAccts[k].n++;
|
||||||
|
if (isFee(t)) cardAccts[k].fees += t.amount;
|
||||||
|
if (isExpense(t)) cardAccts[k].spend += t.amount;
|
||||||
|
}
|
||||||
|
const cardList = Object.entries(cardAccts).sort((a, b) => (b[1].fees + b[1].spend) - (a[1].fees + a[1].spend));
|
||||||
|
|
||||||
|
host.innerHTML = `
|
||||||
|
<div class="grid g-3" style="margin-bottom:16px;">
|
||||||
|
${kpi('Paid in fees & interest', fmtBig(feeTotal), `${fees.length} charges across all cards & lines`, col.fee())}
|
||||||
|
${kpi('Card payments made', fmtBig(cardPayTotal), `${cardPay.length} payments to your own cards`, col.internal())}
|
||||||
|
${kpi('Interest alone', fmtBig(byFee['Interest'] ? byFee['Interest'].value : 0), Math.round((byFee['Interest'] ? byFee['Interest'].value : 0) / feeTotal * 100) + '% of all charges', col.spend())}
|
||||||
|
</div>
|
||||||
|
<div class="grid g-2-1">
|
||||||
|
<div class="card"><h3>What the bank charged you<span class="pill">fees & interest by type</span></h3><div id="cd-fees"></div></div>
|
||||||
|
<div class="card"><h3>Charges by month<span class="pill">CLP / month</span></h3><div class="chart-host" id="cd-month"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="margin-top:16px;"><h3>Per card & line<span class="pill">spend + fees</span></h3>
|
||||||
|
<table class="btable"><thead><tr><th>Card / line</th><th>Txns</th><th>Spend on it</th><th>Fees charged</th><th>Fee rate</th></tr></thead><tbody id="cd-rows"></tbody></table>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
C.hbars($('#cd-fees'), feeArr.map(([label, d]) => ({
|
||||||
|
label, value: d.value, color: FEE_C[label] || col.mute(), sub: d.txns.length + ' charges · ' + Math.round(d.value / feeTotal * 100) + '%',
|
||||||
|
_txns: d.txns, _c: FEE_C[label] || col.mute()
|
||||||
|
})), { max: feeArr[0][1].value, onHover: (e, d) => window.MTtip.rows(e, d.label, d._c, d._txns) });
|
||||||
|
C.monthlyBars($('#cd-month'), months, [{ key: 'f', label: 'Fees & interest', color: col.fee(), values: feeByM }], { height: 200 });
|
||||||
|
const tb = $('#cd-rows');
|
||||||
|
cardList.forEach(([name, c]) => {
|
||||||
|
const rate = c.spend ? c.fees / c.spend * 100 : null;
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td><div class="bk-name"><span class="bk-dot" style="background:${col.fee()}"></span>${esc(name)}</div></td>
|
||||||
|
<td>${c.n}</td>
|
||||||
|
<td>${c.spend ? CLPk(c.spend) : '<span class="muted">—</span>'}</td>
|
||||||
|
<td style="color:${col.fee()}">${c.fees ? CLPk(c.fees) : '<span class="muted">—</span>'}</td>
|
||||||
|
<td class="muted">${rate != null ? Math.round(rate) + '%' : '—'}</td>`;
|
||||||
|
tb.appendChild(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================ RHYTHM / CALENDAR
|
||||||
|
function renderRhythm(host) {
|
||||||
|
const tx = TX();
|
||||||
|
const spend = tx.filter(t => isExpense(t) || isFee(t));
|
||||||
|
const WD = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
|
// weekday × (none) totals
|
||||||
|
const wdAmt = [0, 0, 0, 0, 0, 0, 0], wdTx = [[], [], [], [], [], [], []];
|
||||||
|
for (const t of spend) { const w = new Date(t.date + 'T00:00:00').getDay(); wdAmt[w] += t.amount; wdTx[w].push(t); }
|
||||||
|
// month × day-of-month heatmap for spending
|
||||||
|
const months = MT.months;
|
||||||
|
const grid = {}; months.forEach(m => grid[m] = {});
|
||||||
|
for (const t of spend) { const day = +t.date.slice(8, 10); (grid[t.ym][day] = grid[t.ym][day] || { v: 0, raw: [] }); grid[t.ym][day].v += t.amount; grid[t.ym][day].raw.push(t); }
|
||||||
|
// busiest weekday
|
||||||
|
const peakWd = wdAmt.indexOf(Math.max(...wdAmt));
|
||||||
|
const totalSpend = sum(spend);
|
||||||
|
const weekendShare = Math.round((wdAmt[0] + wdAmt[6]) / totalSpend * 100);
|
||||||
|
const WDLONG = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||||
|
|
||||||
|
host.innerHTML = `
|
||||||
|
<div class="grid g-3" style="margin-bottom:16px;">
|
||||||
|
${kpi('Busiest spending day', WDLONG[peakWd], CLPk(wdAmt[peakWd]) + ' total spent', col.spend())}
|
||||||
|
${kpi('Weekend share', weekendShare + '%', 'of spending lands Sat–Sun', col.fee())}
|
||||||
|
${kpi('Active days', Object.values(grid).reduce((s, m) => s + Object.keys(m).length, 0), 'days with at least one purchase', col.income())}
|
||||||
|
</div>
|
||||||
|
<div class="card"><h3>Spending by day of month<span class="pill">darker = more spent</span></h3><div class="chart-host" id="rh-heat" style="overflow-x:auto;"></div>
|
||||||
|
<div class="hm-scale"><span>less</span><div class="sw"><i style="background:color-mix(in oklab, var(--c-spend) 12%, var(--bg))"></i><i style="background:color-mix(in oklab, var(--c-spend) 40%, var(--bg))"></i><i style="background:color-mix(in oklab, var(--c-spend) 70%, var(--bg))"></i><i style="background:var(--c-spend)"></i></div><span>more</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="margin-top:16px;"><h3>Which weekday do you spend?<span class="pill">total by day of week</span></h3><div id="rh-wd"></div></div>
|
||||||
|
`;
|
||||||
|
// heatmap: rows = months, cols = days 1..31
|
||||||
|
const days = Array.from({ length: 31 }, (_, i) => i + 1);
|
||||||
|
const rows = months.map(m => ({
|
||||||
|
label: MT.MONTH_LABEL(m),
|
||||||
|
cells: days.map(d => {
|
||||||
|
const cell = grid[m][d];
|
||||||
|
return { v: cell ? cell.v : 0, raw: cell ? cell.raw : [], title: cell ? MT.MONTH_LABEL(m) + ' ' + d + ' · ' + CLP(cell.v) : '' };
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
const heatHost = $('#rh-heat');
|
||||||
|
heatHost.style.setProperty('--cols', 31);
|
||||||
|
heatHost.querySelectorAll('.hm-row').forEach(r => r.style.setProperty('--cols', 31));
|
||||||
|
C.heatmap(heatHost, rows, days.map(d => d % 2 ? d : ''), { color: col.spend() });
|
||||||
|
heatHost.querySelectorAll('.hm-row').forEach(r => r.style.gridTemplateColumns = '64px repeat(31, 1fr)');
|
||||||
|
// weekday bars
|
||||||
|
C.hbars($('#rh-wd'), WDLONG.map((w, i) => ({
|
||||||
|
label: w, value: wdAmt[i], color: (i === 0 || i === 6) ? col.fee() : col.spend(),
|
||||||
|
_txns: wdTx[i], _c: (i === 0 || i === 6) ? col.fee() : col.spend()
|
||||||
|
})), { max: Math.max(...wdAmt), onHover: (e, d) => window.MTtip.rows(e, d.label, d._c, d._txns) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================ LEDGER EXPLORER
|
||||||
|
function renderLedger(host) {
|
||||||
|
const tx = TX().slice();
|
||||||
|
if (!host._state) host._state = { q: '', flow: 'all', bank: 'all', sort: 'date', dir: -1, limit: 60 };
|
||||||
|
const st = host._state;
|
||||||
|
const FLOW_C = { income: col.income(), inter_person: col.person(), expense: col.spend(), fee: col.fee(), self_transfer: col.internal(), credit_line: col.internal(), card_payment: col.internal() };
|
||||||
|
const FLOW_LABEL = { income: 'Income', inter_person: 'Person', expense: 'Spending', fee: 'Fee', self_transfer: 'Self-transfer', credit_line: 'Credit line', card_payment: 'Card payment' };
|
||||||
|
|
||||||
|
host.innerHTML = `
|
||||||
|
<div class="lx-controls">
|
||||||
|
<div class="lx-search"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4-4"/></svg>
|
||||||
|
<input id="lx-q" placeholder="Search description, counterparty, bank…" value="${esc(st.q)}" /></div>
|
||||||
|
<select class="lx-select" id="lx-flow"></select>
|
||||||
|
<select class="lx-select" id="lx-bank"></select>
|
||||||
|
<span class="lx-count" id="lx-count"></span>
|
||||||
|
</div>
|
||||||
|
<div class="lx-table-wrap"><table class="lx-table"><thead><tr>
|
||||||
|
<th data-s="date">Date</th><th data-s="desc">Description</th><th data-s="bank">Account</th>
|
||||||
|
<th data-s="flow">Type</th><th class="r" data-s="amount">Amount</th>
|
||||||
|
</tr></thead><tbody id="lx-rows"></tbody></table><div id="lx-more"></div></div>
|
||||||
|
`;
|
||||||
|
// flow select
|
||||||
|
const flowOpts = ['all', 'income', 'inter_person', 'expense', 'fee', 'self_transfer', 'credit_line', 'card_payment'];
|
||||||
|
$('#lx-flow').innerHTML = flowOpts.map(f => `<option value="${f}"${st.flow === f ? ' selected' : ''}>${f === 'all' ? 'All types' : FLOW_LABEL[f]}</option>`).join('');
|
||||||
|
$('#lx-bank').innerHTML = `<option value="all">All banks</option>` + MT.banks.map(b => `<option value="${esc(b)}"${st.bank === b ? ' selected' : ''}>${esc(b)}</option>`).join('');
|
||||||
|
|
||||||
|
function apply() {
|
||||||
|
let rows = tx;
|
||||||
|
const q = st.q.trim().toLowerCase();
|
||||||
|
if (q) rows = rows.filter(t => (t.description || '').toLowerCase().includes(q) || (t.counterparty || '').toLowerCase().includes(q) || t.bank.toLowerCase().includes(q));
|
||||||
|
if (st.flow !== 'all') rows = rows.filter(t => t.flow_type === st.flow);
|
||||||
|
if (st.bank !== 'all') rows = rows.filter(t => t.bank === st.bank);
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
let av, bv;
|
||||||
|
if (st.sort === 'date') { av = a.date; bv = b.date; }
|
||||||
|
else if (st.sort === 'amount') { av = a.amount; bv = b.amount; }
|
||||||
|
else if (st.sort === 'bank') { av = a.bank; bv = b.bank; }
|
||||||
|
else if (st.sort === 'flow') { av = a.flow_type; bv = b.flow_type; }
|
||||||
|
else { av = a.description || ''; bv = b.description || ''; }
|
||||||
|
return (av < bv ? -1 : av > bv ? 1 : 0) * st.dir;
|
||||||
|
});
|
||||||
|
$('#lx-count').textContent = rows.length + ' of ' + tx.length + ' transactions';
|
||||||
|
const shown = rows.slice(0, st.limit);
|
||||||
|
$('#lx-rows').innerHTML = shown.map(t => `
|
||||||
|
<tr><td class="lx-date">${t.date}</td>
|
||||||
|
<td class="lx-desc" title="${esc(t.description)}">${esc(MT.cleanDesc(t.description) || t.counterparty || '—')}</td>
|
||||||
|
<td style="white-space:nowrap;color:var(--ink-mute)">${esc(t.bank)} <span class="lx-pdf">${MT.acctShort(t.doc_type)}${t.last4 ? ' ··' + t.last4 : ''}</span></td>
|
||||||
|
<td><span class="lx-flow-tag"><i style="background:${FLOW_C[t.flow_type] || col.mute()}"></i>${FLOW_LABEL[t.flow_type] || t.flow_type}</span></td>
|
||||||
|
<td class="r lx-amt ${t.direction === 'credit' ? 'cr' : 'db'}">${t.direction === 'credit' ? '+' : '−'}${CLP(t.amount)}</td></tr>`).join('');
|
||||||
|
$('#lx-more').innerHTML = rows.length > st.limit ? `<div class="lx-more">Show ${Math.min(60, rows.length - st.limit)} more (${rows.length - st.limit} hidden)</div>` : '';
|
||||||
|
const more = $('#lx-more').querySelector('.lx-more');
|
||||||
|
if (more) more.addEventListener('click', () => { st.limit += 60; apply(); });
|
||||||
|
}
|
||||||
|
$('#lx-q').addEventListener('input', e => { st.q = e.target.value; st.limit = 60; apply(); });
|
||||||
|
$('#lx-flow').addEventListener('change', e => { st.flow = e.target.value; st.limit = 60; apply(); });
|
||||||
|
$('#lx-bank').addEventListener('change', e => { st.bank = e.target.value; st.limit = 60; apply(); });
|
||||||
|
host.querySelectorAll('.lx-table th[data-s]').forEach(th => th.addEventListener('click', () => {
|
||||||
|
const s = th.dataset.s; if (st.sort === s) st.dir *= -1; else { st.sort = s; st.dir = s === 'date' || s === 'amount' ? -1 : 1; } apply();
|
||||||
|
}));
|
||||||
|
apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================ DATA QUALITY
|
||||||
|
function renderQuality(host) {
|
||||||
|
const tx = TX();
|
||||||
|
const raw = MT.raw;
|
||||||
|
const months = MT.months;
|
||||||
|
// statement coverage: account -> set of months covered (from period_start..period_end)
|
||||||
|
const acctCov = {};
|
||||||
|
for (const s of raw.statements) {
|
||||||
|
const key = MT.bankName(s.bank) + ' · ' + MT.acctShort(s.doc_type) + (s.account_last4 ? ' ··' + s.account_last4 : '');
|
||||||
|
(acctCov[key] = acctCov[key] || { months: new Set(), docs: 0, bank: MT.bankName(s.bank) });
|
||||||
|
acctCov[key].docs++;
|
||||||
|
if (s.period_start && s.period_end) {
|
||||||
|
let c = new Date(s.period_start.slice(0, 7) + '-01T00:00:00');
|
||||||
|
const end = new Date(s.period_end.slice(0, 7) + '-01T00:00:00');
|
||||||
|
while (c <= end) { acctCov[key].months.add(c.toISOString().slice(0, 7)); c.setMonth(c.getMonth() + 1); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const accts = Object.entries(acctCov).sort((a, b) => b[1].docs - a[1].docs);
|
||||||
|
|
||||||
|
// metrics
|
||||||
|
const withBal = tx.filter(t => t.balance != null).length;
|
||||||
|
const withPeriod = raw.statements.filter(s => s.period_start && s.period_end).length;
|
||||||
|
const classified = tx.filter(t => t.flow_type !== 'expense' || MT.spendCategory(t.description) !== 'Other purchases').length;
|
||||||
|
const otherSpend = tx.filter(t => t.flow_type === 'expense' && MT.spendCategory(t.description) === 'Other purchases').length;
|
||||||
|
const namedCp = tx.filter(t => t.counterparty).length;
|
||||||
|
const pct = (a, b) => Math.round(a / b * 100);
|
||||||
|
|
||||||
|
function metric(v, total, label, c) {
|
||||||
|
return `<div class="qstat"><div class="qv">${pct(v, total)}%</div><div class="ql">${label}</div><div class="qbar"><i style="width:${pct(v, total)}%;background:${c}"></i><i style="width:${100 - pct(v, total)}%;background:var(--line)"></i></div></div>`;
|
||||||
|
}
|
||||||
|
host.innerHTML = `
|
||||||
|
<div class="qstats">
|
||||||
|
${metric(withPeriod, raw.statements.length, 'Statements with a clear period', col.income())}
|
||||||
|
${metric(withBal, tx.length, 'Transactions with a running balance', col.fee())}
|
||||||
|
${metric(namedCp, tx.length, 'Transactions with a named counterparty', col.person())}
|
||||||
|
${metric(tx.length - otherSpend, tx.length, 'Transactions confidently categorized', col.income())}
|
||||||
|
</div>
|
||||||
|
<div class="card"><h3>Statement coverage<span class="pill">${accts.length} accounts × ${months.length} months</span></h3>
|
||||||
|
<div class="cov-grid" id="cov-grid"></div>
|
||||||
|
<div class="hm-scale"><span style="color:var(--ink-dim)">Each filled cell = a statement covers that month. Gaps = months with no statement on file.</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="grid g-2" style="margin-top:16px;">
|
||||||
|
<div class="insight"><div class="i-ic">!</div><div><b>${otherSpend}</b> purchases (${pct(otherSpend, tx.length)}%) couldn't be matched to a spending category and sit in "Other purchases" — the raw statements carry no merchant field, so these are inferred from free-text descriptions.</div></div>
|
||||||
|
<div class="insight"><div class="i-ic">i</div><div>Credit-card statements report transactions but <b>not</b> running balances, so the Balances tab covers cash and credit-line accounts only.</div></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
// coverage grid
|
||||||
|
const grid = $('#cov-grid');
|
||||||
|
grid.style.setProperty('--cols', months.length);
|
||||||
|
const head = document.createElement('div'); head.className = 'cov-row cov-head'; head.style.setProperty('--cols', months.length);
|
||||||
|
head.innerHTML = `<div class="cov-rowlab"></div>` + months.map(m => `<div class="cov-collab">${MT.MONTH_LABEL(m)}</div>`).join('');
|
||||||
|
grid.appendChild(head);
|
||||||
|
accts.forEach(([name, a]) => {
|
||||||
|
const row = document.createElement('div'); row.className = 'cov-row'; row.style.setProperty('--cols', months.length);
|
||||||
|
let html = `<div class="cov-rowlab"><span class="cov-dot" style="background:${col.income()}"></span><div>${esc(name)}<div class="cov-meta">${a.docs} stmts · ${a.months.size} mo</div></div></div>`;
|
||||||
|
months.forEach(m => { html += `<div class="cov-cell${a.months.has(m) ? ' has' : ''}" title="${esc(name)} · ${MT.MONTH_LABEL(m)}${a.months.has(m) ? ' — covered' : ' — no statement'}"></div>`; });
|
||||||
|
row.innerHTML = html;
|
||||||
|
grid.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================ PLATFORMS
|
||||||
|
const RAILS = [
|
||||||
|
['MercadoPago', /MERCADO ?PAGO|MERPAGO|MERPAG|MP —|MP\*/i, '#6fd0ff'],
|
||||||
|
['MACH', /\bMACH\b/i, '#2ee6a6'],
|
||||||
|
['WebPay', /WEBPAY|WEB PAY/i, '#b98cff'],
|
||||||
|
['PayU', /\bPAYU\b/i, '#ffc24b'],
|
||||||
|
['Rappi', /RAPPI/i, '#ff8f6b'],
|
||||||
|
['Uber', /\bUBER\b|UBER ?EATS/i, '#5fd0a0'],
|
||||||
|
['SumUp', /SUMUP/i, '#8c9eff'],
|
||||||
|
['Payscan', /PAYSCAN/i, '#ff5f73'],
|
||||||
|
['Tuu', /\bTUU\b|TUU\*/i, '#d0d6e0'],
|
||||||
|
['Fintoc', /FINTOC/i, '#46c6ba'],
|
||||||
|
['Servipag', /SERVIPAG/i, '#ffd45a'],
|
||||||
|
['Sencillito', /SENCILLITO/i, '#c9a6ff'],
|
||||||
|
['Khipu', /KHIPU/i, '#7CFFB2'],
|
||||||
|
['Kushki', /KUSHKI/i, '#FF8FA3'],
|
||||||
|
['OneClick', /ONECLICK|ONE CLICK/i, '#6fd0ff'],
|
||||||
|
['Compraqui', /COMPRAQUI/i, '#ff8f6b']
|
||||||
|
];
|
||||||
|
function railOf(t) {
|
||||||
|
if (t.platform) { for (const [n, re] of RAILS) if (re.test(t.platform)) return n; }
|
||||||
|
for (const [n, re] of RAILS) if (re.test(t.description || '')) return n;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const railColor = name => (RAILS.find(r => r[0] === name) || [, , col.mute()])[2];
|
||||||
|
|
||||||
|
function renderPlatforms(host) {
|
||||||
|
const tx = TX();
|
||||||
|
const railTx = {};
|
||||||
|
for (const t of tx) { const r = railOf(t); if (!r) continue; (railTx[r] = railTx[r] || []).push(t); }
|
||||||
|
let rails = Object.entries(railTx).map(([name, txns]) => {
|
||||||
|
const out = txns.filter(t => t.direction === 'debit'), inn = txns.filter(t => t.direction === 'credit');
|
||||||
|
return { name, txns, n: txns.length, amt: sum(txns), out: sum(out), in: sum(inn), color: railColor(name) };
|
||||||
|
}).sort((a, b) => b.amt - a.amt);
|
||||||
|
const totalAmt = rails.reduce((s, r) => s + r.amt, 0);
|
||||||
|
const totalN = rails.reduce((s, r) => s + r.n, 0);
|
||||||
|
const byCount = rails.slice().sort((a, b) => b.n - a.n);
|
||||||
|
const outTotal = rails.reduce((s, r) => s + r.out, 0), inTotal = rails.reduce((s, r) => s + r.in, 0);
|
||||||
|
|
||||||
|
host.innerHTML = `
|
||||||
|
<div class="grid g-3" style="margin-bottom:16px;">
|
||||||
|
${kpi('Payment rails seen', rails.length, `across ${totalN} transactions`, col.person())}
|
||||||
|
${kpi('Most used', byCount.length ? esc(byCount[0].name) : '—', byCount.length ? byCount[0].n + ' transactions' : '', byCount.length ? byCount[0].color : col.mute())}
|
||||||
|
${kpi('Routed through platforms', fmtBig(totalAmt), '~' + Math.round(totalN / tx.length * 100) + '% of all transactions', col.income())}
|
||||||
|
</div>
|
||||||
|
<div class="grid g-2-1">
|
||||||
|
<div class="card"><h3>Platforms by value<span class="pill">money moved per rail</span></h3><div id="pf-amt"></div></div>
|
||||||
|
<div class="card"><h3>By transaction count<span class="pill">how often used</span></h3><div id="pf-cnt"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="insight" style="margin-top:16px;"><div class="i-ic">i</div><div>Rails are detected from the <b>platform</b> field and transaction descriptions, so this covers the ~${Math.round(totalN / tx.length * 100)}% of transactions that name a processor. <b>Servipag</b> and <b>MACH</b> move large sums in a few bill-payments, while <b>MercadoPago</b> and <b>PayU</b> appear in many small card purchases.</div></div>
|
||||||
|
`;
|
||||||
|
C.hbars($('#pf-amt'), rails.map(r => ({
|
||||||
|
label: r.name, value: r.amt, color: r.color,
|
||||||
|
sub: r.n + ' txns' + (r.in && r.out ? ' · in+out' : r.in ? ' · money in' : ' · purchases'),
|
||||||
|
_txns: r.txns, _c: r.color
|
||||||
|
})), { max: rails[0] ? rails[0].amt : 1, onHover: (e, d) => window.MTtip.rows(e, d.label, d._c, d._txns) });
|
||||||
|
C.hbars($('#pf-cnt'), byCount.map(r => ({
|
||||||
|
label: r.name, value: r.n, color: r.color, sub: CLPk(r.amt), _txns: r.txns, _c: r.color
|
||||||
|
})), { max: byCount[0] ? byCount[0].n : 1, money: false, onHover: (e, d) => window.MTtip.rows(e, d.label, d._c, d._txns) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// register all
|
||||||
|
D.register('balances', renderBalances);
|
||||||
|
D.register('people', renderPeople);
|
||||||
|
D.register('cards', renderCards);
|
||||||
|
D.register('rhythm', renderRhythm);
|
||||||
|
D.register('ledger', renderLedger);
|
||||||
|
D.register('quality', renderQuality);
|
||||||
|
D.register('platforms', renderPlatforms);
|
||||||
|
})();
|
||||||
|
|
@ -0,0 +1,348 @@
|
||||||
|
/* money-trace · data engine
|
||||||
|
Loads ledger.json, normalizes counterparties, categorizes spend,
|
||||||
|
resolves internal-flow endpoints, and builds a layered node/link graph.
|
||||||
|
Exposes window.MT. Pure vanilla JS — no deps. */
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ---------- formatting ----------
|
||||||
|
const CLP = n => '$' + Math.round(n).toLocaleString('es-CL');
|
||||||
|
const CLPk = n => {
|
||||||
|
const a = Math.abs(n);
|
||||||
|
if (a >= 1e6) return '$' + (n / 1e6).toLocaleString('es-CL', { maximumFractionDigits: 1 }) + 'M';
|
||||||
|
if (a >= 1e3) return '$' + Math.round(n / 1e3).toLocaleString('es-CL') + 'k';
|
||||||
|
return CLP(n);
|
||||||
|
};
|
||||||
|
const MONTH_LABEL = ym => {
|
||||||
|
const [y, m] = ym.split('-');
|
||||||
|
const names = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic'];
|
||||||
|
return names[+m - 1] + " '" + y.slice(2);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- flow categories (visual spine) ----------
|
||||||
|
const CAT = {
|
||||||
|
income: { key: 'income', kind: 'real', label: 'Income' },
|
||||||
|
inter_in: { key: 'inter_in', kind: 'real', label: 'From a person' },
|
||||||
|
inter_out: { key: 'inter_out', kind: 'real', label: 'To a person' },
|
||||||
|
expense: { key: 'expense', kind: 'real', label: 'Spending' },
|
||||||
|
fee: { key: 'fee', kind: 'real', label: 'Fees & interest' },
|
||||||
|
internal_card: { key: 'internal_card', kind: 'internal', label: 'Card payment' },
|
||||||
|
internal_line: { key: 'internal_line', kind: 'internal', label: 'Credit-line sweep' },
|
||||||
|
internal_self: { key: 'internal_self', kind: 'internal', label: 'Between my accounts' }
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- counterparty normalization (income + people) ----------
|
||||||
|
function normIncome(cp) {
|
||||||
|
if (!cp) return 'Other income';
|
||||||
|
const u = cp.toUpperCase();
|
||||||
|
if (u.includes('VINOS LABERINTO') || u.includes('LABERINTO')) return 'Vinos Laberinto';
|
||||||
|
if (u.includes('HEIDI') || u.includes('ANDRESEN MULLER HEIDI')) return 'Heidi Andresen';
|
||||||
|
if (u.includes('MURALLA')) return 'Muralla SPA';
|
||||||
|
if (u.startsWith('SDE ') || u.includes('MERCADO PAGO') || u.includes('MERCADOPAGO')) return 'Deposits & checks';
|
||||||
|
if (u.includes('EMA BLANCA') || u.includes('EMA MULLER')) return 'Ema Muller';
|
||||||
|
if (u.includes('RETAMAL')) return 'Bryan Retamal';
|
||||||
|
if (u.includes('MARCELINO')) return 'Marcelino Fuentes';
|
||||||
|
// title-case fallback, trimmed
|
||||||
|
return titleCase(cp);
|
||||||
|
}
|
||||||
|
function normPerson(cp) {
|
||||||
|
if (!cp) return 'Unknown person';
|
||||||
|
const u = cp.toUpperCase();
|
||||||
|
if (u.includes('VICENTE') && u.includes('TIRAD')) return 'Vicente (self)';
|
||||||
|
if (u.includes('MURALLA')) return 'Muralla SPA';
|
||||||
|
if (u.includes('FINTOC')) return 'Fintoc';
|
||||||
|
if (u.includes('ARISMENDI')) return 'Cristian Arismendi';
|
||||||
|
if (u.includes('BRUNA')) return 'Darwin Bruna';
|
||||||
|
if (u.includes('HEIDI') || u.includes('ANDRESEN MULLER HEIDI')) return 'Heidi Andresen';
|
||||||
|
if (u.includes('BUSTOS')) return 'G. Bustos';
|
||||||
|
if (u === 'RUT' || u.includes('MI CUENTA')) return 'Other transfer';
|
||||||
|
return titleCase(cp);
|
||||||
|
}
|
||||||
|
function titleCase(s) {
|
||||||
|
return String(s).toLowerCase().replace(/\b\w/g, c => c.toUpperCase()).trim().slice(0, 26);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- spend categorization (expense has no counterparty) ----------
|
||||||
|
const SPEND_RULES = [
|
||||||
|
['Debt & loans', /DEUDA|CIERRE DE CUENTA|REPACTAC|PRESTAMO|AVANCE (EN |DE )?EFECTIVO|CUOTA CREDITO/i],
|
||||||
|
['Cash withdrawals', /RETIRO ATM|RETIRO (DE )?EFECTIVO|GIRO (EN )?CAJERO|RETIRO CAJERO|CARGA DE TRANSFERENCIA/i],
|
||||||
|
['Utilities & bills',/SERVIPAG|SENCILLITO|\bENEL\b|COMPRAQUI|AGUAS ANDINA|METROGAS|GASCO|CHILECTRA|ESVAL|PAGO DE CUENTA|CUENTA DE LUZ|VTR|MOVISTAR|ENTEL|CLARO|WOM/i],
|
||||||
|
['Groceries', /SANTA ISABEL|STA ISABEL|JUMBO|\bLIDER\b|HIP LIDER|TOTTUS|UNIMARC|OXXO|MINIMARKET|MAYORISTA|NORTE VERDE|COMERCIAL|ABARROTE|MERCADO\b/i],
|
||||||
|
['Food & dining', /KFC|MC ?DONALD|BURGER|FORK|DELICIA|RESTAU|CABROS|NARESH|TENDERINO|PIZZA|SUSHI|CAFE|DONDE|COMIDA|EMPANAD|MACKENNA|SENLE|FABRICA|CALETA|IRIS|UBER ?EATS|OISHII|RAPPI|PEDIDOSYA/i],
|
||||||
|
['Fuel & transport',/COPEC|SHELL|PETROBRAS|PETROBA|\bUBER\b|CABIFY|DIDI|METRO\b|\bBIP\b|ESTACION|PEAJE|PARKING|AUTOPISTA/i],
|
||||||
|
['Health & pharmacy',/FARMACIA|CRUZ VERDE|SALCOBRAND|AHUMADA|NUTRAFIT|CLINICA|DENTAL|DENTIMAGEN|MEDIC|ULTRA SOLIDAR/i],
|
||||||
|
['Subscriptions & web',/HETZNER|SITEGROUND|GOOGLE|SPOTIFY|NETFLIX|APPLE\.COM|OPENAI|ANTHROPIC|MICROSOFT|\bAWS\b|HOSTING|GODADDY|NAMECHEAP|CLOUD|GITHUB|VERCEL|NOTION|FIGMA|ADOBE/i],
|
||||||
|
['Shopping & retail',/FALABELLA|RIPLEY|PARIS|ALIEXPRESS|AMAZON|FOTOGRAFO|TIENDA|SODIMAC|EASY|SHEIN|LOKAL|ELECTRONICA|BACKSTAGE|RETAIL|\bDP\b/i],
|
||||||
|
['Government & docs',/LICENCIA|IMPUESTO|REGISTRO CIVIL|MUNICIPAL|TESORERIA|NOTARIA|PERMISO/i],
|
||||||
|
['Wallets & online', /MERCADO ?PAGO|MERPAGO|\bMACH\b|MERCADOPAGO|WEBPAY|\bPAYU\b|\bTUU\b|SUMUP|PAYSCAN|COMPRA POR INTERNET|COMPRA NACIONAL|TARJETA (DIGITAL|VIRTUAL)|\bMP\b|\bCV\b|CUOTAS|ONECLICK|KUSHKI|YAPO|TARJETA ·/i]
|
||||||
|
];
|
||||||
|
function spendCategory(desc) {
|
||||||
|
const d = desc || '';
|
||||||
|
for (const [name, re] of SPEND_RULES) if (re.test(d)) return name;
|
||||||
|
return 'Other purchases';
|
||||||
|
}
|
||||||
|
// clean a merchant/desc for tooltip display
|
||||||
|
function cleanDesc(desc) {
|
||||||
|
if (!desc) return '—';
|
||||||
|
return desc.replace(/^(MERPAGO|MERCADOPAGO|PAYSCAN|SUMUP|TUU|MACH)\*?/i, '').trim() || desc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- bank display ----------
|
||||||
|
function bankName(b) {
|
||||||
|
return ({
|
||||||
|
'Tarjeta_Spin': 'Spin', 'Tarjeta Spin': 'Spin',
|
||||||
|
'Banco_de_Chile': 'Banco de Chile', 'Ita': 'Itaú'
|
||||||
|
})[b] || b;
|
||||||
|
}
|
||||||
|
const isCardType = dt => dt === 'tarjeta_credito' || dt === 'linea_credito';
|
||||||
|
function acctShort(dt) {
|
||||||
|
return ({ cuenta_corriente: 'Cta Cte', cuenta_vista: 'Cta Vista', linea_credito: 'Línea de crédito', tarjeta_credito: 'Tarjeta' })[dt] || dt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- load ----------
|
||||||
|
let RAW = null, TX = [], MONTHS = [], BANKS = [], ACCT_LAST4 = new Set();
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const res = await fetch('ledger.json');
|
||||||
|
RAW = await res.json();
|
||||||
|
TX = [];
|
||||||
|
for (const s of RAW.statements) {
|
||||||
|
for (const t of (s.transactions || [])) {
|
||||||
|
if (!t.date) continue;
|
||||||
|
TX.push({
|
||||||
|
date: t.date,
|
||||||
|
ym: t.date.slice(0, 7),
|
||||||
|
amount: t.amount,
|
||||||
|
direction: t.direction,
|
||||||
|
description: t.description || '',
|
||||||
|
counterparty: t.counterparty || null,
|
||||||
|
counterparty_rut: t.counterparty_rut || null,
|
||||||
|
platform: t.platform || null,
|
||||||
|
balance: (t.balance === 0 || t.balance == null) ? null : t.balance,
|
||||||
|
flow_type: t.flow_type,
|
||||||
|
internal: !!t.internal,
|
||||||
|
bank: bankName(s.bank),
|
||||||
|
rawBank: s.bank,
|
||||||
|
doc_type: s.doc_type,
|
||||||
|
last4: s.account_last4 || null,
|
||||||
|
pdf: s.pdf_url || null
|
||||||
|
});
|
||||||
|
if (s.account_last4) ACCT_LAST4.add(s.account_last4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MONTHS = [...new Set(TX.map(t => t.ym))].sort();
|
||||||
|
BANKS = [...new Set(TX.map(t => t.bank))].sort();
|
||||||
|
return { months: MONTHS, banks: BANKS, totals: RAW.real_totals };
|
||||||
|
}
|
||||||
|
|
||||||
|
// node id helpers
|
||||||
|
const acctId = t => 'acc:' + t.bank + ':' + (t.last4 || t.doc_type);
|
||||||
|
const cardId = t => 'card:' + t.bank + ':' + (t.last4 || t.doc_type);
|
||||||
|
function acctLabel(t) { return { id: acctId(t), col: 1, kind: 'account', bank: t.bank, label: t.bank, sub: acctShort(t.doc_type) + (t.last4 ? ' ··' + t.last4 : '') }; }
|
||||||
|
function cardLabel(t) { return { id: cardId(t), col: 2, kind: 'card', bank: t.bank, label: t.bank, sub: acctShort(t.doc_type) + (t.last4 ? ' ··' + t.last4 : '') }; }
|
||||||
|
|
||||||
|
// find a same-bank node from the live node map, preferring a real (non-synthetic) one
|
||||||
|
function pickSameBank(nodes, bank, col) {
|
||||||
|
let synth = null;
|
||||||
|
for (const n of nodes.values()) {
|
||||||
|
if (n.bank === bank && n.col === col) {
|
||||||
|
if (!/:(pago|linea|cta)$/.test(n.id)) return n;
|
||||||
|
synth = synth || n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return synth;
|
||||||
|
}
|
||||||
|
// parse trailing account digits, match to my known last4 set
|
||||||
|
function matchOwnAccount(desc, exclude) {
|
||||||
|
const digits = (desc.match(/\d{3,}/g) || []);
|
||||||
|
for (const d of digits) {
|
||||||
|
for (const l4 of ACCT_LAST4) {
|
||||||
|
if (l4 && l4 !== exclude && d.endsWith(l4)) return l4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- build graph for a filter set ----------
|
||||||
|
function build(opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
const months = opts.months ? new Set(opts.months) : null; // null = all
|
||||||
|
const banks = opts.banks ? new Set(opts.banks) : null;
|
||||||
|
const hideInternal = !!opts.hideInternal;
|
||||||
|
|
||||||
|
let nodes = new Map(); // id -> node
|
||||||
|
let links = new Map(); // key -> link
|
||||||
|
const ensure = (desc) => { if (!nodes.has(desc.id)) nodes.set(desc.id, Object.assign({ value: 0, inLinks: [], outLinks: [] }, desc)); return nodes.get(desc.id); };
|
||||||
|
function addLink(srcId, tgtId, amount, cat, tx) {
|
||||||
|
const key = srcId + '→' + tgtId + '#' + cat;
|
||||||
|
let L = links.get(key);
|
||||||
|
if (!L) { L = { source: srcId, target: tgtId, value: 0, cat, kind: CAT[cat].kind, txns: [] }; links.set(key, L); }
|
||||||
|
L.value += amount; L.txns.push(tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
for (const t of TX) {
|
||||||
|
if (months && !months.has(t.ym)) continue;
|
||||||
|
if (banks && !banks.has(t.bank)) continue;
|
||||||
|
const ft = t.flow_type;
|
||||||
|
const internal = ['self_transfer', 'credit_line', 'card_payment'].includes(ft);
|
||||||
|
if (hideInternal && internal) continue;
|
||||||
|
|
||||||
|
const isCard = isCardType(t.doc_type);
|
||||||
|
const selfNodeDesc = isCard ? cardLabel(t) : acctLabel(t);
|
||||||
|
|
||||||
|
if (ft === 'income') {
|
||||||
|
const src = ensure({ id: 'inc:' + normIncome(t.counterparty), col: 0, kind: 'source', label: normIncome(t.counterparty), sub: 'income' });
|
||||||
|
const acc = ensure(selfNodeDesc);
|
||||||
|
addLink(src.id, acc.id, t.amount, 'income', t);
|
||||||
|
} else if (ft === 'inter_person') {
|
||||||
|
const person = { id: 'per:' + normPerson(t.counterparty), kind: 'person' };
|
||||||
|
if (t.direction === 'credit') {
|
||||||
|
const src = ensure({ id: person.id, col: 0, kind: 'person', label: normPerson(t.counterparty), sub: 'transfer in' });
|
||||||
|
const acc = ensure(selfNodeDesc);
|
||||||
|
addLink(src.id, acc.id, t.amount, 'inter_in', t);
|
||||||
|
} else {
|
||||||
|
const acc = ensure(selfNodeDesc);
|
||||||
|
const tgt = ensure({ id: person.id, col: 3, kind: 'person', label: normPerson(t.counterparty), sub: 'transfer out' });
|
||||||
|
addLink(acc.id, tgt.id, t.amount, 'inter_out', t);
|
||||||
|
}
|
||||||
|
} else if (ft === 'expense') {
|
||||||
|
const acc = ensure(selfNodeDesc);
|
||||||
|
const cat = spendCategory(t.description);
|
||||||
|
const tgt = ensure({ id: 'spend:' + cat, col: 3, kind: 'spend', label: cat, sub: 'spending' });
|
||||||
|
addLink(acc.id, tgt.id, t.amount, 'expense', t);
|
||||||
|
} else if (ft === 'fee') {
|
||||||
|
const acc = ensure(selfNodeDesc);
|
||||||
|
const tgt = ensure({ id: 'spend:Fees & interest', col: 3, kind: 'fee', label: 'Fees & interest', sub: 'bank fees' });
|
||||||
|
addLink(acc.id, tgt.id, t.amount, 'fee', t);
|
||||||
|
} else if (ft === 'card_payment') {
|
||||||
|
// resolve account (col1) -> card (col2), within bank where possible
|
||||||
|
let accDesc, cardDesc;
|
||||||
|
if (isCard) {
|
||||||
|
cardDesc = cardLabel(t);
|
||||||
|
const peer = pickSameBank(nodes, t.bank, 1);
|
||||||
|
accDesc = peer || { id: 'acc:' + t.bank + ':pago', col: 1, kind: 'account', bank: t.bank, label: t.bank, sub: 'account' };
|
||||||
|
} else {
|
||||||
|
accDesc = acctLabel(t);
|
||||||
|
const peer = pickSameBank(nodes, t.bank, 2);
|
||||||
|
cardDesc = peer || { id: 'card:' + t.bank + ':pago', col: 2, kind: 'card', bank: t.bank, label: t.bank, sub: 'Tarjeta' };
|
||||||
|
}
|
||||||
|
const a = ensure(accDesc), c = ensure(cardDesc);
|
||||||
|
addLink(a.id, c.id, t.amount, 'internal_card', t);
|
||||||
|
} else if (ft === 'credit_line') {
|
||||||
|
// cuenta (col1) <-> linea (col2)
|
||||||
|
let accDesc, lineDesc;
|
||||||
|
if (t.doc_type === 'linea_credito') {
|
||||||
|
lineDesc = cardLabel(t);
|
||||||
|
const l4 = matchOwnAccount(t.description, t.last4);
|
||||||
|
accDesc = pickSameBank(nodes, t.bank, 1) || { id: 'acc:' + t.bank + ':' + (l4 || 'cta'), col: 1, kind: 'account', bank: t.bank, label: t.bank, sub: l4 ? 'Cta ··' + l4 : 'Cta Cte' };
|
||||||
|
} else {
|
||||||
|
accDesc = acctLabel(t);
|
||||||
|
lineDesc = pickSameBank(nodes, t.bank, 2) || { id: 'card:' + t.bank + ':linea', col: 2, kind: 'card', bank: t.bank, label: t.bank, sub: 'Línea de crédito' };
|
||||||
|
}
|
||||||
|
const a = ensure(accDesc), l = ensure(lineDesc);
|
||||||
|
addLink(a.id, l.id, t.amount, 'internal_line', t);
|
||||||
|
} else if (ft === 'self_transfer') {
|
||||||
|
const anchor = ensure(acctLabel(t));
|
||||||
|
const l4 = matchOwnAccount(t.description, t.last4);
|
||||||
|
let other = anchor;
|
||||||
|
if (l4) {
|
||||||
|
let found = null;
|
||||||
|
for (const n of nodes.values()) if (n.kind === 'account' && n.id.endsWith(':' + l4)) { found = n; break; }
|
||||||
|
other = found || ensure({ id: 'acc:own:' + l4, col: 1, kind: 'account', bank: 'My account', label: 'My account', sub: '··' + l4 });
|
||||||
|
}
|
||||||
|
// unmatched transfers loop back on the same account (visible self-cycle)
|
||||||
|
if (t.direction === 'debit') addLink(anchor.id, other.id, t.amount, 'internal_self', t);
|
||||||
|
else addLink(other.id, anchor.id, t.amount, 'internal_self', t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- collapse small tail nodes (col0 sources/people, col3 people) into "Other" ----
|
||||||
|
const COLLAPSE_T = 220000;
|
||||||
|
const prelim = {};
|
||||||
|
for (const L of links.values()) {
|
||||||
|
(prelim[L.source] = prelim[L.source] || { in: 0, out: 0 }).out += L.value;
|
||||||
|
(prelim[L.target] = prelim[L.target] || { in: 0, out: 0 }).in += L.value;
|
||||||
|
}
|
||||||
|
const idMap = {}; const otherDescs = {};
|
||||||
|
const OTHER_ACC = { id: 'acc:Other accounts', col: 1, kind: 'account', bank: 'Other', label: 'Other accounts', sub: 'unresolved / small' };
|
||||||
|
const OTHER_CARD = { id: 'card:Other cards', col: 2, kind: 'card', bank: 'Other', label: 'Other cards', sub: 'small' };
|
||||||
|
for (const [id, node] of nodes) {
|
||||||
|
const pv = prelim[id] || { in: 0, out: 0 };
|
||||||
|
const val = Math.max(pv.in, pv.out);
|
||||||
|
// unresolved / phantom accounts created by one-sided internal records — merge regardless of size
|
||||||
|
if (node.col === 1 && node.kind === 'account' && /:(pago|cta)$/.test(id)) { idMap[id] = OTHER_ACC.id; otherDescs[OTHER_ACC.id] = OTHER_ACC; continue; }
|
||||||
|
if (val >= COLLAPSE_T) continue;
|
||||||
|
if (node.col === 0 && (node.kind === 'source' || node.kind === 'person')) {
|
||||||
|
idMap[id] = 'inc:Other income';
|
||||||
|
otherDescs['inc:Other income'] = { id: 'inc:Other income', col: 0, kind: 'source', label: 'Other income', sub: 'small sources' };
|
||||||
|
} else if (node.col === 3 && node.kind === 'person') {
|
||||||
|
idMap[id] = 'per:Other people';
|
||||||
|
otherDescs['per:Other people'] = { id: 'per:Other people', col: 3, kind: 'person', label: 'Other people', sub: 'small transfers' };
|
||||||
|
} else if (node.col === 3 && node.kind === 'spend') {
|
||||||
|
idMap[id] = 'spend:Other purchases';
|
||||||
|
otherDescs['spend:Other purchases'] = { id: 'spend:Other purchases', col: 3, kind: 'spend', label: 'Other purchases', sub: 'spending' };
|
||||||
|
} else if (node.col === 1 && node.kind === 'account' && val < 150000) {
|
||||||
|
idMap[id] = OTHER_ACC.id; otherDescs[OTHER_ACC.id] = OTHER_ACC;
|
||||||
|
} else if (node.col === 2 && node.kind === 'card' && val < 110000) {
|
||||||
|
idMap[id] = OTHER_CARD.id; otherDescs[OTHER_CARD.id] = OTHER_CARD;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(idMap).length) {
|
||||||
|
const remap = id => idMap[id] || id;
|
||||||
|
const links2 = new Map();
|
||||||
|
for (const L of links.values()) {
|
||||||
|
const s = remap(L.source), t = remap(L.target);
|
||||||
|
if (s === t && CAT[L.cat].kind !== 'internal') continue;
|
||||||
|
const key = s + '→' + t + '#' + L.cat;
|
||||||
|
let n = links2.get(key);
|
||||||
|
if (!n) { n = { source: s, target: t, value: 0, cat: L.cat, kind: L.kind, txns: [] }; links2.set(key, n); }
|
||||||
|
n.value += L.value; n.txns.push(...L.txns);
|
||||||
|
}
|
||||||
|
const nodes2 = new Map();
|
||||||
|
const descOf = id => otherDescs[id] || nodes.get(id);
|
||||||
|
for (const L of links2.values()) for (const id of [L.source, L.target]) {
|
||||||
|
if (!nodes2.has(id)) nodes2.set(id, Object.assign({ value: 0, inLinks: [], outLinks: [] }, descOf(id)));
|
||||||
|
}
|
||||||
|
nodes = nodes2; links = links2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// wire link refs onto nodes, compute values
|
||||||
|
const linkArr = [...links.values()];
|
||||||
|
for (const L of linkArr) {
|
||||||
|
const s = nodes.get(L.source), tg = nodes.get(L.target);
|
||||||
|
if (!s || !tg) continue;
|
||||||
|
s.outLinks.push(L); tg.inLinks.push(L);
|
||||||
|
}
|
||||||
|
for (const n of nodes.values()) {
|
||||||
|
const inSum = n.inLinks.reduce((a, l) => a + l.value, 0);
|
||||||
|
const outSum = n.outLinks.reduce((a, l) => a + l.value, 0);
|
||||||
|
n.value = Math.max(inSum, outSum);
|
||||||
|
n.inSum = inSum; n.outSum = outSum;
|
||||||
|
}
|
||||||
|
|
||||||
|
// totals (real only)
|
||||||
|
let realIn = 0, realOut = 0, internalAmt = 0;
|
||||||
|
for (const L of linkArr) {
|
||||||
|
if (L.cat === 'income' || L.cat === 'inter_in') realIn += L.value;
|
||||||
|
else if (L.cat === 'expense' || L.cat === 'fee' || L.cat === 'inter_out') realOut += L.value;
|
||||||
|
else internalAmt += L.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes: [...nodes.values()].filter(n => n.value > 0),
|
||||||
|
links: linkArr.filter(l => nodes.has(l.source) && nodes.has(l.target)),
|
||||||
|
totals: { realIn, realOut, internal: internalAmt, net: realIn - realOut }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
window.MT = {
|
||||||
|
load, build,
|
||||||
|
CLP, CLPk, MONTH_LABEL, cleanDesc, CAT,
|
||||||
|
spendCategory, normIncome, normPerson, bankName, isCardType, acctShort,
|
||||||
|
get months() { return MONTHS; },
|
||||||
|
get banks() { return BANKS; },
|
||||||
|
get tx() { return TX; },
|
||||||
|
get raw() { return RAW; }
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
@ -0,0 +1,191 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>money-trace · ledger</title>
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--bg:#13131a; --panel:#1b1b24; --line:#2a2a37; --txt:#e7e7ee; --mut:#8a8a9a;
|
||||||
|
--in:#5ec48a; --out:#d98a8a; --accent:#c8b5d1; --chip:#23232e;
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
body{margin:0;background:var(--bg);color:var(--txt);font:14px/1.45 ui-sans-serif,-apple-system,Segoe UI,Roboto,sans-serif}
|
||||||
|
header{position:sticky;top:0;z-index:5;background:var(--bg);border-bottom:1px solid var(--line);
|
||||||
|
padding:14px 20px;display:flex;gap:16px;align-items:center;flex-wrap:wrap}
|
||||||
|
h1{font-size:15px;font-weight:600;margin:0;letter-spacing:.3px}
|
||||||
|
h1 small{color:var(--mut);font-weight:400}
|
||||||
|
.stats{display:flex;gap:18px;margin-left:auto;font-variant-numeric:tabular-nums}
|
||||||
|
.stat b{display:block;font-size:11px;color:var(--mut);font-weight:500;text-transform:uppercase;letter-spacing:.5px}
|
||||||
|
.stat span{font-size:15px;font-weight:600}
|
||||||
|
.in{color:var(--in)} .out{color:var(--out)}
|
||||||
|
.controls{padding:12px 20px;display:flex;gap:10px;flex-wrap:wrap;align-items:center;border-bottom:1px solid var(--line)}
|
||||||
|
input[type=search],select{background:var(--panel);border:1px solid var(--line);color:var(--txt);
|
||||||
|
padding:7px 11px;border-radius:8px;font-size:13px;outline:none}
|
||||||
|
input[type=search]{min-width:240px}
|
||||||
|
input:focus,select:focus{border-color:var(--accent)}
|
||||||
|
.seg{display:flex;border:1px solid var(--line);border-radius:8px;overflow:hidden}
|
||||||
|
.seg button{background:var(--panel);border:0;color:var(--mut);padding:7px 13px;cursor:pointer;font-size:13px}
|
||||||
|
.seg button.on{background:var(--chip);color:var(--txt)}
|
||||||
|
.wrap{padding:0 20px 60px}
|
||||||
|
table{width:100%;border-collapse:collapse;font-variant-numeric:tabular-nums}
|
||||||
|
thead th{position:sticky;top:0;background:var(--bg);text-align:left;color:var(--mut);font-weight:500;
|
||||||
|
font-size:11px;text-transform:uppercase;letter-spacing:.5px;padding:10px 10px;border-bottom:1px solid var(--line);cursor:pointer;white-space:nowrap}
|
||||||
|
tbody td{padding:9px 10px;border-bottom:1px solid var(--line)}
|
||||||
|
tbody tr:hover{background:#1e1e28}
|
||||||
|
.amt{text-align:right;font-weight:600}
|
||||||
|
.bal{text-align:right;color:var(--mut)}
|
||||||
|
.dir{font-size:11px;font-weight:600;letter-spacing:.4px}
|
||||||
|
.desc{max-width:520px}
|
||||||
|
.tag{display:inline-block;background:var(--chip);color:var(--mut);font-size:11px;padding:2px 7px;border-radius:6px;margin-right:6px}
|
||||||
|
a.pdf{color:var(--accent);text-decoration:none;border:1px solid var(--line);padding:3px 8px;border-radius:6px;font-size:12px;white-space:nowrap}
|
||||||
|
a.pdf:hover{border-color:var(--accent)}
|
||||||
|
.platform{color:var(--accent);font-size:11px;margin-left:6px}
|
||||||
|
.empty{color:var(--mut);padding:40px;text-align:center}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>money‑trace <small id="sub">· Santander</small></h1>
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat"><b>Movimientos</b><span id="s-count">–</span></div>
|
||||||
|
<div class="stat"><b>Entradas reales</b><span class="in" id="s-in">–</span></div>
|
||||||
|
<div class="stat"><b>Salidas reales</b><span class="out" id="s-out">–</span></div>
|
||||||
|
<div class="stat"><b>Internos</b><span id="s-int" style="color:var(--mut)">–</span></div>
|
||||||
|
<div class="stat"><b>Neto real</b><span id="s-net">–</span></div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="controls">
|
||||||
|
<input type="search" id="q" placeholder="Buscar descripción, cuenta, RUT…">
|
||||||
|
<div class="seg" id="dir">
|
||||||
|
<button data-d="all" class="on">Todo</button>
|
||||||
|
<button data-d="credit">▲ Entradas</button>
|
||||||
|
<button data-d="debit">▼ Salidas</button>
|
||||||
|
</div>
|
||||||
|
<select id="bank"><option value="">Todos los bancos</option></select>
|
||||||
|
<select id="flow"><option value="">Todos los flujos</option></select>
|
||||||
|
<select id="type"><option value="">Todos los tipos</option></select>
|
||||||
|
<select id="acct"><option value="">Todas las cuentas</option></select>
|
||||||
|
<label style="display:flex;align-items:center;gap:6px;color:var(--mut);font-size:13px;cursor:pointer">
|
||||||
|
<input type="checkbox" id="hideInternal"> Ocultar internos</label>
|
||||||
|
</div>
|
||||||
|
<div class="wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr>
|
||||||
|
<th data-k="date">Fecha</th>
|
||||||
|
<th data-k="bank">Banco</th>
|
||||||
|
<th data-k="flow_type">Flujo</th>
|
||||||
|
<th data-k="account_last4">Cuenta</th>
|
||||||
|
<th data-k="direction">Dir</th>
|
||||||
|
<th data-k="amount" class="amt">Monto</th>
|
||||||
|
<th data-k="balance" class="bal">Saldo</th>
|
||||||
|
<th data-k="description">Descripción</th>
|
||||||
|
<th>PDF</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody id="rows"></tbody>
|
||||||
|
</table>
|
||||||
|
<div id="empty" class="empty" hidden>Sin movimientos para este filtro.</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const fmt = n => n==null ? "" : n.toLocaleString("es-CL");
|
||||||
|
let TXNS=[], sortK="date", sortDir=1;
|
||||||
|
|
||||||
|
fetch("ledger.json").then(r=>r.json()).then(data=>{
|
||||||
|
const banks=new Set(), flows=new Set(), types=new Set(), accts=new Set();
|
||||||
|
data.statements.forEach((s,si)=>{
|
||||||
|
banks.add(s.bank); types.add(s.doc_type); if(s.account_last4) accts.add(s.account_last4);
|
||||||
|
s.transactions.forEach(t=>{
|
||||||
|
if(t.flow_type) flows.add(t.flow_type);
|
||||||
|
TXNS.push({...t, bank:s.bank, doc_type:s.doc_type, account_last4:s.account_last4||"—",
|
||||||
|
pdf_url:s.pdf_url, period:s.period_start});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.getElementById("sub").textContent = "· "+banks.size+" bancos · "+TXNS.length+" movimientos";
|
||||||
|
const bSel=document.getElementById("bank"), fSel=document.getElementById("flow"),
|
||||||
|
tSel=document.getElementById("type"), aSel=document.getElementById("acct");
|
||||||
|
[...banks].sort().forEach(b=>bSel.add(new Option(b,b)));
|
||||||
|
[...flows].sort().forEach(f=>fSel.add(new Option(flowLabel(f),f)));
|
||||||
|
[...types].sort().forEach(t=>tSel.add(new Option(label(t),t)));
|
||||||
|
[...accts].sort().forEach(a=>aSel.add(new Option("…"+a,a)));
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
const label=t=>({cuenta_corriente:"Cta Corriente",cuenta_vista:"Cta Vista",linea_credito:"Línea Crédito",tarjeta_credito:"Tarjeta"}[t]||t);
|
||||||
|
const FLOW={self_transfer:["Auto-transf.","#8a8a9a"],credit_line:["Línea créd.","#8a8a9a"],card_payment:["Pago tarjeta","#8a8a9a"],fee:["Comisión","#b59a6a"],income:["Ingreso","#5ec48a"],inter_person:["A persona","#c8b5d1"],expense:["Gasto","#d98a8a"],other:["Otro","#8a8a9a"]};
|
||||||
|
const flowLabel=f=>(FLOW[f]?FLOW[f][0]:f);
|
||||||
|
const INTERNAL=new Set(["self_transfer","credit_line","card_payment"]);
|
||||||
|
|
||||||
|
function current(){
|
||||||
|
const q=document.getElementById("q").value.toLowerCase().trim();
|
||||||
|
const d=document.querySelector("#dir .on").dataset.d;
|
||||||
|
const bk=document.getElementById("bank").value;
|
||||||
|
const fl=document.getElementById("flow").value;
|
||||||
|
const ac=document.getElementById("acct").value;
|
||||||
|
const hideInt=document.getElementById("hideInternal").checked;
|
||||||
|
let r=TXNS.filter(t=>{
|
||||||
|
if(d!=="all" && t.direction!==d) return false;
|
||||||
|
if(bk && t.bank!==bk) return false;
|
||||||
|
if(fl && t.flow_type!==fl) return false;
|
||||||
|
if(hideInt && INTERNAL.has(t.flow_type)) return false;
|
||||||
|
const ty=document.getElementById("type").value;
|
||||||
|
if(ty && t.doc_type!==ty) return false;
|
||||||
|
if(ac && t.account_last4!==ac) return false;
|
||||||
|
if(q && !(t.description||"").toLowerCase().includes(q) && !(t.account_last4||"").includes(q)
|
||||||
|
&& !(t.counterparty||"").toLowerCase().includes(q)) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
r.sort((a,b)=>{
|
||||||
|
let x=a[sortK], y=b[sortK];
|
||||||
|
if(sortK==="amount"||sortK==="balance"){x=x??-1e18;y=y??-1e18;}
|
||||||
|
return (x>y?1:x<y?-1:0)*sortDir;
|
||||||
|
});
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
function render(){
|
||||||
|
const r=current(), tb=document.getElementById("rows");
|
||||||
|
document.getElementById("empty").hidden = r.length>0;
|
||||||
|
tb.innerHTML = r.map(t=>{
|
||||||
|
const cls=t.direction==="credit"?"in":"out", arr=t.direction==="credit"?"▲":"▼";
|
||||||
|
const plat=/MERCADOPAGO|MERPAGO|PAYSCAN|SUMUP|TUU/i.test(t.description||"")?'<span class="platform">plataforma</span>':"";
|
||||||
|
const cp = t.counterparty ? `<span class="platform">→ ${esc(t.counterparty)}</span>` : "";
|
||||||
|
const fdef = FLOW[t.flow_type]||["",""];
|
||||||
|
const fchip = t.flow_type ? `<span class="tag" style="color:${fdef[1]}">${fdef[0]}</span>` : "";
|
||||||
|
const rowcls = INTERNAL.has(t.flow_type) ? ' style="opacity:.6"' : "";
|
||||||
|
return `<tr${rowcls}>
|
||||||
|
<td>${t.date||""}</td>
|
||||||
|
<td>${esc(t.bank||"")}</td>
|
||||||
|
<td>${fchip}</td>
|
||||||
|
<td>…${t.account_last4}</td>
|
||||||
|
<td class="dir ${cls}">${arr}</td>
|
||||||
|
<td class="amt ${cls}">${fmt(t.amount)}</td>
|
||||||
|
<td class="bal">${fmt(t.balance)}</td>
|
||||||
|
<td class="desc">${esc(t.description||"")}${plat}${cp}</td>
|
||||||
|
<td><a class="pdf" href="${t.pdf_url}" target="_blank">📄 abrir</a></td>
|
||||||
|
</tr>`;}).join("");
|
||||||
|
let cr=0,db=0,intern=0;
|
||||||
|
for(const t of r){
|
||||||
|
if(INTERNAL.has(t.flow_type)){ intern+=t.amount; }
|
||||||
|
else if(t.direction==="credit") cr+=t.amount;
|
||||||
|
else db+=t.amount;
|
||||||
|
}
|
||||||
|
document.getElementById("s-count").textContent=r.length;
|
||||||
|
document.getElementById("s-in").textContent=fmt(cr);
|
||||||
|
document.getElementById("s-out").textContent=fmt(db);
|
||||||
|
document.getElementById("s-int").textContent=fmt(intern);
|
||||||
|
const net=cr-db; const ne=document.getElementById("s-net");
|
||||||
|
ne.textContent=(net<0?"-":"")+fmt(Math.abs(net)); ne.className=net<0?"out":"in";
|
||||||
|
}
|
||||||
|
const esc=s=>s.replace(/[&<>]/g,c=>({"&":"&","<":"<",">":">"}[c]));
|
||||||
|
document.getElementById("q").addEventListener("input",render);
|
||||||
|
document.getElementById("bank").addEventListener("change",render);
|
||||||
|
document.getElementById("flow").addEventListener("change",render);
|
||||||
|
document.getElementById("type").addEventListener("change",render);
|
||||||
|
document.getElementById("acct").addEventListener("change",render);
|
||||||
|
document.getElementById("hideInternal").addEventListener("change",render);
|
||||||
|
document.querySelectorAll("#dir button").forEach(b=>b.onclick=()=>{
|
||||||
|
document.querySelectorAll("#dir button").forEach(x=>x.classList.remove("on"));
|
||||||
|
b.classList.add("on"); render();});
|
||||||
|
document.querySelectorAll("thead th[data-k]").forEach(th=>th.onclick=()=>{
|
||||||
|
const k=th.dataset.k; if(sortK===k) sortDir*=-1; else {sortK=k; sortDir=1;} render();});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,305 @@
|
||||||
|
<!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>
|
||||||
|
|
@ -0,0 +1,156 @@
|
||||||
|
/* 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 };
|
||||||
|
})();
|
||||||
|
|
@ -0,0 +1,225 @@
|
||||||
|
/* money-trace · layered Sankey renderer (SVG, vanilla)
|
||||||
|
Columns: 0 sources · 1 accounts · 2 cards/lines · 3 destinations.
|
||||||
|
Real flows pop (solid, glow); internal flows recede (dashed, grey).
|
||||||
|
Self-transfers (col1↔col1) render as right-bulging recirculation loops. */
|
||||||
|
(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 COL_HEAD = ['Income sources', 'My accounts', 'Cards & lines', 'Where it goes'];
|
||||||
|
|
||||||
|
function dominantCat(links) {
|
||||||
|
const m = {};
|
||||||
|
for (const l of links) m[l.cat] = (m[l.cat] || 0) + l.value;
|
||||||
|
let best = null, bv = -1; for (const k in m) if (m[k] > bv) { bv = m[k]; best = k; }
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
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';
|
||||||
|
const cssv = v => getComputedStyle(document.documentElement).getPropertyValue(v).trim();
|
||||||
|
|
||||||
|
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; }
|
||||||
|
|
||||||
|
// layout constants
|
||||||
|
const padTop = 40, padBot = 26;
|
||||||
|
const nodeW = 15, nodePad = 12;
|
||||||
|
const mLeft = 168, mRight = 172;
|
||||||
|
const availH = H - padTop - padBot;
|
||||||
|
const innerW = W - mLeft - mRight;
|
||||||
|
const colX = [mLeft, mLeft + innerW * 0.34, mLeft + innerW * 0.67, mLeft + innerW];
|
||||||
|
|
||||||
|
// group + sort nodes by column
|
||||||
|
const cols = [[], [], [], []];
|
||||||
|
for (const n of nodes) cols[n.col].push(n);
|
||||||
|
const orderKind = { source: 0, person: 0, account: 0, card: 0, spend: 0, fee: 1 };
|
||||||
|
for (const c of cols) c.sort((a, b) => b.value - a.value);
|
||||||
|
|
||||||
|
// shared value→px scale (binding column fills availH)
|
||||||
|
let scale = Infinity;
|
||||||
|
cols.forEach(c => {
|
||||||
|
if (!c.length) return;
|
||||||
|
const sum = c.reduce((s, n) => s + n.value, 0);
|
||||||
|
const s = (availH - (c.length - 1) * nodePad) / sum;
|
||||||
|
if (s < scale) scale = s;
|
||||||
|
});
|
||||||
|
if (!isFinite(scale)) scale = 1;
|
||||||
|
|
||||||
|
// position nodes
|
||||||
|
cols.forEach((c, ci) => {
|
||||||
|
const total = c.reduce((s, n) => s + n.value * scale, 0) + (c.length - 1) * nodePad;
|
||||||
|
let y = padTop + (availH - total) / 2;
|
||||||
|
for (const n of c) {
|
||||||
|
n._x = colX[ci]; n._w = nodeW; n._y = y; n._h = Math.max(2, n.value * scale);
|
||||||
|
n._rcur = n._y; n._lcur = n._y; // edge cursors
|
||||||
|
y += n._h + nodePad;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// split links
|
||||||
|
const fwd = [], self = [];
|
||||||
|
for (const l of links) {
|
||||||
|
l._s = graph.nodes.find ? null : null;
|
||||||
|
}
|
||||||
|
const byId = new Map(nodes.map(n => [n.id, n]));
|
||||||
|
for (const l of links) {
|
||||||
|
const s = byId.get(l.source), t = byId.get(l.target);
|
||||||
|
if (!s || !t) continue;
|
||||||
|
l._sn = s; l._tn = t;
|
||||||
|
if (s.col === t.col) self.push(l); else fwd.push(l);
|
||||||
|
}
|
||||||
|
|
||||||
|
// assign forward endpoints: source right edge, target left edge
|
||||||
|
// order each node's links by opposite node center to reduce crossings
|
||||||
|
const sortKey = (l, side) => (side === 'src' ? l._tn._y : l._sn._y);
|
||||||
|
for (const n of nodes) {
|
||||||
|
n._out = fwd.filter(l => l._sn === n).sort((a, b) => sortKey(a, 'src') - sortKey(b, 'src'));
|
||||||
|
n._in = fwd.filter(l => l._tn === n).sort((a, b) => sortKey(a, 'tgt') - sortKey(b, 'tgt'));
|
||||||
|
}
|
||||||
|
for (const n of nodes) {
|
||||||
|
for (const l of n._out) { const w = Math.max(1, l.value * scale); l._sx = n._x + n._w; l._sy = n._rcur + w / 2; n._rcur += w; l._w = w; }
|
||||||
|
for (const l of n._in) { const w = Math.max(1, l.value * scale); l._tx = n._x; l._ty = n._lcur + w / 2; n._lcur += w; }
|
||||||
|
}
|
||||||
|
// self links: both endpoints on right edge, bulge right
|
||||||
|
for (const n of nodes) {
|
||||||
|
n._selfOut = self.filter(l => l._sn === n).sort((a, b) => a._tn._y - b._tn._y);
|
||||||
|
n._selfIn = self.filter(l => l._tn === n).sort((a, b) => a._sn._y - b._sn._y);
|
||||||
|
}
|
||||||
|
for (const n of nodes) {
|
||||||
|
for (const l of n._selfOut) { const w = Math.max(1, l.value * scale); l._sx = n._x + n._w; l._sy = n._rcur + w / 2; n._rcur += w; l._w = w; }
|
||||||
|
for (const l of n._selfIn) { const w = Math.max(1, l.value * scale); l._tx = n._x + n._w; l._ty = n._rcur + w / 2; n._rcur += w; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- draw ----
|
||||||
|
const defs = el('defs');
|
||||||
|
svg.appendChild(defs);
|
||||||
|
|
||||||
|
// label de-collision for outer columns (shift label centres, keep node positions)
|
||||||
|
function spread(colArr, minGap) {
|
||||||
|
const arr = colArr.slice().sort((a, b) => a._y - b._y);
|
||||||
|
let prev = -Infinity;
|
||||||
|
for (const n of arr) { let c = n._y + n._h / 2; if (c < prev + minGap) c = prev + minGap; n._lc = c; prev = c; }
|
||||||
|
}
|
||||||
|
spread(cols[0], 30); spread(cols[3], 30);
|
||||||
|
spread(cols[1], 30); spread(cols[2], 30);
|
||||||
|
|
||||||
|
// column headers
|
||||||
|
cols.forEach((c, ci) => {
|
||||||
|
if (!c.length) return;
|
||||||
|
const x = ci === 3 ? colX[ci] + nodeW : colX[ci];
|
||||||
|
const anchor = ci === 0 ? 'start' : (ci === 3 ? 'end' : 'start');
|
||||||
|
const tx = ci === 0 ? colX[0] - 0 : (ci === 3 ? colX[3] + nodeW : colX[ci]);
|
||||||
|
const th = el('text', { x: tx, y: 22, class: 'colhead', 'text-anchor': ci === 3 ? 'start' : 'start' });
|
||||||
|
th.textContent = COL_HEAD[ci];
|
||||||
|
svg.appendChild(th);
|
||||||
|
});
|
||||||
|
|
||||||
|
const linkG = el('g'); svg.appendChild(linkG);
|
||||||
|
const nodeG = el('g'); svg.appendChild(nodeG);
|
||||||
|
|
||||||
|
function linkPath(l) {
|
||||||
|
if (l._sn.col === l._tn.col) {
|
||||||
|
// self loop, bulge right
|
||||||
|
const depth = 46 + Math.min(70, l._w * 1.2) + (l._sy % 23);
|
||||||
|
const x = l._sx;
|
||||||
|
return `M${x},${l._sy} C${x + depth},${l._sy} ${x + depth},${l._ty} ${x},${l._ty}`;
|
||||||
|
}
|
||||||
|
const sx = l._sx, sy = l._sy, tx = l._tx, ty = l._ty;
|
||||||
|
const mx = (sx + tx) / 2;
|
||||||
|
return `M${sx},${sy} C${mx},${sy} ${mx},${ty} ${tx},${ty}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkEls = [];
|
||||||
|
// draw internal first (under), then real (over)
|
||||||
|
const ordered = links.slice().sort((a, b) => (a.kind === 'internal' ? -1 : 1) - (b.kind === 'internal' ? -1 : 1));
|
||||||
|
for (const l of ordered) {
|
||||||
|
if (l._sx == null || l._tx == null) continue;
|
||||||
|
const p = el('path', {
|
||||||
|
d: linkPath(l),
|
||||||
|
class: 'link ' + (l.kind === 'real' ? 'real glow-real ' : 'internal ') + l.cat,
|
||||||
|
'stroke-width': l._w
|
||||||
|
});
|
||||||
|
p.__link = l;
|
||||||
|
linkG.appendChild(p);
|
||||||
|
linkEls.push(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
// nodes
|
||||||
|
const nodeEls = [];
|
||||||
|
for (const n of nodes) {
|
||||||
|
const g = el('g'); g.__node = n;
|
||||||
|
const dom = dominantCat([...n._out, ...n._in, ...n._selfOut, ...n._selfIn]);
|
||||||
|
const col = cssv(catColor(dom)) || cssv('--ink-mute');
|
||||||
|
const r = el('rect', { x: n._x, y: n._y, width: n._w, height: n._h, rx: 3, fill: col, class: 'node-rect' });
|
||||||
|
r.style.opacity = n.kind === 'account' || n.kind === 'card' ? 0.92 : 0.85;
|
||||||
|
g.appendChild(r);
|
||||||
|
|
||||||
|
// labels
|
||||||
|
const cy = n._lc != null ? n._lc : n._y + n._h / 2;
|
||||||
|
if (n.col === 0) {
|
||||||
|
const t = el('text', { x: n._x - 9, y: cy - 1, class: 'node-label', 'text-anchor': 'end', 'dominant-baseline': 'middle' });
|
||||||
|
t.textContent = n.label;
|
||||||
|
const v = el('text', { x: n._x - 9, y: cy + 13, class: 'node-val', 'text-anchor': 'end', 'dominant-baseline': 'middle' });
|
||||||
|
v.textContent = MTfmt(n.value);
|
||||||
|
g.appendChild(t); g.appendChild(v);
|
||||||
|
} else if (n.col === 3) {
|
||||||
|
const t = el('text', { x: n._x + n._w + 9, y: cy - 1, class: 'node-label', 'dominant-baseline': 'middle' });
|
||||||
|
t.textContent = n.label;
|
||||||
|
const v = el('text', { x: n._x + n._w + 9, y: cy + 13, class: 'node-val', 'dominant-baseline': 'middle' });
|
||||||
|
v.textContent = MTfmt(n.value);
|
||||||
|
g.appendChild(t); g.appendChild(v);
|
||||||
|
} else {
|
||||||
|
// middle: label above node
|
||||||
|
const t = el('text', { x: n._x + n._w + 8, y: cy - 5, class: 'node-label', 'dominant-baseline': 'middle' });
|
||||||
|
t.textContent = n.label;
|
||||||
|
const s = el('text', { x: n._x + n._w + 8, y: cy + 9, class: 'node-sub', 'dominant-baseline': 'middle' });
|
||||||
|
s.textContent = n.sub + ' ' + MTfmt(n.value);
|
||||||
|
g.appendChild(t); g.appendChild(s);
|
||||||
|
}
|
||||||
|
nodeG.appendChild(g);
|
||||||
|
nodeEls.push(g);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- interactions ----
|
||||||
|
function setFocus(activeLinks, activeNodes) {
|
||||||
|
linkEls.forEach(p => {
|
||||||
|
if (!activeLinks || activeLinks.has(p.__link)) p.classList.remove('dim');
|
||||||
|
else p.classList.add('dim');
|
||||||
|
});
|
||||||
|
nodeEls.forEach(g => {
|
||||||
|
if (!activeNodes || activeNodes.has(g.__node)) g.classList.remove('dim');
|
||||||
|
else g.classList.add('dim');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function clearFocus() { 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', () => { const s = new Set([p.__link]); setFocus(s, new Set([p.__link._sn, p.__link._tn])); });
|
||||||
|
p.addEventListener('mouseleave', () => { window.MTtip.hide(); clearFocus(); });
|
||||||
|
});
|
||||||
|
nodeEls.forEach(g => {
|
||||||
|
const n = g.__node;
|
||||||
|
const ls = new Set([...n._out, ...n._in, ...n._selfOut, ...n._selfIn]);
|
||||||
|
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', () => setFocus(ls, ns));
|
||||||
|
g.addEventListener('mouseleave', () => { window.MTtip.hide(); clearFocus(); });
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MTfmt(v) { return window.MT.CLPk(v); }
|
||||||
|
|
||||||
|
window.SankeyView = { render };
|
||||||
|
})();
|
||||||
|
|
@ -0,0 +1,193 @@
|
||||||
|
/* money-trace · dark finance-terminal aesthetic */
|
||||||
|
:root {
|
||||||
|
--bg: #090b0f;
|
||||||
|
--bg-grid: rgba(255,255,255,0.022);
|
||||||
|
--panel: #10141b;
|
||||||
|
--panel-2: #161b24;
|
||||||
|
--line: rgba(255,255,255,0.075);
|
||||||
|
--line-2: rgba(255,255,255,0.13);
|
||||||
|
--ink: #eaeff7;
|
||||||
|
--ink-mute: #8b95a6;
|
||||||
|
--ink-dim: #545d6d;
|
||||||
|
|
||||||
|
--c-income: #2ee6a6; /* real money in */
|
||||||
|
--c-spend: #ff5f73; /* real money out */
|
||||||
|
--c-person: #b98cff; /* inter-person */
|
||||||
|
--c-fee: #ffc24b; /* fees & interest*/
|
||||||
|
--c-internal:#4b5466; /* cycles / noise */
|
||||||
|
|
||||||
|
--glow: 1; /* 0..1, multiplied into drop-shadows */
|
||||||
|
--link-op: 0.62; /* base opacity of real links */
|
||||||
|
--font-ui: 'Space Grotesk', system-ui, sans-serif;
|
||||||
|
--font-num: 'IBM Plex Mono', ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; height: 100%; }
|
||||||
|
body {
|
||||||
|
background:
|
||||||
|
radial-gradient(1200px 700px at 78% -8%, rgba(46,230,166,0.05), transparent 60%),
|
||||||
|
radial-gradient(900px 600px at 6% 110%, rgba(185,140,255,0.045), transparent 55%),
|
||||||
|
linear-gradient(0deg, var(--bg-grid) 1px, transparent 1px) 0 0 / 100% 34px,
|
||||||
|
var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.num { font-family: var(--font-num); font-feature-settings: 'tnum' 1; letter-spacing: -0.01em; }
|
||||||
|
|
||||||
|
/* ---- app frame ---- */
|
||||||
|
#app { display: flex; flex-direction: column; height: 100vh; }
|
||||||
|
|
||||||
|
/* ---- header / summary bar ---- */
|
||||||
|
header.bar {
|
||||||
|
display: flex; align-items: stretch; gap: 0;
|
||||||
|
padding: 14px 22px 13px; border-bottom: 1px solid var(--line);
|
||||||
|
background: linear-gradient(180deg, rgba(255,255,255,0.018), transparent);
|
||||||
|
}
|
||||||
|
.brand { display: flex; flex-direction: column; justify-content: center; min-width: 168px; }
|
||||||
|
.brand .mark {
|
||||||
|
font-weight: 600; font-size: 15px; letter-spacing: 0.26em;
|
||||||
|
text-transform: uppercase; color: var(--ink);
|
||||||
|
}
|
||||||
|
.brand .mark b { color: var(--c-income); font-weight: 600; }
|
||||||
|
.brand .sub { font-size: 11px; color: var(--ink-dim); letter-spacing: 0.04em; margin-top: 3px; }
|
||||||
|
|
||||||
|
.stats { display: flex; gap: 30px; margin-left: auto; align-items: center; }
|
||||||
|
.stat { display: flex; flex-direction: column; gap: 4px; min-width: 132px; }
|
||||||
|
.stat .lab {
|
||||||
|
font-size: 10.5px; letter-spacing: 0.14em; text-transform: uppercase;
|
||||||
|
color: var(--ink-mute); display: flex; align-items: center; gap: 6px;
|
||||||
|
}
|
||||||
|
.stat .lab i { width: 7px; height: 7px; border-radius: 2px; display: inline-block; }
|
||||||
|
.stat .val { font-size: 27px; font-weight: 500; line-height: 1; }
|
||||||
|
.stat .val small { font-size: 13px; color: var(--ink-dim); margin-left: 2px; }
|
||||||
|
.stat.income .val { color: var(--c-income); }
|
||||||
|
.stat.spend .val { color: var(--c-spend); }
|
||||||
|
.stat.internal .val { color: var(--ink-mute); }
|
||||||
|
.stat.net .val { color: var(--ink); }
|
||||||
|
.stat .delta { font-size: 11px; color: var(--ink-dim); }
|
||||||
|
|
||||||
|
/* ---- controls strip ---- */
|
||||||
|
.controls {
|
||||||
|
display: flex; align-items: center; gap: 18px; flex-wrap: wrap;
|
||||||
|
padding: 11px 22px; border-bottom: 1px solid var(--line);
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
.seg { display: inline-flex; background: var(--bg); border: 1px solid var(--line); border-radius: 9px; padding: 3px; gap: 2px; }
|
||||||
|
.seg button {
|
||||||
|
font-family: var(--font-ui); font-size: 12.5px; color: var(--ink-mute);
|
||||||
|
background: transparent; border: 0; padding: 7px 15px; border-radius: 6px;
|
||||||
|
cursor: pointer; display: flex; align-items: center; gap: 7px; transition: .16s;
|
||||||
|
}
|
||||||
|
.seg button:hover { color: var(--ink); }
|
||||||
|
.seg button.on { background: var(--panel-2); color: var(--ink); box-shadow: inset 0 0 0 1px var(--line-2); }
|
||||||
|
.seg button svg { width: 14px; height: 14px; }
|
||||||
|
|
||||||
|
.toggle { display: inline-flex; align-items: center; gap: 9px; cursor: pointer; user-select: none; }
|
||||||
|
.toggle .track { width: 38px; height: 21px; border-radius: 20px; background: var(--bg); border: 1px solid var(--line-2); position: relative; transition: .18s; }
|
||||||
|
.toggle .track::after { content: ''; position: absolute; top: 2px; left: 2px; width: 15px; height: 15px; border-radius: 50%; background: var(--ink-mute); transition: .18s; }
|
||||||
|
.toggle.on .track { background: rgba(46,230,166,0.22); border-color: var(--c-income); }
|
||||||
|
.toggle.on .track::after { left: 19px; background: var(--c-income); }
|
||||||
|
.toggle .lbl { font-size: 12.5px; color: var(--ink); }
|
||||||
|
.toggle .hint { font-size: 11px; color: var(--ink-dim); }
|
||||||
|
|
||||||
|
.ctl-label { font-size: 10.5px; letter-spacing: 0.13em; text-transform: uppercase; color: var(--ink-dim); margin-right: -8px; }
|
||||||
|
|
||||||
|
/* month scrubber */
|
||||||
|
.scrub { display: flex; align-items: center; gap: 12px; flex: 1; min-width: 280px; max-width: 560px; }
|
||||||
|
.scrub .play { width: 30px; height: 30px; border-radius: 8px; border: 1px solid var(--line); background: var(--bg); color: var(--ink); cursor: pointer; display: grid; place-items: center; }
|
||||||
|
.scrub .play:hover { border-color: var(--line-2); }
|
||||||
|
.scrub .track-wrap { flex: 1; }
|
||||||
|
.scrub .months { display: flex; justify-content: space-between; font-size: 10px; color: var(--ink-dim); margin-bottom: 5px; }
|
||||||
|
.scrub .months b { color: var(--c-income); font-weight: 500; font-family: var(--font-num); }
|
||||||
|
.bars { display: flex; gap: 2px; align-items: flex-end; height: 30px; }
|
||||||
|
.bars .b { flex: 1; background: var(--line); border-radius: 2px 2px 0 0; cursor: pointer; transition: .14s; min-height: 2px; }
|
||||||
|
.bars .b:hover { background: var(--ink-dim); }
|
||||||
|
.bars .b.active { background: linear-gradient(180deg, var(--c-income), rgba(46,230,166,0.35)); }
|
||||||
|
|
||||||
|
/* bank filter */
|
||||||
|
.dropdown { position: relative; }
|
||||||
|
.dropdown > .btn {
|
||||||
|
font-family: var(--font-ui); font-size: 12.5px; color: var(--ink); background: var(--bg);
|
||||||
|
border: 1px solid var(--line); border-radius: 8px; padding: 8px 13px; cursor: pointer;
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
}
|
||||||
|
.dropdown > .btn .cnt { color: var(--ink-dim); font-family: var(--font-num); font-size: 11px; }
|
||||||
|
.dropdown .menu {
|
||||||
|
position: absolute; top: calc(100% + 6px); right: 0; z-index: 40;
|
||||||
|
background: var(--panel-2); border: 1px solid var(--line-2); border-radius: 10px;
|
||||||
|
padding: 8px; width: 220px; box-shadow: 0 18px 50px rgba(0,0,0,0.55); display: none;
|
||||||
|
max-height: 360px; overflow: auto;
|
||||||
|
}
|
||||||
|
.dropdown.open .menu { display: block; }
|
||||||
|
.dropdown .menu .row { display: flex; align-items: center; gap: 9px; padding: 7px 9px; border-radius: 7px; cursor: pointer; font-size: 12.5px; }
|
||||||
|
.dropdown .menu .row:hover { background: var(--panel); }
|
||||||
|
.dropdown .menu .row .ck { width: 15px; height: 15px; border-radius: 4px; border: 1px solid var(--line-2); display: grid; place-items: center; color: var(--bg); }
|
||||||
|
.dropdown .menu .row.on .ck { background: var(--c-income); border-color: var(--c-income); }
|
||||||
|
.dropdown .menu .row .n { color: var(--ink-dim); margin-left: auto; font-family: var(--font-num); font-size: 11px; }
|
||||||
|
.dropdown .menu .sep { height: 1px; background: var(--line); margin: 6px 4px; }
|
||||||
|
.dropdown .menu .all { font-size: 11px; color: var(--c-income); padding: 6px 9px; cursor: pointer; }
|
||||||
|
|
||||||
|
/* ---- legend ---- */
|
||||||
|
.legend { display: flex; align-items: center; gap: 16px; margin-left: auto; }
|
||||||
|
.legend .item { display: flex; align-items: center; gap: 7px; font-size: 11.5px; color: var(--ink-mute); }
|
||||||
|
.legend .item i { width: 16px; height: 3px; border-radius: 2px; display: inline-block; }
|
||||||
|
.legend .item.dash i { height: 0; border-top: 2px dashed var(--c-internal); width: 18px; }
|
||||||
|
|
||||||
|
/* ---- view area ---- */
|
||||||
|
.view { position: relative; flex: 1; overflow: hidden; }
|
||||||
|
.view svg { width: 100%; height: 100%; display: block; }
|
||||||
|
|
||||||
|
/* sankey */
|
||||||
|
.node-rect { transition: opacity .18s; }
|
||||||
|
.node-label { fill: var(--ink); font-size: 12.5px; font-family: var(--font-ui); }
|
||||||
|
.node-sub { fill: var(--ink-dim); font-size: 10px; font-family: var(--font-num); }
|
||||||
|
.node-val { fill: var(--ink-mute); font-size: 10.5px; font-family: var(--font-num); }
|
||||||
|
.colhead { fill: var(--ink-dim); font-size: 10.5px; letter-spacing: 0.16em; text-transform: uppercase; font-family: var(--font-ui); }
|
||||||
|
|
||||||
|
.link { fill: none; transition: opacity .18s, stroke-opacity .18s; }
|
||||||
|
.link.real { opacity: var(--link-op); }
|
||||||
|
.link.internal { opacity: 0.4; }
|
||||||
|
.link.income { stroke: var(--c-income); }
|
||||||
|
.link.inter_in, .link.inter_out { stroke: var(--c-person); }
|
||||||
|
.link.expense { stroke: var(--c-spend); }
|
||||||
|
.link.fee { stroke: var(--c-fee); }
|
||||||
|
.link.internal_card, .link.internal_line, .link.internal_self { stroke: var(--c-internal); stroke-dasharray: 5 5; }
|
||||||
|
.glow-real { filter: drop-shadow(0 0 calc(5px * var(--glow)) currentColor); }
|
||||||
|
|
||||||
|
.dim { opacity: 0.07 !important; }
|
||||||
|
.hl { opacity: 0.95 !important; }
|
||||||
|
|
||||||
|
/* ---- tooltip ---- */
|
||||||
|
#tip {
|
||||||
|
position: fixed; z-index: 80; pointer-events: none; max-width: 320px;
|
||||||
|
background: rgba(13,17,23,0.96); border: 1px solid var(--line-2); border-radius: 11px;
|
||||||
|
padding: 12px 13px; box-shadow: 0 16px 44px rgba(0,0,0,0.6); backdrop-filter: blur(8px);
|
||||||
|
opacity: 0; transform: translateY(4px); transition: opacity .12s, transform .12s; font-size: 12.5px;
|
||||||
|
}
|
||||||
|
#tip.show { opacity: 1; transform: translateY(0); }
|
||||||
|
#tip .t-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||||||
|
#tip .t-dot { width: 9px; height: 9px; border-radius: 3px; }
|
||||||
|
#tip .t-title { font-weight: 600; font-size: 13px; }
|
||||||
|
#tip .t-amt { font-family: var(--font-num); font-size: 19px; margin: 2px 0 9px; }
|
||||||
|
#tip .t-meta { color: var(--ink-mute); font-size: 11px; margin-bottom: 9px; }
|
||||||
|
#tip .t-rows { display: flex; flex-direction: column; gap: 5px; border-top: 1px solid var(--line); padding-top: 9px; max-height: 200px; overflow: hidden; }
|
||||||
|
#tip .t-row { display: flex; gap: 10px; font-size: 11.5px; align-items: baseline; }
|
||||||
|
#tip .t-row .d { color: var(--ink-dim); font-family: var(--font-num); font-size: 10.5px; width: 42px; flex: none; }
|
||||||
|
#tip .t-row .desc { color: var(--ink-mute); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
#tip .t-row .a { font-family: var(--font-num); color: var(--ink); }
|
||||||
|
#tip .t-more { color: var(--ink-dim); font-size: 10.5px; margin-top: 7px; }
|
||||||
|
|
||||||
|
/* orbit view */
|
||||||
|
.orbit-ring { fill: none; stroke: var(--line); stroke-width: 1; }
|
||||||
|
.orbit-node-label { fill: var(--ink); font-size: 11.5px; font-family: var(--font-ui); }
|
||||||
|
.orbit-sub { fill: var(--ink-dim); font-size: 9.5px; font-family: var(--font-num); }
|
||||||
|
.orbit-center-lab { fill: var(--ink); font-size: 14px; font-family: var(--font-ui); font-weight: 600; }
|
||||||
|
|
||||||
|
/* empty state */
|
||||||
|
.empty { position: absolute; inset: 0; display: grid; place-items: center; color: var(--ink-dim); font-size: 14px; }
|
||||||
|
|
||||||
|
/* loading */
|
||||||
|
#loading { position: fixed; inset: 0; display: grid; place-items: center; background: var(--bg); z-index: 200; color: var(--ink-mute); font-family: var(--font-num); font-size: 13px; letter-spacing: 0.1em; }
|
||||||
|
|
@ -0,0 +1,540 @@
|
||||||
|
|
||||||
|
/* BEGIN USAGE */
|
||||||
|
// tweaks-panel.jsx
|
||||||
|
// Reusable Tweaks shell + form-control helpers.
|
||||||
|
// Exports (to window): useTweaks, TweaksPanel, TweakSection, TweakRow, TweakSlider,
|
||||||
|
// TweakToggle, TweakRadio, TweakSelect, TweakText, TweakNumber, TweakColor, TweakButton.
|
||||||
|
//
|
||||||
|
// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode,
|
||||||
|
// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so
|
||||||
|
// individual prototypes don't re-roll it. Ships a consistent set of controls so you
|
||||||
|
// don't hand-draw <input type="range">, segmented radios, steppers, etc.
|
||||||
|
//
|
||||||
|
// Usage (in an HTML file that loads React + Babel):
|
||||||
|
//
|
||||||
|
// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
|
||||||
|
// "primaryColor": "#D97757",
|
||||||
|
// "palette": ["#D97757", "#29261b", "#f6f4ef"],
|
||||||
|
// "fontSize": 16,
|
||||||
|
// "density": "regular",
|
||||||
|
// "dark": false
|
||||||
|
// }/*EDITMODE-END*/;
|
||||||
|
//
|
||||||
|
// function App() {
|
||||||
|
// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
|
||||||
|
// return (
|
||||||
|
// <div style={{ fontSize: t.fontSize, color: t.primaryColor }}>
|
||||||
|
// Hello
|
||||||
|
// <TweaksPanel>
|
||||||
|
// <TweakSection label="Typography" />
|
||||||
|
// <TweakSlider label="Font size" value={t.fontSize} min={10} max={32} unit="px"
|
||||||
|
// onChange={(v) => setTweak('fontSize', v)} />
|
||||||
|
// <TweakRadio label="Density" value={t.density}
|
||||||
|
// options={['compact', 'regular', 'comfy']}
|
||||||
|
// onChange={(v) => setTweak('density', v)} />
|
||||||
|
// <TweakSection label="Theme" />
|
||||||
|
// <TweakColor label="Primary" value={t.primaryColor}
|
||||||
|
// options={['#D97757', '#2A6FDB', '#1F8A5B', '#7A5AE0']}
|
||||||
|
// onChange={(v) => setTweak('primaryColor', v)} />
|
||||||
|
// <TweakColor label="Palette" value={t.palette}
|
||||||
|
// options={[['#D97757', '#29261b', '#f6f4ef'],
|
||||||
|
// ['#475569', '#0f172a', '#f1f5f9']]}
|
||||||
|
// onChange={(v) => setTweak('palette', v)} />
|
||||||
|
// <TweakToggle label="Dark mode" value={t.dark}
|
||||||
|
// onChange={(v) => setTweak('dark', v)} />
|
||||||
|
// </TweaksPanel>
|
||||||
|
// </div>
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// TweakRadio is the segmented control for 2–3 short options (auto-falls-back to
|
||||||
|
// TweakSelect past ~16/~10 chars per label); reach for TweakSelect directly when
|
||||||
|
// options are many or long. For color tweaks always curate 3-4 options rather than
|
||||||
|
// a free picker; an option can also be a whole 2–5 color palette (the stored value
|
||||||
|
// is the array). The Tweak* controls are a floor, not a ceiling — build custom
|
||||||
|
// controls inside the panel if a tweak calls for UI they don't cover.
|
||||||
|
/* END USAGE */
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const __TWEAKS_STYLE = `
|
||||||
|
.twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
|
||||||
|
max-height:calc(100vh - 32px);display:flex;flex-direction:column;
|
||||||
|
transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom right;
|
||||||
|
background:rgba(250,249,247,.78);color:#29261b;
|
||||||
|
-webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
|
||||||
|
border:.5px solid rgba(255,255,255,.6);border-radius:14px;
|
||||||
|
box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
|
||||||
|
font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
|
||||||
|
.twk-hd{display:flex;align-items:center;justify-content:space-between;
|
||||||
|
padding:10px 8px 10px 14px;cursor:move;user-select:none}
|
||||||
|
.twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
|
||||||
|
.twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
|
||||||
|
width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
|
||||||
|
.twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
|
||||||
|
.twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
|
||||||
|
overflow-y:auto;overflow-x:hidden;min-height:0;
|
||||||
|
scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
|
||||||
|
.twk-body::-webkit-scrollbar{width:8px}
|
||||||
|
.twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
|
||||||
|
.twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
|
||||||
|
border:2px solid transparent;background-clip:content-box}
|
||||||
|
.twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25);
|
||||||
|
border:2px solid transparent;background-clip:content-box}
|
||||||
|
.twk-row{display:flex;flex-direction:column;gap:5px}
|
||||||
|
.twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
|
||||||
|
.twk-lbl{display:flex;justify-content:space-between;align-items:baseline;
|
||||||
|
color:rgba(41,38,27,.72)}
|
||||||
|
.twk-lbl>span:first-child{font-weight:500}
|
||||||
|
.twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}
|
||||||
|
|
||||||
|
.twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
|
||||||
|
color:rgba(41,38,27,.45);padding:10px 0 0}
|
||||||
|
.twk-sect:first-child{padding-top:0}
|
||||||
|
|
||||||
|
.twk-field{appearance:none;box-sizing:border-box;width:100%;min-width:0;height:26px;padding:0 8px;
|
||||||
|
border:.5px solid rgba(0,0,0,.1);border-radius:7px;
|
||||||
|
background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
|
||||||
|
.twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
|
||||||
|
select.twk-field{padding-right:22px;
|
||||||
|
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='rgba(0,0,0,.5)' d='M0 0h10L5 6z'/></svg>");
|
||||||
|
background-repeat:no-repeat;background-position:right 8px center}
|
||||||
|
|
||||||
|
.twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
|
||||||
|
border-radius:999px;background:rgba(0,0,0,.12);outline:none}
|
||||||
|
.twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
|
||||||
|
width:14px;height:14px;border-radius:50%;background:#fff;
|
||||||
|
border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
|
||||||
|
.twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%;
|
||||||
|
background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
|
||||||
|
|
||||||
|
.twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
|
||||||
|
background:rgba(0,0,0,.06);user-select:none}
|
||||||
|
.twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
|
||||||
|
background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
|
||||||
|
transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
|
||||||
|
.twk-seg.dragging .twk-seg-thumb{transition:none}
|
||||||
|
.twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
|
||||||
|
background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px;
|
||||||
|
border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2;
|
||||||
|
overflow-wrap:anywhere}
|
||||||
|
|
||||||
|
.twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
|
||||||
|
background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
|
||||||
|
.twk-toggle[data-on="1"]{background:#34c759}
|
||||||
|
.twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
|
||||||
|
background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
|
||||||
|
.twk-toggle[data-on="1"] i{transform:translateX(14px)}
|
||||||
|
|
||||||
|
.twk-num{display:flex;align-items:center;box-sizing:border-box;min-width:0;height:26px;padding:0 0 0 8px;
|
||||||
|
border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)}
|
||||||
|
.twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize;
|
||||||
|
user-select:none;padding-right:8px}
|
||||||
|
.twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent;
|
||||||
|
font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0;
|
||||||
|
outline:none;color:inherit;-moz-appearance:textfield}
|
||||||
|
.twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{
|
||||||
|
-webkit-appearance:none;margin:0}
|
||||||
|
.twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)}
|
||||||
|
|
||||||
|
.twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px;
|
||||||
|
background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default}
|
||||||
|
.twk-btn:hover{background:rgba(0,0,0,.88)}
|
||||||
|
.twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit}
|
||||||
|
.twk-btn.secondary:hover{background:rgba(0,0,0,.1)}
|
||||||
|
|
||||||
|
.twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
|
||||||
|
border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;
|
||||||
|
background:transparent;flex-shrink:0}
|
||||||
|
.twk-swatch::-webkit-color-swatch-wrapper{padding:0}
|
||||||
|
.twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
|
||||||
|
.twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px}
|
||||||
|
|
||||||
|
.twk-chips{display:flex;gap:6px}
|
||||||
|
.twk-chip{position:relative;appearance:none;flex:1;min-width:0;height:46px;
|
||||||
|
padding:0;border:0;border-radius:6px;overflow:hidden;cursor:default;
|
||||||
|
box-shadow:0 0 0 .5px rgba(0,0,0,.12),0 1px 2px rgba(0,0,0,.06);
|
||||||
|
transition:transform .12s cubic-bezier(.3,.7,.4,1),box-shadow .12s}
|
||||||
|
.twk-chip:hover{transform:translateY(-1px);
|
||||||
|
box-shadow:0 0 0 .5px rgba(0,0,0,.18),0 4px 10px rgba(0,0,0,.12)}
|
||||||
|
.twk-chip[data-on="1"]{box-shadow:0 0 0 1.5px rgba(0,0,0,.85),
|
||||||
|
0 2px 6px rgba(0,0,0,.15)}
|
||||||
|
.twk-chip>span{position:absolute;top:0;bottom:0;right:0;width:34%;
|
||||||
|
display:flex;flex-direction:column;box-shadow:-1px 0 0 rgba(0,0,0,.1)}
|
||||||
|
.twk-chip>span>i{flex:1;box-shadow:0 -1px 0 rgba(0,0,0,.1)}
|
||||||
|
.twk-chip>span>i:first-child{box-shadow:none}
|
||||||
|
.twk-chip svg{position:absolute;top:6px;left:6px;width:13px;height:13px;
|
||||||
|
filter:drop-shadow(0 1px 1px rgba(0,0,0,.3))}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ── useTweaks ───────────────────────────────────────────────────────────────
|
||||||
|
// Single source of truth for tweak values. setTweak persists via the host
|
||||||
|
// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk).
|
||||||
|
function useTweaks(defaults) {
|
||||||
|
const [values, setValues] = React.useState(defaults);
|
||||||
|
// Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a
|
||||||
|
// useState-style call doesn't write a "[object Object]" key into the persisted
|
||||||
|
// JSON block.
|
||||||
|
const setTweak = React.useCallback((keyOrEdits, val) => {
|
||||||
|
const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null
|
||||||
|
? keyOrEdits : { [keyOrEdits]: val };
|
||||||
|
setValues((prev) => ({ ...prev, ...edits }));
|
||||||
|
window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*');
|
||||||
|
// Same-window signal so in-page listeners (deck-stage rail thumbnails)
|
||||||
|
// can react — the parent message only reaches the host, not peers.
|
||||||
|
window.dispatchEvent(new CustomEvent('tweakchange', { detail: edits }));
|
||||||
|
}, []);
|
||||||
|
return [values, setTweak];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TweaksPanel ─────────────────────────────────────────────────────────────
|
||||||
|
// Floating shell. Registers the protocol listener BEFORE announcing
|
||||||
|
// availability — if the announce ran first, the host's activate could land
|
||||||
|
// before our handler exists and the toolbar toggle would silently no-op.
|
||||||
|
// The close button posts __edit_mode_dismissed so the host's toolbar toggle
|
||||||
|
// flips off in lockstep; the host echoes __deactivate_edit_mode back which
|
||||||
|
// is what actually hides the panel.
|
||||||
|
function TweaksPanel({ title = 'Tweaks', children }) {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const dragRef = React.useRef(null);
|
||||||
|
const offsetRef = React.useRef({ x: 16, y: 16 });
|
||||||
|
const PAD = 16;
|
||||||
|
|
||||||
|
const clampToViewport = React.useCallback(() => {
|
||||||
|
const panel = dragRef.current;
|
||||||
|
if (!panel) return;
|
||||||
|
const w = panel.offsetWidth, h = panel.offsetHeight;
|
||||||
|
const maxRight = Math.max(PAD, window.innerWidth - w - PAD);
|
||||||
|
const maxBottom = Math.max(PAD, window.innerHeight - h - PAD);
|
||||||
|
offsetRef.current = {
|
||||||
|
x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)),
|
||||||
|
y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)),
|
||||||
|
};
|
||||||
|
panel.style.right = offsetRef.current.x + 'px';
|
||||||
|
panel.style.bottom = offsetRef.current.y + 'px';
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
clampToViewport();
|
||||||
|
if (typeof ResizeObserver === 'undefined') {
|
||||||
|
window.addEventListener('resize', clampToViewport);
|
||||||
|
return () => window.removeEventListener('resize', clampToViewport);
|
||||||
|
}
|
||||||
|
const ro = new ResizeObserver(clampToViewport);
|
||||||
|
ro.observe(document.documentElement);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, [open, clampToViewport]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const onMsg = (e) => {
|
||||||
|
const t = e?.data?.type;
|
||||||
|
if (t === '__activate_edit_mode') setOpen(true);
|
||||||
|
else if (t === '__deactivate_edit_mode') setOpen(false);
|
||||||
|
};
|
||||||
|
window.addEventListener('message', onMsg);
|
||||||
|
window.parent.postMessage({ type: '__edit_mode_available' }, '*');
|
||||||
|
return () => window.removeEventListener('message', onMsg);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dismiss = () => {
|
||||||
|
setOpen(false);
|
||||||
|
window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*');
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDragStart = (e) => {
|
||||||
|
const panel = dragRef.current;
|
||||||
|
if (!panel) return;
|
||||||
|
const r = panel.getBoundingClientRect();
|
||||||
|
const sx = e.clientX, sy = e.clientY;
|
||||||
|
const startRight = window.innerWidth - r.right;
|
||||||
|
const startBottom = window.innerHeight - r.bottom;
|
||||||
|
const move = (ev) => {
|
||||||
|
offsetRef.current = {
|
||||||
|
x: startRight - (ev.clientX - sx),
|
||||||
|
y: startBottom - (ev.clientY - sy),
|
||||||
|
};
|
||||||
|
clampToViewport();
|
||||||
|
};
|
||||||
|
const up = () => {
|
||||||
|
window.removeEventListener('mousemove', move);
|
||||||
|
window.removeEventListener('mouseup', up);
|
||||||
|
};
|
||||||
|
window.addEventListener('mousemove', move);
|
||||||
|
window.addEventListener('mouseup', up);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<style>{__TWEAKS_STYLE}</style>
|
||||||
|
<div ref={dragRef} className="twk-panel" data-omelette-chrome=""
|
||||||
|
style={{ right: offsetRef.current.x, bottom: offsetRef.current.y }}>
|
||||||
|
<div className="twk-hd" onMouseDown={onDragStart}>
|
||||||
|
<b>{title}</b>
|
||||||
|
<button className="twk-x" aria-label="Close tweaks"
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={dismiss}>✕</button>
|
||||||
|
</div>
|
||||||
|
<div className="twk-body">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Layout helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function TweakSection({ label, children }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="twk-sect">{label}</div>
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakRow({ label, value, children, inline = false }) {
|
||||||
|
return (
|
||||||
|
<div className={inline ? 'twk-row twk-row-h' : 'twk-row'}>
|
||||||
|
<div className="twk-lbl">
|
||||||
|
<span>{label}</span>
|
||||||
|
{value != null && <span className="twk-val">{value}</span>}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Controls ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
|
||||||
|
return (
|
||||||
|
<TweakRow label={label} value={`${value}${unit}`}>
|
||||||
|
<input type="range" className="twk-slider" min={min} max={max} step={step}
|
||||||
|
value={value} onChange={(e) => onChange(Number(e.target.value))} />
|
||||||
|
</TweakRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakToggle({ label, value, onChange }) {
|
||||||
|
return (
|
||||||
|
<div className="twk-row twk-row-h">
|
||||||
|
<div className="twk-lbl"><span>{label}</span></div>
|
||||||
|
<button type="button" className="twk-toggle" data-on={value ? '1' : '0'}
|
||||||
|
role="switch" aria-checked={!!value}
|
||||||
|
onClick={() => onChange(!value)}><i /></button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakRadio({ label, value, options, onChange }) {
|
||||||
|
const trackRef = React.useRef(null);
|
||||||
|
const [dragging, setDragging] = React.useState(false);
|
||||||
|
// The active value is read by pointer-move handlers attached for the lifetime
|
||||||
|
// of a drag — ref it so a stale closure doesn't fire onChange for every move.
|
||||||
|
const valueRef = React.useRef(value);
|
||||||
|
valueRef.current = value;
|
||||||
|
|
||||||
|
// Segments wrap mid-word once per-segment width runs out. The track is
|
||||||
|
// ~248px (280 panel − 28 body pad − 4 seg pad), each button loses 12px
|
||||||
|
// to its own padding, and 11.5px system-ui averages ~6.3px/char — so 2
|
||||||
|
// options fit ~16 chars each, 3 fit ~10. Past that (or >3 options), fall
|
||||||
|
// back to a dropdown rather than wrap.
|
||||||
|
const labelLen = (o) => String(typeof o === 'object' ? o.label : o).length;
|
||||||
|
const maxLen = options.reduce((m, o) => Math.max(m, labelLen(o)), 0);
|
||||||
|
const fitsAsSegments = maxLen <= ({ 2: 16, 3: 10 }[options.length] ?? 0);
|
||||||
|
if (!fitsAsSegments) {
|
||||||
|
// <select> emits strings — map back to the original option value so the
|
||||||
|
// fallback stays type-preserving (numbers, booleans) like the segment path.
|
||||||
|
const resolve = (s) => {
|
||||||
|
const m = options.find((o) => String(typeof o === 'object' ? o.value : o) === s);
|
||||||
|
return m === undefined ? s : typeof m === 'object' ? m.value : m;
|
||||||
|
};
|
||||||
|
return <TweakSelect label={label} value={value} options={options}
|
||||||
|
onChange={(s) => onChange(resolve(s))} />;
|
||||||
|
}
|
||||||
|
const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
|
||||||
|
const idx = Math.max(0, opts.findIndex((o) => o.value === value));
|
||||||
|
const n = opts.length;
|
||||||
|
|
||||||
|
const segAt = (clientX) => {
|
||||||
|
const r = trackRef.current.getBoundingClientRect();
|
||||||
|
const inner = r.width - 4;
|
||||||
|
const i = Math.floor(((clientX - r.left - 2) / inner) * n);
|
||||||
|
return opts[Math.max(0, Math.min(n - 1, i))].value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerDown = (e) => {
|
||||||
|
setDragging(true);
|
||||||
|
const v0 = segAt(e.clientX);
|
||||||
|
if (v0 !== valueRef.current) onChange(v0);
|
||||||
|
const move = (ev) => {
|
||||||
|
if (!trackRef.current) return;
|
||||||
|
const v = segAt(ev.clientX);
|
||||||
|
if (v !== valueRef.current) onChange(v);
|
||||||
|
};
|
||||||
|
const up = () => {
|
||||||
|
setDragging(false);
|
||||||
|
window.removeEventListener('pointermove', move);
|
||||||
|
window.removeEventListener('pointerup', up);
|
||||||
|
};
|
||||||
|
window.addEventListener('pointermove', move);
|
||||||
|
window.addEventListener('pointerup', up);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TweakRow label={label}>
|
||||||
|
<div ref={trackRef} role="radiogroup" onPointerDown={onPointerDown}
|
||||||
|
className={dragging ? 'twk-seg dragging' : 'twk-seg'}>
|
||||||
|
<div className="twk-seg-thumb"
|
||||||
|
style={{ left: `calc(2px + ${idx} * (100% - 4px) / ${n})`,
|
||||||
|
width: `calc((100% - 4px) / ${n})` }} />
|
||||||
|
{opts.map((o) => (
|
||||||
|
<button key={o.value} type="button" role="radio" aria-checked={o.value === value}>
|
||||||
|
{o.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TweakRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakSelect({ label, value, options, onChange }) {
|
||||||
|
return (
|
||||||
|
<TweakRow label={label}>
|
||||||
|
<select className="twk-field" value={value} onChange={(e) => onChange(e.target.value)}>
|
||||||
|
{options.map((o) => {
|
||||||
|
const v = typeof o === 'object' ? o.value : o;
|
||||||
|
const l = typeof o === 'object' ? o.label : o;
|
||||||
|
return <option key={v} value={v}>{l}</option>;
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</TweakRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakText({ label, value, placeholder, onChange }) {
|
||||||
|
return (
|
||||||
|
<TweakRow label={label}>
|
||||||
|
<input className="twk-field" type="text" value={value} placeholder={placeholder}
|
||||||
|
onChange={(e) => onChange(e.target.value)} />
|
||||||
|
</TweakRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) {
|
||||||
|
const clamp = (n) => {
|
||||||
|
if (min != null && n < min) return min;
|
||||||
|
if (max != null && n > max) return max;
|
||||||
|
return n;
|
||||||
|
};
|
||||||
|
const startRef = React.useRef({ x: 0, val: 0 });
|
||||||
|
const onScrubStart = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
startRef.current = { x: e.clientX, val: value };
|
||||||
|
const decimals = (String(step).split('.')[1] || '').length;
|
||||||
|
const move = (ev) => {
|
||||||
|
const dx = ev.clientX - startRef.current.x;
|
||||||
|
const raw = startRef.current.val + dx * step;
|
||||||
|
const snapped = Math.round(raw / step) * step;
|
||||||
|
onChange(clamp(Number(snapped.toFixed(decimals))));
|
||||||
|
};
|
||||||
|
const up = () => {
|
||||||
|
window.removeEventListener('pointermove', move);
|
||||||
|
window.removeEventListener('pointerup', up);
|
||||||
|
};
|
||||||
|
window.addEventListener('pointermove', move);
|
||||||
|
window.addEventListener('pointerup', up);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="twk-num">
|
||||||
|
<span className="twk-num-lbl" onPointerDown={onScrubStart}>{label}</span>
|
||||||
|
<input type="number" value={value} min={min} max={max} step={step}
|
||||||
|
onChange={(e) => onChange(clamp(Number(e.target.value)))} />
|
||||||
|
{unit && <span className="twk-num-unit">{unit}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relative-luminance contrast pick — checkmarks drawn over a swatch need to
|
||||||
|
// read on both #111 and #fafafa without per-option configuration. Hex input
|
||||||
|
// only (#rgb / #rrggbb); named or rgb()/hsl() colors fall through to "light".
|
||||||
|
function __twkIsLight(hex) {
|
||||||
|
const h = String(hex).replace('#', '');
|
||||||
|
const x = h.length === 3 ? h.replace(/./g, (c) => c + c) : h.padEnd(6, '0');
|
||||||
|
const n = parseInt(x.slice(0, 6), 16);
|
||||||
|
if (Number.isNaN(n)) return true;
|
||||||
|
const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
|
||||||
|
return r * 299 + g * 587 + b * 114 > 148000;
|
||||||
|
}
|
||||||
|
|
||||||
|
const __TwkCheck = ({ light }) => (
|
||||||
|
<svg viewBox="0 0 14 14" aria-hidden="true">
|
||||||
|
<path d="M3 7.2 5.8 10 11 4.2" fill="none" strokeWidth="2.2"
|
||||||
|
strokeLinecap="round" strokeLinejoin="round"
|
||||||
|
stroke={light ? 'rgba(0,0,0,.78)' : '#fff'} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// TweakColor — curated color/palette picker. Each option is either a single
|
||||||
|
// hex string or an array of 1-5 hex strings; the card adapts — a lone color
|
||||||
|
// renders solid, a palette renders colors[0] as the hero (left ~2/3) with the
|
||||||
|
// rest stacked in a sharp column on the right. onChange emits the
|
||||||
|
// option in the shape it was passed (string stays string, array stays array).
|
||||||
|
// Without options it falls back to the native color input for back-compat.
|
||||||
|
function TweakColor({ label, value, options, onChange }) {
|
||||||
|
if (!options || !options.length) {
|
||||||
|
return (
|
||||||
|
<div className="twk-row twk-row-h">
|
||||||
|
<div className="twk-lbl"><span>{label}</span></div>
|
||||||
|
<input type="color" className="twk-swatch" value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Native <input type=color> emits lowercase hex per the HTML spec, so
|
||||||
|
// compare case-insensitively. String() guards JSON.stringify(undefined),
|
||||||
|
// which returns the primitive undefined (no .toLowerCase).
|
||||||
|
const key = (o) => String(JSON.stringify(o)).toLowerCase();
|
||||||
|
const cur = key(value);
|
||||||
|
return (
|
||||||
|
<TweakRow label={label}>
|
||||||
|
<div className="twk-chips" role="radiogroup">
|
||||||
|
{options.map((o, i) => {
|
||||||
|
const colors = Array.isArray(o) ? o : [o];
|
||||||
|
const [hero, ...rest] = colors;
|
||||||
|
const sup = rest.slice(0, 4);
|
||||||
|
const on = key(o) === cur;
|
||||||
|
return (
|
||||||
|
<button key={i} type="button" className="twk-chip" role="radio"
|
||||||
|
aria-checked={on} data-on={on ? '1' : '0'}
|
||||||
|
aria-label={colors.join(', ')} title={colors.join(' · ')}
|
||||||
|
style={{ background: hero }}
|
||||||
|
onClick={() => onChange(o)}>
|
||||||
|
{sup.length > 0 && (
|
||||||
|
<span>
|
||||||
|
{sup.map((c, j) => <i key={j} style={{ background: c }} />)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{on && <__TwkCheck light={__twkIsLight(hero)} />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</TweakRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakButton({ label, onClick, secondary = false }) {
|
||||||
|
return (
|
||||||
|
<button type="button" className={secondary ? 'twk-btn secondary' : 'twk-btn'}
|
||||||
|
onClick={onClick}>{label}</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(window, {
|
||||||
|
useTweaks, TweaksPanel, TweakSection, TweakRow,
|
||||||
|
TweakSlider, TweakToggle, TweakRadio, TweakSelect,
|
||||||
|
TweakText, TweakNumber, TweakColor, TweakButton,
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue