Add view navigation tabs and zoomable diagrams

Users can now switch between Multi-Chain Flow, Balance River, and
Single-Chain Sankey views without returning to the homepage — wallet
address is preserved across navigation. Added d3.zoom() to Sankey
and multi-chain flow charts with pan, scroll-zoom, and reset controls.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-13 08:47:26 -07:00
parent 391db9aa20
commit c02fa2652b
4 changed files with 156 additions and 22 deletions

View File

@ -59,6 +59,45 @@ const Router = (() => {
return /^0x[a-fA-F0-9]{40}$/.test(address);
}
// ─── View Nav Styles (injected once) ────────────────────────
let stylesInjected = false;
function injectViewNavStyles() {
if (stylesInjected) return;
stylesInjected = true;
const style = document.createElement('style');
style.textContent = `
.view-nav {
display: flex; justify-content: center; gap: 4px; margin-top: 12px;
}
.view-tab {
padding: 8px 16px; border-radius: 8px 8px 0 0;
border: 1px solid rgba(255,255,255,0.08); border-bottom: 2px solid transparent;
background: rgba(255,255,255,0.02); color: #888;
text-decoration: none; font-size: 0.85rem; font-weight: 500;
transition: all 0.2s; display: flex; align-items: center; gap: 6px;
white-space: nowrap;
}
.view-tab:hover { background: rgba(255,255,255,0.06); color: #ccc; }
.view-tab.active {
border-bottom-color: #00d4ff; color: #00d4ff;
background: rgba(0,212,255,0.08);
}
.view-icon { font-size: 1rem; }
@media (max-width: 640px) {
.view-nav { gap: 2px; }
.view-tab { padding: 6px 10px; font-size: 0.75rem; }
}
`;
document.head.appendChild(style);
}
// ─── View Definitions ───────────────────────────────────────
const VIEWS = [
{ page: 'wallet-multichain-visualization.html', label: 'Multi-Chain Flow', icon: '&#8644;' },
{ page: 'wallet-timeline-visualization.html', label: 'Balance River', icon: '&#8776;' },
{ page: 'wallet-visualization.html', label: 'Single-Chain Sankey', icon: '&#9776;' },
];
/**
* Create a standard wallet address input bar for visualization pages.
* Returns the input element for event binding.
@ -68,6 +107,20 @@ const Router = (() => {
const container = document.getElementById(containerId);
if (!container) return null;
injectViewNavStyles();
// Detect current page for active tab
const currentPage = window.location.pathname.split('/').pop() || 'index.html';
// Build view nav tabs
const viewTabs = VIEWS.map(v => {
const isActive = currentPage === v.page;
const href = buildUrl(v.page, address, getParams().chain, getParams().chainId);
return `<a href="${href}" class="view-tab${isActive ? ' active' : ''}" title="${v.label}">
<span class="view-icon">${v.icon}</span> ${v.label}
</a>`;
}).join('');
container.innerHTML = `
<div class="address-bar">
<div class="address-bar-inner">
@ -79,6 +132,7 @@ const Router = (() => {
value="${address}" spellcheck="false" autocomplete="off" />
<button id="load-wallet-btn" title="Load wallet">Explore</button>
</div>
<div class="view-nav">${viewTabs}</div>
</div>
`;
@ -93,6 +147,10 @@ const Router = (() => {
return;
}
updateParams({ address: addr });
// Update nav tab hrefs with new address
container.querySelectorAll('.view-tab').forEach((tab, i) => {
tab.href = buildUrl(VIEWS[i].page, addr, getParams().chain, getParams().chainId);
});
// Dispatch custom event for the page to handle
window.dispatchEvent(new CustomEvent('wallet-changed', { detail: { address: addr } }));
}

View File

@ -7,7 +7,7 @@
<script src="https://d3js.org/d3.v7.min.js"></script>
<script src="js/safe-api.js?v=4"></script>
<script src="js/data-transform.js?v=2"></script>
<script src="js/router.js?v=2"></script>
<script src="js/router.js?v=5"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
@ -66,8 +66,16 @@
/* Flow */
.flow-section { background: rgba(255,255,255,0.02); border-radius: 16px; padding: 24px; margin-bottom: 30px; border: 1px solid rgba(255,255,255,0.1); }
.flow-section h2 { margin-bottom: 20px; font-size: 1.2rem; color: #00d4ff; }
#flow-chart { width: 100%; min-height: 400px; }
.flow-section h2 { margin-bottom: 12px; font-size: 1.2rem; color: #00d4ff; }
#flow-chart { width: 100%; min-height: 400px; overflow: hidden; cursor: grab; }
#flow-chart svg { display: block; }
.zoom-hint { text-align: center; font-size: 0.8rem; color: #555; margin-bottom: 12px; }
.zoom-hint button {
padding: 4px 12px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.15);
background: rgba(255,255,255,0.05); color: #ccc; cursor: pointer; font-size: 0.8rem;
margin-left: 12px;
}
.zoom-hint button:hover { background: rgba(255,255,255,0.1); }
/* Tables */
.tables-section { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin-bottom: 30px; }
@ -194,6 +202,7 @@
<div class="flow-section">
<h2>Transaction Flow Diagram</h2>
<div class="zoom-hint">Scroll to zoom &bull; Drag to pan <button id="reset-flow-zoom">Reset View</button></div>
<div id="flow-chart"></div>
</div>
@ -287,6 +296,9 @@
document.getElementById('outflow-count').textContent = filteredOut.length;
}
let flowZoom = null;
let flowSvg = null;
function drawFlowChart(chain) {
const container = document.getElementById('flow-chart');
if (!container) return;
@ -302,7 +314,12 @@
const h = 400;
const svg = d3.select('#flow-chart').append('svg')
.attr('width', '100%').attr('height', h).attr('viewBox', `0 0 ${w} ${h}`);
.attr('width', '100%').attr('height', h).attr('viewBox', `0 0 ${w} ${h}`)
.style('cursor', 'grab');
flowSvg = svg;
// Zoom group wraps all content
const g = svg.append('g');
const inflows = flows.filter(f => f.to === 'Safe Wallet');
const outflows = flows.filter(f => f.from === 'Safe Wallet');
@ -310,11 +327,11 @@
const walletX = w / 2, walletY = h / 2;
// Central wallet
svg.append('rect').attr('x', walletX - 70).attr('y', walletY - 35).attr('width', 140).attr('height', 70)
g.append('rect').attr('x', walletX - 70).attr('y', walletY - 35).attr('width', 140).attr('height', 70)
.attr('rx', 12).attr('fill', '#00d4ff').attr('opacity', 0.9);
svg.append('text').attr('x', walletX).attr('y', walletY - 8).attr('text-anchor', 'middle')
g.append('text').attr('x', walletX).attr('y', walletY - 8).attr('text-anchor', 'middle')
.attr('fill', '#000').attr('font-weight', 'bold').attr('font-size', '13px').text('Safe Wallet');
svg.append('text').attr('x', walletX).attr('y', walletY + 12).attr('text-anchor', 'middle')
g.append('text').attr('x', walletX).attr('y', walletY + 12).attr('text-anchor', 'middle')
.attr('fill', '#000').attr('font-family', 'monospace').attr('font-size', '10px')
.text(DataTransform.shortenAddress(Router.getParams().address));
@ -336,14 +353,14 @@
const path = d3.path();
path.moveTo(sourceX + 60, y);
path.bezierCurveTo(sourceX + 150, y, walletX - 150, walletY, walletX - 70, walletY);
svg.append('path').attr('d', path.toString()).attr('fill', 'none')
g.append('path').attr('d', path.toString()).attr('fill', 'none')
.attr('stroke', '#4ade80').attr('stroke-width', Math.max(2, Math.log(flow.value + 1) * 1.2)).attr('stroke-opacity', 0.6);
svg.append('rect').attr('x', sourceX - 60).attr('y', y - 14).attr('width', 120).attr('height', 28)
g.append('rect').attr('x', sourceX - 60).attr('y', y - 14).attr('width', 120).attr('height', 28)
.attr('rx', 6).attr('fill', color).attr('opacity', 0.3).attr('stroke', color);
svg.append('text').attr('x', sourceX).attr('y', y + 4).attr('text-anchor', 'middle')
g.append('text').attr('x', sourceX).attr('y', y + 4).attr('text-anchor', 'middle')
.attr('fill', '#e0e0e0').attr('font-family', 'monospace').attr('font-size', '10px').text(flow.from);
svg.append('text').attr('x', sourceX + 100).attr('y', y - 20)
g.append('text').attr('x', sourceX + 100).attr('y', y - 20)
.attr('fill', '#4ade80').attr('font-size', '9px').text(`+${flow.value.toLocaleString()} ${flow.token}`);
});
@ -357,20 +374,39 @@
const path = d3.path();
path.moveTo(walletX + 70, walletY);
path.bezierCurveTo(walletX + 150, walletY, targetX - 150, y, targetX - 60, y);
svg.append('path').attr('d', path.toString()).attr('fill', 'none')
g.append('path').attr('d', path.toString()).attr('fill', 'none')
.attr('stroke', '#f87171').attr('stroke-width', Math.max(2, Math.log(flow.value + 1) * 1.2)).attr('stroke-opacity', 0.6);
svg.append('rect').attr('x', targetX - 60).attr('y', y - 14).attr('width', 120).attr('height', 28)
g.append('rect').attr('x', targetX - 60).attr('y', y - 14).attr('width', 120).attr('height', 28)
.attr('rx', 6).attr('fill', color).attr('opacity', 0.3).attr('stroke', color);
svg.append('text').attr('x', targetX).attr('y', y + 4).attr('text-anchor', 'middle')
g.append('text').attr('x', targetX).attr('y', y + 4).attr('text-anchor', 'middle')
.attr('fill', '#e0e0e0').attr('font-family', 'monospace').attr('font-size', '10px').text(flow.to);
svg.append('text').attr('x', targetX - 100).attr('y', y - 20)
g.append('text').attr('x', targetX - 100).attr('y', y - 20)
.attr('fill', '#f87171').attr('font-size', '9px').text(`-${flow.value.toLocaleString()} ${flow.token}`);
});
const chainLabel = chain === 'all' ? 'All Chains' : chain.charAt(0).toUpperCase() + chain.slice(1);
svg.append('text').attr('x', w / 2).attr('y', 25).attr('text-anchor', 'middle')
g.append('text').attr('x', w / 2).attr('y', 25).attr('text-anchor', 'middle')
.attr('fill', '#888').attr('font-size', '12px').text(`${chainLabel} - Major Fund Flows`);
// Zoom behavior
flowZoom = d3.zoom()
.scaleExtent([0.3, 5])
.on('zoom', (event) => {
g.attr('transform', event.transform);
})
.on('start', () => svg.style('cursor', 'grabbing'))
.on('end', () => svg.style('cursor', 'grab'));
svg.call(flowZoom);
// Reset zoom button
const resetBtn = document.getElementById('reset-flow-zoom');
if (resetBtn) {
resetBtn.onclick = () => {
svg.transition().duration(500).call(flowZoom.transform, d3.zoomIdentity);
};
}
}
function hexToRgb(hex) {

View File

@ -7,7 +7,7 @@
<script src="https://d3js.org/d3.v7.min.js"></script>
<script src="js/safe-api.js?v=4"></script>
<script src="js/data-transform.js?v=2"></script>
<script src="js/router.js?v=2"></script>
<script src="js/router.js?v=5"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {

View File

@ -8,7 +8,7 @@
<script src="https://cdn.jsdelivr.net/npm/d3-sankey@0.12.3/dist/d3-sankey.min.js"></script>
<script src="js/safe-api.js?v=4"></script>
<script src="js/data-transform.js?v=2"></script>
<script src="js/router.js?v=2"></script>
<script src="js/router.js?v=5"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
@ -80,7 +80,18 @@
#sankey-chart {
background: rgba(255,255,255,0.02); border-radius: 12px;
border: 1px solid rgba(255,255,255,0.1); margin-bottom: 30px;
overflow: hidden; cursor: grab;
}
#sankey-chart svg { display: block; }
.zoom-controls {
display: flex; justify-content: center; gap: 16px; align-items: center;
margin-bottom: 12px; font-size: 0.8rem; color: #666;
}
.zoom-controls button {
padding: 6px 14px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.15);
background: rgba(255,255,255,0.05); color: #ccc; cursor: pointer; font-size: 0.8rem;
}
.zoom-controls button:hover { background: rgba(255,255,255,0.1); }
.node rect { stroke: #333; stroke-width: 1px; }
.node text { font-size: 11px; fill: #e0e0e0; }
.link { fill: none; stroke-opacity: 0.4; }
@ -307,6 +318,10 @@
<div class="legend-item"><div class="legend-color" style="background: #f87171"></div> Outflow</div>
</div>
<div class="zoom-controls">
<span>Scroll to zoom &bull; Drag to pan</span>
<button id="reset-sankey-zoom">Reset View</button>
</div>
<div id="sankey-chart"></div>
<div class="tables-grid">
@ -358,6 +373,8 @@
drawSankey(sankeyData, chainId);
}
let sankeyZoom = null;
function drawSankey(data, chainId) {
const container = document.getElementById('sankey-chart');
container.innerHTML = '';
@ -368,9 +385,13 @@
const svg = d3.select('#sankey-chart')
.append('svg')
.attr('width', width)
.attr('width', '100%')
.attr('height', height)
.attr('viewBox', `0 0 ${width} ${height}`);
.attr('viewBox', `0 0 ${width} ${height}`)
.style('cursor', 'grab');
// Zoom group wraps all content
const zoomGroup = svg.append('g');
const sankey = d3.sankey()
.nodeWidth(20)
@ -383,7 +404,7 @@
});
// Links
svg.append('g')
zoomGroup.append('g')
.selectAll('path')
.data(links)
.join('path')
@ -398,7 +419,7 @@
.text(d => `${d.source.name} → ${d.target.name}\n${d.value.toLocaleString()} ${d.token}`);
// Nodes
const node = svg.append('g')
const node = zoomGroup.append('g')
.selectAll('g')
.data(nodes)
.join('g');
@ -420,6 +441,25 @@
.style('font-family', 'monospace')
.style('font-size', d => d.type === 'wallet' ? '14px' : '11px')
.style('font-weight', d => d.type === 'wallet' ? 'bold' : 'normal');
// Zoom behavior
sankeyZoom = d3.zoom()
.scaleExtent([0.3, 5])
.on('zoom', (event) => {
zoomGroup.attr('transform', event.transform);
})
.on('start', () => svg.style('cursor', 'grabbing'))
.on('end', () => svg.style('cursor', 'grab'));
svg.call(sankeyZoom);
// Reset zoom button
const resetBtn = document.getElementById('reset-sankey-zoom');
if (resetBtn) {
resetBtn.onclick = () => {
svg.transition().duration(500).call(sankeyZoom.transform, d3.zoomIdentity);
};
}
}
// Listen for wallet changes