|
|
@@ -0,0 +1,444 @@
|
|
|
+import * as d3 from 'd3';
|
|
|
+import type { GraphNode, GraphLink } from '@/lib/graph/graphBuilder';
|
|
|
+import { SOL_MINT } from '@/lib/helius/constants';
|
|
|
+
|
|
|
+interface SimNode extends GraphNode {
|
|
|
+ x: number;
|
|
|
+ y: number;
|
|
|
+ vx: number;
|
|
|
+ vy: number;
|
|
|
+}
|
|
|
+
|
|
|
+interface SimLink {
|
|
|
+ source: SimNode;
|
|
|
+ target: SimNode;
|
|
|
+ swapCount: number;
|
|
|
+ volume: number;
|
|
|
+ width: number;
|
|
|
+}
|
|
|
+
|
|
|
+interface EngineOptions {
|
|
|
+ onNodeClick?: (id: string) => void;
|
|
|
+ onNodeHover?: (id: string | null) => void;
|
|
|
+}
|
|
|
+
|
|
|
+export class ForceGraphEngine {
|
|
|
+ private canvas: HTMLCanvasElement;
|
|
|
+ private ctx: CanvasRenderingContext2D;
|
|
|
+ private simulation: d3.Simulation<SimNode, SimLink>;
|
|
|
+ private nodes: SimNode[] = [];
|
|
|
+ private links: SimLink[] = [];
|
|
|
+ private transform: d3.ZoomTransform = d3.zoomIdentity;
|
|
|
+ private width = 0;
|
|
|
+ private height = 0;
|
|
|
+ private hoveredNode: SimNode | null = null;
|
|
|
+ private draggedNode: SimNode | null = null;
|
|
|
+ private options: EngineOptions;
|
|
|
+ private animFrameId = 0;
|
|
|
+ private dpr = 1;
|
|
|
+ private solImage: HTMLImageElement | null = null;
|
|
|
+ private solImageLoaded = false;
|
|
|
+
|
|
|
+ constructor(canvas: HTMLCanvasElement, options: EngineOptions = {}) {
|
|
|
+ this.canvas = canvas;
|
|
|
+ this.ctx = canvas.getContext('2d')!;
|
|
|
+ this.options = options;
|
|
|
+ this.dpr = window.devicePixelRatio || 1;
|
|
|
+
|
|
|
+ this.loadSolImage();
|
|
|
+ this.resize();
|
|
|
+
|
|
|
+ const radialRadius = Math.min(this.width, this.height) * 0.42;
|
|
|
+
|
|
|
+ this.simulation = d3.forceSimulation<SimNode, SimLink>()
|
|
|
+ .force('link', d3.forceLink<SimNode, SimLink>().id((d) => d.id).distance(radialRadius).strength(0.01))
|
|
|
+ .force('charge', d3.forceManyBody<SimNode>().strength((d) => d.id === SOL_MINT ? 0 : -80).distanceMax(400))
|
|
|
+ .force('collision', d3.forceCollide<SimNode>().radius((d) => d.radius + 6).strength(0.7))
|
|
|
+ .force('radial', d3.forceRadial<SimNode>(
|
|
|
+ (d) => d.id === SOL_MINT ? 0 : radialRadius,
|
|
|
+ this.width / 2,
|
|
|
+ this.height / 2
|
|
|
+ ).strength(1.2))
|
|
|
+ .alphaDecay(0.05)
|
|
|
+ .velocityDecay(0.6)
|
|
|
+ .on('tick', () => this.render());
|
|
|
+
|
|
|
+ this.setupInteractions();
|
|
|
+ this.startRenderLoop();
|
|
|
+
|
|
|
+ window.addEventListener('resize', this.handleResize);
|
|
|
+ }
|
|
|
+
|
|
|
+ private loadSolImage() {
|
|
|
+ const img = new Image();
|
|
|
+ img.crossOrigin = 'anonymous';
|
|
|
+ img.onload = () => {
|
|
|
+ this.solImage = img;
|
|
|
+ this.solImageLoaded = true;
|
|
|
+ this.render();
|
|
|
+ };
|
|
|
+ img.src = '/solana.jpg';
|
|
|
+ }
|
|
|
+
|
|
|
+ private handleResize = () => {
|
|
|
+ this.resize();
|
|
|
+ const radialRadius = Math.min(this.width, this.height) * 0.42;
|
|
|
+ this.simulation
|
|
|
+ .force('radial', d3.forceRadial<SimNode>(
|
|
|
+ (d) => d.id === SOL_MINT ? 0 : radialRadius,
|
|
|
+ this.width / 2,
|
|
|
+ this.height / 2
|
|
|
+ ).strength(1.2));
|
|
|
+ this.simulation.alpha(0.1).restart();
|
|
|
+ };
|
|
|
+
|
|
|
+ private resize() {
|
|
|
+ const rect = this.canvas.parentElement?.getBoundingClientRect();
|
|
|
+ if (!rect) return;
|
|
|
+ this.width = rect.width;
|
|
|
+ this.height = rect.height;
|
|
|
+ this.canvas.width = this.width * this.dpr;
|
|
|
+ this.canvas.height = this.height * this.dpr;
|
|
|
+ this.canvas.style.width = `${this.width}px`;
|
|
|
+ this.canvas.style.height = `${this.height}px`;
|
|
|
+ this.ctx.scale(this.dpr, this.dpr);
|
|
|
+ }
|
|
|
+
|
|
|
+ private setupInteractions() {
|
|
|
+ const canvas = d3.select(this.canvas);
|
|
|
+
|
|
|
+ // Zoom & pan
|
|
|
+ const zoom = d3.zoom<HTMLCanvasElement, unknown>()
|
|
|
+ .scaleExtent([0.1, 8])
|
|
|
+ .on('zoom', (event) => {
|
|
|
+ this.transform = event.transform;
|
|
|
+ this.render();
|
|
|
+ });
|
|
|
+
|
|
|
+ canvas.call(zoom);
|
|
|
+
|
|
|
+ // Drag
|
|
|
+ const drag = d3.drag<HTMLCanvasElement, unknown>()
|
|
|
+ .subject((event) => {
|
|
|
+ const [x, y] = this.screenToWorld(event.x, event.y);
|
|
|
+ return this.findNode(x, y);
|
|
|
+ })
|
|
|
+ .on('start', (event) => {
|
|
|
+ if (!event.subject) return;
|
|
|
+ this.draggedNode = event.subject as SimNode;
|
|
|
+ this.simulation.alphaTarget(0.3).restart();
|
|
|
+ this.draggedNode.fx = this.draggedNode.x;
|
|
|
+ this.draggedNode.fy = this.draggedNode.y;
|
|
|
+ })
|
|
|
+ .on('drag', (event) => {
|
|
|
+ if (!this.draggedNode) return;
|
|
|
+ const [x, y] = this.screenToWorld(event.x, event.y);
|
|
|
+ this.draggedNode.fx = x;
|
|
|
+ this.draggedNode.fy = y;
|
|
|
+ })
|
|
|
+ .on('end', (event) => {
|
|
|
+ if (!this.draggedNode) return;
|
|
|
+ this.simulation.alphaTarget(0);
|
|
|
+ // Keep SOL fixed at center, release others
|
|
|
+ if (this.draggedNode.id !== SOL_MINT) {
|
|
|
+ this.draggedNode.fx = null;
|
|
|
+ this.draggedNode.fy = null;
|
|
|
+ }
|
|
|
+ this.draggedNode = null;
|
|
|
+ });
|
|
|
+
|
|
|
+ canvas.call(drag as any);
|
|
|
+
|
|
|
+ // Click
|
|
|
+ this.canvas.addEventListener('click', (e) => {
|
|
|
+ const rect = this.canvas.getBoundingClientRect();
|
|
|
+ const [x, y] = this.screenToWorld(e.clientX - rect.left, e.clientY - rect.top);
|
|
|
+ const node = this.findNode(x, y);
|
|
|
+ if (node && this.options.onNodeClick) {
|
|
|
+ this.options.onNodeClick(node.id);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // Hover
|
|
|
+ this.canvas.addEventListener('mousemove', (e) => {
|
|
|
+ const rect = this.canvas.getBoundingClientRect();
|
|
|
+ const [x, y] = this.screenToWorld(e.clientX - rect.left, e.clientY - rect.top);
|
|
|
+ const node = this.findNode(x, y);
|
|
|
+ if (node !== this.hoveredNode) {
|
|
|
+ this.hoveredNode = node;
|
|
|
+ this.canvas.style.cursor = node ? 'pointer' : 'grab';
|
|
|
+ if (this.options.onNodeHover) {
|
|
|
+ this.options.onNodeHover(node?.id || null);
|
|
|
+ }
|
|
|
+ this.render();
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ private screenToWorld(sx: number, sy: number): [number, number] {
|
|
|
+ return [
|
|
|
+ (sx - this.transform.x) / this.transform.k,
|
|
|
+ (sy - this.transform.y) / this.transform.k,
|
|
|
+ ];
|
|
|
+ }
|
|
|
+
|
|
|
+ private findNode(x: number, y: number): SimNode | null {
|
|
|
+ // Search in reverse order (top-most first)
|
|
|
+ for (let i = this.nodes.length - 1; i >= 0; i--) {
|
|
|
+ const node = this.nodes[i];
|
|
|
+ const dx = x - node.x;
|
|
|
+ const dy = y - node.y;
|
|
|
+ if (dx * dx + dy * dy < (node.radius + 5) * (node.radius + 5)) {
|
|
|
+ return node;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ private startRenderLoop() {
|
|
|
+ const loop = () => {
|
|
|
+ this.animFrameId = requestAnimationFrame(loop);
|
|
|
+ };
|
|
|
+ loop();
|
|
|
+ }
|
|
|
+
|
|
|
+ private drawNode(ctx: CanvasRenderingContext2D, node: SimNode) {
|
|
|
+ if (node.x === undefined || node.y === undefined) return;
|
|
|
+
|
|
|
+ const isHovered = this.hoveredNode?.id === node.id;
|
|
|
+ const isSol = node.id === SOL_MINT;
|
|
|
+
|
|
|
+ // Outer glow
|
|
|
+ const glowRadius = node.radius * (isSol ? 2.5 : 2);
|
|
|
+ const gradient = ctx.createRadialGradient(
|
|
|
+ node.x, node.y, node.radius * 0.8,
|
|
|
+ node.x, node.y, glowRadius
|
|
|
+ );
|
|
|
+ gradient.addColorStop(0, node.color + (isHovered ? '40' : '20'));
|
|
|
+ gradient.addColorStop(1, 'transparent');
|
|
|
+ ctx.beginPath();
|
|
|
+ ctx.arc(node.x, node.y, glowRadius, 0, Math.PI * 2);
|
|
|
+ ctx.fillStyle = gradient;
|
|
|
+ ctx.fill();
|
|
|
+
|
|
|
+ // SOL node: draw icon image if loaded
|
|
|
+ if (isSol && this.solImageLoaded && this.solImage) {
|
|
|
+ ctx.save();
|
|
|
+ ctx.beginPath();
|
|
|
+ ctx.arc(node.x, node.y, node.radius, 0, Math.PI * 2);
|
|
|
+ ctx.clip();
|
|
|
+ ctx.drawImage(
|
|
|
+ this.solImage,
|
|
|
+ node.x - node.radius,
|
|
|
+ node.y - node.radius,
|
|
|
+ node.radius * 2,
|
|
|
+ node.radius * 2
|
|
|
+ );
|
|
|
+ ctx.restore();
|
|
|
+
|
|
|
+ // Border ring around SOL icon
|
|
|
+ ctx.beginPath();
|
|
|
+ ctx.arc(node.x, node.y, node.radius, 0, Math.PI * 2);
|
|
|
+ ctx.strokeStyle = isHovered ? '#ffffff' : '#b380ff';
|
|
|
+ ctx.lineWidth = isHovered ? 3 : 2.5;
|
|
|
+ ctx.stroke();
|
|
|
+ } else {
|
|
|
+ // Opacity scales with swap activity: more swaps = more opaque (40 to aa)
|
|
|
+ const activity = Math.min(1, node.swapCount / 50);
|
|
|
+ const fillAlpha = isHovered ? 'cc' : Math.round(0x40 + activity * 0x6a).toString(16).padStart(2, '0');
|
|
|
+ const borderWidth = isHovered ? 3 : 1.2 + activity * 1.8;
|
|
|
+
|
|
|
+ // Node fill
|
|
|
+ ctx.beginPath();
|
|
|
+ ctx.arc(node.x, node.y, node.radius, 0, Math.PI * 2);
|
|
|
+ ctx.fillStyle = node.color + fillAlpha;
|
|
|
+ ctx.fill();
|
|
|
+
|
|
|
+ // Border ring — thicker for more active tokens
|
|
|
+ ctx.beginPath();
|
|
|
+ ctx.arc(node.x, node.y, node.radius, 0, Math.PI * 2);
|
|
|
+ ctx.strokeStyle = isHovered ? '#ffffff' : node.color;
|
|
|
+ ctx.lineWidth = borderWidth;
|
|
|
+ ctx.stroke();
|
|
|
+
|
|
|
+ // Inner highlight ring for larger nodes
|
|
|
+ if (node.radius > 12) {
|
|
|
+ ctx.beginPath();
|
|
|
+ ctx.arc(node.x, node.y, node.radius * 0.55, 0, Math.PI * 2);
|
|
|
+ ctx.strokeStyle = node.color + '30';
|
|
|
+ ctx.lineWidth = 0.8;
|
|
|
+ ctx.stroke();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Label — inside the circle, skip for SOL (icon is enough)
|
|
|
+ if (!isSol) {
|
|
|
+ // Fit text inside circle: shrink font until it fits
|
|
|
+ const maxWidth = node.radius * 1.6;
|
|
|
+ let fontSize = Math.max(7, Math.min(11, node.radius * 0.7));
|
|
|
+ ctx.font = `${isHovered ? 'bold ' : ''}${fontSize}px ui-monospace, monospace`;
|
|
|
+ let text = node.symbol;
|
|
|
+ let metrics = ctx.measureText(text);
|
|
|
+
|
|
|
+ // Truncate if still too wide
|
|
|
+ if (metrics.width > maxWidth && text.length > 4) {
|
|
|
+ text = text.slice(0, 4) + '…';
|
|
|
+ metrics = ctx.measureText(text);
|
|
|
+ }
|
|
|
+
|
|
|
+ ctx.textAlign = 'center';
|
|
|
+ ctx.textBaseline = 'middle';
|
|
|
+ ctx.fillStyle = isHovered ? '#ffffff' : 'rgba(255, 255, 255, 0.9)';
|
|
|
+ ctx.fillText(text, node.x, node.y);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private render() {
|
|
|
+ const ctx = this.ctx;
|
|
|
+ const w = this.width;
|
|
|
+ const h = this.height;
|
|
|
+
|
|
|
+ ctx.save();
|
|
|
+ ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
|
|
|
+ ctx.clearRect(0, 0, w, h);
|
|
|
+
|
|
|
+ // Background
|
|
|
+ ctx.fillStyle = '#06060a';
|
|
|
+ ctx.fillRect(0, 0, w, h);
|
|
|
+
|
|
|
+ ctx.translate(this.transform.x, this.transform.y);
|
|
|
+ ctx.scale(this.transform.k, this.transform.k);
|
|
|
+
|
|
|
+ // Draw links
|
|
|
+ for (const link of this.links) {
|
|
|
+ const source = link.source;
|
|
|
+ const target = link.target;
|
|
|
+ if (!source.x || !target.x) continue;
|
|
|
+
|
|
|
+ const isHovered = this.hoveredNode &&
|
|
|
+ (source.id === this.hoveredNode.id || target.id === this.hoveredNode.id);
|
|
|
+
|
|
|
+ ctx.beginPath();
|
|
|
+ ctx.moveTo(source.x, source.y);
|
|
|
+ ctx.lineTo(target.x, target.y);
|
|
|
+
|
|
|
+ if (isHovered) {
|
|
|
+ ctx.strokeStyle = 'rgba(100, 220, 255, 0.5)';
|
|
|
+ ctx.lineWidth = link.width + 1;
|
|
|
+ } else {
|
|
|
+ // Tint links by the dominant flow direction
|
|
|
+ const sourceNet = source.netFlow;
|
|
|
+ const targetNet = target.netFlow;
|
|
|
+ const avgNet = (sourceNet + targetNet) / 2;
|
|
|
+ const alpha = Math.min(0.25, 0.08 + link.width * 0.03);
|
|
|
+ ctx.strokeStyle = avgNet >= 0
|
|
|
+ ? `rgba(34, 197, 94, ${alpha})` // green tint
|
|
|
+ : `rgba(239, 68, 68, ${alpha})`; // red tint
|
|
|
+ ctx.lineWidth = link.width;
|
|
|
+ }
|
|
|
+ ctx.stroke();
|
|
|
+ }
|
|
|
+
|
|
|
+ // Draw nodes — SOL rendered last so it's always on top
|
|
|
+ let solNode: SimNode | null = null;
|
|
|
+ for (const node of this.nodes) {
|
|
|
+ if (node.id === SOL_MINT) { solNode = node; continue; }
|
|
|
+ this.drawNode(ctx, node);
|
|
|
+ }
|
|
|
+ if (solNode) this.drawNode(ctx, solNode);
|
|
|
+
|
|
|
+ ctx.restore();
|
|
|
+ }
|
|
|
+
|
|
|
+ updateData(newNodes: GraphNode[], newLinks: GraphLink[]) {
|
|
|
+ // Build id→existing node map
|
|
|
+ const existingMap = new Map<string, SimNode>();
|
|
|
+ for (const n of this.nodes) {
|
|
|
+ existingMap.set(n.id, n);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Update or create nodes
|
|
|
+ const updatedNodes: SimNode[] = [];
|
|
|
+ for (const n of newNodes) {
|
|
|
+ const existing = existingMap.get(n.id);
|
|
|
+ if (existing) {
|
|
|
+ // Update data, keep position
|
|
|
+ existing.symbol = n.symbol;
|
|
|
+ existing.inflow = n.inflow;
|
|
|
+ existing.outflow = n.outflow;
|
|
|
+ existing.netFlow = n.netFlow;
|
|
|
+ existing.swapCount = n.swapCount;
|
|
|
+ existing.radius = n.radius;
|
|
|
+ existing.color = n.color;
|
|
|
+ updatedNodes.push(existing);
|
|
|
+ } else {
|
|
|
+ // New node - position near center or near a connected node
|
|
|
+ const simNode: SimNode = {
|
|
|
+ ...n,
|
|
|
+ x: n.x ?? this.width / 2 + (Math.random() - 0.5) * 100,
|
|
|
+ y: n.y ?? this.height / 2 + (Math.random() - 0.5) * 100,
|
|
|
+ vx: 0,
|
|
|
+ vy: 0,
|
|
|
+ };
|
|
|
+
|
|
|
+ // Fix SOL at center
|
|
|
+ if (n.id === SOL_MINT) {
|
|
|
+ simNode.fx = this.width / 2;
|
|
|
+ simNode.fy = this.height / 2;
|
|
|
+ }
|
|
|
+
|
|
|
+ updatedNodes.push(simNode);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ this.nodes = updatedNodes;
|
|
|
+
|
|
|
+ // Build links
|
|
|
+ const nodeMap = new Map(this.nodes.map((n) => [n.id, n]));
|
|
|
+ this.links = newLinks
|
|
|
+ .filter((l) => nodeMap.has(l.source as any) && nodeMap.has(l.target as any))
|
|
|
+ .map((l) => ({
|
|
|
+ source: nodeMap.get(l.source as string)!,
|
|
|
+ target: nodeMap.get(l.target as string)!,
|
|
|
+ swapCount: l.swapCount,
|
|
|
+ volume: l.volume,
|
|
|
+ width: l.width,
|
|
|
+ }));
|
|
|
+
|
|
|
+ // Track if new nodes were added
|
|
|
+ const hasNewNodes = updatedNodes.length !== existingMap.size ||
|
|
|
+ updatedNodes.some((n) => !existingMap.has(n.id));
|
|
|
+
|
|
|
+ // Update simulation
|
|
|
+ this.simulation.nodes(this.nodes);
|
|
|
+ (this.simulation.force('link') as d3.ForceLink<SimNode, SimLink>)
|
|
|
+ .links(this.links);
|
|
|
+
|
|
|
+ if (hasNewNodes) {
|
|
|
+ // Strong reheat only when graph structure changes
|
|
|
+ this.simulation.alpha(0.3).restart();
|
|
|
+ } else if (this.simulation.alpha() < 0.05) {
|
|
|
+ // Minimal nudge for data-only updates, just re-render
|
|
|
+ this.render();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ resetView() {
|
|
|
+ this.transform = d3.zoomIdentity;
|
|
|
+ // Release all fixed positions except SOL
|
|
|
+ for (const node of this.nodes) {
|
|
|
+ if (node.id !== SOL_MINT) {
|
|
|
+ node.fx = null;
|
|
|
+ node.fy = null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ this.simulation.alpha(0.5).restart();
|
|
|
+ this.render();
|
|
|
+ }
|
|
|
+
|
|
|
+ destroy() {
|
|
|
+ window.removeEventListener('resize', this.handleResize);
|
|
|
+ cancelAnimationFrame(this.animFrameId);
|
|
|
+ this.simulation.stop();
|
|
|
+ }
|
|
|
+}
|