import { Graph, Node as GraphNode, Link as GraphLink } from "ngraph.graph"; import * as three from "three"; import { Color, DataTexture, OrthographicCamera, RGBAFormat, Scene, UnsignedByteType, Vector2, WebGLRenderer, WebGLRenderTarget, } from "three"; import { OrbitControls } from "three/examples/jsm/Addons.js"; import createForceLayout, { Layout } from "ngraph.forcelayout"; import { Edge, Node } from "./graph/types"; import createGraph from "./graph/createGraph"; import createLabel from "./meshes/createLabel"; import pickNodeIndex from "./picking/pickNodeIndex"; import createEdgeMesh from "./meshes/createEdgeMesh"; import createPickingMesh from "./meshes/createPickingMesh"; import createNodeSwarmMesh from "./meshes/createNodeSwarmMesh"; import createNodePositionsTexture from "./textures/createNodePositionsTexture"; import createDensityRenderTarget from "./render-targets/createDensityRenderTarget"; import createDensityAccumulatorMesh from "./meshes/createDensityAccumulatorMesh"; import createMetaballMesh from "./meshes/createMetaballMesh"; import createClusterBoundaryMesh, { ClusterInfo } from "./meshes/createClusterBoundaryMesh"; const INITIAL_CAMERA_DISTANCE = 2000; // Extended config for layered view controls + zoom semantics interface Config { fontSize?: number; showNodes?: boolean; showEdges?: boolean; showMetaballs?: boolean; pathFilterMode?: "all" | "hoverOnly" | "strongOnly"; // Path filtering zoomLevel?: "far" | "mid" | "near"; // Zoom-based semantics highlightedNodeIds?: Set; // Nodes to highlight (neutral-by-default) } export default function animate( nodes: Node[], edges: Edge[], parentElement: HTMLElement, config?: Config ): () => void { const nodeLabelMap = new Map(); const edgeLabelMap = new Map(); // Semantic color encoding: hierarchy drives saturation + brightness const typeColorMap: Record = { "Domain": "#C4B5FD", // Bright Purple - Highest importance "Field": "#67E8F9", // Bright Cyan - High importance "Subfield": "#A78BFA", // Medium Purple - Medium-high importance "Concept": "#5EEAD4", // Teal - Medium importance "Method": "#6EE7B7", // Green - Medium importance "Theory": "#F9A8D4", // Pink - Medium importance "Technology": "#FCA5A5", // Soft Red - Lower importance "Application": "#71717A", // Desaturated Gray - Background/lowest importance }; // Size hierarchy: more important = larger const typeSizeMap: Record = { "Domain": 2.5, // Largest "Field": 2.0, "Subfield": 1.6, "Concept": 1.2, "Method": 1.1, "Theory": 1.0, "Technology": 0.9, "Application": 0.6, // Smallest }; function getColorForType(nodeType: string): Color { const colorHex = typeColorMap[nodeType]; if (colorHex) { return new Color(colorHex); } // Fallback for unknown types return new Color("#9CA3AF"); // Gray for unknown types } const mousePosition = new Vector2(); // Node related data const nodeColors = new Float32Array(nodes.length * 3); const nodeSizes = new Float32Array(nodes.length); // Size per node for hierarchy const nodeHighlights = new Float32Array(nodes.length); // 1.0 = highlighted, 0.3 = dimmed const nodeIndices = new Map(); const textureSize = Math.ceil(Math.sqrt(nodes.length)); const nodePositionsData = new Float32Array(textureSize * textureSize * 4); // Determine which nodes are highlighted const highlightedIds = config?.highlightedNodeIds; const hasHighlights = highlightedIds && highlightedIds.size > 0; let nodeIndex = 0; function forNode(node: Node) { const color = getColorForType(node.type); nodeColors[nodeIndex * 3 + 0] = color.r; nodeColors[nodeIndex * 3 + 1] = color.g; nodeColors[nodeIndex * 3 + 2] = color.b; // Set highlight state: if no highlights, all at 1.0; if highlights exist, dim non-highlighted if (hasHighlights) { nodeHighlights[nodeIndex] = highlightedIds!.has(node.id) ? 1.0 : 0.3; } else { nodeHighlights[nodeIndex] = 1.0; // All visible when no highlights } // Store size multiplier based on type nodeSizes[nodeIndex] = typeSizeMap[node.type] || 1.0; nodePositionsData[nodeIndex * 4 + 0] = 0.0; nodePositionsData[nodeIndex * 4 + 1] = 0.0; nodePositionsData[nodeIndex * 4 + 2] = 0.0; nodePositionsData[nodeIndex * 4 + 3] = 1.0; nodeIndices.set(node.id, nodeIndex); nodeIndex += 1; } // Node related data const edgeIndices = new Float32Array(edges.length * 2); let edgeIndex = 0; function forEdge(edge: Edge) { const fromIndex = nodeIndices.get(edge.source); const toIndex = nodeIndices.get(edge.target); edgeIndices[edgeIndex * 2 + 0] = fromIndex; edgeIndices[edgeIndex * 2 + 1] = toIndex; edgeIndex += 1; } // Graph creation and layout const graph = createGraph(nodes, edges, forNode, forEdge); // Adaptive layout parameters based on graph size const nodeCount = nodes.length; const isLargeGraph = nodeCount > 5000; const isMassiveGraph = nodeCount > 15000; // Apple embedding atlas style: stronger repulsion for clear cluster separation const graphLayout = createForceLayout(graph, { dragCoefficient: isMassiveGraph ? 0.95 : 0.85, springLength: isMassiveGraph ? 120 : isLargeGraph ? 180 : 220, // Longer springs for spacing springCoefficient: isMassiveGraph ? 0.12 : isLargeGraph ? 0.15 : 0.18, // Weaker springs gravity: isMassiveGraph ? -1200 : isLargeGraph ? -1500 : -1800, // Stronger repulsion }); // Node Mesh const nodePositionsTexture = createNodePositionsTexture( nodes, nodePositionsData ); const nodeSwarmMesh = createNodeSwarmMesh( nodes, nodePositionsTexture, nodeColors, nodeSizes, nodeHighlights, INITIAL_CAMERA_DISTANCE ); const edgeMesh = createEdgeMesh( edges, nodePositionsTexture, edgeIndices, INITIAL_CAMERA_DISTANCE ); // Density cloud setup - adaptive resolution for performance const densityCloudScene = new Scene(); const densityResolution = isMassiveGraph ? 256 : isLargeGraph ? 384 : 512; const densityCloudTarget = createDensityRenderTarget(densityResolution); const densityAccumulatorMesh = createDensityAccumulatorMesh( nodes, nodeColors, nodePositionsTexture, INITIAL_CAMERA_DISTANCE ); const metaballMesh = createMetaballMesh(densityCloudTarget); // const densityCloudDebugMesh = createDebugViewMesh(densityCloudTarget); // Density cloud setup end let pickedNodeIndex = -1; const lastPickedNodeIndex = -1; const pickNodeFromScene = (event: unknown) => { pickedNodeIndex = pickNodeIndexFromScene(event as MouseEvent); }; parentElement.addEventListener("mousemove", (event) => { const rect = parentElement.getBoundingClientRect(); mousePosition.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; mousePosition.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; pickNodeFromScene(event); }); // Group nodes by type for cluster boundaries const nodesByType = new Map(); nodes.forEach(node => { if (!nodesByType.has(node.type)) { nodesByType.set(node.type, []); } nodesByType.get(node.type)!.push(node); }); const scene = new Scene(); // Apple embedding atlas style: pure black background scene.background = new Color("#000000"); const renderer = new WebGLRenderer({ antialias: true }); renderer.setPixelRatio(window.devicePixelRatio); renderer.setSize(parentElement.clientWidth, parentElement.clientHeight); if (parentElement.children.length === 0) { parentElement.appendChild(renderer.domElement); } // Setup camera const aspect = parentElement.clientWidth / parentElement.clientHeight; const frustumSize = INITIAL_CAMERA_DISTANCE; const camera = new OrthographicCamera( (-frustumSize * aspect) / 2, (frustumSize * aspect) / 2, frustumSize / 2, -frustumSize / 2, 1, 5000 ); camera.position.set(0, 0, INITIAL_CAMERA_DISTANCE); camera.lookAt(0, 0, 0); // Setup controls const controls = new OrbitControls(camera, renderer.domElement); controls.enableRotate = false; controls.enablePan = true; controls.enableZoom = true; controls.screenSpacePanning = true; controls.minZoom = 0.5; // Allow zooming out more controls.maxZoom = 6; // Allow closer zoom for detail controls.enableDamping = true; controls.dampingFactor = 0.08; // Smoother, more fluid motion controls.target.set(0, 0, 0); controls.update(); // Handle resizing window.addEventListener("resize", () => { const aspect = parentElement.clientWidth / parentElement.clientHeight; camera.left = (-frustumSize * aspect) / 2; camera.right = (frustumSize * aspect) / 2; camera.top = frustumSize / 2; camera.bottom = -frustumSize / 2; camera.updateProjectionMatrix(); renderer.setSize(parentElement.clientWidth, parentElement.clientHeight); }); // Node picking setup const pickingTarget = new WebGLRenderTarget( window.innerWidth, window.innerHeight, { format: RGBAFormat, type: UnsignedByteType, depthBuffer: true, stencilBuffer: false, } ); const pickingScene = new Scene(); function pickNodeIndexFromScene(event: MouseEvent): number { pickingScene.add(pickingMesh); const pickedNodeIndex = pickNodeIndex( event, renderer, pickingScene, camera, pickingTarget ); return pickedNodeIndex; } const pickingMesh = createPickingMesh( nodes, nodePositionsTexture, nodeColors, INITIAL_CAMERA_DISTANCE ); renderer.domElement.addEventListener("mousedown", (event) => { const pickedNodeIndex = pickNodeIndexFromScene(event); console.log("Picked node index: ", pickedNodeIndex); }); // Node picking setup end // Adaptive layout iterations based on graph size const layoutIterations = isMassiveGraph ? 300 : isLargeGraph ? 500 : 800; console.log(`Running ${layoutIterations} layout iterations for ${nodeCount} nodes...`); for (let i = 0; i < layoutIterations; i++) { graphLayout.step(); // Progress logging for large graphs if (isMassiveGraph && i % 50 === 0) { console.log(`Layout progress: ${((i / layoutIterations) * 100).toFixed(0)}%`); } } console.log("Layout complete!"); let visibleLabels: unknown[] = []; // Only create entity type labels for smaller graphs (performance optimization) const entityTypeLabels: [string, unknown][] = []; if (!isMassiveGraph) { for (const node of nodes) { if (node.type === "EntityType") { const label = createLabel(node.label, config?.fontSize); entityTypeLabels.push([node.id, label]); } } } // const processingStep = 0; // Performance monitoring let frameCount = 0; let lastFpsUpdate = performance.now(); let currentFps = 60; // Cluster boundaries let clusterBoundariesCreated = false; const clusterBoundaryMeshes: three.Mesh[] = []; function calculateClusterBoundaries(): ClusterInfo[] { const clusters: ClusterInfo[] = []; nodesByType.forEach((typeNodes, nodeType) => { if (typeNodes.length < 3) return; // Skip small clusters // Calculate center and radius from actual node positions let sumX = 0; let sumY = 0; typeNodes.forEach(node => { const pos = graphLayout.getNodePosition(node.id); sumX += pos.x; sumY += pos.y; }); const center = { x: sumX / typeNodes.length, y: sumY / typeNodes.length }; // Calculate radius as max distance from center + padding let maxDist = 0; typeNodes.forEach(node => { const pos = graphLayout.getNodePosition(node.id); const dist = Math.sqrt( Math.pow(pos.x - center.x, 2) + Math.pow(pos.y - center.y, 2) ); maxDist = Math.max(maxDist, dist); }); clusters.push({ center, radius: maxDist + 350, // Apple style: more spacing between clusters color: getColorForType(nodeType) }); }); return clusters; } // Render loop function render() { // Adaptive physics updates - skip for large graphs after stabilization if (!isMassiveGraph || frameCount < 100) { graphLayout.step(); } else if (frameCount % 2 === 0) { // Update physics every other frame for massive graphs graphLayout.step(); } // FPS monitoring frameCount++; const now = performance.now(); if (now - lastFpsUpdate > 1000) { currentFps = Math.round((frameCount * 1000) / (now - lastFpsUpdate)); frameCount = 0; lastFpsUpdate = now; if (isMassiveGraph && currentFps < 30) { console.warn(`Low FPS detected: ${currentFps} fps`); } } controls.update(); // Create cluster boundaries after layout stabilizes if (!clusterBoundariesCreated && frameCount === 60) { const clusters = calculateClusterBoundaries(); clusters.forEach(cluster => { const mesh = createClusterBoundaryMesh(cluster); clusterBoundaryMeshes.push(mesh); scene.add(mesh); }); clusterBoundariesCreated = true; } updateNodePositions( nodes, graphLayout, nodePositionsData, nodePositionsTexture ); const textScale = Math.max(1, 4 / camera.zoom); nodeSwarmMesh.material.uniforms.camDist.value = Math.floor( camera.zoom * 500 ); nodeSwarmMesh.material.uniforms.mousePos.value.set( mousePosition.x, mousePosition.y ); // @ts-expect-error uniforms does exist on material edgeMesh.material.uniforms.camDist.value = Math.floor(camera.zoom * 500); // @ts-expect-error uniforms does exist on material edgeMesh.material.uniforms.mousePos.value.set( mousePosition.x, mousePosition.y ); // @ts-expect-error uniforms does exist on material pickingMesh.material.uniforms.camDist.value = Math.floor(camera.zoom * 500); // Zoom-level semantics: determine what to show based on zoom let zoomLevel: "far" | "mid" | "near" = "mid"; if (camera.zoom < 1.0) { zoomLevel = "far"; // Show clusters, domains, density } else if (camera.zoom > 3.0) { zoomLevel = "near"; // Show applications, paths, labels } edgeMesh.renderOrder = 1; nodeSwarmMesh.renderOrder = 2; // IMPROVEMENT #8: Conditional layer rendering based on config and zoom const showEdges = config?.showEdges !== false && zoomLevel !== "far"; // Hide edges when far const showNodes = config?.showNodes !== false; // Always show nodes const showMetaballs = config?.showMetaballs !== false && zoomLevel === "far"; // Only show at far zoom // Path filtering based on hover const pathFilterMode = config?.pathFilterMode || "all"; const shouldShowPath = pathFilterMode === "all" || (pathFilterMode === "hoverOnly" && pickedNodeIndex >= 0); if (showEdges) { scene.add(edgeMesh); } if (showNodes) { scene.add(nodeSwarmMesh); } // Metaball rendering - reduce frequency for massive graphs const shouldRenderMetaballs = showMetaballs && (!isMassiveGraph || frameCount % 2 === 0); if (shouldRenderMetaballs) { // Pass 1: draw points into density texture renderer.setRenderTarget(densityCloudTarget); renderer.clear(); densityCloudScene.clear(); densityCloudScene.add(densityAccumulatorMesh); renderer.render(densityCloudScene, camera); // Pass 2: render density map to screen renderer.setRenderTarget(null); renderer.clear(); metaballMesh.renderOrder = 0; scene.add(metaballMesh); } else { renderer.setRenderTarget(null); renderer.clear(); } for (const [nodeId, label] of entityTypeLabels) { const nodePosition = graphLayout.getNodePosition(nodeId); // @ts-expect-error label is Text from troika-three-text label.position.set(nodePosition.x, nodePosition.y, 1.0); // @ts-expect-error label is Text from troika-three-text label.scale.setScalar(textScale); // @ts-expect-error label is Text from troika-three-text scene.add(label); } if (pickedNodeIndex >= 0) { if (pickedNodeIndex !== lastPickedNodeIndex) { for (const label of visibleLabels) { // @ts-expect-error label is Text from troika-three-text label.visible = false; } visibleLabels = []; } const pickedNode = nodes[pickedNodeIndex]; parentElement.style.cursor = "pointer"; const pickedNodePosition = graphLayout.getNodePosition(pickedNode.id); let pickedNodeLabel = nodeLabelMap.get(pickedNode.id); if (!pickedNodeLabel) { pickedNodeLabel = createLabel(pickedNode.label, config?.fontSize); nodeLabelMap.set(pickedNode.id, pickedNodeLabel); } pickedNodeLabel.position.set( pickedNodePosition.x, pickedNodePosition.y, 1.0 ); pickedNodeLabel.scale.setScalar(textScale); // Adaptive label display based on graph size and zoom const minZoomForLabels = isMassiveGraph ? 4 : isLargeGraph ? 3 : 2; const maxLabels = isMassiveGraph ? 5 : isLargeGraph ? 10 : 15; if (camera.zoom > minZoomForLabels) { graph.forEachLinkedNode( pickedNode.id, (otherNode: GraphNode, edge: GraphLink) => { if (visibleLabels.length > maxLabels) { return; } let otherNodeLabel = nodeLabelMap.get(otherNode.id); if (!otherNodeLabel) { otherNodeLabel = createLabel(otherNode.data.label, config?.fontSize); nodeLabelMap.set(otherNode.id, otherNodeLabel); } const otherNodePosition = graphLayout.getNodePosition(otherNode.id); otherNodeLabel.position.set( otherNodePosition.x, otherNodePosition.y, 1.0 ); let linkLabel = edgeLabelMap.get(edge.id); if (!linkLabel) { linkLabel = createLabel(edge.data.label, config?.fontSize); edgeLabelMap.set(edge.id, linkLabel); } const linkPosition = graphLayout.getLinkPosition(edge.id); const middleLinkPosition = new Vector2( (linkPosition.from.x + linkPosition.to.x) / 2, (linkPosition.from.y + linkPosition.to.y) / 2 ); linkLabel.position.set( middleLinkPosition.x, middleLinkPosition.y, 1.0 ); linkLabel.visible = true; linkLabel.scale.setScalar(textScale); visibleLabels.push(linkLabel); otherNodeLabel.visible = true; otherNodeLabel.scale.setScalar(textScale); visibleLabels.push(otherNodeLabel); scene.add(linkLabel); scene.add(otherNodeLabel); } ); } pickedNodeLabel.visible = true; visibleLabels.push(pickedNodeLabel); scene.add(pickedNodeLabel); } else { parentElement.style.cursor = "default"; for (const label of visibleLabels) { // @ts-expect-error label is Text from troika-three-text label.visible = false; } visibleLabels = []; } renderer.render(scene, camera); animationFrameId = requestAnimationFrame(render); } let animationFrameId: number; render(); // Return cleanup function return () => { if (animationFrameId) { cancelAnimationFrame(animationFrameId); } // Clean up cluster boundaries clusterBoundaryMeshes.forEach(mesh => { scene.remove(mesh); mesh.geometry.dispose(); if (mesh.material instanceof three.Material) { mesh.material.dispose(); } }); graphLayout.dispose(); renderer.dispose(); controls.dispose(); }; } function updateNodePositions( nodes: Node[], graphLayout: Layout, nodePositionsData: Float32Array, nodePositionsTexture: DataTexture ) { for (let i = 0; i < nodes.length; i++) { const p = graphLayout.getNodePosition(nodes[i].id); nodePositionsData[i * 4 + 0] = p.x; nodePositionsData[i * 4 + 1] = p.y; nodePositionsData[i * 4 + 2] = 0.0; nodePositionsData[i * 4 + 3] = 1.0; } nodePositionsTexture.needsUpdate = true; }