629 lines
29 KiB
TypeScript
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);
|
|
}
|