- Add cluster boundary visualization with color-coded type grouping - Implement new MemoryGraphVisualization component for Cognee integration - Add TypeScript types for Cognee API integration (CogneeAPI, NodeSet) - Enhance node swarm materials with better color hierarchy - Improve edge materials with opacity controls - Add metaball density rendering for visual clustering - Update demo and dataset visualization pages with new features 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
643 lines
20 KiB
TypeScript
643 lines
20 KiB
TypeScript
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<string>; // 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<string, string> = {
|
|
"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<string, number> = {
|
|
"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<string, Node[]>();
|
|
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<Graph>,
|
|
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;
|
|
}
|