fix: improve balance river accuracy, scaling, and waterfall flow direction

Switch to all-transactions API endpoint for proper tokenInfo with correct
decimals (fixes ERC20 amounts like USDC/6 decimals being treated as 18).
Fix flow scale to use maxTx instead of maxBalance so individual flows are
visible. Change waterfall flows from vertical to diagonal down-right curves.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-18 12:50:59 -07:00
parent a6be330708
commit f197a20d0f
2 changed files with 87 additions and 42 deletions

View File

@ -157,7 +157,8 @@ const SafeAPI = (() => {
}
/**
* Fetch all-transactions (combines multisig + module + incoming)
* Fetch all-transactions (combines multisig + module + incoming, with inter-page delays)
* Returns enriched transactions with transfers[] containing proper tokenInfo.
*/
async function getAllTransactions(address, chainId, limit = 100) {
const allTxs = [];
@ -168,7 +169,8 @@ const SafeAPI = (() => {
if (!data || !data.results) break;
allTxs.push(...data.results);
url = data.next;
if (allTxs.length >= 1000) break;
if (allTxs.length >= 3000) break;
if (url) await sleep(400);
}
return allTxs;
}
@ -197,7 +199,7 @@ const SafeAPI = (() => {
/**
* Fetch comprehensive wallet data for a single chain.
* Sequential within the same chain with delays to respect rate limits.
* Uses all-transactions endpoint for enriched transfer data with proper tokenInfo.
* Returns { chainId, info, balances, outgoing, incoming }
*/
async function fetchChainData(address, chainId) {
@ -205,9 +207,34 @@ const SafeAPI = (() => {
await sleep(600);
const balances = await getBalances(address, chainId);
await sleep(600);
const outgoing = await getAllMultisigTransactions(address, chainId);
await sleep(600);
const incoming = await getAllIncomingTransfers(address, chainId);
// all-transactions includes transfers[] with proper tokenInfo (decimals, symbol)
// This is more accurate than parsing dataDecoded which loses decimal info
const allTxs = await getAllTransactions(address, chainId);
const addrLower = address.toLowerCase();
const outgoing = [];
const incoming = [];
for (const tx of allTxs) {
// Collect multisig transactions as outgoing (they have transfers[] with tokenInfo)
if (tx.txType === 'MULTISIG_TRANSACTION') {
outgoing.push(tx);
}
// Extract incoming transfers from all transaction transfer events
if (tx.transfers) {
for (const t of tx.transfers) {
if (t.to?.toLowerCase() === addrLower &&
t.from?.toLowerCase() !== addrLower) {
incoming.push({
...t,
executionDate: t.executionDate || tx.executionDate,
});
}
}
}
}
return { chainId, info, balances, outgoing, incoming };
}

View File

@ -162,7 +162,7 @@
let currentFilter = { chain: 'all', token: 'all' };
let currentZoomTransform = d3.zoomIdentity;
let svgElement, mainGroup, xScale, xAxisGroup, contentGroup;
let txs, balanceData, maxBalance, maxTx, balanceScale, flowScale;
let txs, balanceData, maxBalance, maxTx, balanceScale;
let margin, width, height, centerY, timeExtent, timePadding;
async function loadWallet(address) {
@ -315,19 +315,17 @@
const defs = mainGroup.append('defs');
const inflowGradient = defs.append('linearGradient').attr('id', 'inflowGradient').attr('x1', '0%').attr('y1', '0%').attr('x2', '0%').attr('y2', '100%');
inflowGradient.append('stop').attr('offset', '0%').attr('stop-color', '#4ade80').attr('stop-opacity', 0.3);
inflowGradient.append('stop').attr('offset', '30%').attr('stop-color', '#4ade80').attr('stop-opacity', 0.8);
inflowGradient.append('stop').attr('offset', '60%').attr('stop-color', '#22d3ee').attr('stop-opacity', 0.9);
inflowGradient.append('stop').attr('offset', '85%').attr('stop-color', '#00d4ff').attr('stop-opacity', 0.95);
const inflowGradient = defs.append('linearGradient').attr('id', 'inflowGradient').attr('x1', '0%').attr('y1', '0%').attr('x2', '100%').attr('y2', '100%');
inflowGradient.append('stop').attr('offset', '0%').attr('stop-color', '#4ade80').attr('stop-opacity', 0.4);
inflowGradient.append('stop').attr('offset', '40%').attr('stop-color', '#4ade80').attr('stop-opacity', 0.85);
inflowGradient.append('stop').attr('offset', '70%').attr('stop-color', '#22d3ee').attr('stop-opacity', 0.9);
inflowGradient.append('stop').attr('offset', '100%').attr('stop-color', '#00d4ff').attr('stop-opacity', 1);
const outflowGradient = defs.append('linearGradient').attr('id', 'outflowGradient').attr('x1', '0%').attr('y1', '0%').attr('x2', '0%').attr('y2', '100%');
const outflowGradient = defs.append('linearGradient').attr('id', 'outflowGradient').attr('x1', '0%').attr('y1', '0%').attr('x2', '100%').attr('y2', '100%');
outflowGradient.append('stop').attr('offset', '0%').attr('stop-color', '#00d4ff').attr('stop-opacity', 1);
outflowGradient.append('stop').attr('offset', '15%').attr('stop-color', '#00d4ff').attr('stop-opacity', 0.95);
outflowGradient.append('stop').attr('offset', '40%').attr('stop-color', '#f472b6').attr('stop-opacity', 0.9);
outflowGradient.append('stop').attr('offset', '70%').attr('stop-color', '#f87171').attr('stop-opacity', 0.8);
outflowGradient.append('stop').attr('offset', '100%').attr('stop-color', '#f87171').attr('stop-opacity', 0.3);
outflowGradient.append('stop').attr('offset', '30%').attr('stop-color', '#f472b6').attr('stop-opacity', 0.9);
outflowGradient.append('stop').attr('offset', '60%').attr('stop-color', '#f87171').attr('stop-opacity', 0.85);
outflowGradient.append('stop').attr('offset', '100%').attr('stop-color', '#f87171').attr('stop-opacity', 0.4);
const riverGradient = defs.append('linearGradient').attr('id', 'riverGradient').attr('x1', '0%').attr('y1', '0%').attr('x2', '0%').attr('y2', '100%');
riverGradient.append('stop').attr('offset', '0%').attr('stop-color', '#00d4ff').attr('stop-opacity', 0.9);
@ -358,8 +356,7 @@
maxBalance = d3.max(balanceData, d => d.balance) || 1;
maxTx = d3.max(txs, d => d.usd) || 1;
balanceScale = d3.scaleLinear().domain([0, maxBalance]).range([12, 120]);
flowScale = d3.scaleLinear().domain([0, maxBalance]).range([0, 120 - 12]);
balanceScale = d3.scaleLinear().domain([0, maxBalance]).range([8, 120]);
contentGroup = mainGroup.append('g').attr('clip-path', 'url(#chart-clip)').attr('class', 'content-group');
@ -397,10 +394,10 @@
if (currentZoomTransform !== d3.zoomIdentity) svgElement.call(zoom.transform, currentZoomTransform);
window.currentZoom = zoom;
mainGroup.append('text').attr('x', width / 2).attr('y', -60).attr('text-anchor', 'middle')
.attr('fill', '#4ade80').attr('font-size', '14px').attr('font-weight', 'bold').text('INFLOWS');
mainGroup.append('text').attr('x', width / 2).attr('y', height + 65).attr('text-anchor', 'middle')
.attr('fill', '#f87171').attr('font-size', '14px').attr('font-weight', 'bold').text('OUTFLOWS');
mainGroup.append('text').attr('x', 30).attr('y', -60).attr('text-anchor', 'start')
.attr('fill', '#4ade80').attr('font-size', '13px').attr('font-weight', 'bold').attr('opacity', 0.8).text('INFLOWS ↘');
mainGroup.append('text').attr('x', width - 30).attr('y', height + 65).attr('text-anchor', 'end')
.attr('fill', '#f87171').attr('font-size', '13px').attr('font-weight', 'bold').attr('opacity', 0.8).text('↘ OUTFLOWS');
}
function zoomed(event) {
@ -444,8 +441,9 @@
contentGroup.append('path').datum(balanceData).attr('fill', 'none').attr('stroke', 'rgba(0,0,0,0.2)').attr('stroke-width', 1)
.attr('d', d3.line().x(d => scale(d.date)).y(d => centerY + balanceScale(d.balance) / 2).curve(smoothCurve));
// Sankey-proportional flows: width proportional to river width at that point
// Diagonal waterfall flows with sankey-proportional widths
const flowHeight = 80;
const xOffset = flowHeight * 0.7; // horizontal displacement for diagonal
let prevBalance = 0;
txs.forEach(tx => {
@ -472,18 +470,28 @@
const riverBottomBefore = centerY + balanceScale(balanceBefore) / 2;
if (tx.type === 'in') {
// Inflow: narrow at top (source), wide at river
// Inflow waterfall: originates above-left, flows diagonally down-right into river
const endY = riverTopAfter;
const startY = endY - flowHeight;
const cpY1 = startY + flowHeight * 0.55;
const cpY2 = startY + flowHeight * 0.75;
const startX = x - xOffset;
const endX = x;
const path = d3.path();
// Left edge: narrow at top, flares at bottom
path.moveTo(x - farEndHalf, startY);
path.bezierCurveTo(x - farEndHalf, cpY1, x - riverEndHalf, cpY2, x - riverEndHalf, endY);
path.lineTo(x + riverEndHalf, endY);
// Right edge: flares at bottom, narrow at top
path.bezierCurveTo(x + riverEndHalf, cpY2, x + farEndHalf, cpY1, x + farEndHalf, startY);
// Left edge: narrow at top-left, curves down-right to river
path.moveTo(startX - farEndHalf, startY);
path.bezierCurveTo(
startX - farEndHalf, startY + flowHeight * 0.55,
endX - riverEndHalf, endY - flowHeight * 0.45,
endX - riverEndHalf, endY
);
// Bottom: full proportional width at river surface
path.lineTo(endX + riverEndHalf, endY);
// Right edge: curve back up-left to narrow top
path.bezierCurveTo(
endX + riverEndHalf, endY - flowHeight * 0.45,
startX + farEndHalf, startY + flowHeight * 0.55,
startX + farEndHalf, startY
);
path.closePath();
contentGroup.append('path').attr('d', path.toString()).attr('fill', 'url(#inflowGradient)')
.attr('class', 'flow-path').attr('opacity', 0.85)
@ -491,18 +499,28 @@
.on('mousemove', event => moveTooltip(event))
.on('mouseout', hideTooltip);
} else {
// Outflow: wide at river, narrow at bottom
// Outflow waterfall: exits river bottom, flows diagonally down-right away
const startY = riverBottomBefore;
const endY = startY + flowHeight;
const cpY1 = startY + flowHeight * 0.25;
const cpY2 = startY + flowHeight * 0.45;
const startX = x;
const endX = x + xOffset;
const path = d3.path();
// Left edge: wide at top (river), narrows at bottom
path.moveTo(x - riverEndHalf, startY);
path.bezierCurveTo(x - riverEndHalf, cpY1, x - farEndHalf, cpY2, x - farEndHalf, endY);
path.lineTo(x + farEndHalf, endY);
// Right edge: narrows at bottom, wide at top
path.bezierCurveTo(x + farEndHalf, cpY2, x + riverEndHalf, cpY1, x + riverEndHalf, startY);
// Left edge: wide at river, curves down-right to narrow end
path.moveTo(startX - riverEndHalf, startY);
path.bezierCurveTo(
startX - riverEndHalf, startY + flowHeight * 0.45,
endX - farEndHalf, endY - flowHeight * 0.55,
endX - farEndHalf, endY
);
// Bottom: narrow end
path.lineTo(endX + farEndHalf, endY);
// Right edge: curve back up to river
path.bezierCurveTo(
endX + farEndHalf, endY - flowHeight * 0.55,
startX + riverEndHalf, startY + flowHeight * 0.45,
startX + riverEndHalf, startY
);
path.closePath();
contentGroup.append('path').attr('d', path.toString()).attr('fill', 'url(#outflowGradient)')
.attr('class', 'flow-path').attr('opacity', 0.85)