feat(rwallet): port D3 visualizations from rwallet-online
Add 3 interactive D3 visualizations (Balance River Timeline, Multi-Chain Flow Map, Single-Chain Sankey) as tabbed views alongside the existing balance table. D3 loaded lazily from CDN on first viz tab click. Demo mode shows all visualizations with mock TEC Commons Fund data. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
aad08c2eec
commit
5ddc345341
|
|
@ -6,6 +6,11 @@
|
|||
* via EIP-6963 + SIWE.
|
||||
*/
|
||||
|
||||
import { transformToTimelineData, transformToSankeyData, transformToMultichainData } from "../lib/data-transform";
|
||||
import type { TimelineEntry, SankeyData, MultichainData } from "../lib/data-transform";
|
||||
import { loadD3, renderTimeline, renderFlowChart, renderSankey } from "../lib/wallet-viz";
|
||||
import { DEMO_TIMELINE_DATA, DEMO_SANKEY_DATA, DEMO_MULTICHAIN_DATA } from "../lib/wallet-demo-data";
|
||||
|
||||
interface ChainInfo {
|
||||
chainId: string;
|
||||
name: string;
|
||||
|
|
@ -69,6 +74,8 @@ const EXAMPLE_WALLETS = [
|
|||
{ name: "Vitalik.eth", address: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", type: "EOA" },
|
||||
];
|
||||
|
||||
type ViewTab = "balances" | "timeline" | "flow" | "sankey";
|
||||
|
||||
class FolkWalletViewer extends HTMLElement {
|
||||
private shadow: ShadowRoot;
|
||||
private address = "";
|
||||
|
|
@ -90,6 +97,17 @@ class FolkWalletViewer extends HTMLElement {
|
|||
private linkingInProgress = false;
|
||||
private linkError = "";
|
||||
|
||||
// Visualization state
|
||||
private activeView: ViewTab = "balances";
|
||||
private transfers: Map<string, any> | null = null;
|
||||
private transfersLoading = false;
|
||||
private d3Ready = false;
|
||||
private vizData: {
|
||||
timeline?: TimelineEntry[];
|
||||
sankey?: SankeyData;
|
||||
multichain?: MultichainData;
|
||||
} = {};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.shadow = this.attachShadow({ mode: "open" });
|
||||
|
|
@ -179,6 +197,12 @@ class FolkWalletViewer extends HTMLElement {
|
|||
{ tokenAddress: "0xDDAfbb505ad214D7b80b1f830fcCc89B60fb7A83", token: { name: "USD Coin", symbol: "USDC", decimals: 6 }, balance: "15750000000", fiatBalance: "15750", fiatConversion: "1" },
|
||||
{ tokenAddress: "0x4f4F9b8D5B4d0Dc10506e5551B0513B61fD59e75", token: { name: "Giveth", symbol: "GIV", decimals: 18 }, balance: "500000000000000000000000", fiatBalance: "2500", fiatConversion: "0.005" },
|
||||
];
|
||||
// Pre-load demo viz data
|
||||
this.vizData = {
|
||||
timeline: DEMO_TIMELINE_DATA,
|
||||
sankey: DEMO_SANKEY_DATA,
|
||||
multichain: DEMO_MULTICHAIN_DATA,
|
||||
};
|
||||
this.render();
|
||||
}
|
||||
|
||||
|
|
@ -201,6 +225,9 @@ class FolkWalletViewer extends HTMLElement {
|
|||
this.detectedChains = [];
|
||||
this.balances = [];
|
||||
this.walletType = "";
|
||||
this.activeView = "balances";
|
||||
this.transfers = null;
|
||||
this.vizData = {};
|
||||
this.render();
|
||||
|
||||
try {
|
||||
|
|
@ -263,6 +290,131 @@ class FolkWalletViewer extends HTMLElement {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Transfer Loading for Visualizations ──
|
||||
|
||||
private async loadTransfers() {
|
||||
if (this.isDemo || this.transfers || this.transfersLoading) return;
|
||||
if (this.walletType !== "safe") return; // Transfer API only for Safes
|
||||
|
||||
this.transfersLoading = true;
|
||||
this.render();
|
||||
|
||||
try {
|
||||
const base = this.getApiBase();
|
||||
const chainDataMap = new Map<string, any>();
|
||||
|
||||
// Fan out requests to all detected chains
|
||||
await Promise.allSettled(
|
||||
this.detectedChains.map(async (ch) => {
|
||||
try {
|
||||
const res = await fetch(`${base}/api/safe/${ch.chainId}/${this.address}/transfers?limit=200`);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
// Parse results into incoming/outgoing
|
||||
const incoming: any[] = [];
|
||||
const outgoing: any[] = [];
|
||||
const results = data.results || [];
|
||||
for (const tx of results) {
|
||||
if (tx.txType === "MULTISIG_TRANSACTION") {
|
||||
outgoing.push(tx);
|
||||
}
|
||||
// Incoming transfers from transfers array inside txs
|
||||
if (tx.transfers) {
|
||||
for (const t of tx.transfers) {
|
||||
if (t.to?.toLowerCase() === this.address.toLowerCase()) {
|
||||
incoming.push(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
chainDataMap.set(ch.chainId, { incoming, outgoing, chainId: ch.chainId });
|
||||
} catch {}
|
||||
}),
|
||||
);
|
||||
|
||||
this.transfers = chainDataMap;
|
||||
this.computeVizData();
|
||||
} catch {
|
||||
this.transfers = new Map();
|
||||
}
|
||||
|
||||
this.transfersLoading = false;
|
||||
this.render();
|
||||
if (this.activeView !== "balances") {
|
||||
requestAnimationFrame(() => this.drawActiveVisualization());
|
||||
}
|
||||
}
|
||||
|
||||
private computeVizData() {
|
||||
if (!this.transfers) return;
|
||||
|
||||
this.vizData.timeline = transformToTimelineData(this.transfers, this.address, CHAIN_NAMES);
|
||||
|
||||
// Sankey for selected chain
|
||||
if (this.selectedChain && this.transfers.has(this.selectedChain)) {
|
||||
this.vizData.sankey = transformToSankeyData(
|
||||
this.transfers.get(this.selectedChain),
|
||||
this.address,
|
||||
this.selectedChain,
|
||||
);
|
||||
}
|
||||
|
||||
this.vizData.multichain = transformToMultichainData(this.transfers, this.address, CHAIN_NAMES);
|
||||
}
|
||||
|
||||
private async drawActiveVisualization() {
|
||||
const container = this.shadow.querySelector("#viz-container") as HTMLElement;
|
||||
if (!container) return;
|
||||
|
||||
// Lazy-load D3
|
||||
if (!this.d3Ready) {
|
||||
container.innerHTML = '<div class="loading"><span class="spinner"></span> Loading visualization library...</div>';
|
||||
try {
|
||||
await loadD3();
|
||||
this.d3Ready = true;
|
||||
} catch {
|
||||
container.innerHTML = '<div class="error">Failed to load D3 visualization library.</div>';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Build chain color map for flow chart
|
||||
const chainColorMap: Record<string, string> = {};
|
||||
for (const ch of this.detectedChains) {
|
||||
chainColorMap[ch.name.toLowerCase()] = ch.color;
|
||||
}
|
||||
|
||||
switch (this.activeView) {
|
||||
case "timeline":
|
||||
if (this.vizData.timeline && this.vizData.timeline.length > 0) {
|
||||
renderTimeline(container, this.vizData.timeline, { chainColors: chainColorMap });
|
||||
} else {
|
||||
container.innerHTML = '<div class="empty">No timeline data available. Transfer data may still be loading.</div>';
|
||||
}
|
||||
break;
|
||||
|
||||
case "flow":
|
||||
if (this.vizData.multichain) {
|
||||
const mc = this.vizData.multichain;
|
||||
renderFlowChart(container, mc.flowData["all"] || [], mc.chainStats["all"], {
|
||||
chainColors: chainColorMap,
|
||||
safeAddress: this.address,
|
||||
});
|
||||
} else {
|
||||
container.innerHTML = '<div class="empty">No flow data available.</div>';
|
||||
}
|
||||
break;
|
||||
|
||||
case "sankey":
|
||||
if (this.vizData.sankey && this.vizData.sankey.links.length > 0) {
|
||||
renderSankey(container, this.vizData.sankey);
|
||||
} else {
|
||||
container.innerHTML = '<div class="empty">No Sankey data available for the selected chain.</div>';
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private formatBalance(balance: string, decimals: number): string {
|
||||
const val = Number(balance) / Math.pow(10, decimals);
|
||||
if (val >= 1000000) return `${(val / 1000000).toFixed(2)}M`;
|
||||
|
|
@ -305,8 +457,34 @@ class FolkWalletViewer extends HTMLElement {
|
|||
this.loading = true;
|
||||
this.render();
|
||||
await this.loadBalances();
|
||||
// Recompute sankey for new chain
|
||||
if (this.transfers && this.transfers.has(chainId)) {
|
||||
this.vizData.sankey = transformToSankeyData(
|
||||
this.transfers.get(chainId),
|
||||
this.address,
|
||||
chainId,
|
||||
);
|
||||
}
|
||||
this.loading = false;
|
||||
this.render();
|
||||
if (this.activeView !== "balances") {
|
||||
requestAnimationFrame(() => this.drawActiveVisualization());
|
||||
}
|
||||
}
|
||||
|
||||
private handleViewTabClick(view: ViewTab) {
|
||||
if (this.activeView === view) return;
|
||||
this.activeView = view;
|
||||
|
||||
if (view !== "balances" && !this.transfers && !this.isDemo) {
|
||||
this.loadTransfers();
|
||||
}
|
||||
|
||||
this.render();
|
||||
|
||||
if (view !== "balances") {
|
||||
requestAnimationFrame(() => this.drawActiveVisualization());
|
||||
}
|
||||
}
|
||||
|
||||
private hasData(): boolean {
|
||||
|
|
@ -714,6 +892,24 @@ class FolkWalletViewer extends HTMLElement {
|
|||
.stat-label { font-size: 11px; color: var(--rs-text-secondary); text-transform: uppercase; margin-bottom: 6px; }
|
||||
.stat-value { font-size: 20px; font-weight: 700; color: #00d4ff; }
|
||||
|
||||
/* ── View Tabs ── */
|
||||
.view-tabs {
|
||||
display: flex; gap: 4px; margin-bottom: 20px;
|
||||
border-bottom: 2px solid var(--rs-border-subtle); padding-bottom: 0;
|
||||
}
|
||||
.view-tab {
|
||||
padding: 10px 18px; border: none; background: transparent;
|
||||
color: var(--rs-text-secondary); cursor: pointer; font-size: 13px; font-weight: 500;
|
||||
border-bottom: 2px solid transparent; margin-bottom: -2px; transition: all 0.2s;
|
||||
}
|
||||
.view-tab:hover { color: var(--rs-text-primary); background: var(--rs-bg-hover); }
|
||||
.view-tab.active {
|
||||
color: #00d4ff; border-bottom-color: #00d4ff;
|
||||
}
|
||||
|
||||
/* ── Viz container ── */
|
||||
.viz-container { min-height: 200px; }
|
||||
|
||||
/* ── Dashboard: balance table ── */
|
||||
.balance-table { width: 100%; border-collapse: collapse; }
|
||||
.balance-table th {
|
||||
|
|
@ -753,6 +949,7 @@ class FolkWalletViewer extends HTMLElement {
|
|||
.address-bar input { min-width: 0; }
|
||||
.chains { flex-wrap: wrap; }
|
||||
.features { grid-template-columns: 1fr 1fr; }
|
||||
.view-tabs { overflow-x: auto; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.features { grid-template-columns: 1fr; }
|
||||
|
|
@ -896,6 +1093,55 @@ class FolkWalletViewer extends HTMLElement {
|
|||
</div>`;
|
||||
}
|
||||
|
||||
private renderViewTabs(): string {
|
||||
if (!this.hasData()) return "";
|
||||
const tabs: { id: ViewTab; label: string }[] = [
|
||||
{ id: "balances", label: "Balances" },
|
||||
{ id: "timeline", label: "Timeline" },
|
||||
{ id: "flow", label: "Flow Map" },
|
||||
{ id: "sankey", label: "Sankey" },
|
||||
];
|
||||
// Only show viz tabs for Safe wallets (or demo)
|
||||
const showViz = this.walletType === "safe" || this.isDemo;
|
||||
const visibleTabs = showViz ? tabs : [tabs[0]];
|
||||
|
||||
return `
|
||||
<div class="view-tabs">
|
||||
${visibleTabs.map(t => `
|
||||
<button class="view-tab ${this.activeView === t.id ? "active" : ""}" data-view="${t.id}">${t.label}</button>
|
||||
`).join("")}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private renderBalanceTable(): string {
|
||||
return this.balances.length > 0 ? `
|
||||
<table class="balance-table">
|
||||
<thead>
|
||||
<tr><th>Token</th><th class="amount-cell">Balance</th><th class="amount-cell">USD Value</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${this.balances
|
||||
.filter((b) => parseFloat(b.fiatBalance || "0") > 0.01 || BigInt(b.balance || "0") > 0n)
|
||||
.sort((a, b) => {
|
||||
const fiatDiff = parseFloat(b.fiatBalance || "0") - parseFloat(a.fiatBalance || "0");
|
||||
if (fiatDiff !== 0) return fiatDiff;
|
||||
return Number(BigInt(b.balance || "0") - BigInt(a.balance || "0"));
|
||||
})
|
||||
.map((b) => `
|
||||
<tr>
|
||||
<td>
|
||||
<span class="token-symbol">${this.esc(b.token?.symbol || "ETH")}</span>
|
||||
<span class="token-name">${this.esc(b.token?.name || "Ether")}</span>
|
||||
</td>
|
||||
<td class="amount-cell">${this.formatBalance(b.balance, b.token?.decimals || 18)}</td>
|
||||
<td class="amount-cell fiat">${this.formatUSD(b.fiatBalance)}</td>
|
||||
</tr>
|
||||
`).join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
` : '<div class="empty">No token balances found on this chain.</div>';
|
||||
}
|
||||
|
||||
private renderDashboard(): string {
|
||||
if (!this.hasData()) return "";
|
||||
const totalUSD = this.balances.reduce((sum, b) => sum + parseFloat(b.fiatBalance || "0"), 0);
|
||||
|
|
@ -930,32 +1176,14 @@ class FolkWalletViewer extends HTMLElement {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
${this.balances.length > 0 ? `
|
||||
<table class="balance-table">
|
||||
<thead>
|
||||
<tr><th>Token</th><th class="amount-cell">Balance</th><th class="amount-cell">USD Value</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${this.balances
|
||||
.filter((b) => parseFloat(b.fiatBalance || "0") > 0.01 || BigInt(b.balance || "0") > 0n)
|
||||
.sort((a, b) => {
|
||||
const fiatDiff = parseFloat(b.fiatBalance || "0") - parseFloat(a.fiatBalance || "0");
|
||||
if (fiatDiff !== 0) return fiatDiff;
|
||||
return Number(BigInt(b.balance || "0") - BigInt(a.balance || "0"));
|
||||
})
|
||||
.map((b) => `
|
||||
<tr>
|
||||
<td>
|
||||
<span class="token-symbol">${this.esc(b.token?.symbol || "ETH")}</span>
|
||||
<span class="token-name">${this.esc(b.token?.name || "Ether")}</span>
|
||||
</td>
|
||||
<td class="amount-cell">${this.formatBalance(b.balance, b.token?.decimals || 18)}</td>
|
||||
<td class="amount-cell fiat">${this.formatUSD(b.fiatBalance)}</td>
|
||||
</tr>
|
||||
`).join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
` : '<div class="empty">No token balances found on this chain.</div>'}`;
|
||||
${this.renderViewTabs()}
|
||||
|
||||
${this.activeView === "balances"
|
||||
? this.renderBalanceTable()
|
||||
: `<div class="viz-container" id="viz-container">
|
||||
${this.transfersLoading ? '<div class="loading"><span class="spinner"></span> Loading transfer data...</div>' : ""}
|
||||
</div>`
|
||||
}`;
|
||||
}
|
||||
|
||||
private render() {
|
||||
|
|
@ -1024,6 +1252,14 @@ class FolkWalletViewer extends HTMLElement {
|
|||
});
|
||||
});
|
||||
|
||||
// View tab listeners
|
||||
this.shadow.querySelectorAll(".view-tab").forEach((tab) => {
|
||||
tab.addEventListener("click", () => {
|
||||
const view = (tab as HTMLElement).dataset.view as ViewTab;
|
||||
this.handleViewTabClick(view);
|
||||
});
|
||||
});
|
||||
|
||||
// Linked wallet event listeners
|
||||
this.shadow.querySelector("#link-wallet-btn")?.addEventListener("click", () => {
|
||||
this.startProviderDiscovery();
|
||||
|
|
@ -1059,6 +1295,11 @@ class FolkWalletViewer extends HTMLElement {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Draw visualization if active
|
||||
if (this.activeView !== "balances" && this.hasData()) {
|
||||
requestAnimationFrame(() => this.drawActiveVisualization());
|
||||
}
|
||||
}
|
||||
|
||||
private esc(s: string): string {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,589 @@
|
|||
/**
|
||||
* Data Transform Module for rWallet
|
||||
* Converts Safe Global API responses into formats for D3 visualizations.
|
||||
* TypeScript port of rwallet-online/js/data-transform.js
|
||||
*/
|
||||
|
||||
// ── Interfaces ──
|
||||
|
||||
export interface TimelineEntry {
|
||||
date: Date;
|
||||
type: "in" | "out";
|
||||
amount: number;
|
||||
token: string;
|
||||
usd: number;
|
||||
hasUsdEstimate: boolean;
|
||||
chain: string;
|
||||
chainId: string;
|
||||
from?: string;
|
||||
fromFull?: string;
|
||||
to?: string;
|
||||
toFull?: string;
|
||||
}
|
||||
|
||||
export interface SankeyNode {
|
||||
name: string;
|
||||
type: "wallet" | "source" | "target";
|
||||
address: string;
|
||||
}
|
||||
|
||||
export interface SankeyLink {
|
||||
source: number;
|
||||
target: number;
|
||||
value: number;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface SankeyData {
|
||||
nodes: SankeyNode[];
|
||||
links: SankeyLink[];
|
||||
}
|
||||
|
||||
export interface ChainStats {
|
||||
transfers: number;
|
||||
inflow: string;
|
||||
outflow: string;
|
||||
addresses: string;
|
||||
period: string;
|
||||
}
|
||||
|
||||
export interface TransferRecord {
|
||||
chainId: string;
|
||||
chainName: string;
|
||||
date: string;
|
||||
from?: string;
|
||||
fromShort?: string;
|
||||
to?: string;
|
||||
toShort?: string;
|
||||
token: string;
|
||||
amount: number;
|
||||
usd: number;
|
||||
}
|
||||
|
||||
export interface MultichainData {
|
||||
chainStats: Record<string, ChainStats>;
|
||||
flowData: Record<string, FlowEntry[]>;
|
||||
allTransfers: { incoming: TransferRecord[]; outgoing: TransferRecord[] };
|
||||
}
|
||||
|
||||
export interface FlowEntry {
|
||||
from: string;
|
||||
to: string;
|
||||
value: number;
|
||||
token: string;
|
||||
chain: string;
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
export function shortenAddress(addr: string): string {
|
||||
if (!addr || addr.length < 10) return addr || "Unknown";
|
||||
return addr.slice(0, 6) + "..." + addr.slice(-4);
|
||||
}
|
||||
|
||||
const EXPLORER_URLS: Record<string, string> = {
|
||||
"1": "https://etherscan.io",
|
||||
"10": "https://optimistic.etherscan.io",
|
||||
"100": "https://gnosisscan.io",
|
||||
"137": "https://polygonscan.com",
|
||||
"8453": "https://basescan.org",
|
||||
"42161": "https://arbiscan.io",
|
||||
"42220": "https://celoscan.io",
|
||||
"43114": "https://snowtrace.io",
|
||||
"56": "https://bscscan.com",
|
||||
"324": "https://explorer.zksync.io",
|
||||
};
|
||||
|
||||
export function explorerLink(address: string, chainId: string): string {
|
||||
const base = EXPLORER_URLS[chainId];
|
||||
if (!base) return "#";
|
||||
return `${base}/address/${address}`;
|
||||
}
|
||||
|
||||
export function txExplorerLink(txHash: string, chainId: string): string {
|
||||
const base = EXPLORER_URLS[chainId];
|
||||
if (!base) return "#";
|
||||
return `${base}/tx/${txHash}`;
|
||||
}
|
||||
|
||||
export function getTransferValue(transfer: any): number {
|
||||
if (transfer.type === "ERC20_TRANSFER" || transfer.transferType === "ERC20_TRANSFER") {
|
||||
const decimals = transfer.tokenInfo?.decimals || transfer.token?.decimals || 18;
|
||||
const raw = transfer.value || "0";
|
||||
return parseFloat(raw) / Math.pow(10, decimals);
|
||||
}
|
||||
if (transfer.type === "ETHER_TRANSFER" || transfer.transferType === "ETHER_TRANSFER") {
|
||||
return parseFloat(transfer.value || "0") / 1e18;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function getTokenSymbol(transfer: any): string {
|
||||
return transfer.tokenInfo?.symbol || transfer.token?.symbol || "ETH";
|
||||
}
|
||||
|
||||
function getTokenName(transfer: any): string {
|
||||
return transfer.tokenInfo?.name || transfer.token?.name || "Native";
|
||||
}
|
||||
|
||||
// ── Stablecoin USD estimation ──
|
||||
|
||||
const STABLECOINS = new Set([
|
||||
"USDC", "USDT", "DAI", "WXDAI", "BUSD", "TUSD", "USDP", "FRAX",
|
||||
"LUSD", "GUSD", "sUSD", "USDD", "USDGLO", "USD+", "USDe", "crvUSD",
|
||||
"GHO", "PYUSD", "DOLA", "Yield-USD", "yUSD",
|
||||
]);
|
||||
|
||||
export function estimateUSD(value: number, symbol: string): number | null {
|
||||
if (STABLECOINS.has(symbol)) return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Native token symbols per chain ──
|
||||
|
||||
const CHAIN_NATIVE_SYMBOL: Record<string, string> = {
|
||||
"1": "ETH", "10": "ETH", "100": "xDAI", "137": "MATIC",
|
||||
"8453": "ETH", "42161": "ETH", "42220": "CELO", "43114": "AVAX",
|
||||
"56": "BNB", "324": "ETH",
|
||||
};
|
||||
|
||||
// ── Transform: Timeline Data (for Balance River) ──
|
||||
|
||||
export function transformToTimelineData(
|
||||
chainDataMap: Map<string, any>,
|
||||
safeAddress: string,
|
||||
chainNames: Record<string, string>,
|
||||
): TimelineEntry[] {
|
||||
const timeline: any[] = [];
|
||||
|
||||
for (const [chainId, data] of chainDataMap) {
|
||||
const chainName = (chainNames[chainId] || `chain-${chainId}`).toLowerCase();
|
||||
|
||||
// Incoming transfers
|
||||
if (data.incoming) {
|
||||
for (const transfer of data.incoming) {
|
||||
const value = getTransferValue(transfer);
|
||||
const symbol = getTokenSymbol(transfer);
|
||||
if (value <= 0) continue;
|
||||
|
||||
const usd = estimateUSD(value, symbol);
|
||||
timeline.push({
|
||||
date: transfer.executionDate || transfer.blockTimestamp || transfer.timestamp,
|
||||
type: "in",
|
||||
amount: value,
|
||||
token: symbol,
|
||||
usd: usd !== null ? usd : value,
|
||||
hasUsdEstimate: usd !== null,
|
||||
chain: chainName,
|
||||
chainId,
|
||||
from: shortenAddress(transfer.from),
|
||||
fromFull: transfer.from,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Outgoing multisig transactions
|
||||
if (data.outgoing) {
|
||||
for (const tx of data.outgoing) {
|
||||
if (!tx.isExecuted) continue;
|
||||
|
||||
const txTransfers: { to: string; value: number; symbol: string; usd: number | null }[] = [];
|
||||
|
||||
// Check transfers array if available
|
||||
if (tx.transfers && tx.transfers.length > 0) {
|
||||
for (const t of tx.transfers) {
|
||||
if (t.from?.toLowerCase() === safeAddress.toLowerCase()) {
|
||||
const value = getTransferValue(t);
|
||||
const symbol = getTokenSymbol(t);
|
||||
if (value > 0) {
|
||||
txTransfers.push({ to: t.to, value, symbol, usd: estimateUSD(value, symbol) });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: parse from dataDecoded or direct value
|
||||
if (txTransfers.length === 0) {
|
||||
if (tx.value && tx.value !== "0") {
|
||||
const val = parseFloat(tx.value) / 1e18;
|
||||
const sym = CHAIN_NATIVE_SYMBOL[chainId] || "ETH";
|
||||
txTransfers.push({ to: tx.to, value: val, symbol: sym, usd: estimateUSD(val, sym) });
|
||||
}
|
||||
|
||||
if (tx.dataDecoded?.method === "transfer") {
|
||||
const params = tx.dataDecoded.parameters || [];
|
||||
const to = params.find((p: any) => p.name === "to")?.value;
|
||||
const rawVal = params.find((p: any) => p.name === "value")?.value || "0";
|
||||
const val = parseFloat(rawVal) / 1e18;
|
||||
txTransfers.push({ to, value: val, symbol: "Token", usd: null });
|
||||
}
|
||||
|
||||
if (tx.dataDecoded?.method === "multiSend") {
|
||||
const txsParam = tx.dataDecoded.parameters?.find((p: any) => p.name === "transactions");
|
||||
if (txsParam?.valueDecoded) {
|
||||
for (const inner of txsParam.valueDecoded) {
|
||||
if (inner.value && inner.value !== "0") {
|
||||
const val = parseFloat(inner.value) / 1e18;
|
||||
const sym = CHAIN_NATIVE_SYMBOL[chainId] || "ETH";
|
||||
txTransfers.push({ to: inner.to, value: val, symbol: sym, usd: estimateUSD(val, sym) });
|
||||
}
|
||||
if (inner.dataDecoded?.method === "transfer") {
|
||||
const to2 = inner.dataDecoded.parameters?.find((p: any) => p.name === "to")?.value;
|
||||
const raw2 = inner.dataDecoded.parameters?.find((p: any) => p.name === "value")?.value || "0";
|
||||
const val2 = parseFloat(raw2) / 1e18;
|
||||
txTransfers.push({ to: to2, value: val2, symbol: "Token", usd: null });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const t of txTransfers) {
|
||||
const usd = t.usd !== null ? t.usd : t.value;
|
||||
timeline.push({
|
||||
date: tx.executionDate,
|
||||
type: "out",
|
||||
amount: t.value,
|
||||
token: t.symbol,
|
||||
usd,
|
||||
hasUsdEstimate: t.usd !== null,
|
||||
chain: chainName,
|
||||
chainId,
|
||||
to: shortenAddress(t.to),
|
||||
toFull: t.to,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return timeline
|
||||
.filter((t) => t.date)
|
||||
.map((t) => ({ ...t, date: new Date(t.date) }))
|
||||
.sort((a, b) => a.date.getTime() - b.date.getTime());
|
||||
}
|
||||
|
||||
// ── Transform: Sankey Data (for single-chain flow) ──
|
||||
|
||||
export function transformToSankeyData(chainData: any, safeAddress: string, chainId?: string): SankeyData {
|
||||
const nodeMap = new Map<string, number>();
|
||||
const nodes: SankeyNode[] = [];
|
||||
const links: SankeyLink[] = [];
|
||||
const walletLabel = "Safe Wallet";
|
||||
|
||||
function getNodeIndex(address: string, type: "wallet" | "source" | "target"): number {
|
||||
const key = address.toLowerCase() === safeAddress.toLowerCase()
|
||||
? "wallet"
|
||||
: `${type}:${address.toLowerCase()}`;
|
||||
|
||||
if (!nodeMap.has(key)) {
|
||||
const idx = nodes.length;
|
||||
nodeMap.set(key, idx);
|
||||
const label = address.toLowerCase() === safeAddress.toLowerCase()
|
||||
? walletLabel
|
||||
: shortenAddress(address);
|
||||
nodes.push({ name: label, type, address });
|
||||
}
|
||||
return nodeMap.get(key)!;
|
||||
}
|
||||
|
||||
// Wallet node always first
|
||||
getNodeIndex(safeAddress, "wallet");
|
||||
|
||||
// Aggregate inflows by source address + token
|
||||
const inflowAgg = new Map<string, { from: string; value: number; symbol: string }>();
|
||||
if (chainData.incoming) {
|
||||
for (const transfer of chainData.incoming) {
|
||||
const value = getTransferValue(transfer);
|
||||
const symbol = getTokenSymbol(transfer);
|
||||
if (value <= 0 || !transfer.from) continue;
|
||||
|
||||
const key = `${transfer.from.toLowerCase()}:${symbol}`;
|
||||
const existing = inflowAgg.get(key) || { from: transfer.from, value: 0, symbol };
|
||||
existing.value += value;
|
||||
inflowAgg.set(key, existing);
|
||||
}
|
||||
}
|
||||
|
||||
// Add inflow links
|
||||
for (const [, agg] of inflowAgg) {
|
||||
const sourceIdx = getNodeIndex(agg.from, "source");
|
||||
const walletIdx = nodeMap.get("wallet")!;
|
||||
links.push({ source: sourceIdx, target: walletIdx, value: agg.value, token: agg.symbol });
|
||||
}
|
||||
|
||||
// Aggregate outflows by target address + token
|
||||
const outflowAgg = new Map<string, { to: string; value: number; symbol: string }>();
|
||||
if (chainData.outgoing) {
|
||||
for (const tx of chainData.outgoing) {
|
||||
if (!tx.isExecuted) continue;
|
||||
|
||||
if (tx.value && tx.value !== "0" && tx.to) {
|
||||
const val = parseFloat(tx.value) / 1e18;
|
||||
const sym = (chainId && CHAIN_NATIVE_SYMBOL[chainId]) || "ETH";
|
||||
const key = `${tx.to.toLowerCase()}:${sym}`;
|
||||
const existing = outflowAgg.get(key) || { to: tx.to, value: 0, symbol: sym };
|
||||
existing.value += val;
|
||||
outflowAgg.set(key, existing);
|
||||
}
|
||||
|
||||
if (tx.dataDecoded?.method === "transfer") {
|
||||
const params = tx.dataDecoded.parameters || [];
|
||||
const to = params.find((p: any) => p.name === "to")?.value;
|
||||
const rawVal = params.find((p: any) => p.name === "value")?.value || "0";
|
||||
if (to) {
|
||||
const val = parseFloat(rawVal) / 1e18;
|
||||
const key = `${to.toLowerCase()}:Token`;
|
||||
const existing = outflowAgg.get(key) || { to, value: 0, symbol: "Token" };
|
||||
existing.value += val;
|
||||
outflowAgg.set(key, existing);
|
||||
}
|
||||
}
|
||||
|
||||
if (tx.dataDecoded?.method === "multiSend") {
|
||||
const txsParam = tx.dataDecoded.parameters?.find((p: any) => p.name === "transactions");
|
||||
if (txsParam?.valueDecoded) {
|
||||
for (const inner of txsParam.valueDecoded) {
|
||||
if (inner.value && inner.value !== "0" && inner.to) {
|
||||
const val = parseFloat(inner.value) / 1e18;
|
||||
const sym = (chainId && CHAIN_NATIVE_SYMBOL[chainId]) || "ETH";
|
||||
const key = `${inner.to.toLowerCase()}:${sym}`;
|
||||
const existing = outflowAgg.get(key) || { to: inner.to, value: 0, symbol: sym };
|
||||
existing.value += val;
|
||||
outflowAgg.set(key, existing);
|
||||
}
|
||||
if (inner.dataDecoded?.method === "transfer") {
|
||||
const to2 = inner.dataDecoded.parameters?.find((p: any) => p.name === "to")?.value;
|
||||
const raw2 = inner.dataDecoded.parameters?.find((p: any) => p.name === "value")?.value || "0";
|
||||
if (to2) {
|
||||
const val2 = parseFloat(raw2) / 1e18;
|
||||
const key = `${to2.toLowerCase()}:Token`;
|
||||
const existing = outflowAgg.get(key) || { to: to2, value: 0, symbol: "Token" };
|
||||
existing.value += val2;
|
||||
outflowAgg.set(key, existing);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add outflow links
|
||||
const walletIdx = nodeMap.get("wallet")!;
|
||||
for (const [, agg] of outflowAgg) {
|
||||
const targetIdx = getNodeIndex(agg.to, "target");
|
||||
links.push({ source: walletIdx, target: targetIdx, value: agg.value, token: agg.symbol });
|
||||
}
|
||||
|
||||
// Filter out tiny values (noise)
|
||||
const maxValue = Math.max(...links.map((l) => l.value), 1);
|
||||
const threshold = maxValue * 0.001;
|
||||
const filteredLinks = links.filter((l) => l.value >= threshold);
|
||||
|
||||
return { nodes, links: filteredLinks };
|
||||
}
|
||||
|
||||
// ── Transform: Multi-Chain Flow Data ──
|
||||
|
||||
function formatUSDValue(value: number): string {
|
||||
if (value >= 1000000) return `~$${(value / 1000000).toFixed(1)}M`;
|
||||
if (value >= 1000) return `~$${Math.round(value / 1000)}K`;
|
||||
return `~$${Math.round(value)}`;
|
||||
}
|
||||
|
||||
export function transformToMultichainData(
|
||||
chainDataMap: Map<string, any>,
|
||||
safeAddress: string,
|
||||
chainNames: Record<string, string>,
|
||||
): MultichainData {
|
||||
const chainStats: Record<string, ChainStats> = {};
|
||||
const flowData: Record<string, FlowEntry[]> = {};
|
||||
const allTransfers: { incoming: TransferRecord[]; outgoing: TransferRecord[] } = { incoming: [], outgoing: [] };
|
||||
let totalTransfers = 0;
|
||||
let totalInflow = 0;
|
||||
let totalOutflow = 0;
|
||||
const allAddresses = new Set<string>();
|
||||
let minDate: Date | null = null;
|
||||
let maxDate: Date | null = null;
|
||||
|
||||
for (const [chainId, data] of chainDataMap) {
|
||||
const chainName = (chainNames[chainId] || `chain-${chainId}`).toLowerCase();
|
||||
let chainTransfers = 0;
|
||||
let chainInflow = 0;
|
||||
let chainOutflow = 0;
|
||||
const chainAddresses = new Set<string>();
|
||||
let chainMinDate: Date | null = null;
|
||||
let chainMaxDate: Date | null = null;
|
||||
const flows: FlowEntry[] = [];
|
||||
|
||||
// Incoming
|
||||
const inflowAgg = new Map<string, { from: string; value: number; token: string }>();
|
||||
if (data.incoming) {
|
||||
for (const transfer of data.incoming) {
|
||||
const value = getTransferValue(transfer);
|
||||
const symbol = getTokenSymbol(transfer);
|
||||
if (value <= 0) continue;
|
||||
|
||||
const usd = estimateUSD(value, symbol);
|
||||
const usdVal = usd !== null ? usd : value;
|
||||
chainTransfers++;
|
||||
chainInflow += usdVal;
|
||||
if (transfer.from) {
|
||||
chainAddresses.add(transfer.from.toLowerCase());
|
||||
allAddresses.add(transfer.from.toLowerCase());
|
||||
}
|
||||
|
||||
const date = transfer.executionDate || transfer.blockTimestamp;
|
||||
if (date) {
|
||||
const d = new Date(date);
|
||||
if (!chainMinDate || d < chainMinDate) chainMinDate = d;
|
||||
if (!chainMaxDate || d > chainMaxDate) chainMaxDate = d;
|
||||
}
|
||||
|
||||
const from = transfer.from || "Unknown";
|
||||
const key = shortenAddress(from);
|
||||
const existing = inflowAgg.get(key) || { from: shortenAddress(from), value: 0, token: symbol };
|
||||
existing.value += usdVal;
|
||||
inflowAgg.set(key, existing);
|
||||
|
||||
allTransfers.incoming.push({
|
||||
chainId, chainName,
|
||||
date: date || "",
|
||||
from: transfer.from,
|
||||
fromShort: shortenAddress(transfer.from),
|
||||
token: symbol,
|
||||
amount: value,
|
||||
usd: usdVal,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const [, agg] of inflowAgg) {
|
||||
flows.push({ from: agg.from, to: "Safe Wallet", value: Math.round(agg.value), token: agg.token, chain: chainName });
|
||||
}
|
||||
|
||||
// Outgoing
|
||||
const outflowAgg = new Map<string, { to: string; value: number; token: string }>();
|
||||
if (data.outgoing) {
|
||||
for (const tx of data.outgoing) {
|
||||
if (!tx.isExecuted) continue;
|
||||
chainTransfers++;
|
||||
|
||||
const date = tx.executionDate;
|
||||
if (date) {
|
||||
const d = new Date(date);
|
||||
if (!chainMinDate || d < chainMinDate) chainMinDate = d;
|
||||
if (!chainMaxDate || d > chainMaxDate) chainMaxDate = d;
|
||||
}
|
||||
|
||||
const outTransfers: { to: string; value: number; symbol: string }[] = [];
|
||||
|
||||
if (tx.value && tx.value !== "0" && tx.to) {
|
||||
const val = parseFloat(tx.value) / 1e18;
|
||||
const sym = CHAIN_NATIVE_SYMBOL[chainId] || "ETH";
|
||||
outTransfers.push({ to: tx.to, value: val, symbol: sym });
|
||||
}
|
||||
|
||||
if (tx.dataDecoded?.method === "transfer") {
|
||||
const params = tx.dataDecoded.parameters || [];
|
||||
const to = params.find((p: any) => p.name === "to")?.value;
|
||||
const rawVal = params.find((p: any) => p.name === "value")?.value || "0";
|
||||
if (to) outTransfers.push({ to, value: parseFloat(rawVal) / 1e18, symbol: "Token" });
|
||||
}
|
||||
|
||||
if (tx.dataDecoded?.method === "multiSend") {
|
||||
const txsParam = tx.dataDecoded.parameters?.find((p: any) => p.name === "transactions");
|
||||
if (txsParam?.valueDecoded) {
|
||||
for (const inner of txsParam.valueDecoded) {
|
||||
if (inner.value && inner.value !== "0" && inner.to) {
|
||||
const val = parseFloat(inner.value) / 1e18;
|
||||
const sym = CHAIN_NATIVE_SYMBOL[chainId] || "ETH";
|
||||
outTransfers.push({ to: inner.to, value: val, symbol: sym });
|
||||
}
|
||||
if (inner.dataDecoded?.method === "transfer") {
|
||||
const to2 = inner.dataDecoded.parameters?.find((p: any) => p.name === "to")?.value;
|
||||
const raw2 = inner.dataDecoded.parameters?.find((p: any) => p.name === "value")?.value || "0";
|
||||
if (to2) outTransfers.push({ to: to2, value: parseFloat(raw2) / 1e18, symbol: "Token" });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const t of outTransfers) {
|
||||
const usd = estimateUSD(t.value, t.symbol);
|
||||
const usdVal = usd !== null ? usd : t.value;
|
||||
chainOutflow += usdVal;
|
||||
if (t.to) {
|
||||
chainAddresses.add(t.to.toLowerCase());
|
||||
allAddresses.add(t.to.toLowerCase());
|
||||
}
|
||||
|
||||
const key = shortenAddress(t.to);
|
||||
const existing = outflowAgg.get(key) || { to: shortenAddress(t.to), value: 0, token: t.symbol };
|
||||
existing.value += usdVal;
|
||||
outflowAgg.set(key, existing);
|
||||
|
||||
allTransfers.outgoing.push({
|
||||
chainId, chainName,
|
||||
date: date || "",
|
||||
to: t.to,
|
||||
toShort: shortenAddress(t.to),
|
||||
token: t.symbol,
|
||||
amount: t.value,
|
||||
usd: usdVal,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [, agg] of outflowAgg) {
|
||||
flows.push({ from: "Safe Wallet", to: agg.to, value: Math.round(agg.value), token: agg.token, chain: chainName });
|
||||
}
|
||||
|
||||
const fmt = (d: Date) => d.toLocaleDateString("en-US", { month: "short", year: "numeric" });
|
||||
const period = (chainMinDate && chainMaxDate)
|
||||
? `${fmt(chainMinDate)} - ${fmt(chainMaxDate)}`
|
||||
: "No data";
|
||||
|
||||
chainStats[chainName] = {
|
||||
transfers: chainTransfers,
|
||||
inflow: formatUSDValue(chainInflow),
|
||||
outflow: formatUSDValue(chainOutflow),
|
||||
addresses: String(chainAddresses.size),
|
||||
period,
|
||||
};
|
||||
|
||||
flowData[chainName] = flows;
|
||||
|
||||
totalTransfers += chainTransfers;
|
||||
totalInflow += chainInflow;
|
||||
totalOutflow += chainOutflow;
|
||||
if (chainMinDate && (!minDate || chainMinDate < minDate)) minDate = chainMinDate;
|
||||
if (chainMaxDate && (!maxDate || chainMaxDate > maxDate)) maxDate = chainMaxDate;
|
||||
}
|
||||
|
||||
// Aggregate "all" stats
|
||||
const fmtAll = (d: Date) => d.toLocaleDateString("en-US", { month: "short", year: "numeric" });
|
||||
chainStats["all"] = {
|
||||
transfers: totalTransfers,
|
||||
inflow: formatUSDValue(totalInflow),
|
||||
outflow: formatUSDValue(totalOutflow),
|
||||
addresses: String(allAddresses.size),
|
||||
period: (minDate && maxDate) ? `${fmtAll(minDate)} - ${fmtAll(maxDate)}` : "No data",
|
||||
};
|
||||
|
||||
// Aggregate "all" flows: top 15 by value
|
||||
const allFlows: FlowEntry[] = [];
|
||||
for (const flows of Object.values(flowData)) {
|
||||
allFlows.push(...flows);
|
||||
}
|
||||
allFlows.sort((a, b) => b.value - a.value);
|
||||
flowData["all"] = allFlows.slice(0, 15);
|
||||
|
||||
// Sort transfers by date
|
||||
allTransfers.incoming.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||
allTransfers.outgoing.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||
|
||||
return { chainStats, flowData, allTransfers };
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
/**
|
||||
* Mock visualization data for demo mode (TEC Commons Fund).
|
||||
*/
|
||||
|
||||
import type { TimelineEntry, SankeyData, MultichainData, FlowEntry, TransferRecord } from "./data-transform";
|
||||
|
||||
// ── Timeline: ~30 entries over ~2 years ──
|
||||
|
||||
function d(y: number, m: number, day: number): Date {
|
||||
return new Date(y, m - 1, day);
|
||||
}
|
||||
|
||||
export const DEMO_TIMELINE_DATA: TimelineEntry[] = [
|
||||
{ date: d(2024, 1, 15), type: "in", amount: 50000, token: "WXDAI", usd: 50000, hasUsdEstimate: true, chain: "gnosis", chainId: "100", from: "0x1234...abcd", fromFull: "0x1234567890abcdef1234567890abcdef12345678" },
|
||||
{ date: d(2024, 1, 28), type: "in", amount: 15, token: "WETH", usd: 37500, hasUsdEstimate: false, chain: "gnosis", chainId: "100", from: "0x2345...bcde", fromFull: "0x234567890abcdef1234567890abcdef123456789" },
|
||||
{ date: d(2024, 2, 10), type: "out", amount: 12000, token: "WXDAI", usd: 12000, hasUsdEstimate: true, chain: "gnosis", chainId: "100", to: "0x3456...cdef", toFull: "0x34567890abcdef1234567890abcdef1234567890" },
|
||||
{ date: d(2024, 2, 22), type: "in", amount: 100000, token: "TEC", usd: 1000, hasUsdEstimate: false, chain: "gnosis", chainId: "100", from: "0x4567...def0", fromFull: "0x4567890abcdef1234567890abcdef12345678901" },
|
||||
{ date: d(2024, 3, 5), type: "out", amount: 8500, token: "USDC", usd: 8500, hasUsdEstimate: true, chain: "gnosis", chainId: "100", to: "0x5678...ef01", toFull: "0x567890abcdef1234567890abcdef123456789012" },
|
||||
{ date: d(2024, 3, 18), type: "in", amount: 25000, token: "WXDAI", usd: 25000, hasUsdEstimate: true, chain: "gnosis", chainId: "100", from: "0x6789...f012", fromFull: "0x67890abcdef1234567890abcdef1234567890123" },
|
||||
{ date: d(2024, 4, 2), type: "out", amount: 5000, token: "WXDAI", usd: 5000, hasUsdEstimate: true, chain: "gnosis", chainId: "100", to: "0x7890...0123", toFull: "0x7890abcdef1234567890abcdef12345678901234" },
|
||||
{ date: d(2024, 4, 15), type: "in", amount: 3, token: "ETH", usd: 9000, hasUsdEstimate: false, chain: "ethereum", chainId: "1", from: "0x8901...1234", fromFull: "0x890abcdef1234567890abcdef123456789012345" },
|
||||
{ date: d(2024, 5, 1), type: "out", amount: 15000, token: "WXDAI", usd: 15000, hasUsdEstimate: true, chain: "gnosis", chainId: "100", to: "0x9012...2345", toFull: "0x90abcdef1234567890abcdef1234567890123456" },
|
||||
{ date: d(2024, 5, 20), type: "in", amount: 200000, token: "GIV", usd: 1000, hasUsdEstimate: false, chain: "gnosis", chainId: "100", from: "0xa012...3456", fromFull: "0xa0abcdef1234567890abcdef1234567890123456" },
|
||||
{ date: d(2024, 6, 8), type: "out", amount: 2, token: "WETH", usd: 6000, hasUsdEstimate: false, chain: "gnosis", chainId: "100", to: "0xb123...4567", toFull: "0xb1234567890abcdef1234567890abcdef12345678" },
|
||||
{ date: d(2024, 6, 25), type: "in", amount: 30000, token: "WXDAI", usd: 30000, hasUsdEstimate: true, chain: "gnosis", chainId: "100", from: "0xc234...5678", fromFull: "0xc234567890abcdef1234567890abcdef123456789" },
|
||||
{ date: d(2024, 7, 10), type: "out", amount: 20000, token: "WXDAI", usd: 20000, hasUsdEstimate: true, chain: "gnosis", chainId: "100", to: "0xd345...6789", toFull: "0xd34567890abcdef1234567890abcdef1234567890" },
|
||||
{ date: d(2024, 7, 28), type: "in", amount: 10000, token: "USDC", usd: 10000, hasUsdEstimate: true, chain: "optimism", chainId: "10", from: "0xe456...7890", fromFull: "0xe4567890abcdef1234567890abcdef12345678901" },
|
||||
{ date: d(2024, 8, 14), type: "out", amount: 7500, token: "USDC", usd: 7500, hasUsdEstimate: true, chain: "gnosis", chainId: "100", to: "0xf567...8901", toFull: "0xf567890abcdef1234567890abcdef123456789012" },
|
||||
{ date: d(2024, 9, 1), type: "in", amount: 45000, token: "WXDAI", usd: 45000, hasUsdEstimate: true, chain: "gnosis", chainId: "100", from: "0x1234...abcd", fromFull: "0x1234567890abcdef1234567890abcdef12345678" },
|
||||
{ date: d(2024, 9, 18), type: "out", amount: 10000, token: "WXDAI", usd: 10000, hasUsdEstimate: true, chain: "gnosis", chainId: "100", to: "0x3456...cdef", toFull: "0x34567890abcdef1234567890abcdef1234567890" },
|
||||
{ date: d(2024, 10, 5), type: "in", amount: 5, token: "ETH", usd: 12500, hasUsdEstimate: false, chain: "ethereum", chainId: "1", from: "0x8901...1234", fromFull: "0x890abcdef1234567890abcdef123456789012345" },
|
||||
{ date: d(2024, 10, 22), type: "out", amount: 18000, token: "WXDAI", usd: 18000, hasUsdEstimate: true, chain: "gnosis", chainId: "100", to: "0x5678...ef01", toFull: "0x567890abcdef1234567890abcdef123456789012" },
|
||||
{ date: d(2024, 11, 8), type: "in", amount: 20000, token: "DAI", usd: 20000, hasUsdEstimate: true, chain: "ethereum", chainId: "1", from: "0x2345...bcde", fromFull: "0x234567890abcdef1234567890abcdef123456789" },
|
||||
{ date: d(2024, 11, 25), type: "out", amount: 6000, token: "WXDAI", usd: 6000, hasUsdEstimate: true, chain: "gnosis", chainId: "100", to: "0x7890...0123", toFull: "0x7890abcdef1234567890abcdef12345678901234" },
|
||||
{ date: d(2024, 12, 10), type: "in", amount: 35000, token: "WXDAI", usd: 35000, hasUsdEstimate: true, chain: "gnosis", chainId: "100", from: "0xc234...5678", fromFull: "0xc234567890abcdef1234567890abcdef123456789" },
|
||||
{ date: d(2024, 12, 28), type: "out", amount: 22000, token: "WXDAI", usd: 22000, hasUsdEstimate: true, chain: "gnosis", chainId: "100", to: "0xd345...6789", toFull: "0xd34567890abcdef1234567890abcdef1234567890" },
|
||||
{ date: d(2025, 1, 12), type: "in", amount: 15000, token: "USDC", usd: 15000, hasUsdEstimate: true, chain: "base", chainId: "8453", from: "0xe456...7890", fromFull: "0xe4567890abcdef1234567890abcdef12345678901" },
|
||||
{ date: d(2025, 1, 30), type: "out", amount: 9000, token: "WXDAI", usd: 9000, hasUsdEstimate: true, chain: "gnosis", chainId: "100", to: "0x9012...2345", toFull: "0x90abcdef1234567890abcdef1234567890123456" },
|
||||
{ date: d(2025, 2, 14), type: "in", amount: 40000, token: "WXDAI", usd: 40000, hasUsdEstimate: true, chain: "gnosis", chainId: "100", from: "0x6789...f012", fromFull: "0x67890abcdef1234567890abcdef1234567890123" },
|
||||
{ date: d(2025, 2, 28), type: "out", amount: 14000, token: "WXDAI", usd: 14000, hasUsdEstimate: true, chain: "gnosis", chainId: "100", to: "0xf567...8901", toFull: "0xf567890abcdef1234567890abcdef123456789012" },
|
||||
{ date: d(2025, 3, 5), type: "in", amount: 8000, token: "USDC", usd: 8000, hasUsdEstimate: true, chain: "arbitrum", chainId: "42161", from: "0xa012...3456", fromFull: "0xa0abcdef1234567890abcdef1234567890123456" },
|
||||
];
|
||||
|
||||
// ── Sankey: mock DAO fund distribution ──
|
||||
|
||||
export const DEMO_SANKEY_DATA: SankeyData = {
|
||||
nodes: [
|
||||
{ name: "Gitcoin Grants", type: "source", address: "0x1234567890abcdef1234567890abcdef12345678" },
|
||||
{ name: "Token Bonding", type: "source", address: "0x234567890abcdef1234567890abcdef123456789" },
|
||||
{ name: "Community Donations", type: "source", address: "0x34567890abcdef1234567890abcdef1234567890" },
|
||||
{ name: "Safe Wallet", type: "wallet", address: "0x29567BdBcC92aCF37AC6B56B69180857bB69f7D1" },
|
||||
{ name: "Dev Fund", type: "target", address: "0x4567890abcdef1234567890abcdef12345678901" },
|
||||
{ name: "Research Grants", type: "target", address: "0x567890abcdef1234567890abcdef123456789012" },
|
||||
{ name: "Operations", type: "target", address: "0x67890abcdef1234567890abcdef1234567890123" },
|
||||
{ name: "Community Events", type: "target", address: "0x7890abcdef1234567890abcdef12345678901234" },
|
||||
],
|
||||
links: [
|
||||
{ source: 0, target: 3, value: 85000, token: "WXDAI" },
|
||||
{ source: 1, target: 3, value: 120000, token: "TEC" },
|
||||
{ source: 2, target: 3, value: 45000, token: "WXDAI" },
|
||||
{ source: 3, target: 4, value: 75000, token: "WXDAI" },
|
||||
{ source: 3, target: 5, value: 55000, token: "WXDAI" },
|
||||
{ source: 3, target: 6, value: 30000, token: "WXDAI" },
|
||||
{ source: 3, target: 7, value: 15000, token: "WXDAI" },
|
||||
],
|
||||
};
|
||||
|
||||
// ── Multi-chain: mock stats + flows ──
|
||||
|
||||
const demoFlowsGnosis: FlowEntry[] = [
|
||||
{ from: "0x1234...abcd", to: "Safe Wallet", value: 85000, token: "WXDAI", chain: "gnosis" },
|
||||
{ from: "0x2345...bcde", to: "Safe Wallet", value: 37500, token: "WETH", chain: "gnosis" },
|
||||
{ from: "0x6789...f012", to: "Safe Wallet", value: 65000, token: "WXDAI", chain: "gnosis" },
|
||||
{ from: "Safe Wallet", to: "0x3456...cdef", value: 22000, token: "WXDAI", chain: "gnosis" },
|
||||
{ from: "Safe Wallet", to: "0x5678...ef01", value: 26500, token: "USDC", chain: "gnosis" },
|
||||
{ from: "Safe Wallet", to: "0xd345...6789", value: 42000, token: "WXDAI", chain: "gnosis" },
|
||||
];
|
||||
|
||||
const demoFlowsEthereum: FlowEntry[] = [
|
||||
{ from: "0x8901...1234", to: "Safe Wallet", value: 21500, token: "ETH", chain: "ethereum" },
|
||||
{ from: "0x2345...bcde", to: "Safe Wallet", value: 20000, token: "DAI", chain: "ethereum" },
|
||||
];
|
||||
|
||||
export const DEMO_MULTICHAIN_DATA: MultichainData = {
|
||||
chainStats: {
|
||||
all: { transfers: 28, inflow: "~$330K", outflow: "~$153K", addresses: "14", period: "Jan 2024 - Mar 2025" },
|
||||
gnosis: { transfers: 20, inflow: "~$247K", outflow: "~$120K", addresses: "10", period: "Jan 2024 - Feb 2025" },
|
||||
ethereum: { transfers: 5, inflow: "~$59K", outflow: "~$0", addresses: "3", period: "Apr 2024 - Nov 2024" },
|
||||
optimism: { transfers: 1, inflow: "~$10K", outflow: "~$0", addresses: "1", period: "Jul 2024" },
|
||||
base: { transfers: 1, inflow: "~$15K", outflow: "~$0", addresses: "1", period: "Jan 2025" },
|
||||
arbitrum: { transfers: 1, inflow: "~$8K", outflow: "~$0", addresses: "1", period: "Mar 2025" },
|
||||
},
|
||||
flowData: {
|
||||
all: [...demoFlowsGnosis, ...demoFlowsEthereum].sort((a, b) => b.value - a.value).slice(0, 15),
|
||||
gnosis: demoFlowsGnosis,
|
||||
ethereum: demoFlowsEthereum,
|
||||
optimism: [{ from: "0xe456...7890", to: "Safe Wallet", value: 10000, token: "USDC", chain: "optimism" }],
|
||||
base: [{ from: "0xe456...7890", to: "Safe Wallet", value: 15000, token: "USDC", chain: "base" }],
|
||||
arbitrum: [{ from: "0xa012...3456", to: "Safe Wallet", value: 8000, token: "USDC", chain: "arbitrum" }],
|
||||
},
|
||||
allTransfers: {
|
||||
incoming: DEMO_TIMELINE_DATA.filter(t => t.type === "in").map(t => ({
|
||||
chainId: t.chainId, chainName: t.chain, date: t.date.toISOString(),
|
||||
from: t.fromFull || "", fromShort: t.from || "", token: t.token, amount: t.amount, usd: t.usd,
|
||||
})) as TransferRecord[],
|
||||
outgoing: DEMO_TIMELINE_DATA.filter(t => t.type === "out").map(t => ({
|
||||
chainId: t.chainId, chainName: t.chain, date: t.date.toISOString(),
|
||||
to: t.toFull || "", toShort: t.to || "", token: t.token, amount: t.amount, usd: t.usd,
|
||||
})) as TransferRecord[],
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,628 @@
|
|||
/**
|
||||
* D3 rendering functions for rWallet visualizations.
|
||||
* Ported from rwallet-online standalone HTML pages.
|
||||
*
|
||||
* All render functions take DOM elements directly (not CSS selectors)
|
||||
* for shadow DOM compatibility.
|
||||
*/
|
||||
|
||||
import type { TimelineEntry, SankeyData, FlowEntry, ChainStats } from "./data-transform";
|
||||
|
||||
declare const d3: any;
|
||||
|
||||
// ── CDN loader (lazy, same pattern as rmaps loadMapLibre) ──
|
||||
|
||||
let d3Loaded = false;
|
||||
let d3LoadPromise: Promise<void> | null = null;
|
||||
|
||||
export function loadD3(): Promise<void> {
|
||||
if (d3Loaded) return Promise.resolve();
|
||||
if (d3LoadPromise) return d3LoadPromise;
|
||||
|
||||
d3LoadPromise = new Promise<void>((resolve, reject) => {
|
||||
const d3Script = document.createElement("script");
|
||||
d3Script.src = "https://d3js.org/d3.v7.min.js";
|
||||
d3Script.onload = () => {
|
||||
// Load d3-sankey after d3 core
|
||||
const sankeyScript = document.createElement("script");
|
||||
sankeyScript.src = "https://cdn.jsdelivr.net/npm/d3-sankey@0.12.3/dist/d3-sankey.min.js";
|
||||
sankeyScript.onload = () => {
|
||||
d3Loaded = true;
|
||||
resolve();
|
||||
};
|
||||
sankeyScript.onerror = () => reject(new Error("Failed to load d3-sankey"));
|
||||
document.head.appendChild(sankeyScript);
|
||||
};
|
||||
d3Script.onerror = () => reject(new Error("Failed to load D3"));
|
||||
document.head.appendChild(d3Script);
|
||||
});
|
||||
|
||||
return d3LoadPromise;
|
||||
}
|
||||
|
||||
// ── Color constants (matching wallet theme) ──
|
||||
|
||||
const COLORS = {
|
||||
cyan: "#00d4ff",
|
||||
green: "#4ade80",
|
||||
red: "#f87171",
|
||||
pink: "#f472b6",
|
||||
teal: "#0891b2",
|
||||
darkTeal: "#0e7490",
|
||||
};
|
||||
|
||||
// ── Unique gradient IDs (scoped per render to avoid shadow DOM conflicts) ──
|
||||
|
||||
let vizIdCounter = 0;
|
||||
function nextVizId(): string {
|
||||
return `wv${++vizIdCounter}`;
|
||||
}
|
||||
|
||||
// ── Timeline (Balance River) ──
|
||||
|
||||
export interface TimelineOptions {
|
||||
width?: number;
|
||||
height?: number;
|
||||
chainColors?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function renderTimeline(
|
||||
container: HTMLElement,
|
||||
data: TimelineEntry[],
|
||||
options: TimelineOptions = {},
|
||||
): void {
|
||||
container.innerHTML = "";
|
||||
|
||||
if (data.length === 0) {
|
||||
container.innerHTML = '<p style="text-align:center;color:var(--rs-text-muted,#666);padding:60px;">No transaction data available.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const id = nextVizId();
|
||||
const margin = { top: 80, right: 50, bottom: 80, left: 60 };
|
||||
const width = (options.width || container.clientWidth || 1200) - margin.left - margin.right;
|
||||
const height = (options.height || 500) - margin.top - margin.bottom;
|
||||
const centerY = height / 2;
|
||||
|
||||
// Create tooltip inside container (shadow DOM safe)
|
||||
const tooltip = document.createElement("div");
|
||||
Object.assign(tooltip.style, {
|
||||
position: "absolute", background: "rgba(10,10,20,0.98)", border: "1px solid rgba(255,255,255,0.2)",
|
||||
borderRadius: "10px", padding: "14px 18px", fontSize: "0.85rem", pointerEvents: "none",
|
||||
zIndex: "1000", maxWidth: "320px", boxShadow: "0 8px 32px rgba(0,0,0,0.6)", display: "none",
|
||||
color: "#e0e0e0",
|
||||
});
|
||||
container.style.position = "relative";
|
||||
container.appendChild(tooltip);
|
||||
|
||||
// Stats
|
||||
let totalIn = 0, totalOut = 0, balance = 0, peak = 0;
|
||||
data.forEach(tx => {
|
||||
if (tx.type === "in") { totalIn += tx.usd; balance += tx.usd; }
|
||||
else { totalOut += tx.usd; balance -= tx.usd; }
|
||||
if (balance > peak) peak = balance;
|
||||
});
|
||||
|
||||
// Stats row
|
||||
const statsRow = document.createElement("div");
|
||||
statsRow.style.cssText = "display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:10px;margin-bottom:16px;";
|
||||
const statItems = [
|
||||
{ label: "Total Inflow", value: `$${Math.round(totalIn).toLocaleString()}`, color: COLORS.green },
|
||||
{ label: "Total Outflow", value: `$${Math.round(totalOut).toLocaleString()}`, color: COLORS.red },
|
||||
{ label: "Net Change", value: `$${Math.round(totalIn - totalOut).toLocaleString()}`, color: COLORS.cyan },
|
||||
{ label: "Peak Balance", value: `$${Math.round(peak).toLocaleString()}`, color: COLORS.cyan },
|
||||
{ label: "Transactions", value: String(data.length), color: "#e0e0e0" },
|
||||
];
|
||||
statsRow.innerHTML = statItems.map(s => `
|
||||
<div style="background:var(--rs-bg-surface,rgba(255,255,255,0.03));border-radius:10px;padding:12px;border:1px solid var(--rs-border-subtle,rgba(255,255,255,0.1));text-align:center;">
|
||||
<div style="color:var(--rs-text-secondary,#888);font-size:10px;text-transform:uppercase;margin-bottom:4px;">${s.label}</div>
|
||||
<div style="font-size:1.1rem;font-weight:700;color:${s.color};">${s.value}</div>
|
||||
</div>
|
||||
`).join("");
|
||||
container.appendChild(statsRow);
|
||||
|
||||
// Legend
|
||||
const legend = document.createElement("div");
|
||||
legend.style.cssText = "display:flex;justify-content:center;gap:24px;margin-bottom:12px;font-size:0.8rem;";
|
||||
legend.innerHTML = `
|
||||
<span style="display:flex;align-items:center;gap:6px;"><span style="display:inline-block;width:30px;height:12px;border-radius:6px;background:linear-gradient(90deg,${COLORS.green},${COLORS.cyan});"></span> Inflows</span>
|
||||
<span style="display:flex;align-items:center;gap:6px;"><span style="display:inline-block;width:30px;height:12px;border-radius:6px;background:${COLORS.cyan};border:1px solid rgba(0,212,255,0.3);"></span> Balance</span>
|
||||
<span style="display:flex;align-items:center;gap:6px;"><span style="display:inline-block;width:30px;height:12px;border-radius:6px;background:linear-gradient(90deg,${COLORS.cyan},${COLORS.red});"></span> Outflows</span>
|
||||
`;
|
||||
container.appendChild(legend);
|
||||
|
||||
// SVG container
|
||||
const svgContainer = document.createElement("div");
|
||||
svgContainer.style.cssText = "overflow-x:auto;border-radius:12px;border:1px solid var(--rs-border-subtle,rgba(255,255,255,0.1));background:var(--rs-bg-surface,rgba(255,255,255,0.02));padding:8px;";
|
||||
container.appendChild(svgContainer);
|
||||
|
||||
const svg = d3.select(svgContainer)
|
||||
.append("svg")
|
||||
.attr("width", width + margin.left + margin.right)
|
||||
.attr("height", height + margin.top + margin.bottom)
|
||||
.style("cursor", "grab");
|
||||
|
||||
const mainGroup = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`);
|
||||
|
||||
// Defs (scoped IDs)
|
||||
const defs = mainGroup.append("defs");
|
||||
|
||||
const inflowGrad = defs.append("linearGradient").attr("id", `${id}-inflow`).attr("x1", "0%").attr("y1", "0%").attr("x2", "100%").attr("y2", "100%");
|
||||
inflowGrad.append("stop").attr("offset", "0%").attr("stop-color", COLORS.green).attr("stop-opacity", 0.4);
|
||||
inflowGrad.append("stop").attr("offset", "40%").attr("stop-color", COLORS.green).attr("stop-opacity", 0.85);
|
||||
inflowGrad.append("stop").attr("offset", "70%").attr("stop-color", "#22d3ee").attr("stop-opacity", 0.9);
|
||||
inflowGrad.append("stop").attr("offset", "100%").attr("stop-color", COLORS.cyan).attr("stop-opacity", 1);
|
||||
|
||||
const outflowGrad = defs.append("linearGradient").attr("id", `${id}-outflow`).attr("x1", "0%").attr("y1", "0%").attr("x2", "100%").attr("y2", "100%");
|
||||
outflowGrad.append("stop").attr("offset", "0%").attr("stop-color", COLORS.cyan).attr("stop-opacity", 1);
|
||||
outflowGrad.append("stop").attr("offset", "30%").attr("stop-color", COLORS.pink).attr("stop-opacity", 0.9);
|
||||
outflowGrad.append("stop").attr("offset", "60%").attr("stop-color", COLORS.red).attr("stop-opacity", 0.85);
|
||||
outflowGrad.append("stop").attr("offset", "100%").attr("stop-color", COLORS.red).attr("stop-opacity", 0.4);
|
||||
|
||||
const riverGrad = defs.append("linearGradient").attr("id", `${id}-river`).attr("x1", "0%").attr("y1", "0%").attr("x2", "0%").attr("y2", "100%");
|
||||
riverGrad.append("stop").attr("offset", "0%").attr("stop-color", COLORS.cyan).attr("stop-opacity", 0.9);
|
||||
riverGrad.append("stop").attr("offset", "30%").attr("stop-color", COLORS.teal).attr("stop-opacity", 1);
|
||||
riverGrad.append("stop").attr("offset", "50%").attr("stop-color", COLORS.darkTeal).attr("stop-opacity", 1);
|
||||
riverGrad.append("stop").attr("offset", "70%").attr("stop-color", COLORS.teal).attr("stop-opacity", 1);
|
||||
riverGrad.append("stop").attr("offset", "100%").attr("stop-color", COLORS.cyan).attr("stop-opacity", 0.9);
|
||||
|
||||
defs.append("clipPath").attr("id", `${id}-clip`).append("rect")
|
||||
.attr("x", 0).attr("y", -margin.top).attr("width", width).attr("height", height + margin.top + margin.bottom);
|
||||
|
||||
// Scales
|
||||
const timeExtent = d3.extent(data, (d: TimelineEntry) => d.date) as [Date, Date];
|
||||
const timePadding = (timeExtent[1].getTime() - timeExtent[0].getTime()) * 0.05;
|
||||
const xScale = d3.scaleTime()
|
||||
.domain([new Date(timeExtent[0].getTime() - timePadding), new Date(timeExtent[1].getTime() + timePadding)])
|
||||
.range([0, width]);
|
||||
|
||||
// Balance data
|
||||
const balanceData: { date: Date; balance: number }[] = [];
|
||||
let runBal = 0;
|
||||
balanceData.push({ date: new Date(timeExtent[0].getTime() - timePadding), balance: 0 });
|
||||
data.forEach(tx => {
|
||||
if (tx.type === "in") runBal += tx.usd;
|
||||
else runBal -= tx.usd;
|
||||
balanceData.push({ date: tx.date, balance: Math.max(0, runBal) });
|
||||
});
|
||||
balanceData.push({ date: new Date(timeExtent[1].getTime() + timePadding), balance: Math.max(0, runBal) });
|
||||
|
||||
const maxBalance = d3.max(balanceData, (d: any) => d.balance) || 1;
|
||||
const balanceScale = d3.scaleLinear().domain([0, maxBalance]).range([8, 120]);
|
||||
|
||||
const contentGroup = mainGroup.append("g").attr("clip-path", `url(#${id}-clip)`);
|
||||
const xAxisGroup = mainGroup.append("g").attr("transform", `translate(0,${height + 20})`);
|
||||
|
||||
function updateAxis(scale: any) {
|
||||
const domain = scale.domain();
|
||||
const days = (domain[1] - domain[0]) / (1000 * 60 * 60 * 24);
|
||||
let tickInterval, tickFormat;
|
||||
if (days < 14) { tickInterval = d3.timeDay.every(1); tickFormat = d3.timeFormat("%b %d"); }
|
||||
else if (days < 60) { tickInterval = d3.timeWeek.every(1); tickFormat = d3.timeFormat("%b %d"); }
|
||||
else if (days < 180) { tickInterval = d3.timeMonth.every(1); tickFormat = d3.timeFormat("%b %Y"); }
|
||||
else if (days < 365) { tickInterval = d3.timeMonth.every(2); tickFormat = d3.timeFormat("%b %Y"); }
|
||||
else { tickInterval = d3.timeMonth.every(3); tickFormat = d3.timeFormat("%b %Y"); }
|
||||
|
||||
const xAxis = d3.axisBottom(scale).ticks(tickInterval).tickFormat(tickFormat);
|
||||
xAxisGroup.call(xAxis).selectAll("text").attr("fill", "#888").attr("font-size", "11px").attr("transform", "rotate(-30)").attr("text-anchor", "end");
|
||||
xAxisGroup.selectAll(".domain, .tick line").attr("stroke", "#444");
|
||||
}
|
||||
|
||||
function drawContent(scale: any) {
|
||||
contentGroup.selectAll("*").remove();
|
||||
const smoothCurve = d3.curveBasis;
|
||||
|
||||
// River glow
|
||||
contentGroup.append("path").datum(balanceData).attr("fill", "rgba(0,212,255,0.08)")
|
||||
.attr("d", d3.area().x((d: any) => scale(d.date)).y0((d: any) => centerY + balanceScale(d.balance) / 2 + 15).y1((d: any) => centerY - balanceScale(d.balance) / 2 - 15).curve(smoothCurve));
|
||||
|
||||
// Main river
|
||||
contentGroup.append("path").datum(balanceData).attr("fill", `url(#${id}-river)`)
|
||||
.attr("d", d3.area().x((d: any) => scale(d.date)).y0((d: any) => centerY + balanceScale(d.balance) / 2).y1((d: any) => centerY - balanceScale(d.balance) / 2).curve(smoothCurve));
|
||||
|
||||
// Edge highlights
|
||||
contentGroup.append("path").datum(balanceData).attr("fill", "none").attr("stroke", "rgba(255,255,255,0.3)").attr("stroke-width", 1.5)
|
||||
.attr("d", d3.line().x((d: any) => scale(d.date)).y((d: any) => centerY - balanceScale(d.balance) / 2).curve(smoothCurve));
|
||||
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: any) => scale(d.date)).y((d: any) => centerY + balanceScale(d.balance) / 2).curve(smoothCurve));
|
||||
|
||||
// Diagonal waterfall flows
|
||||
const flowHeight = 80;
|
||||
const xOffset = flowHeight * 0.7;
|
||||
let prevBalance = 0;
|
||||
|
||||
data.forEach(tx => {
|
||||
const x = scale(tx.date);
|
||||
const balanceBefore = Math.max(0, prevBalance);
|
||||
if (tx.type === "in") prevBalance += tx.usd;
|
||||
else prevBalance -= tx.usd;
|
||||
const balanceAfter = Math.max(0, prevBalance);
|
||||
|
||||
const relevantBalance = tx.type === "in" ? balanceAfter : balanceBefore;
|
||||
const riverW = balanceScale(relevantBalance);
|
||||
const proportion = relevantBalance > 0 ? Math.min(1, tx.usd / relevantBalance) : 0.5;
|
||||
const riverEndHalf = Math.max(4, (proportion * riverW) / 2);
|
||||
const farEndHalf = Math.max(2, riverEndHalf * 0.3);
|
||||
|
||||
const riverTopAfter = centerY - balanceScale(balanceAfter) / 2;
|
||||
const riverBottomBefore = centerY + balanceScale(balanceBefore) / 2;
|
||||
|
||||
if (tx.type === "in") {
|
||||
const endY = riverTopAfter;
|
||||
const startY = endY - flowHeight;
|
||||
const startX = x - xOffset;
|
||||
const endX = x;
|
||||
|
||||
const path = d3.path();
|
||||
path.moveTo(startX - farEndHalf, startY);
|
||||
path.bezierCurveTo(startX - farEndHalf, startY + flowHeight * 0.55, endX - riverEndHalf, endY - flowHeight * 0.45, endX - riverEndHalf, endY);
|
||||
path.lineTo(endX + riverEndHalf, endY);
|
||||
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(#${id}-inflow)`)
|
||||
.attr("opacity", 0.85).style("cursor", "pointer").style("transition", "opacity 0.2s, filter 0.2s")
|
||||
.on("mouseover", (event: any) => showTxTooltip(event, tx, prevBalance))
|
||||
.on("mousemove", (event: any) => moveTip(event))
|
||||
.on("mouseout", () => { tooltip.style.display = "none"; });
|
||||
} else {
|
||||
const startY = riverBottomBefore;
|
||||
const endY = startY + flowHeight;
|
||||
const startX = x;
|
||||
const endX = x + xOffset;
|
||||
|
||||
const path = d3.path();
|
||||
path.moveTo(startX - riverEndHalf, startY);
|
||||
path.bezierCurveTo(startX - riverEndHalf, startY + flowHeight * 0.45, endX - farEndHalf, endY - flowHeight * 0.55, endX - farEndHalf, endY);
|
||||
path.lineTo(endX + farEndHalf, endY);
|
||||
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(#${id}-outflow)`)
|
||||
.attr("opacity", 0.85).style("cursor", "pointer").style("transition", "opacity 0.2s, filter 0.2s")
|
||||
.on("mouseover", (event: any) => showTxTooltip(event, tx, prevBalance))
|
||||
.on("mousemove", (event: any) => moveTip(event))
|
||||
.on("mouseout", () => { tooltip.style.display = "none"; });
|
||||
}
|
||||
});
|
||||
|
||||
// River hover
|
||||
const riverHoverGroup = contentGroup.append("g");
|
||||
riverHoverGroup.append("rect")
|
||||
.attr("x", scale.range()[0] - 50).attr("y", centerY - 100)
|
||||
.attr("width", scale.range()[1] - scale.range()[0] + 100).attr("height", 200)
|
||||
.attr("fill", "transparent").style("cursor", "crosshair")
|
||||
.on("mousemove", function(event: any) {
|
||||
const [mouseX] = d3.pointer(event);
|
||||
const hoveredDate = scale.invert(mouseX);
|
||||
let balAtPoint = 0;
|
||||
for (const tx of data) {
|
||||
if (tx.date <= hoveredDate) {
|
||||
if (tx.type === "in") balAtPoint += tx.usd;
|
||||
else balAtPoint -= tx.usd;
|
||||
} else break;
|
||||
}
|
||||
riverHoverGroup.selectAll(".bal-ind").remove();
|
||||
const t = balanceScale(Math.max(0, balAtPoint));
|
||||
riverHoverGroup.append("line").attr("class", "bal-ind")
|
||||
.attr("x1", mouseX).attr("x2", mouseX).attr("y1", centerY - t / 2 - 5).attr("y2", centerY + t / 2 + 5)
|
||||
.attr("stroke", "#fff").attr("stroke-width", 2).attr("stroke-dasharray", "4,2").attr("opacity", 0.8).style("pointer-events", "none");
|
||||
riverHoverGroup.append("circle").attr("class", "bal-ind")
|
||||
.attr("cx", mouseX).attr("cy", centerY).attr("r", 5)
|
||||
.attr("fill", COLORS.cyan).attr("stroke", "#fff").attr("stroke-width", 2).style("pointer-events", "none");
|
||||
|
||||
tooltip.innerHTML = `
|
||||
<div style="color:#888;font-size:0.75rem;margin-bottom:4px;">${hoveredDate.toLocaleDateString("en-US", { weekday: "short", year: "numeric", month: "short", day: "numeric" })}</div>
|
||||
<span style="font-weight:bold;font-size:1.3rem;color:${COLORS.cyan};">$${Math.round(Math.max(0, balAtPoint)).toLocaleString()}</span>
|
||||
<div style="margin-top:6px;font-size:0.8rem;color:#888;">Balance at this point</div>
|
||||
`;
|
||||
tooltip.style.display = "block";
|
||||
moveTip(event);
|
||||
})
|
||||
.on("mouseout", function() {
|
||||
riverHoverGroup.selectAll(".bal-ind").remove();
|
||||
tooltip.style.display = "none";
|
||||
});
|
||||
|
||||
// Labels
|
||||
mainGroup.selectAll(".viz-label").remove();
|
||||
mainGroup.append("text").attr("class", "viz-label").attr("x", 30).attr("y", -50).attr("text-anchor", "start")
|
||||
.attr("fill", COLORS.green).attr("font-size", "13px").attr("font-weight", "bold").attr("opacity", 0.8).text("INFLOWS");
|
||||
mainGroup.append("text").attr("class", "viz-label").attr("x", width - 30).attr("y", height + 55).attr("text-anchor", "end")
|
||||
.attr("fill", COLORS.red).attr("font-size", "13px").attr("font-weight", "bold").attr("opacity", 0.8).text("OUTFLOWS");
|
||||
}
|
||||
|
||||
function showTxTooltip(event: any, tx: TimelineEntry, balAfter: number) {
|
||||
const chain = tx.chain || "";
|
||||
tooltip.innerHTML = `
|
||||
<div style="color:#888;font-size:0.75rem;margin-bottom:4px;">${tx.date.toLocaleDateString("en-US", { weekday: "short", year: "numeric", month: "short", day: "numeric" })}</div>
|
||||
<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:0.7rem;font-weight:600;background:var(--rs-bg-hover,#333);margin-bottom:6px;">${chain.charAt(0).toUpperCase() + chain.slice(1)}</span>
|
||||
<span style="font-weight:bold;font-size:1.3rem;display:block;margin-bottom:4px;color:${tx.type === "in" ? COLORS.green : COLORS.red};">
|
||||
${tx.type === "in" ? "+" : "-"}$${Math.round(tx.usd).toLocaleString()}
|
||||
</span>
|
||||
<div style="color:${COLORS.cyan};font-size:0.9rem;">${tx.amount.toLocaleString(undefined, { maximumFractionDigits: 4 })} ${tx.token}</div>
|
||||
<div style="font-family:monospace;font-size:0.75rem;color:#888;margin-top:6px;padding-top:6px;border-top:1px solid rgba(255,255,255,0.1);">
|
||||
${tx.type === "in" ? "From: " + (tx.from || "Unknown") : "To: " + (tx.to || "Unknown")}
|
||||
</div>
|
||||
<div style="margin-top:6px;padding-top:6px;border-top:1px solid rgba(255,255,255,0.1);font-size:0.8rem;color:${COLORS.cyan};">
|
||||
Balance after: $${Math.round(Math.max(0, balAfter)).toLocaleString()}
|
||||
</div>
|
||||
`;
|
||||
tooltip.style.display = "block";
|
||||
moveTip(event);
|
||||
}
|
||||
|
||||
function moveTip(event: any) {
|
||||
const rect = container.getBoundingClientRect();
|
||||
let x = event.clientX - rect.left + 15;
|
||||
let y = event.clientY - rect.top - 10;
|
||||
if (x + 300 > rect.width) x = event.clientX - rect.left - 300 - 15;
|
||||
if (y + 200 > rect.height) y = event.clientY - rect.top - 200;
|
||||
tooltip.style.left = x + "px";
|
||||
tooltip.style.top = y + "px";
|
||||
}
|
||||
|
||||
updateAxis(xScale);
|
||||
drawContent(xScale);
|
||||
|
||||
// Zoom
|
||||
const zoom = d3.zoom()
|
||||
.scaleExtent([0.5, 20])
|
||||
.translateExtent([[-width * 2, 0], [width * 3, height]])
|
||||
.extent([[0, 0], [width, height]])
|
||||
.on("zoom", (event: any) => {
|
||||
const newXScale = event.transform.rescaleX(xScale);
|
||||
updateAxis(newXScale);
|
||||
drawContent(newXScale);
|
||||
})
|
||||
.on("start", () => svg.style("cursor", "grabbing"))
|
||||
.on("end", () => svg.style("cursor", "grab"));
|
||||
|
||||
svg.on("wheel", function(event: any) {
|
||||
event.preventDefault();
|
||||
const currentTransform = d3.zoomTransform(svg.node());
|
||||
if (Math.abs(event.deltaX) > Math.abs(event.deltaY) || event.shiftKey) {
|
||||
const panAmount = event.deltaX !== 0 ? event.deltaX : event.deltaY;
|
||||
const newTransform = currentTransform.translate(-panAmount * 0.5, 0);
|
||||
svg.call(zoom.transform, newTransform);
|
||||
} else {
|
||||
const scaleFactor = event.deltaY > 0 ? 0.9 : 1.1;
|
||||
const [mouseX] = d3.pointer(event);
|
||||
const newScale = Math.max(0.5, Math.min(20, currentTransform.k * scaleFactor));
|
||||
const newX = mouseX - (mouseX - currentTransform.x) * (newScale / currentTransform.k);
|
||||
const newTransform = d3.zoomIdentity.translate(newX, 0).scale(newScale);
|
||||
svg.call(zoom.transform, newTransform);
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
svg.call(zoom);
|
||||
}
|
||||
|
||||
// ── Flow Chart (Multi-Chain Force-Directed) ──
|
||||
|
||||
export interface FlowChartOptions {
|
||||
width?: number;
|
||||
height?: number;
|
||||
chainColors?: Record<string, string>;
|
||||
safeAddress?: string;
|
||||
}
|
||||
|
||||
export function renderFlowChart(
|
||||
container: HTMLElement,
|
||||
flowData: FlowEntry[],
|
||||
stats: ChainStats | undefined,
|
||||
options: FlowChartOptions = {},
|
||||
): void {
|
||||
container.innerHTML = "";
|
||||
|
||||
if (!flowData || flowData.length === 0) {
|
||||
container.innerHTML = '<p style="text-align:center;color:var(--rs-text-muted,#666);padding:60px;">No flow data available.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Stats row
|
||||
if (stats) {
|
||||
const statsRow = document.createElement("div");
|
||||
statsRow.style.cssText = "display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:10px;margin-bottom:16px;";
|
||||
statsRow.innerHTML = [
|
||||
{ label: "Transfers", value: stats.transfers, color: COLORS.cyan },
|
||||
{ label: "Inflow", value: stats.inflow, color: COLORS.green },
|
||||
{ label: "Outflow", value: stats.outflow, color: COLORS.red },
|
||||
{ label: "Addresses", value: stats.addresses, color: COLORS.cyan },
|
||||
{ label: "Period", value: stats.period, color: "#e0e0e0" },
|
||||
].map(s => `
|
||||
<div style="background:var(--rs-bg-surface,rgba(255,255,255,0.03));border-radius:10px;padding:12px;border:1px solid var(--rs-border-subtle,rgba(255,255,255,0.1));text-align:center;">
|
||||
<div style="color:var(--rs-text-secondary,#888);font-size:10px;text-transform:uppercase;margin-bottom:4px;">${s.label}</div>
|
||||
<div style="font-size:1rem;font-weight:700;color:${s.color};">${s.value}</div>
|
||||
</div>
|
||||
`).join("");
|
||||
container.appendChild(statsRow);
|
||||
}
|
||||
|
||||
const chartDiv = document.createElement("div");
|
||||
chartDiv.style.cssText = "border-radius:12px;border:1px solid var(--rs-border-subtle,rgba(255,255,255,0.1));background:var(--rs-bg-surface,rgba(255,255,255,0.02));overflow:hidden;";
|
||||
container.appendChild(chartDiv);
|
||||
|
||||
const w = options.width || container.clientWidth || 1000;
|
||||
const h = options.height || 400;
|
||||
|
||||
const svg = d3.select(chartDiv).append("svg")
|
||||
.attr("width", "100%").attr("height", h).attr("viewBox", `0 0 ${w} ${h}`)
|
||||
.style("cursor", "grab");
|
||||
|
||||
const g = svg.append("g");
|
||||
|
||||
const inflows = flowData.filter(f => f.to === "Safe Wallet");
|
||||
const outflows = flowData.filter(f => f.from === "Safe Wallet");
|
||||
|
||||
const walletX = w / 2, walletY = h / 2;
|
||||
|
||||
// Central wallet node
|
||||
g.append("rect").attr("x", walletX - 70).attr("y", walletY - 35).attr("width", 140).attr("height", 70)
|
||||
.attr("rx", 12).attr("fill", COLORS.cyan).attr("opacity", 0.9);
|
||||
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");
|
||||
if (options.safeAddress) {
|
||||
const short = options.safeAddress.slice(0, 6) + "..." + options.safeAddress.slice(-4);
|
||||
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(short);
|
||||
}
|
||||
|
||||
function getFlowColor(chainName: string): string {
|
||||
const colors: Record<string, string> = options.chainColors || {};
|
||||
return colors[chainName] || COLORS.cyan;
|
||||
}
|
||||
|
||||
// Inflows (left side)
|
||||
const inflowSpacing = h / (inflows.length + 1);
|
||||
inflows.forEach((flow, i) => {
|
||||
const y = inflowSpacing * (i + 1);
|
||||
const sourceX = 120;
|
||||
const color = getFlowColor(flow.chain);
|
||||
|
||||
const path = d3.path();
|
||||
path.moveTo(sourceX + 60, y);
|
||||
path.bezierCurveTo(sourceX + 150, y, walletX - 150, walletY, walletX - 70, walletY);
|
||||
g.append("path").attr("d", path.toString()).attr("fill", "none")
|
||||
.attr("stroke", COLORS.green).attr("stroke-width", Math.max(2, Math.log(flow.value + 1) * 1.2)).attr("stroke-opacity", 0.6);
|
||||
|
||||
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);
|
||||
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);
|
||||
g.append("text").attr("x", sourceX + 100).attr("y", y - 20)
|
||||
.attr("fill", COLORS.green).attr("font-size", "9px").text(`+${flow.value.toLocaleString()} ${flow.token}`);
|
||||
});
|
||||
|
||||
// Outflows (right side)
|
||||
const outflowSpacing = h / (outflows.length + 1);
|
||||
outflows.forEach((flow, i) => {
|
||||
const y = outflowSpacing * (i + 1);
|
||||
const targetX = w - 120;
|
||||
const color = getFlowColor(flow.chain);
|
||||
|
||||
const path = d3.path();
|
||||
path.moveTo(walletX + 70, walletY);
|
||||
path.bezierCurveTo(walletX + 150, walletY, targetX - 150, y, targetX - 60, y);
|
||||
g.append("path").attr("d", path.toString()).attr("fill", "none")
|
||||
.attr("stroke", COLORS.red).attr("stroke-width", Math.max(2, Math.log(flow.value + 1) * 1.2)).attr("stroke-opacity", 0.6);
|
||||
|
||||
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);
|
||||
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);
|
||||
g.append("text").attr("x", targetX - 100).attr("y", y - 20)
|
||||
.attr("fill", COLORS.red).attr("font-size", "9px").text(`-${flow.value.toLocaleString()} ${flow.token}`);
|
||||
});
|
||||
|
||||
// Zoom
|
||||
const flowZoom = d3.zoom()
|
||||
.scaleExtent([0.3, 5])
|
||||
.on("zoom", (event: any) => g.attr("transform", event.transform))
|
||||
.on("start", () => svg.style("cursor", "grabbing"))
|
||||
.on("end", () => svg.style("cursor", "grab"));
|
||||
svg.call(flowZoom);
|
||||
}
|
||||
|
||||
// ── Sankey Diagram ──
|
||||
|
||||
export interface SankeyOptions {
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export function renderSankey(
|
||||
container: HTMLElement,
|
||||
sankeyData: SankeyData,
|
||||
options: SankeyOptions = {},
|
||||
): void {
|
||||
container.innerHTML = "";
|
||||
|
||||
if (!sankeyData || sankeyData.links.length === 0) {
|
||||
container.innerHTML = '<p style="text-align:center;color:var(--rs-text-muted,#666);padding:60px;">No transaction flow data available.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Legend
|
||||
const legend = document.createElement("div");
|
||||
legend.style.cssText = "display:flex;justify-content:center;gap:20px;margin-bottom:12px;font-size:0.85rem;";
|
||||
legend.innerHTML = `
|
||||
<span style="display:flex;align-items:center;gap:6px;"><span style="display:inline-block;width:16px;height:16px;border-radius:3px;background:${COLORS.green};"></span> Inflow Sources</span>
|
||||
<span style="display:flex;align-items:center;gap:6px;"><span style="display:inline-block;width:16px;height:16px;border-radius:3px;background:${COLORS.cyan};"></span> Wallet</span>
|
||||
<span style="display:flex;align-items:center;gap:6px;"><span style="display:inline-block;width:16px;height:16px;border-radius:3px;background:${COLORS.red};"></span> Outflow Targets</span>
|
||||
`;
|
||||
container.appendChild(legend);
|
||||
|
||||
const chartDiv = document.createElement("div");
|
||||
chartDiv.style.cssText = "border-radius:12px;border:1px solid var(--rs-border-subtle,rgba(255,255,255,0.1));background:var(--rs-bg-surface,rgba(255,255,255,0.02));overflow:hidden;cursor:grab;";
|
||||
container.appendChild(chartDiv);
|
||||
|
||||
const width = options.width || 1200;
|
||||
const height = options.height || Math.max(400, sankeyData.nodes.length * 35);
|
||||
const margin = { top: 20, right: 200, bottom: 20, left: 200 };
|
||||
|
||||
const svg = d3.select(chartDiv).append("svg")
|
||||
.attr("width", "100%").attr("height", height).attr("viewBox", `0 0 ${width} ${height}`)
|
||||
.style("cursor", "grab");
|
||||
|
||||
const zoomGroup = svg.append("g");
|
||||
|
||||
const sankey = d3.sankey()
|
||||
.nodeWidth(20)
|
||||
.nodePadding(15)
|
||||
.extent([[margin.left, margin.top], [width - margin.right, height - margin.bottom]]);
|
||||
|
||||
const { nodes, links } = sankey({
|
||||
nodes: sankeyData.nodes.map((d: any) => Object.assign({}, d)),
|
||||
links: sankeyData.links.map((d: any) => Object.assign({}, d)),
|
||||
});
|
||||
|
||||
// Links
|
||||
zoomGroup.append("g")
|
||||
.selectAll("path")
|
||||
.data(links)
|
||||
.join("path")
|
||||
.attr("d", d3.sankeyLinkHorizontal())
|
||||
.attr("fill", "none")
|
||||
.attr("stroke", (d: any) => {
|
||||
const sourceNode = nodes[d.source.index !== undefined ? d.source.index : d.source];
|
||||
return sourceNode?.type === "source" ? COLORS.green : COLORS.red;
|
||||
})
|
||||
.attr("stroke-opacity", 0.4)
|
||||
.attr("stroke-width", (d: any) => Math.max(1, d.width))
|
||||
.style("transition", "stroke-opacity 0.2s")
|
||||
.on("mouseover", function(this: any) { d3.select(this).attr("stroke-opacity", 0.7); })
|
||||
.on("mouseout", function(this: any) { d3.select(this).attr("stroke-opacity", 0.4); })
|
||||
.append("title")
|
||||
.text((d: any) => `${d.source.name} -> ${d.target.name}\n${d.value.toLocaleString()} ${d.token}`);
|
||||
|
||||
// Nodes
|
||||
const node = zoomGroup.append("g").selectAll("g").data(nodes).join("g");
|
||||
|
||||
node.append("rect")
|
||||
.attr("x", (d: any) => d.x0)
|
||||
.attr("y", (d: any) => d.y0)
|
||||
.attr("height", (d: any) => d.y1 - d.y0)
|
||||
.attr("width", (d: any) => d.x1 - d.x0)
|
||||
.attr("fill", (d: any) => d.type === "wallet" ? COLORS.cyan : d.type === "source" ? COLORS.green : COLORS.red)
|
||||
.attr("rx", 3);
|
||||
|
||||
node.append("text")
|
||||
.attr("x", (d: any) => d.x0 < width / 2 ? d.x0 - 6 : d.x1 + 6)
|
||||
.attr("y", (d: any) => (d.y1 + d.y0) / 2)
|
||||
.attr("dy", "0.35em")
|
||||
.attr("text-anchor", (d: any) => d.x0 < width / 2 ? "end" : "start")
|
||||
.text((d: any) => d.name)
|
||||
.style("font-family", "monospace")
|
||||
.style("font-size", (d: any) => d.type === "wallet" ? "14px" : "11px")
|
||||
.style("font-weight", (d: any) => d.type === "wallet" ? "bold" : "normal")
|
||||
.style("fill", "#e0e0e0");
|
||||
|
||||
// Zoom
|
||||
const sankeyZoom = d3.zoom()
|
||||
.scaleExtent([0.3, 5])
|
||||
.on("zoom", (event: any) => zoomGroup.attr("transform", event.transform))
|
||||
.on("start", () => svg.style("cursor", "grabbing"))
|
||||
.on("end", () => svg.style("cursor", "grab"));
|
||||
svg.call(sankeyZoom);
|
||||
}
|
||||
|
|
@ -326,9 +326,16 @@ export default defineConfig({
|
|||
);
|
||||
|
||||
// Build wallet module component
|
||||
const walletAlias = {
|
||||
"../lib/data-transform": resolve(__dirname, "modules/rwallet/lib/data-transform.ts"),
|
||||
"../lib/wallet-viz": resolve(__dirname, "modules/rwallet/lib/wallet-viz.ts"),
|
||||
"../lib/wallet-demo-data": resolve(__dirname, "modules/rwallet/lib/wallet-demo-data.ts"),
|
||||
};
|
||||
|
||||
await build({
|
||||
configFile: false,
|
||||
root: resolve(__dirname, "modules/rwallet/components"),
|
||||
resolve: { alias: walletAlias },
|
||||
build: {
|
||||
emptyOutDir: false,
|
||||
outDir: resolve(__dirname, "dist/modules/rwallet"),
|
||||
|
|
|
|||
Loading…
Reference in New Issue