/** * 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 | null = null; export function loadD3(): Promise { if (d3Loaded) return Promise.resolve(); if (d3LoadPromise) return d3LoadPromise; d3LoadPromise = new Promise((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; } export function renderTimeline( container: HTMLElement, data: TimelineEntry[], options: TimelineOptions = {}, ): void { container.innerHTML = ""; if (data.length === 0) { container.innerHTML = '

No transaction data available.

'; 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 => `
${s.label}
${s.value}
`).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 = ` Inflows Balance Outflows `; 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 = `
${hoveredDate.toLocaleDateString("en-US", { weekday: "short", year: "numeric", month: "short", day: "numeric" })}
$${Math.round(Math.max(0, balAtPoint)).toLocaleString()}
Balance at this point
`; 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 = `
${tx.date.toLocaleDateString("en-US", { weekday: "short", year: "numeric", month: "short", day: "numeric" })}
${chain.charAt(0).toUpperCase() + chain.slice(1)} ${tx.type === "in" ? "+" : "-"}$${Math.round(tx.usd).toLocaleString()}
${tx.amount.toLocaleString(undefined, { maximumFractionDigits: 4 })} ${tx.token}
${tx.type === "in" ? "From: " + (tx.from || "Unknown") : "To: " + (tx.to || "Unknown")}
Balance after: $${Math.round(Math.max(0, balAfter)).toLocaleString()}
`; 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; safeAddress?: string; } export function renderFlowChart( container: HTMLElement, flowData: FlowEntry[], stats: ChainStats | undefined, options: FlowChartOptions = {}, ): void { container.innerHTML = ""; if (!flowData || flowData.length === 0) { container.innerHTML = '

No flow data available.

'; 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 => `
${s.label}
${s.value}
`).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 = 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 = '

No transaction flow data available.

'; 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 = ` Inflow Sources Wallet Outflow Targets `; 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); }