rspace-online/modules/rwallet/lib/wallet-viz.ts

629 lines
29 KiB
TypeScript

/**
* 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);
}