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:
parent
391db9aa20
commit
c02fa2652b
58
js/router.js
58
js/router.js
|
|
@ -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: '⇄' },
|
||||
{ page: 'wallet-timeline-visualization.html', label: 'Balance River', icon: '≈' },
|
||||
{ page: 'wallet-visualization.html', label: 'Single-Chain Sankey', icon: '☰' },
|
||||
];
|
||||
|
||||
/**
|
||||
* 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 } }));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 • 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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 • 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
|
||||
|
|
|
|||
Loading…
Reference in New Issue