import * as THREE from "three"; import type { PositionedNode } from "./graph-data"; // ─── Canvas-based node sprites ────────────────────────────────────────────── const spriteCache = new Map(); /** * Creates a Three.js sprite for a graph node: colored circle with value label. */ export function createNodeSprite(node: PositionedNode): THREE.Sprite { const cacheKey = `${node.id}:${node.value}:${node.color}`; const cached = spriteCache.get(cacheKey); if (cached) return cached.clone(); const canvas = document.createElement("canvas"); const size = 512; canvas.width = size; canvas.height = size; const ctx = canvas.getContext("2d")!; const cx = size / 2; const cy = size / 2; const radius = size / 2 - 16; // Outer glow ring const gradient = ctx.createRadialGradient(cx, cy, radius * 0.8, cx, cy, radius); gradient.addColorStop(0, node.color + "aa"); gradient.addColorStop(1, node.color + "00"); ctx.beginPath(); ctx.arc(cx, cy, radius, 0, Math.PI * 2); ctx.fillStyle = gradient; ctx.fill(); // Dark background circle for contrast ctx.beginPath(); ctx.arc(cx, cy, radius * 0.78, 0, Math.PI * 2); ctx.fillStyle = "#0f172a"; ctx.fill(); // Colored border ring ctx.beginPath(); ctx.arc(cx, cy, radius * 0.78, 0, Math.PI * 2); ctx.strokeStyle = node.color; ctx.lineWidth = 8; ctx.stroke(); // Subtle inner fill ctx.beginPath(); ctx.arc(cx, cy, radius * 0.74, 0, Math.PI * 2); ctx.fillStyle = node.color + "20"; ctx.fill(); // Label (top) ctx.fillStyle = "#ffffff"; ctx.font = "bold 36px system-ui, sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.shadowColor = "#000000"; ctx.shadowBlur = 6; const label = node.label.length > 16 ? node.label.slice(0, 14) + "..." : node.label; ctx.fillText(label, cx, cy - 28); // Value (bottom) — brighter, larger ctx.fillStyle = node.color; ctx.font = "bold 44px system-ui, sans-serif"; ctx.shadowBlur = 4; const valueStr = typeof node.value === "number" ? node.value.toFixed(1) : String(node.value); const displayValue = valueStr.length > 12 ? valueStr.slice(0, 10) + "..." : valueStr; ctx.fillText(displayValue, cx, cy + 24); // Unit (small, below value) if (node.unit) { ctx.fillStyle = "#94a3b8"; ctx.font = "24px system-ui, sans-serif"; ctx.shadowBlur = 0; ctx.fillText(node.unit, cx, cy + 60); } const texture = new THREE.CanvasTexture(canvas); const material = new THREE.SpriteMaterial({ map: texture, transparent: true, depthWrite: false, }); const sprite = new THREE.Sprite(material); sprite.scale.set(50, 50, 1); spriteCache.set(cacheKey, sprite); return sprite; } /** * Creates a dimmed version of a node sprite (for non-highlighted nodes). */ export function createDimmedNodeSprite(node: PositionedNode): THREE.Sprite { const sprite = createNodeSprite({ ...node, color: "#4b5563" }); sprite.material.opacity = 0.3; return sprite; } // ─── Link particle rendering ─────────────────────────────────────────────── /** * Returns a color for a link based on the source node's domain color. */ export function getLinkColor( link: { source: string | { color?: string }; weight: number }, opacity = 0.4, ): string { const sourceColor = typeof link.source === "object" && link.source.color ? link.source.color : "#6b7280"; // Convert hex to rgba const r = parseInt(sourceColor.slice(1, 3), 16); const g = parseInt(sourceColor.slice(3, 5), 16); const b = parseInt(sourceColor.slice(5, 7), 16); return `rgba(${r},${g},${b},${opacity})`; }