mirror of
https://github.com/NVIDIA/dgx-spark-playbooks.git
synced 2026-04-23 02:23:53 +00:00
2896 lines
109 KiB
TypeScript
2896 lines
109 KiB
TypeScript
"use client"
|
|
|
|
import React, { useEffect, useRef, useState, useCallback } from "react"
|
|
import type { Triple } from "@/utils/text-processing"
|
|
import { Maximize2, Minimize2, Pause, Play, RefreshCw, ZoomIn, X, LayoutGrid } from "lucide-react"
|
|
import { WebGPUClusteringEngine } from "@/utils/webgpu-clustering"
|
|
import { EnhancedWebGPUClusteringEngine } from "@/utils/remote-webgpu-clustering"
|
|
import * as d3 from 'd3'
|
|
import * as THREE from 'three'
|
|
|
|
// Define interfaces for graph data
|
|
interface NodeObject {
|
|
id: string
|
|
name: string
|
|
val?: number
|
|
color?: string
|
|
group?: string
|
|
x?: number
|
|
y?: number
|
|
z?: number
|
|
}
|
|
|
|
interface LinkObject {
|
|
id?: string // Add id as optional property
|
|
source: string | NodeObject
|
|
target: string | NodeObject
|
|
name: string
|
|
color?: string
|
|
}
|
|
|
|
interface Connection {
|
|
source: string;
|
|
target: string;
|
|
label?: string;
|
|
nodeName?: string;
|
|
type?: 'incoming' | 'outgoing';
|
|
}
|
|
|
|
interface PerformanceMetrics {
|
|
renderingTime: number
|
|
clusteringTime?: number
|
|
totalNodes: number
|
|
totalLinks: number
|
|
memoryUsage?: number
|
|
}
|
|
|
|
interface ForceGraphWrapperProps {
|
|
jsonData: any; // The graph data in JSON format
|
|
fullscreen?: boolean
|
|
layoutType?: string
|
|
highlightedNodes?: string[]
|
|
enableClustering?: boolean
|
|
enableClusterColors?: boolean // Color nodes by cluster assignment
|
|
clusteringMode?: 'local' | 'hybrid' | 'cpu' // Default clustering mode
|
|
remoteServiceUrl?: string // URL for remote WebGPU service
|
|
onClusteringUpdate?: (metrics: PerformanceMetrics) => void
|
|
onError?: (error: Error) => void
|
|
|
|
// Semantic clustering parameters
|
|
clusteringMethod?: string // "spatial", "semantic", "hybrid"
|
|
semanticAlgorithm?: string // "hierarchical", "kmeans", "dbscan"
|
|
numberOfClusters?: number | null
|
|
similarityThreshold?: number
|
|
nameWeight?: number
|
|
contentWeight?: number
|
|
spatialWeight?: number
|
|
}
|
|
|
|
// Type definitions for Three.js objects
|
|
type ThreeNodeObject = {
|
|
id: string
|
|
name: string
|
|
x?: number
|
|
y?: number
|
|
z?: number
|
|
val?: number
|
|
[key: string]: any
|
|
}
|
|
|
|
type ThreeLinkObject = {
|
|
source: ThreeNodeObject | string
|
|
target: ThreeNodeObject | string
|
|
name: string
|
|
[key: string]: any
|
|
}
|
|
|
|
// Add the fuzzyCompare function before the getLinkId function
|
|
const fuzzyCompare = (str1: string, str2: string): boolean => {
|
|
if (!str1 || !str2) return false;
|
|
|
|
// Convert both strings to lowercase and remove quotes, spaces, and special characters
|
|
const normalize = (s: string) => s.toLowerCase().replace(/['"(){}[\]]/g, '').replace(/\s+/g, '');
|
|
|
|
const norm1 = normalize(str1);
|
|
const norm2 = normalize(str2);
|
|
|
|
// Check exact match after normalization
|
|
if (norm1 === norm2) return true;
|
|
|
|
// Check if one contains the other
|
|
if (norm1.includes(norm2) || norm2.includes(norm1)) return true;
|
|
|
|
// Check for significant partial match (more than 70% of characters match)
|
|
const minLength = Math.min(norm1.length, norm2.length);
|
|
if (minLength > 3) {
|
|
let matchCount = 0;
|
|
for (let i = 0; i < minLength; i++) {
|
|
if (norm1[i] === norm2[i]) matchCount++;
|
|
}
|
|
if (matchCount / minLength > 0.7) return true;
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
// Helper function to get a consistent link ID
|
|
const getLinkId = (link: any): string => {
|
|
const sourceId = typeof link.source === 'object' ? link.source.id : link.source;
|
|
const targetId = typeof link.target === 'object' ? link.target.id : link.target;
|
|
return `${sourceId}-${targetId}`;
|
|
};
|
|
|
|
// Generate cluster colors with a midnight Tokyo vibe - neon colors against dark backdrop
|
|
const generateClusterColors = (numClusters: number): string[] => {
|
|
// Midnight Tokyo inspired color palette - neon lights, electric blues, hot pinks, cyber greens
|
|
const tokyoColors = [
|
|
'#FF0080', // Hot pink neon
|
|
'#00FFFF', // Electric cyan
|
|
'#FF4081', // Neon pink
|
|
'#8A2BE2', // Electric purple
|
|
'#00FF41', // Matrix green
|
|
'#FF6B35', // Neon orange
|
|
'#1E90FF', // Electric blue
|
|
'#FF1493', // Deep pink
|
|
'#00CED1', // Dark turquoise
|
|
'#9932CC', // Dark orchid
|
|
'#32CD32', // Lime green
|
|
'#FF4500', // Orange red
|
|
'#4169E1', // Royal blue
|
|
'#DC143C', // Crimson
|
|
'#00FA9A', // Medium spring green
|
|
'#FF69B4', // Hot pink
|
|
'#1E88E5', // Blue
|
|
'#E91E63', // Pink
|
|
'#00E676', // Green
|
|
'#FF5722', // Deep orange
|
|
'#673AB7', // Deep purple
|
|
'#03DAC6', // Teal
|
|
'#BB86FC', // Light purple
|
|
'#CF6679' // Light pink
|
|
];
|
|
|
|
const colors: string[] = [];
|
|
|
|
for (let i = 0; i < numClusters; i++) {
|
|
if (i < tokyoColors.length) {
|
|
// Use predefined Tokyo colors first
|
|
colors.push(tokyoColors[i]);
|
|
} else {
|
|
// For additional clusters, generate variations of the base palette
|
|
const baseColorIndex = i % tokyoColors.length;
|
|
const baseColor = tokyoColors[baseColorIndex];
|
|
|
|
// Convert hex to HSL and create variations
|
|
const variation = Math.floor(i / tokyoColors.length);
|
|
const hueShift = variation * 30; // Shift hue by 30 degrees for each cycle
|
|
|
|
// Parse hex color and convert to HSL with variation
|
|
const hex = baseColor.replace('#', '');
|
|
const r = parseInt(hex.substr(0, 2), 16) / 255;
|
|
const g = parseInt(hex.substr(2, 2), 16) / 255;
|
|
const b = parseInt(hex.substr(4, 2), 16) / 255;
|
|
|
|
const max = Math.max(r, g, b);
|
|
const min = Math.min(r, g, b);
|
|
let h, s, l = (max + min) / 2;
|
|
|
|
if (max === min) {
|
|
h = s = 0; // achromatic
|
|
} else {
|
|
const d = max - min;
|
|
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
switch (max) {
|
|
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
|
|
case g: h = (b - r) / d + 2; break;
|
|
case b: h = (r - g) / d + 4; break;
|
|
default: h = 0;
|
|
}
|
|
h /= 6;
|
|
}
|
|
|
|
// Apply hue shift and maintain Tokyo neon characteristics
|
|
h = ((h * 360 + hueShift) % 360) / 360;
|
|
s = Math.max(0.7, s); // Keep high saturation for neon effect
|
|
l = Math.min(0.7, Math.max(0.4, l)); // Bright but not too light
|
|
|
|
// Convert back to HSL string
|
|
colors.push(`hsl(${Math.round(h * 360)}, ${Math.round(s * 100)}%, ${Math.round(l * 100)}%)`);
|
|
}
|
|
}
|
|
|
|
return colors;
|
|
};
|
|
|
|
// Assign cluster colors to nodes based on their actual cluster assignment
|
|
const assignClusterColors = (nodes: any[], enableColors: boolean, useSemanticClusters: boolean = false): any[] => {
|
|
if (!enableColors || !nodes || nodes.length === 0) {
|
|
return nodes;
|
|
}
|
|
|
|
// Check if nodes already have semantic cluster assignments
|
|
const hasSemanticClusters = useSemanticClusters && nodes.some(node => node.clusterId !== undefined || node.clusterIndex !== undefined);
|
|
|
|
console.log("🔍 assignClusterColors debug:", {
|
|
enableColors,
|
|
useSemanticClusters,
|
|
nodeCount: nodes.length,
|
|
hasSemanticClusters,
|
|
sampleNodeIds: nodes.slice(0, 3).map(n => ({
|
|
id: n.id,
|
|
clusterId: n.clusterId,
|
|
clusterIndex: n.clusterIndex
|
|
}))
|
|
});
|
|
|
|
if (hasSemanticClusters) {
|
|
console.log("🎯 Using semantic cluster assignments for coloring");
|
|
|
|
// Get unique cluster IDs
|
|
const clusterIds = new Set<number>();
|
|
nodes.forEach(node => {
|
|
const clusterId = node.clusterId !== undefined ? node.clusterId : node.clusterIndex;
|
|
if (clusterId !== undefined) {
|
|
clusterIds.add(clusterId);
|
|
}
|
|
});
|
|
|
|
const clusterColors = generateClusterColors(clusterIds.size);
|
|
const clusterIdToIndex = Array.from(clusterIds).reduce((acc, id, index) => {
|
|
acc[id] = index;
|
|
return acc;
|
|
}, {} as Record<number, number>);
|
|
|
|
return nodes.map(node => ({
|
|
...node,
|
|
color: (() => {
|
|
const clusterId = node.clusterId !== undefined ? node.clusterId : node.clusterIndex;
|
|
if (clusterId !== undefined && clusterIdToIndex[clusterId] !== undefined) {
|
|
return clusterColors[clusterIdToIndex[clusterId]];
|
|
}
|
|
return node.color || '#76b900';
|
|
})()
|
|
}));
|
|
}
|
|
|
|
// Fallback to spatial clustering if no semantic clusters available
|
|
console.log("🗺️ Using spatial clustering for coloring (fallback)");
|
|
|
|
// Simple spatial clustering based on position
|
|
const clusterGrid = 4; // 4x4x4 grid = 64 possible clusters
|
|
const clusters = new Map<string, number>();
|
|
let clusterCount = 0;
|
|
|
|
// Find bounds
|
|
const bounds = nodes.reduce((acc, node) => {
|
|
const x = node.x || 0;
|
|
const y = node.y || 0;
|
|
const z = node.z || 0;
|
|
return {
|
|
minX: Math.min(acc.minX, x),
|
|
maxX: Math.max(acc.maxX, x),
|
|
minY: Math.min(acc.minY, y),
|
|
maxY: Math.max(acc.maxY, y),
|
|
minZ: Math.min(acc.minZ, z),
|
|
maxZ: Math.max(acc.maxZ, z),
|
|
};
|
|
}, { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity, minZ: Infinity, maxZ: -Infinity });
|
|
|
|
const rangeX = bounds.maxX - bounds.minX || 1;
|
|
const rangeY = bounds.maxY - bounds.minY || 1;
|
|
const rangeZ = bounds.maxZ - bounds.minZ || 1;
|
|
|
|
// Assign cluster IDs based on spatial position
|
|
nodes.forEach(node => {
|
|
const x = node.x || 0;
|
|
const y = node.y || 0;
|
|
const z = node.z || 0;
|
|
|
|
// Normalize to grid coordinates
|
|
const gridX = Math.min(Math.floor(((x - bounds.minX) / rangeX) * clusterGrid), clusterGrid - 1);
|
|
const gridY = Math.min(Math.floor(((y - bounds.minY) / rangeY) * clusterGrid), clusterGrid - 1);
|
|
const gridZ = Math.min(Math.floor(((z - bounds.minZ) / rangeZ) * clusterGrid), clusterGrid - 1);
|
|
|
|
const clusterKey = `${gridX},${gridY},${gridZ}`;
|
|
|
|
if (!clusters.has(clusterKey)) {
|
|
clusters.set(clusterKey, clusterCount++);
|
|
}
|
|
|
|
node.clusterIndex = clusters.get(clusterKey);
|
|
});
|
|
|
|
// Generate colors for all clusters
|
|
const clusterColors = generateClusterColors(clusterCount);
|
|
|
|
// Apply colors to nodes
|
|
return nodes.map(node => ({
|
|
...node,
|
|
color: node.clusterIndex !== undefined ? clusterColors[node.clusterIndex] : node.color
|
|
}));
|
|
};
|
|
|
|
export function ForceGraphWrapper({
|
|
jsonData,
|
|
fullscreen = false,
|
|
layoutType,
|
|
highlightedNodes,
|
|
enableClustering = false,
|
|
enableClusterColors = false,
|
|
clusteringMode = 'hybrid',
|
|
remoteServiceUrl = 'http://localhost:8083',
|
|
onClusteringUpdate,
|
|
onError,
|
|
// Semantic clustering parameters
|
|
clusteringMethod = "hybrid",
|
|
semanticAlgorithm = "hierarchical",
|
|
numberOfClusters = null,
|
|
similarityThreshold = 0.7,
|
|
nameWeight = 0.6,
|
|
contentWeight = 0.3,
|
|
spatialWeight = 0.1
|
|
}: ForceGraphWrapperProps) {
|
|
// Check for null or invalid jsonData early and report error
|
|
if (!jsonData || typeof jsonData !== 'object') {
|
|
console.error("Invalid jsonData provided to ForceGraphWrapper:", jsonData);
|
|
if (onError) {
|
|
onError(new Error("Cannot read properties of null (reading 'nodes')"));
|
|
}
|
|
return (
|
|
<div className="h-full w-full flex items-center justify-center bg-black/70">
|
|
<div className="text-red-500 max-w-md p-6 bg-black/90 rounded-lg">
|
|
<p className="font-bold mb-2">Error: Invalid graph data</p>
|
|
<p className="text-sm">The graph data is missing or has an invalid format.</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
const graphRef = useRef<any>(null)
|
|
const [isFullscreen, setIsFullscreen] = useState(fullscreen)
|
|
const [isLoading, setIsLoading] = useState(true)
|
|
const [loadingStep, setLoadingStep] = useState<string>("Initializing...")
|
|
const [loadingProgress, setLoadingProgress] = useState<number>(0)
|
|
const [graphLoaded, setGraphLoaded] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [isPaused, setIsPaused] = useState(false)
|
|
const [debugInfo, setDebugInfo] = useState<string>("")
|
|
const [selectedNode, setSelectedNode] = useState<NodeObject | null>(null)
|
|
const [nodeConnections, setNodeConnections] = useState<Connection[]>([])
|
|
|
|
// Add interaction mode state to toggle between navigation and selection
|
|
const [interactionMode, setInteractionMode] = useState<'navigation' | 'selection'>('navigation')
|
|
|
|
// Track notifications
|
|
const [notification, setNotification] = useState<{message: string, type: 'success' | 'error' | 'info'} | null>(null)
|
|
const notificationTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
|
|
|
// Track highlighted nodes for visual emphasis
|
|
const [internalHighlightedNodes, setInternalHighlightedNodes] = useState<Set<string>>(new Set())
|
|
const [highlightLinks, setHighlightLinks] = useState<Set<string>>(new Set())
|
|
|
|
// Track graph data statistics
|
|
const [graphStats, setGraphStats] = useState<{nodes: number, links: number}>({nodes: 0, links: 0})
|
|
|
|
// Retry mechanism
|
|
const [retryCount, setRetryCount] = useState(0)
|
|
const maxRetries = 3
|
|
|
|
// Track graph data
|
|
const [graphData, setGraphData] = useState<any>(null)
|
|
|
|
// State for tracking hover
|
|
const [hoveredNode, setHoveredNode] = useState<any>(null);
|
|
// Use a ref for hover to prevent recursive state updates
|
|
const hoveredNodeRef = useRef<any>(null);
|
|
|
|
// Add state to track initialization
|
|
const [isInitialized, setIsInitialized] = useState(false);
|
|
|
|
// Add WebGPU clustering engine ref
|
|
const clusteringEngineRef = useRef<any>(null); // Can be WebGPUClusteringEngine or EnhancedWebGPUClusteringEngine
|
|
// Track if WebGPU clustering is available
|
|
const [isClusteringAvailable, setIsClusteringAvailable] = useState<boolean>(false);
|
|
// Track if clustering is enabled
|
|
const [isClusteringEnabled, setIsClusteringEnabled] = useState<boolean>(false);
|
|
|
|
// Helper function to extract node ID reliably
|
|
const getNodeId = (nodeObj: any): string => {
|
|
if (!nodeObj) return '';
|
|
|
|
// If it's a string, return it directly
|
|
if (typeof nodeObj === 'string') return nodeObj;
|
|
|
|
// If it's an object, try various ID properties
|
|
if (nodeObj.id) return nodeObj.id;
|
|
if (nodeObj.name) return nodeObj.name;
|
|
if (nodeObj.key) return nodeObj.key;
|
|
if (nodeObj.label) return nodeObj.label;
|
|
|
|
// Fallback to string representation
|
|
return String(nodeObj);
|
|
};
|
|
|
|
// Add state to track if we're using CPU fallback
|
|
const [usingCpuFallback, setUsingCpuFallback] = useState(false);
|
|
|
|
// Add state for node size control
|
|
const [nodeSize, setNodeSize] = useState(5);
|
|
|
|
// Add performance mode toggle
|
|
const [performanceMode, setPerformanceMode] = useState(false);
|
|
|
|
// Function to show a temporary notification
|
|
const showNotification = (message: string, type: 'success' | 'error' | 'info' = 'info') => {
|
|
// Clear any existing timeouts
|
|
if (notificationTimeoutRef.current) {
|
|
clearTimeout(notificationTimeoutRef.current);
|
|
}
|
|
|
|
// Set new notification
|
|
setNotification({message, type});
|
|
|
|
// Auto-clear after duration
|
|
notificationTimeoutRef.current = setTimeout(() => {
|
|
setNotification(null);
|
|
}, 3000);
|
|
};
|
|
|
|
// Clean up notification timeout on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (notificationTimeoutRef.current) {
|
|
clearTimeout(notificationTimeoutRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
// Toggle interaction mode
|
|
const toggleInteractionMode = () => {
|
|
const newMode = interactionMode === 'navigation' ? 'selection' : 'navigation';
|
|
setInteractionMode(newMode);
|
|
showNotification(`Mode changed to: ${newMode}`, 'info');
|
|
console.log(`Interaction mode changed to: ${newMode}`);
|
|
};
|
|
|
|
// More robust ID normalization function
|
|
const normalizeNodeId = (id: any): string => {
|
|
// Handle null/undefined cases
|
|
if (id === null || id === undefined) return '';
|
|
|
|
// Handle ThreeJS object references that might be passed directly
|
|
if (typeof id === 'object') {
|
|
console.log('Received object for ID normalization:', id);
|
|
// Try __threeObj property which might contain the ThreeJS object
|
|
if (id.__threeObj) {
|
|
console.log('Found __threeObj property');
|
|
// Look for userData which often contains the original node data
|
|
if (id.__threeObj.userData && id.__threeObj.userData.id) {
|
|
console.log(`Using __threeObj.userData.id: "${id.__threeObj.userData.id}"`);
|
|
id = id.__threeObj.userData.id;
|
|
} else {
|
|
// Fall back to the object's id property if it exists
|
|
console.log(`Using object's id property: "${id.id}"`);
|
|
id = id.id || '';
|
|
}
|
|
} else if (id.id) {
|
|
// Simple object with id property
|
|
console.log(`Using simple object id property: "${id.id}"`);
|
|
id = id.id;
|
|
} else {
|
|
// Last resort - try toString or convert to empty string
|
|
console.log('Could not find id property, using toString()');
|
|
id = id.toString() || '';
|
|
}
|
|
}
|
|
|
|
// Convert to string if not already
|
|
const strId = String(id);
|
|
|
|
// Log the original ID for debugging
|
|
console.log(`Normalizing ID: "${strId}" (type: ${typeof id})`);
|
|
|
|
// Remove all quotes, parentheses, and trim whitespace
|
|
const normalized = strId.replace(/['"()]/g, '').trim();
|
|
|
|
console.log(` → Normalized to: "${normalized}"`);
|
|
return normalized;
|
|
};
|
|
|
|
// Debug node connections with additional logging
|
|
const debugNodeConnections = (nodeId: string) => {
|
|
if (!graphData) {
|
|
console.warn("Cannot debug connections: No graph data available");
|
|
return { outgoing: [], incoming: [], total: 0 };
|
|
}
|
|
|
|
console.log(`Debugging connections for node: "${nodeId}"`);
|
|
|
|
// More thorough logging of all nodes and links
|
|
console.log("All nodes:", graphData.nodes.map((n: any) => ({
|
|
id: n.id,
|
|
name: n.name
|
|
})));
|
|
|
|
// Log links with more details
|
|
console.log("All links:", graphData.links.map((l: any) => {
|
|
// Extract source and target properly, handling object references
|
|
const sourceId = typeof l.source === 'object' ? (l.source.__threeObj ? l.source.__threeObj.userData.id : (l.source.id || l.source)) : l.source;
|
|
const targetId = typeof l.target === 'object' ? (l.target.__threeObj ? l.target.__threeObj.userData.id : (l.target.id || l.target)) : l.target;
|
|
|
|
return {
|
|
source: sourceId,
|
|
target: targetId,
|
|
name: l.name,
|
|
// Debug object types
|
|
sourceType: typeof l.source,
|
|
targetType: typeof l.target,
|
|
};
|
|
}));
|
|
|
|
const connections = {
|
|
outgoing: [] as any[],
|
|
incoming: [] as any[],
|
|
total: 0
|
|
};
|
|
|
|
console.log(`Looking for connections with node ID: "${nodeId}"`);
|
|
|
|
// Helper function to extract ID reliably from either string or object reference
|
|
const getReliableId = (idOrObj: any): string => {
|
|
if (typeof idOrObj === 'string') return idOrObj;
|
|
|
|
// Handle ThreeJS object references
|
|
if (idOrObj && idOrObj.__threeObj && idOrObj.__threeObj.userData) {
|
|
return idOrObj.__threeObj.userData.id || '';
|
|
}
|
|
|
|
// Handle regular objects
|
|
return idOrObj && idOrObj.id ? idOrObj.id : (idOrObj || '').toString();
|
|
};
|
|
|
|
// Additional helper to normalize IDs for comparison
|
|
const normalizeForComparison = (id: string): string => {
|
|
return id.toString().toLowerCase().trim();
|
|
};
|
|
|
|
// For reliable comparison
|
|
const normalizedNodeId = normalizeForComparison(nodeId);
|
|
|
|
// Check if the graph data links array exists
|
|
if (!graphData.links || !Array.isArray(graphData.links)) {
|
|
console.warn("No links array found in graph data");
|
|
return { outgoing: [], incoming: [], total: 0 };
|
|
}
|
|
|
|
graphData.links.forEach((link: any, index: number) => {
|
|
try {
|
|
// Get source and target IDs, handling all possible formats
|
|
const sourceId = getReliableId(link.source);
|
|
const targetId = getReliableId(link.target);
|
|
|
|
// Also get names for additional matching
|
|
const sourceName = typeof link.source === 'object' ? (link.source.name || '') : '';
|
|
const targetName = typeof link.target === 'object' ? (link.target.name || '') : '';
|
|
|
|
console.log(`Link ${index}: "${sourceId}" → "${targetId}"`);
|
|
console.log(` Source reference: ${typeof link.source} | Target reference: ${typeof link.target}`);
|
|
|
|
// Normalized versions for comparison
|
|
const normalizedSourceId = normalizeForComparison(sourceId);
|
|
const normalizedTargetId = normalizeForComparison(targetId);
|
|
const normalizedSourceName = sourceName ? normalizeForComparison(sourceName) : '';
|
|
const normalizedTargetName = targetName ? normalizeForComparison(targetName) : '';
|
|
|
|
// Try different ways of comparing
|
|
const sourceMatch =
|
|
normalizedSourceId === normalizedNodeId ||
|
|
normalizedSourceName === normalizedNodeId;
|
|
|
|
const targetMatch =
|
|
normalizedTargetId === normalizedNodeId ||
|
|
normalizedTargetName === normalizedNodeId;
|
|
|
|
if (sourceMatch) {
|
|
console.log(` ✅ SOURCE MATCH! Node is source in this link`);
|
|
connections.outgoing.push({
|
|
target: targetId,
|
|
predicate: link.name,
|
|
link
|
|
});
|
|
}
|
|
|
|
if (targetMatch) {
|
|
console.log(` ✅ TARGET MATCH! Node is target in this link`);
|
|
connections.incoming.push({
|
|
source: sourceId,
|
|
predicate: link.name,
|
|
link
|
|
});
|
|
}
|
|
|
|
if (!sourceMatch && !targetMatch) {
|
|
console.log(` ❌ No match`);
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error processing link ${index}:`, error);
|
|
}
|
|
});
|
|
|
|
connections.total = connections.outgoing.length + connections.incoming.length;
|
|
|
|
console.log(`Total connections found: ${connections.total}`, connections);
|
|
return connections;
|
|
};
|
|
|
|
// Helper function to normalize text by removing quotes and parentheses
|
|
const normalizeText = (text: string | undefined): string => {
|
|
if (!text) return '';
|
|
return text.replace(/['"()]/g, '').trim();
|
|
};
|
|
|
|
// Process the JSON data into the format needed for the graph
|
|
const processGraphData = async (data: any, applyClusteringFirst: boolean = false) => {
|
|
console.log("processGraphData called with input:", {
|
|
hasData: !!data,
|
|
isObject: typeof data === 'object' && data !== null,
|
|
hasNodes: data && 'nodes' in data,
|
|
hasLinks: data && 'links' in data,
|
|
dataType: typeof data,
|
|
keysIfObject: data && typeof data === 'object' ? Object.keys(data) : [],
|
|
applyClusteringFirst
|
|
});
|
|
|
|
// Ensure data exists and has required properties
|
|
if (!data || typeof data !== 'object' || data === null) {
|
|
console.error("Invalid graph data: not an object or null");
|
|
return null;
|
|
}
|
|
|
|
// Check if we need to adapt the data format
|
|
if (!Array.isArray(data.nodes) || !Array.isArray(data.links)) {
|
|
// If data doesn't have nodes/links arrays directly, try to extract from a different format
|
|
console.log("Data doesn't have expected nodes/links format, attempting to adapt...");
|
|
|
|
// Check if the data might be in a nested format (e.g., from the API response)
|
|
if (data.triples && Array.isArray(data.triples)) {
|
|
console.log("Found triples array, converting to nodes/links format");
|
|
return convertTriplesToGraphFormat(data.triples, data.documentName);
|
|
}
|
|
|
|
console.error("Could not adapt data to required format", data);
|
|
return null;
|
|
}
|
|
|
|
// Check if we should apply clustering before rendering (for large datasets)
|
|
if (applyClusteringFirst && data.nodes.length > 10000 && clusteringEngineRef.current) {
|
|
console.log(`🎯 Large dataset detected (${data.nodes.length} nodes), applying clustering before rendering...`);
|
|
|
|
try {
|
|
// Use the remote clustering service to get subsampled data
|
|
const success = await clusteringEngineRef.current.updateNodePositions(
|
|
data.nodes,
|
|
data.links || []
|
|
);
|
|
|
|
if (success) {
|
|
// Get the clustered/subsampled nodes from the engine
|
|
const clusteredData = clusteringEngineRef.current.getClusteredData();
|
|
if (clusteredData && clusteredData.nodes) {
|
|
console.log(`✅ Pre-clustering successful: ${data.nodes.length} → ${clusteredData.nodes.length} nodes`);
|
|
|
|
// Use the subsampled data instead of original
|
|
data = {
|
|
nodes: clusteredData.nodes,
|
|
links: data.links || [] // Keep original links for now
|
|
};
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("Pre-clustering failed, using original data:", error);
|
|
}
|
|
}
|
|
|
|
// Return processed data with normalized node names and IDs
|
|
const processed = {
|
|
nodes: data.nodes.map((node: any) => ({
|
|
...node,
|
|
// Ensure node has all required properties and normalize the ID and name
|
|
id: normalizeText(node.id) || `node-${Math.random().toString(36).substring(2, 9)}`,
|
|
name: normalizeText(node.name || node.id) || "Unnamed",
|
|
group: node.group || "default"
|
|
})),
|
|
links: data.links.map((link: any) => ({
|
|
...link,
|
|
// Ensure link has all required properties
|
|
id: link.id || `link-${Math.random().toString(36).substring(2, 9)}`,
|
|
name: link.name || "related",
|
|
source: link.source,
|
|
target: link.target
|
|
}))
|
|
};
|
|
|
|
console.log("Processed graph data:", {
|
|
nodeCount: processed.nodes.length,
|
|
linkCount: processed.links.length,
|
|
firstNode: processed.nodes.length > 0 ? processed.nodes[0] : null,
|
|
firstLink: processed.links.length > 0 ? processed.links[0] : null
|
|
});
|
|
|
|
return processed;
|
|
};
|
|
|
|
// Helper function to convert triples to graph format
|
|
const convertTriplesToGraphFormat = (triples: any[], documentName: string = "Unnamed Document") => {
|
|
console.log("Converting triples to graph format...");
|
|
|
|
const nodes = new Map<string, NodeObject>();
|
|
const links: LinkObject[] = [];
|
|
|
|
// Process each triple into nodes and links
|
|
triples.forEach((triple, index) => {
|
|
if (!triple.subject || !triple.predicate || !triple.object) {
|
|
console.warn("Invalid triple format at index", index, triple);
|
|
return;
|
|
}
|
|
|
|
// Handle both complex objects and simple string formats
|
|
let subjectId = typeof triple.subject === 'string' ? triple.subject : triple.subject.id;
|
|
let subjectName = typeof triple.subject === 'string' ? triple.subject : (triple.subject.value || triple.subject.id);
|
|
|
|
const predicateId = typeof triple.predicate === 'string' ? triple.predicate : triple.predicate.id;
|
|
const predicateName = typeof triple.predicate === 'string' ? triple.predicate : (triple.predicate.value || triple.predicate.id);
|
|
|
|
let objectId = typeof triple.object === 'string' ? triple.object : triple.object.id;
|
|
let objectName = typeof triple.object === 'string' ? triple.object : (triple.object.value || triple.object.id);
|
|
|
|
// Normalize IDs and names to remove quotes and parentheses
|
|
subjectId = normalizeText(subjectId);
|
|
subjectName = normalizeText(subjectName);
|
|
objectId = normalizeText(objectId);
|
|
objectName = normalizeText(objectName);
|
|
|
|
// Add subject node if it doesn't exist
|
|
if (!nodes.has(subjectId)) {
|
|
nodes.set(subjectId, {
|
|
id: subjectId,
|
|
name: subjectName,
|
|
group: "concept"
|
|
});
|
|
}
|
|
|
|
// Add object node if it doesn't exist
|
|
if (!nodes.has(objectId)) {
|
|
nodes.set(objectId, {
|
|
id: objectId,
|
|
name: objectName,
|
|
group: "concept"
|
|
});
|
|
}
|
|
|
|
// Create the link between subject and object
|
|
links.push({
|
|
id: `link-${subjectId}-${objectId}-${index}`,
|
|
source: subjectId,
|
|
target: objectId,
|
|
name: predicateName
|
|
});
|
|
});
|
|
|
|
// Convert nodes map to array
|
|
const result = {
|
|
nodes: Array.from(nodes.values()),
|
|
links
|
|
};
|
|
|
|
console.log("Converted graph data:", {
|
|
nodeCount: result.nodes.length,
|
|
linkCount: result.links.length
|
|
});
|
|
|
|
return result;
|
|
};
|
|
|
|
// Initialize the graph
|
|
useEffect(() => {
|
|
if (!containerRef.current) return;
|
|
|
|
console.log("Starting graph initialization...");
|
|
|
|
// Flag to track if component is mounted
|
|
let mounted = true;
|
|
|
|
const initializeGraph = async () => {
|
|
try {
|
|
setIsLoading(true);
|
|
setLoadingStep('Initializing 3D engine');
|
|
|
|
if (typeof window === 'undefined') {
|
|
console.error("Cannot initialize 3D graph in non-browser environment");
|
|
setError("Browser environment required for 3D visualization");
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
|
|
// Import ForceGraph3D dynamically to avoid SSR issues
|
|
let ForceGraph3D;
|
|
try {
|
|
ForceGraph3D = (await import('3d-force-graph')).default;
|
|
console.log("ForceGraph3D library loaded successfully");
|
|
} catch (importError) {
|
|
console.error("Failed to import ForceGraph3D:", importError);
|
|
setError(`Failed to load 3D visualization library: ${importError instanceof Error ? importError.message : String(importError)}`);
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
|
|
if (!ForceGraph3D) {
|
|
throw new Error("Failed to load ForceGraph3D library - it's undefined after import");
|
|
}
|
|
|
|
try {
|
|
// Create the graph instance using the same pattern as before
|
|
// @ts-ignore - Calling function directly, letting JS handle it
|
|
const Graph = ForceGraph3D({
|
|
rendererConfig: {
|
|
antialias: true,
|
|
alpha: true,
|
|
powerPreference: 'high-performance',
|
|
precision: 'highp', // High precision for better quality
|
|
depth: true // Enable depth testing for better 3D rendering
|
|
}
|
|
})(containerRef.current);
|
|
|
|
if (!Graph) {
|
|
throw new Error("Failed to create graph instance");
|
|
}
|
|
|
|
// Store the graph reference
|
|
graphRef.current = Graph;
|
|
|
|
console.log("3D Graph initialized successfully");
|
|
|
|
// Enhanced GPU-accelerated setup
|
|
Graph
|
|
.backgroundColor("#000000")
|
|
.nodeRelSize(5)
|
|
.nodeResolution(32) // Higher resolution for smoother nodes
|
|
.nodeOpacity(0.8)
|
|
.linkOpacity(0.2)
|
|
.linkWidth(1)
|
|
.showNavInfo(false)
|
|
.onBackgroundClick(() => {
|
|
if (selectedNode) {
|
|
clearSelection();
|
|
}
|
|
});
|
|
|
|
// Setup safe hover handling to prevent recursion
|
|
Graph.onNodeHover((node: any) => {
|
|
// Only update if the hovered node has changed
|
|
if (node !== hoveredNodeRef.current) {
|
|
hoveredNodeRef.current = node;
|
|
setHoveredNode(node);
|
|
|
|
// Update cursor based on hover state without triggering a re-render
|
|
if (containerRef.current) {
|
|
containerRef.current.style.cursor = node ? 'pointer' : 'default';
|
|
}
|
|
}
|
|
});
|
|
|
|
// Set up click handling with debouncing
|
|
let lastClickTime = 0;
|
|
Graph.onNodeClick((node: any) => {
|
|
const now = Date.now();
|
|
if (now - lastClickTime < 300) return; // Debounce clicks
|
|
lastClickTime = now;
|
|
|
|
console.log("Node click detected", node);
|
|
handleNodeSelection(node);
|
|
});
|
|
|
|
// Ready for data loading
|
|
setLoadingStep('Ready to load graph data');
|
|
setDebugInfo("3D Graph initialized and ready to load data");
|
|
setIsInitialized(true); // Mark initialization as complete
|
|
|
|
// Force immediate data loading if data is available
|
|
if (jsonData) {
|
|
console.log("Data is available at initialization time, triggering immediate load");
|
|
// Use a small timeout to ensure state is updated
|
|
setTimeout(async () => {
|
|
try {
|
|
// Check if we should pre-cluster large datasets
|
|
const shouldPreCluster = jsonData?.nodes?.length > 10000 && isClusteringEnabled;
|
|
const processedData = await processGraphData(jsonData, shouldPreCluster);
|
|
if (processedData && graphRef.current) {
|
|
console.log("Applying data directly after initialization");
|
|
graphRef.current.graphData(processedData);
|
|
setGraphData(processedData);
|
|
setGraphStats({
|
|
nodes: processedData.nodes.length,
|
|
links: processedData.links.length
|
|
});
|
|
|
|
// Zoom to fit
|
|
setTimeout(() => {
|
|
if (graphRef.current) {
|
|
graphRef.current.zoomToFit(800, 30);
|
|
setIsLoading(false);
|
|
}
|
|
}, 500);
|
|
}
|
|
} catch (err) {
|
|
console.error("Error in immediate data loading:", err);
|
|
}
|
|
}, 100);
|
|
}
|
|
} catch (graphError) {
|
|
console.error("Error initializing graph instance:", graphError);
|
|
setError(`Failed to initialize 3D graph: ${graphError instanceof Error ? graphError.message : String(graphError)}`);
|
|
setIsLoading(false);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error in initialization process:', error);
|
|
setError(`Initialization error: ${error instanceof Error ? error.message : String(error)}`);
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
initializeGraph();
|
|
|
|
// Cleanup function
|
|
return () => {
|
|
mounted = false;
|
|
|
|
if (graphRef.current) {
|
|
try {
|
|
// Clean up the graph instance
|
|
graphRef.current._destructor?.();
|
|
} catch (err) {
|
|
console.warn("Error during cleanup:", err);
|
|
}
|
|
}
|
|
};
|
|
}, [jsonData]); // Add jsonData as dependency
|
|
|
|
// Effect for loading data after graph initialization
|
|
useEffect(() => {
|
|
// Current graph ref value for closure
|
|
const currentGraphRef = graphRef.current;
|
|
|
|
if (!currentGraphRef || !jsonData || isLoading || !isInitialized) {
|
|
console.log("Data loading effect - early return:", {
|
|
graphRefExists: !!currentGraphRef,
|
|
jsonDataExists: !!jsonData,
|
|
isCurrentlyLoading: isLoading,
|
|
isInitialized: isInitialized
|
|
});
|
|
return;
|
|
}
|
|
|
|
console.log("Starting data loading process", {
|
|
jsonDataSize: JSON.stringify(jsonData).length,
|
|
jsonDataSample: JSON.stringify(jsonData).substring(0, 200) + '...'
|
|
});
|
|
|
|
const loadGraphData = async () => {
|
|
try {
|
|
setIsLoading(true);
|
|
setLoadingStep('Processing data');
|
|
console.log("Processing graph data...");
|
|
|
|
// Process the graph data
|
|
// Check if we should pre-cluster large datasets
|
|
const shouldPreCluster = jsonData?.nodes?.length > 10000 && isClusteringEnabled;
|
|
let processedData = await processGraphData(jsonData, shouldPreCluster);
|
|
|
|
if (!processedData) {
|
|
console.error("processGraphData returned null");
|
|
throw new Error("Failed to process graph data");
|
|
}
|
|
|
|
// Apply cluster coloring if enabled
|
|
if (enableClusterColors && processedData.nodes) {
|
|
console.log("🎨 Applying cluster colors to", processedData.nodes.length, "nodes");
|
|
processedData.nodes = assignClusterColors(processedData.nodes, enableClusterColors, isClusteringEnabled);
|
|
}
|
|
|
|
console.log("Data processed successfully", {
|
|
nodeCount: processedData.nodes.length,
|
|
linkCount: processedData.links.length,
|
|
sampleNode: processedData.nodes.length > 0 ? processedData.nodes[0] : null
|
|
});
|
|
|
|
// Store the processed data for reference
|
|
setGraphData(processedData);
|
|
|
|
// Update graph stats
|
|
setGraphStats({
|
|
nodes: processedData.nodes.length,
|
|
links: processedData.links.length
|
|
});
|
|
|
|
setLoadingStep('Applying data to graph');
|
|
console.log("Applying data to graph...");
|
|
|
|
// Safety check - use the captured reference
|
|
if (!currentGraphRef) {
|
|
throw new Error("Graph reference lost during data loading");
|
|
}
|
|
|
|
// Apply data to graph with a try/catch
|
|
try {
|
|
console.log("Calling graphData() method on graph instance");
|
|
currentGraphRef.graphData(processedData);
|
|
console.log("Graph data applied successfully");
|
|
} catch (dataError) {
|
|
console.error("Error applying data to graph:", dataError);
|
|
throw new Error(`Failed to apply data to graph: ${dataError instanceof Error ? dataError.message : String(dataError)}`);
|
|
}
|
|
|
|
// Configure force physics with safety checks
|
|
try {
|
|
console.log("Configuring force physics...");
|
|
const charge = currentGraphRef.d3Force('charge');
|
|
if (charge) charge.strength(-120);
|
|
|
|
const link = currentGraphRef.d3Force('link');
|
|
if (link) link.distance(60);
|
|
console.log("Force physics configured");
|
|
} catch (forceError) {
|
|
console.warn("Non-critical error configuring forces:", forceError);
|
|
}
|
|
|
|
// Zoom to fit with a delay and safety mechanism
|
|
console.log("Scheduling zoom to fit...");
|
|
setTimeout(() => {
|
|
try {
|
|
if (currentGraphRef) {
|
|
console.log("Executing zoomToFit");
|
|
currentGraphRef.zoomToFit(800, 30);
|
|
console.log("Graph loading complete");
|
|
showNotification("Graph loaded successfully", "success");
|
|
}
|
|
} catch (zoomError) {
|
|
console.warn("Non-critical error during zoom:", zoomError);
|
|
} finally {
|
|
setIsLoading(false);
|
|
console.log("Loading state set to false");
|
|
}
|
|
}, 1000);
|
|
|
|
} catch (error) {
|
|
console.error("Error loading graph data:", error);
|
|
setError(`Failed to load graph data: ${error instanceof Error ? error.message : String(error)}`);
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
loadGraphData();
|
|
}, [jsonData, isInitialized]);
|
|
|
|
// Manual retry function
|
|
const handleRetry = () => {
|
|
setRetryCount(prev => prev + 1);
|
|
setError(null);
|
|
setIsLoading(true);
|
|
setLoadingProgress(0);
|
|
setLoadingStep("Restarting...");
|
|
};
|
|
|
|
const toggleFullscreen = () => {
|
|
const newFullscreenState = !isFullscreen;
|
|
setIsFullscreen(newFullscreenState);
|
|
|
|
if (typeof document !== 'undefined') {
|
|
// Toggle body overflow to prevent scrolling in fullscreen
|
|
document.body.style.overflow = newFullscreenState ? 'hidden' : '';
|
|
}
|
|
|
|
// Force graph resize after state change
|
|
if (graphRef.current) {
|
|
setTimeout(() => {
|
|
if (graphRef.current) {
|
|
try {
|
|
// Force the graph to update dimensions
|
|
graphRef.current.width(containerRef.current?.clientWidth || window.innerWidth);
|
|
graphRef.current.height(containerRef.current?.clientHeight || window.innerHeight);
|
|
graphRef.current.zoomToFit(400);
|
|
} catch (err) {
|
|
console.warn("Error resizing graph:", err);
|
|
}
|
|
}
|
|
}, 300);
|
|
}
|
|
};
|
|
|
|
// Update container styles when fullscreen prop changes
|
|
useEffect(() => {
|
|
setIsFullscreen(fullscreen);
|
|
if (containerRef.current && fullscreen) {
|
|
containerRef.current.style.position = 'fixed';
|
|
containerRef.current.style.top = '0';
|
|
containerRef.current.style.left = '0';
|
|
containerRef.current.style.right = '0';
|
|
containerRef.current.style.bottom = '0';
|
|
containerRef.current.style.width = '100vw';
|
|
containerRef.current.style.height = '100vh';
|
|
containerRef.current.style.zIndex = '50';
|
|
}
|
|
}, [fullscreen]);
|
|
|
|
const togglePause = () => {
|
|
if (!graphRef.current) return;
|
|
|
|
setIsPaused(!isPaused);
|
|
if (isPaused) {
|
|
graphRef.current.resumeAnimation();
|
|
} else {
|
|
graphRef.current.pauseAnimation();
|
|
}
|
|
};
|
|
|
|
// Helper function to clear selection
|
|
const clearSelection = () => {
|
|
setSelectedNode(null);
|
|
setNodeConnections([]);
|
|
|
|
// Restore cluster colors if enabled
|
|
if (graphRef.current && enableClusterColors && graphData?.nodes) {
|
|
console.log("🔄 Restoring cluster colors after clearSelection");
|
|
console.log("🔧 clearSelection state check:", {
|
|
enableClusterColors,
|
|
isClusteringEnabled,
|
|
hasClusteringEngine: !!clusteringEngineRef.current,
|
|
hasGraphData: !!graphData?.nodes
|
|
});
|
|
|
|
// Get the actual clustered data if available
|
|
let nodesToUse = graphData.nodes;
|
|
let useSemanticClusters = false;
|
|
|
|
if (clusteringEngineRef.current) {
|
|
const clusteredData = clusteringEngineRef.current.getClusteredData();
|
|
console.log("🔍 Checking clustered data:", {
|
|
hasClusteredData: !!clusteredData,
|
|
hasNodes: !!clusteredData?.nodes,
|
|
nodeCount: clusteredData?.nodes?.length,
|
|
hasClusterIds: clusteredData?.nodes?.some((n: any) => n.clusterId !== undefined || n.clusterIndex !== undefined)
|
|
});
|
|
|
|
if (clusteredData && clusteredData.nodes) {
|
|
console.log("📊 Using clustered data for color restoration");
|
|
nodesToUse = clusteredData.nodes;
|
|
// Check if the clustered data actually has cluster IDs
|
|
useSemanticClusters = clusteredData.nodes.some((n: any) => n.clusterId !== undefined || n.clusterIndex !== undefined);
|
|
}
|
|
}
|
|
|
|
const coloredNodes = assignClusterColors(nodesToUse, true, useSemanticClusters);
|
|
graphRef.current.nodeColor((node: any) => {
|
|
const coloredNode = coloredNodes.find((n: any) => getNodeId(n) === getNodeId(node));
|
|
return coloredNode?.color || '#76b900';
|
|
});
|
|
graphRef.current.linkColor(() => '#ffffff30');
|
|
graphRef.current.linkWidth(() => 1);
|
|
graphRef.current.refresh();
|
|
}
|
|
|
|
console.log("Selection cleared");
|
|
};
|
|
|
|
// Function to safely zoom to fit
|
|
const zoomToFit = () => {
|
|
if (!graphRef.current) return;
|
|
|
|
try {
|
|
// Use a more conservative zoom with a delay to prevent stack overflow
|
|
setTimeout(() => {
|
|
if (graphRef.current) {
|
|
graphRef.current.zoomToFit(800, 30);
|
|
}
|
|
}, 50);
|
|
} catch (err) {
|
|
console.warn("Error in zoomToFit:", err);
|
|
}
|
|
};
|
|
|
|
// Focus on a specific node with safety mechanism
|
|
const focusOnNode = (nodeId: string) => {
|
|
if (!graphData || !graphRef.current) return;
|
|
|
|
const node = graphData.nodes.find((n: any) => n.id === nodeId);
|
|
if (node) {
|
|
handleNodeSelection(node);
|
|
|
|
// Use setTimeout to prevent possible recursion
|
|
setTimeout(() => {
|
|
if (graphRef.current) {
|
|
try {
|
|
// Use centerAt and zoom separately with a delay in between
|
|
graphRef.current.centerAt(node.x, node.y, node.z, 800);
|
|
|
|
// Add delay before zooming
|
|
setTimeout(() => {
|
|
if (graphRef.current) {
|
|
graphRef.current.zoom(1.5, 800);
|
|
}
|
|
}, 100);
|
|
} catch (err) {
|
|
console.warn("Error focusing on node:", err);
|
|
}
|
|
}
|
|
}, 50);
|
|
}
|
|
};
|
|
|
|
// Replace or enhance the handleNodeSelection function
|
|
const handleNodeSelection = (node: any) => {
|
|
// Function to reliably extract a node's ID
|
|
const getNodeId = (nodeObj: any): string => {
|
|
if (!nodeObj) return '';
|
|
|
|
// If it's a string, return it directly
|
|
if (typeof nodeObj === 'string') return nodeObj;
|
|
|
|
// If it has an ID property, use that
|
|
if (nodeObj.id && typeof nodeObj.id === 'string') {
|
|
return nodeObj.id;
|
|
}
|
|
|
|
// If it's a ThreeJS object with userData
|
|
if (nodeObj.__threeObj && nodeObj.__threeObj.userData) {
|
|
return nodeObj.__threeObj.userData.id || '';
|
|
}
|
|
|
|
// Fallback
|
|
return '';
|
|
};
|
|
|
|
// Normalize the node ID
|
|
const nodeId = getNodeId(node);
|
|
const prevSelectedNode = selectedNode;
|
|
|
|
// Toggle selection state for the node
|
|
if (selectedNode && getNodeId(selectedNode) === nodeId) {
|
|
// Deselect current node
|
|
setSelectedNode(null);
|
|
setNodeConnections([]);
|
|
|
|
// Reset any highlight styles while preserving cluster colors
|
|
if (graphRef.current) {
|
|
// Reset node colors - preserve cluster colors if enabled
|
|
if (enableClusterColors && graphData?.nodes) {
|
|
console.log("🔄 Restoring cluster colors after deselection");
|
|
|
|
// Get the actual clustered data if available
|
|
let nodesToUse = graphData.nodes;
|
|
let useSemanticClusters = false;
|
|
|
|
if (clusteringEngineRef.current) {
|
|
const clusteredData = clusteringEngineRef.current.getClusteredData();
|
|
console.log("🔍 Checking clustered data:", {
|
|
hasClusteredData: !!clusteredData,
|
|
hasNodes: !!clusteredData?.nodes,
|
|
nodeCount: clusteredData?.nodes?.length,
|
|
sampleNode: clusteredData?.nodes?.[0],
|
|
hasClusterIds: clusteredData?.nodes?.some((n: any) => n.clusterId !== undefined || n.clusterIndex !== undefined)
|
|
});
|
|
if (clusteredData && clusteredData.nodes) {
|
|
console.log("📊 Using clustered data for color restoration");
|
|
nodesToUse = clusteredData.nodes;
|
|
// Check if the clustered data actually has cluster IDs
|
|
useSemanticClusters = clusteredData.nodes.some((n: any) => n.clusterId !== undefined || n.clusterIndex !== undefined);
|
|
}
|
|
}
|
|
|
|
// Regenerate cluster colors properly
|
|
const coloredNodes = assignClusterColors(nodesToUse, true, useSemanticClusters);
|
|
graphRef.current.nodeColor((node: any) => {
|
|
const coloredNode = coloredNodes.find((n: any) => getNodeId(n) === getNodeId(node));
|
|
return coloredNode?.color || '#76b900';
|
|
});
|
|
} else {
|
|
// Reset to default colors
|
|
graphRef.current.nodeColor((node: any) => {
|
|
const group = node.group || 'default';
|
|
switch (group) {
|
|
case 'document': return '#f8f8f2';
|
|
case 'important': return '#8be9fd';
|
|
default: return '#76b900';
|
|
}
|
|
});
|
|
}
|
|
|
|
graphRef.current.linkColor(() => '#ffffff30'); // Reset link colors too
|
|
graphRef.current.linkWidth(() => 1); // Reset to default width
|
|
graphRef.current.refresh();
|
|
}
|
|
|
|
showNotification("Node deselected", "info");
|
|
} else {
|
|
// Select new node
|
|
setSelectedNode(node);
|
|
|
|
if (graphRef.current) {
|
|
// Find all connections to this node
|
|
const connections: Connection[] = [];
|
|
const connectedNodes = new Set<string>();
|
|
|
|
// Process link objects from the graph
|
|
graphRef.current.graphData().links.forEach((link: any) => {
|
|
const source = typeof link.source === 'object' ? link.source : { id: link.source };
|
|
const target = typeof link.target === 'object' ? link.target : { id: link.target };
|
|
|
|
const sourceId = getNodeId(source);
|
|
const targetId = getNodeId(target);
|
|
|
|
if (sourceId === nodeId) {
|
|
// Outgoing connection
|
|
connections.push({
|
|
source: sourceId,
|
|
target: targetId,
|
|
label: link.name || link.label,
|
|
nodeName: target.name || targetId,
|
|
type: 'outgoing'
|
|
});
|
|
connectedNodes.add(targetId);
|
|
} else if (targetId === nodeId) {
|
|
// Incoming connection
|
|
connections.push({
|
|
source: sourceId,
|
|
target: targetId,
|
|
label: link.name || link.label,
|
|
nodeName: source.name || sourceId,
|
|
type: 'incoming'
|
|
});
|
|
connectedNodes.add(sourceId);
|
|
}
|
|
});
|
|
|
|
setNodeConnections(connections);
|
|
|
|
// Apply visual highlighting for the node and its connections
|
|
// Preserve cluster colors when highlighting nodes
|
|
graphRef.current
|
|
.nodeColor((n: any) => {
|
|
const nId = getNodeId(n);
|
|
if (nId === nodeId) return '#ffcf00'; // Selected node: bright yellow
|
|
if (connectedNodes.has(nId)) return '#ff6200'; // Connected nodes: orange
|
|
|
|
// Preserve cluster colors if enabled, otherwise use default
|
|
if (enableClusterColors) {
|
|
// Find the original node data to get its cluster color
|
|
const originalNode = graphData.nodes.find((node: any) => getNodeId(node) === nId);
|
|
if (originalNode && originalNode.color) {
|
|
return originalNode.color;
|
|
}
|
|
}
|
|
return '#76b900'; // Default: green
|
|
})
|
|
.linkWidth((link: any) => {
|
|
const sourceId = getNodeId(link.source);
|
|
const targetId = getNodeId(link.target);
|
|
|
|
// Highlight links that connect to the selected node
|
|
if (sourceId === nodeId || targetId === nodeId) {
|
|
return 3; // Thicker line for direct connections
|
|
}
|
|
return 1; // Default thickness
|
|
})
|
|
.linkColor((link: any) => {
|
|
const sourceId = getNodeId(link.source);
|
|
const targetId = getNodeId(link.target);
|
|
|
|
// Highlight links that connect to the selected node
|
|
if (sourceId === nodeId || targetId === nodeId) {
|
|
return '#ff9500'; // Orange for connections
|
|
}
|
|
return '#cccccc'; // Default: light gray
|
|
})
|
|
.refresh();
|
|
|
|
// Zoom to focus on the selected node
|
|
focusOnNode(nodeId);
|
|
}
|
|
|
|
showNotification(`Selected node: ${node.name || nodeId}`, "success");
|
|
}
|
|
};
|
|
|
|
// Function to get node color with optimization
|
|
const getNodeColor = (node: any) => {
|
|
try {
|
|
// Use the current hover state from the ref to prevent recursive calls
|
|
const isHovered = hoveredNodeRef.current === node;
|
|
const isSelected = selectedNode && node.id === selectedNode.id;
|
|
const isConnected = nodeConnections.some(conn =>
|
|
conn.target === node.id || conn.source === node.id
|
|
);
|
|
|
|
if (isSelected) return '#50fa7b'; // Bright green for selected
|
|
if (isHovered) return '#8be9fd'; // Cyan for hovered
|
|
if (isConnected) return '#bd93f9'; // Purple for connected nodes
|
|
|
|
// Default colors based on group
|
|
const group = node.group || 'default';
|
|
switch (group) {
|
|
case 'document': return '#f8f8f2'; // White for documents
|
|
case 'important': return '#8be9fd'; // Teal for important nodes
|
|
default: return '#50fa7b'; // Bright green for most nodes
|
|
}
|
|
} catch (error) {
|
|
console.warn('Error in getNodeColor:', error);
|
|
return '#50fa7b'; // Default fallback
|
|
}
|
|
};
|
|
|
|
// Add effect for logging render state
|
|
useEffect(() => {
|
|
console.log("Component state:", {
|
|
isLoading,
|
|
hasGraphData: !!graphData,
|
|
graphDataSize: graphData ? { nodes: graphData.nodes.length, links: graphData.links.length } : null,
|
|
error,
|
|
selectedNode: selectedNode?.id,
|
|
isInitialized
|
|
});
|
|
}, [isLoading, graphData, error, selectedNode, isInitialized]);
|
|
|
|
// Manual data loading function to allow retrying
|
|
const manuallyLoadGraphData = useCallback(async () => {
|
|
if (!graphRef.current || !jsonData) {
|
|
console.warn("Cannot manually load graph data: Missing graph reference or data");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
console.log("Manual graph data loading initiated");
|
|
setError(null);
|
|
|
|
// Check data format
|
|
console.log("Validating input data:", {
|
|
dataType: typeof jsonData,
|
|
hasNodes: jsonData?.nodes ? true : false,
|
|
hasLinks: jsonData?.links ? true : false,
|
|
firstKeys: typeof jsonData === 'object' ? Object.keys(jsonData).slice(0, 3) : []
|
|
});
|
|
|
|
// Try to process the data
|
|
// Check if we should pre-cluster large datasets
|
|
const shouldPreCluster = jsonData?.nodes?.length > 10000 && isClusteringEnabled;
|
|
const processedData = await processGraphData(jsonData, shouldPreCluster);
|
|
|
|
if (!processedData) {
|
|
throw new Error("Failed to process graph data");
|
|
}
|
|
|
|
console.log("Applying data to graph instance");
|
|
|
|
// Apply the data to the graph
|
|
graphRef.current.graphData(processedData);
|
|
|
|
// Update our internal state
|
|
setGraphData(processedData);
|
|
|
|
// Update graph stats
|
|
setGraphStats({
|
|
nodes: processedData.nodes.length,
|
|
links: processedData.links.length
|
|
});
|
|
|
|
// Show notification
|
|
showNotification(`Loaded ${processedData.nodes.length} nodes and ${processedData.links.length} links`, "success");
|
|
|
|
// Zoom to fit after a short delay
|
|
setTimeout(() => {
|
|
if (graphRef.current) {
|
|
graphRef.current.zoomToFit(800, 30);
|
|
}
|
|
}, 500);
|
|
|
|
} catch (error) {
|
|
console.error("Manual data loading failed:", error);
|
|
setError(`Manual data loading failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}, [graphRef.current, jsonData]);
|
|
|
|
// Enhanced WebGPU clustering initialization for 3D views with remote fallback
|
|
useEffect(() => {
|
|
async function initClustering() {
|
|
try {
|
|
// Only create engine if we're in 3D mode and don't already have one
|
|
if (layoutType === '3d' && !clusteringEngineRef.current) {
|
|
console.log("🔧 Initializing clustering engine for 3D view...");
|
|
|
|
// Use enhanced clustering engine for 3D views with remote fallback
|
|
const enhancedEngine = new EnhancedWebGPUClusteringEngine([32, 18, 24], 'http://localhost:8083');
|
|
|
|
console.log("⏳ Waiting for engine initialization...");
|
|
// Wait longer for proper initialization (remote service check takes time)
|
|
await new Promise(resolve => setTimeout(resolve, 2000)); // Increased timeout even more
|
|
|
|
console.log("🔍 Checking enhanced engine availability...");
|
|
console.log("Engine available:", enhancedEngine.isAvailable());
|
|
console.log("Engine using remote:", enhancedEngine.isUsingRemote());
|
|
console.log("Engine capabilities:", enhancedEngine.getCapabilities());
|
|
|
|
// Store the engine reference regardless of availability for debugging
|
|
clusteringEngineRef.current = enhancedEngine;
|
|
|
|
if (enhancedEngine.isAvailable()) {
|
|
setIsClusteringAvailable(true);
|
|
|
|
if (enhancedEngine.isUsingRemote()) {
|
|
console.log("✅ Using remote WebGPU clustering service for 3D view");
|
|
setUsingCpuFallback(false); // Remote GPU is available
|
|
|
|
// Set up event listeners for remote clustering updates
|
|
enhancedEngine.on('clusteringComplete', (result: any) => {
|
|
console.log(`🚀 Remote clustering completed in ${result.processingTime}s`);
|
|
});
|
|
} else {
|
|
console.log("✅ Local WebGPU clustering engine initialized for 3D view");
|
|
setUsingCpuFallback(false); // Local WebGPU is also not CPU fallback
|
|
}
|
|
|
|
// Auto-enable clustering for large 3D graphs
|
|
if (graphData && graphData.nodes && graphData.nodes.length > 200) {
|
|
console.log("🎯 Auto-enabling clustering for large 3D graph with", graphData.nodes.length, "nodes");
|
|
setIsClusteringEnabled(true);
|
|
console.log("✅ Enhanced WebGPU clustering auto-enabled for large 3D graph");
|
|
} else {
|
|
console.log("📊 Graph has", graphData?.nodes?.length || 0, "nodes (threshold: 200)");
|
|
}
|
|
} else {
|
|
console.log("❌ Neither local WebGPU nor remote clustering available");
|
|
console.log("🔧 But engine reference stored for manual activation");
|
|
setUsingCpuFallback(true);
|
|
}
|
|
} else if (layoutType !== '3d') {
|
|
console.log("📋 Using standard CPU rendering for 2D view");
|
|
setUsingCpuFallback(true);
|
|
setIsClusteringAvailable(false);
|
|
} else {
|
|
console.log("♻️ Clustering engine already initialized");
|
|
}
|
|
} catch (error) {
|
|
console.warn("❌ Failed to initialize enhanced WebGPU clustering:", error);
|
|
setUsingCpuFallback(true);
|
|
}
|
|
}
|
|
|
|
initClustering();
|
|
|
|
// Cleanup
|
|
return () => {
|
|
if (clusteringEngineRef.current) {
|
|
clusteringEngineRef.current.dispose();
|
|
clusteringEngineRef.current = null;
|
|
}
|
|
};
|
|
}, [layoutType, graphData]);
|
|
|
|
// Update clustering options when semantic clustering parameters change
|
|
useEffect(() => {
|
|
if (clusteringEngineRef.current && clusteringEngineRef.current.setClusteringOptions) {
|
|
const options = {
|
|
clusteringMethod,
|
|
semanticAlgorithm,
|
|
numberOfClusters,
|
|
similarityThreshold,
|
|
nameWeight,
|
|
contentWeight,
|
|
spatialWeight
|
|
};
|
|
|
|
console.log("🔧 Updating semantic clustering options:", options);
|
|
clusteringEngineRef.current.setClusteringOptions(options);
|
|
}
|
|
}, [clusteringMethod, semanticAlgorithm, numberOfClusters, similarityThreshold, nameWeight, contentWeight, spatialWeight]);
|
|
|
|
// Force re-clustering when algorithm parameters change
|
|
useEffect(() => {
|
|
console.log("🔄 Algorithm parameters changed - checking conditions:", {
|
|
isClusteringEnabled,
|
|
hasClusteringEngine: !!clusteringEngineRef.current,
|
|
hasGraphData: !!(graphData && graphData.nodes),
|
|
nodeCount: graphData?.nodes?.length || 0
|
|
});
|
|
|
|
if (isClusteringEnabled && clusteringEngineRef.current && graphData && graphData.nodes) {
|
|
console.log("🔄 Algorithm parameters changed, triggering re-clustering...");
|
|
|
|
// Delay to ensure the clustering options are updated first
|
|
setTimeout(() => {
|
|
if (clusteringEngineRef.current && graphData) {
|
|
console.log("🎯 Calling updateNodePositions with", graphData.nodes.length, "nodes");
|
|
// Trigger re-clustering with updated parameters
|
|
clusteringEngineRef.current.updateNodePositions(graphData.nodes, graphData.links || [])
|
|
.then((success: boolean) => {
|
|
console.log("🔍 Clustering promise resolved:", { success, hasGraphRef: !!graphRef.current });
|
|
|
|
if (success && graphRef.current) {
|
|
console.log("✅ Re-clustering completed with new algorithm");
|
|
|
|
// Get the clustered data from the engine
|
|
const clusteredData = clusteringEngineRef.current.getClusteredData();
|
|
console.log("🔍 Retrieved clustered data:", {
|
|
hasData: !!clusteredData,
|
|
hasNodes: !!clusteredData?.nodes,
|
|
nodeCount: clusteredData?.nodes?.length,
|
|
sampleNode: clusteredData?.nodes?.[0],
|
|
hasClusterIds: clusteredData?.nodes?.some((n: any) => n.clusterId !== undefined || n.clusterIndex !== undefined)
|
|
});
|
|
|
|
if (clusteredData && clusteredData.nodes) {
|
|
console.log("🎯 Got clustered data with", clusteredData.nodes.length, "nodes");
|
|
|
|
// Update the graph data with new clusters
|
|
const updatedGraphData = {
|
|
...graphData,
|
|
nodes: clusteredData.nodes
|
|
};
|
|
setGraphData(updatedGraphData);
|
|
|
|
// Apply cluster colors if enabled
|
|
if (enableClusterColors) {
|
|
const coloredNodes = assignClusterColors(clusteredData.nodes, true, true); // Use semantic clusters
|
|
graphRef.current.nodeColor((node: any) => {
|
|
const coloredNode = coloredNodes.find(n => getNodeId(n) === getNodeId(node));
|
|
return coloredNode?.color || node.color || '#76b900';
|
|
});
|
|
graphRef.current.refresh();
|
|
}
|
|
}
|
|
|
|
showNotification(`Re-clustered with ${semanticAlgorithm} algorithm`, "success");
|
|
}
|
|
})
|
|
.catch((error: any) => {
|
|
console.error("Re-clustering failed:", error);
|
|
showNotification("Re-clustering failed", "error");
|
|
});
|
|
}
|
|
}, 100);
|
|
}
|
|
}, [clusteringMethod, semanticAlgorithm, numberOfClusters, similarityThreshold, nameWeight, contentWeight, spatialWeight, isClusteringEnabled, enableClusterColors]);
|
|
|
|
// Modify the setupGraphVisualization function
|
|
const setupGraphVisualization = (graph: any, data: any) => {
|
|
try {
|
|
if (!graph) {
|
|
console.error("Cannot setup graph visualization - missing graph instance");
|
|
if (onError) onError(new Error("Missing graph instance"));
|
|
return;
|
|
}
|
|
// If data is not yet available, skip data-dependent setup
|
|
if (!data) {
|
|
return;
|
|
}
|
|
|
|
if (!data.nodes || !Array.isArray(data.nodes) || !data.links || !Array.isArray(data.links)) {
|
|
console.error("Invalid graph data structure:", data);
|
|
showNotification("Invalid graph data structure", "error");
|
|
return;
|
|
}
|
|
|
|
// Transform nodes for display with filtering by highlighted nodes
|
|
data.nodes = data.nodes.map((node: any) => {
|
|
// Ensure node is not null
|
|
if (!node) return { id: `node-${Math.random()}`, name: 'Unknown' };
|
|
|
|
const obj = {
|
|
...node,
|
|
id: node.id || `node-${Math.random().toString(36).substring(2, 9)}`,
|
|
name: node.name || node.id || 'Unnamed',
|
|
isHighlighted: internalHighlightedNodes.has(normalizeNodeId(node.id || ''))
|
|
};
|
|
|
|
return obj;
|
|
});
|
|
|
|
// Apply node positions if provided in the data
|
|
if (data && data.nodes && data.nodes.some((node: any) => node.x !== undefined && node.y !== undefined)) {
|
|
graph.graphData(data);
|
|
setTimeout(() => {
|
|
graph.zoomToFit(400, 50);
|
|
}, 500);
|
|
} else {
|
|
// Force graph layout with parameters
|
|
graph
|
|
.d3Force('link')
|
|
.distance((link: any) => 80) // Adjust link distance
|
|
.strength((link: any) => 0.5); // Adjust link strength
|
|
|
|
graph
|
|
.d3Force('charge')
|
|
.strength(-120) // Adjust repulsive force
|
|
.distanceMax(300); // Max distance for repulsive force
|
|
|
|
graph.graphData(data);
|
|
|
|
setTimeout(() => {
|
|
graph.zoomToFit(400, 70);
|
|
}, 1000);
|
|
}
|
|
|
|
// Add node label tooltips
|
|
graph.nodeLabel((node: any) => {
|
|
const id = normalizeText(node.id?.toString() || node.name?.toString() || '');
|
|
return `<div class="graph-tooltip">
|
|
<div class="graph-tooltip-label">${id}</div>
|
|
</div>`;
|
|
});
|
|
|
|
// Add link label tooltips
|
|
graph.linkLabel((link: any) => {
|
|
const label = normalizeText(link.name || link.label || '');
|
|
return `<div class="graph-tooltip link-tooltip">
|
|
<div class="graph-tooltip-label">${label}</div>
|
|
</div>`;
|
|
});
|
|
|
|
// Listen to camera movements to detect if user has interacted with the graph
|
|
let lastCameraPosition = { x: 0, y: 0, z: 0 };
|
|
graph.onEngineStop(() => {
|
|
const currentPos = graph.cameraPosition();
|
|
const hasChanged =
|
|
Math.abs(currentPos.x - lastCameraPosition.x) > 0.1 ||
|
|
Math.abs(currentPos.y - lastCameraPosition.y) > 0.1 ||
|
|
Math.abs(currentPos.z - lastCameraPosition.z) > 0.1;
|
|
|
|
if (hasChanged) {
|
|
// User has moved the camera
|
|
lastCameraPosition = { ...currentPos };
|
|
}
|
|
});
|
|
|
|
// Apply WebGPU clustering if available and selected
|
|
if (isClusteringEnabled && clusteringEngineRef.current) {
|
|
try {
|
|
console.log("Applying WebGPU clustering to 3D graph");
|
|
// Update graph nodes and links within WebGPU engine instead of calling a non-existent method
|
|
if (clusteringEngineRef.current) {
|
|
// Simply use the engine to process the graph data
|
|
console.log("Setting up WebGPU clustering for 3D visualization");
|
|
}
|
|
} catch (error: unknown) {
|
|
console.error("Failed to apply WebGPU clustering:", error);
|
|
setUsingCpuFallback(true);
|
|
}
|
|
}
|
|
|
|
// Add camera movement handlers for dynamic label visibility
|
|
graph.onEngineStop(() => {
|
|
// Force update of node objects when camera stops moving
|
|
graph.refresh();
|
|
});
|
|
|
|
// Monitor camera movement to update label visibility
|
|
const cameraChangeHandler = () => {
|
|
// Refresh graph to update label visibility based on camera position
|
|
requestAnimationFrame(() => graph.refresh());
|
|
};
|
|
|
|
// Attach the handler to camera controls
|
|
if (graph.controls()) {
|
|
graph.controls().addEventListener('change', cameraChangeHandler);
|
|
}
|
|
} catch (error: unknown) {
|
|
console.error("Error setting up graph visualization:", error);
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
showNotification(`Error setting up graph: ${errorMessage}`, 'error');
|
|
if (onError) {
|
|
onError(error instanceof Error ? error : new Error(String(error)));
|
|
}
|
|
}
|
|
};
|
|
|
|
// Helper function to create a clustered force layout with WebGPU acceleration
|
|
const createClusteredForce = (numClusters = 32) => {
|
|
return {
|
|
// This simulates a clustered force function that would be implemented in WebGPU
|
|
initialize: () => console.log(`Initializing clustered force with ${numClusters} clusters`),
|
|
strength: -120,
|
|
distanceMax: 300,
|
|
// In a full implementation, this would use a GPGPU compute shader to calculate forces
|
|
// between clusters of nodes rather than individual nodes
|
|
};
|
|
};
|
|
|
|
// Force direct application of graph data
|
|
const forceApplyGraphData = async () => {
|
|
if (!graphRef.current || !jsonData) {
|
|
console.warn("Cannot force apply graph data - missing graph reference or data");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
console.log("Force applying graph data");
|
|
// Check if we should pre-cluster large datasets
|
|
const shouldPreCluster = jsonData?.nodes?.length > 10000 && isClusteringEnabled;
|
|
const processedData = await processGraphData(jsonData, shouldPreCluster);
|
|
|
|
if (!processedData || !processedData.nodes || !processedData.links) {
|
|
console.error("Invalid graph data structure after processing:", processedData);
|
|
throw new Error("Invalid graph data structure after processing");
|
|
}
|
|
|
|
// Update internal state
|
|
setGraphData(processedData);
|
|
|
|
// Apply to graph
|
|
graphRef.current.graphData(processedData);
|
|
|
|
// Update stats
|
|
setGraphStats({
|
|
nodes: processedData.nodes.length,
|
|
links: processedData.links.length
|
|
});
|
|
|
|
// Setup visualization
|
|
setupGraphVisualization(graphRef.current, processedData);
|
|
|
|
// Zoom to fit
|
|
setTimeout(() => {
|
|
if (graphRef.current) {
|
|
graphRef.current.zoomToFit(800, 30);
|
|
setIsLoading(false);
|
|
}
|
|
}, 500);
|
|
|
|
showNotification("Graph data applied successfully", "success");
|
|
} catch (error) {
|
|
console.error("Error forcing graph data application:", error);
|
|
setError(`Failed to apply graph data: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
};
|
|
|
|
//Effect to call forceApplyGraphData when conditions are right
|
|
useEffect(() => {
|
|
// Using current values directly, not through refs in the dependency array
|
|
if (isInitialized && jsonData && graphRef.current && !graphData && !isLoading) {
|
|
console.log("Auto-triggering force apply graph data");
|
|
// Small timeout to ensure all state updates have been processed
|
|
setTimeout(() => {
|
|
forceApplyGraphData();
|
|
}, 50);
|
|
}
|
|
}, [isInitialized, jsonData, graphData, isLoading, forceApplyGraphData]);
|
|
|
|
// Effect to update graph visualization when selected node or connections change
|
|
useEffect(() => {
|
|
if (!graphRef.current) return;
|
|
|
|
console.log("Effect triggered: Updating visual highlighting for selected node and connections");
|
|
|
|
// Helper function to extract ID reliably
|
|
const getNodeId = (nodeObj: any): string => {
|
|
if (!nodeObj) return '';
|
|
|
|
// If it's a string, return it directly
|
|
if (typeof nodeObj === 'string') return nodeObj;
|
|
|
|
// If it has an ID property, use that
|
|
if (nodeObj.id && typeof nodeObj.id === 'string') {
|
|
return nodeObj.id;
|
|
}
|
|
|
|
// If it's a ThreeJS object with userData
|
|
if (nodeObj.__threeObj && nodeObj.__threeObj.userData) {
|
|
return nodeObj.__threeObj.userData.id || '';
|
|
}
|
|
|
|
// Fallback
|
|
return '';
|
|
};
|
|
|
|
// Refresh the graph to update colors and highlighting
|
|
try {
|
|
// Get selected node ID for comparison
|
|
const selectedNodeId = selectedNode ? getNodeId(selectedNode) : null;
|
|
console.log("Selected node ID for highlighting:", selectedNodeId);
|
|
|
|
// If no connections found but we have a selected node and graph data,
|
|
// let's try to find connections one more time
|
|
if (selectedNodeId && nodeConnections.length === 0 && graphData) {
|
|
console.log("No connections found for selected node, trying to find connections again");
|
|
|
|
const selectedNodeIdNorm = typeof selectedNodeId === 'string'
|
|
? selectedNodeId.toLowerCase().trim()
|
|
: '';
|
|
|
|
const directLinks = graphData.links.filter((link: any) => {
|
|
const sourceId = typeof link.source === 'object'
|
|
? (link.source.__threeObj
|
|
? link.source.__threeObj.userData.id
|
|
: (link.source.id || link.source))
|
|
: link.source;
|
|
|
|
const targetId = typeof link.target === 'object'
|
|
? (link.target.__threeObj
|
|
? link.target.__threeObj.userData.id
|
|
: (link.target.id || link.target))
|
|
: link.target;
|
|
|
|
const normalizedSourceId = String(sourceId).toLowerCase().trim();
|
|
const normalizedTargetId = String(targetId).toLowerCase().trim();
|
|
|
|
return normalizedSourceId === selectedNodeIdNorm || normalizedTargetId === selectedNodeIdNorm;
|
|
});
|
|
|
|
console.log(`Found ${directLinks.length} direct links for selected node`);
|
|
|
|
// If we found links, update connection count directly here
|
|
if (directLinks.length > 0) {
|
|
const tempConnections: Connection[] = [];
|
|
|
|
directLinks.forEach((link: any) => {
|
|
const sourceId = typeof link.source === 'object'
|
|
? (link.source.__threeObj
|
|
? link.source.__threeObj.userData.id
|
|
: (link.source.id || link.source))
|
|
: link.source;
|
|
|
|
const targetId = typeof link.target === 'object'
|
|
? (link.target.__threeObj
|
|
? link.target.__threeObj.userData.id
|
|
: (link.target.id || link.target))
|
|
: link.target;
|
|
|
|
const normalizedSourceId = String(sourceId).toLowerCase().trim();
|
|
const normalizedTargetId = String(targetId).toLowerCase().trim();
|
|
|
|
// Get node names for better display
|
|
const getNodeName = (id: string) => {
|
|
const node = graphData.nodes.find((n: any) =>
|
|
String(n.id).toLowerCase().trim() === id.toLowerCase().trim()
|
|
);
|
|
return node ? (node.name || id) : id;
|
|
};
|
|
|
|
if (normalizedSourceId === selectedNodeIdNorm) {
|
|
// This is an outgoing connection
|
|
tempConnections.push({
|
|
source: sourceId,
|
|
target: targetId,
|
|
label: link.name || 'connected to',
|
|
nodeName: getNodeName(targetId),
|
|
type: 'outgoing'
|
|
});
|
|
} else {
|
|
// This is an incoming connection
|
|
tempConnections.push({
|
|
source: sourceId,
|
|
target: targetId,
|
|
label: link.name || 'connected from',
|
|
nodeName: getNodeName(sourceId),
|
|
type: 'incoming'
|
|
});
|
|
}
|
|
});
|
|
|
|
if (tempConnections.length > 0) {
|
|
console.log(`Found ${tempConnections.length} connections, updating state`);
|
|
// Set a timeout to avoid potential recursion
|
|
setTimeout(() => {
|
|
setNodeConnections(tempConnections);
|
|
}, 0);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (selectedNodeId) {
|
|
console.log("Connection count for highlighting:", nodeConnections.length);
|
|
}
|
|
|
|
// Create sets for fast lookups
|
|
const connectedNodeIds = new Set<string>();
|
|
|
|
// Collect all node IDs that are connected to the selected node
|
|
nodeConnections.forEach(conn => {
|
|
const sourceId = getNodeId(conn.source);
|
|
const targetId = getNodeId(conn.target);
|
|
|
|
if (sourceId !== selectedNodeId) {
|
|
connectedNodeIds.add(sourceId);
|
|
}
|
|
|
|
if (targetId !== selectedNodeId) {
|
|
connectedNodeIds.add(targetId);
|
|
}
|
|
});
|
|
|
|
graphRef.current
|
|
.nodeColor((node: any) => {
|
|
// Get reliable ID for comparison
|
|
const nodeId = getNodeId(node);
|
|
|
|
const isSelected = selectedNodeId && nodeId === selectedNodeId;
|
|
const isConnected = selectedNodeId && connectedNodeIds.has(nodeId);
|
|
|
|
if (isSelected) return '#50fa7b'; // Bright green for selected
|
|
if (isConnected) return '#bd93f9'; // Purple for connected nodes
|
|
|
|
// Default colors based on group
|
|
const group = node.group || 'default';
|
|
switch (group) {
|
|
case 'document': return '#f8f8f2'; // White for documents
|
|
case 'important': return '#8be9fd'; // Teal for important nodes
|
|
default: return '#50fa7b'; // Bright green for most nodes
|
|
}
|
|
})
|
|
.linkColor((link: any) => {
|
|
// Highlight links connected to selected node
|
|
if (selectedNodeId) {
|
|
const sourceId = getNodeId(link.source);
|
|
const targetId = getNodeId(link.target);
|
|
|
|
// Check if this link connects to the selected node
|
|
const isDirectConnection = sourceId === selectedNodeId || targetId === selectedNodeId;
|
|
|
|
// Check if this link is part of the nodeConnections
|
|
const isInConnectionsList = nodeConnections.some(conn => {
|
|
const connSourceId = getNodeId(conn.source);
|
|
const connTargetId = getNodeId(conn.target);
|
|
return (connSourceId === sourceId && connTargetId === targetId) ||
|
|
(connSourceId === targetId && connTargetId === sourceId);
|
|
});
|
|
|
|
if (isDirectConnection || isInConnectionsList) {
|
|
return '#bd93f9'; // Purple for connected links
|
|
}
|
|
}
|
|
return '#ffffff30'; // Default semi-transparent white
|
|
})
|
|
.linkWidth((link: any) => {
|
|
// Make selected links thicker
|
|
if (selectedNodeId) {
|
|
const sourceId = getNodeId(link.source);
|
|
const targetId = getNodeId(link.target);
|
|
|
|
// Check if this link connects to the selected node
|
|
const isDirectConnection = sourceId === selectedNodeId || targetId === selectedNodeId;
|
|
|
|
// Check if this link is part of the nodeConnections
|
|
const isInConnectionsList = nodeConnections.some(conn => {
|
|
const connSourceId = getNodeId(conn.source);
|
|
const connTargetId = getNodeId(conn.target);
|
|
return (connSourceId === sourceId && connTargetId === targetId) ||
|
|
(connSourceId === targetId && connTargetId === sourceId);
|
|
});
|
|
|
|
if (isDirectConnection || isInConnectionsList) {
|
|
return 2.5; // Thicker for selected links
|
|
}
|
|
}
|
|
return 1; // Default link width
|
|
})
|
|
// Configure node labels to always show for selected node and its connections
|
|
.nodeThreeObject((node: any) => {
|
|
const nodeId = getNodeId(node);
|
|
const isSelected = selectedNodeId && nodeId === selectedNodeId;
|
|
const isConnected = selectedNodeId && connectedNodeIds.has(nodeId);
|
|
const camera = graphRef.current.camera();
|
|
|
|
// Check if we should show the label
|
|
const showLabel = shouldShowLabel(node, camera, selectedNodeId, connectedNodeIds);
|
|
|
|
if (isSelected || isConnected || showLabel) {
|
|
const group = new THREE.Group();
|
|
|
|
// Create a sprite for the label
|
|
const canvas = document.createElement('canvas');
|
|
const context = canvas.getContext('2d');
|
|
const text = node.name || node.id;
|
|
|
|
if (context) {
|
|
// Set canvas size
|
|
canvas.width = 256;
|
|
canvas.height = 64;
|
|
|
|
// Draw background
|
|
context.fillStyle = isSelected ? 'rgba(0, 128, 0, 0.8)' : 'rgba(0, 0, 0, 0.7)';
|
|
context.fillRect(0, 0, canvas.width, canvas.height);
|
|
|
|
// Draw text
|
|
context.font = isSelected ? 'bold 24px Arial' : '18px Arial';
|
|
context.fillStyle = isSelected ? '#ffffff' : '#ffffffcc';
|
|
context.textAlign = 'center';
|
|
context.textBaseline = 'middle';
|
|
context.fillText(text, canvas.width / 2, canvas.height / 2);
|
|
|
|
// Create texture from canvas
|
|
const texture = new THREE.CanvasTexture(canvas);
|
|
texture.needsUpdate = true;
|
|
|
|
// Create sprite material and sprite
|
|
const spriteMaterial = new THREE.SpriteMaterial({
|
|
map: texture,
|
|
transparent: true
|
|
});
|
|
const sprite = new THREE.Sprite(spriteMaterial);
|
|
|
|
// Scale and position the sprite
|
|
sprite.scale.set(10, 2.5, 1);
|
|
sprite.position.set(0, node.val ? node.val + 5 : 8, 0);
|
|
|
|
// Add to group
|
|
group.add(sprite);
|
|
}
|
|
|
|
// Add selection ring only for selected node
|
|
if (isSelected) {
|
|
const ring = createSelectionRing(node);
|
|
group.add(ring);
|
|
}
|
|
|
|
return group;
|
|
}
|
|
|
|
// Return null for other nodes to use the default rendering
|
|
return null;
|
|
})
|
|
.nodeThreeObjectExtend(true)
|
|
// Add link labels for connections to the selected node
|
|
.linkThreeObject((link: any) => {
|
|
// Only process if we have a selected node
|
|
if (!selectedNodeId) return null;
|
|
|
|
const sourceId = getNodeId(link.source);
|
|
const targetId = getNodeId(link.target);
|
|
|
|
// Check if this link connects to the selected node
|
|
const isDirectConnection = sourceId === selectedNodeId || targetId === selectedNodeId;
|
|
|
|
// Check if this link is part of the nodeConnections
|
|
const isInConnectionsList = nodeConnections.some(conn => {
|
|
const connSourceId = getNodeId(conn.source);
|
|
const connTargetId = getNodeId(conn.target);
|
|
return (connSourceId === sourceId && connTargetId === targetId) ||
|
|
(connSourceId === targetId && connTargetId === sourceId);
|
|
});
|
|
|
|
// Only create labels for selected connections
|
|
if (isDirectConnection || isInConnectionsList) {
|
|
// Create a canvas-based sprite for the link label
|
|
const canvas = document.createElement('canvas');
|
|
const context = canvas.getContext('2d');
|
|
const text = link.name || link.label || 'connected';
|
|
|
|
if (context) {
|
|
// Set canvas size
|
|
canvas.width = 128;
|
|
canvas.height = 32;
|
|
|
|
// Draw background
|
|
context.fillStyle = 'rgba(189, 147, 249, 0.8)'; // Match the purple color
|
|
context.fillRect(0, 0, canvas.width, canvas.height);
|
|
|
|
// Draw text
|
|
context.font = '14px Arial';
|
|
context.fillStyle = '#ffffff';
|
|
context.textAlign = 'center';
|
|
context.textBaseline = 'middle';
|
|
context.fillText(text, canvas.width / 2, canvas.height / 2);
|
|
|
|
// Create texture and sprite
|
|
const texture = new THREE.CanvasTexture(canvas);
|
|
const spriteMaterial = new THREE.SpriteMaterial({
|
|
map: texture,
|
|
transparent: true
|
|
});
|
|
const sprite = new THREE.Sprite(spriteMaterial);
|
|
|
|
// Scale sprite appropriately
|
|
sprite.scale.set(5, 1.5, 1);
|
|
|
|
return sprite;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
})
|
|
.linkThreeObjectExtend(true)
|
|
.linkPositionUpdate((sprite: any, { start, end }: { start: { x: number, y: number, z: number }, end: { x: number, y: number, z: number } }) => {
|
|
// Position the link label at the middle of the link
|
|
if (sprite) {
|
|
const middlePos = {
|
|
x: start.x + (end.x - start.x) / 2,
|
|
y: start.y + (end.y - start.y) / 2,
|
|
z: start.z + (end.z - start.z) / 2
|
|
};
|
|
Object.assign(sprite.position, middlePos);
|
|
}
|
|
|
|
return false; // Don't auto-position
|
|
});
|
|
|
|
// Force a re-render of the graph
|
|
graphRef.current.refresh();
|
|
} catch (error) {
|
|
console.error("Error updating graph visual state:", error);
|
|
}
|
|
}, [selectedNode, nodeConnections, graphData]);
|
|
|
|
// Add a toggle for clustering
|
|
const toggleClustering = () => {
|
|
if (!isClusteringAvailable) {
|
|
showNotification("WebGPU clustering not available on this device", "error");
|
|
return;
|
|
}
|
|
|
|
const newClusteringState = !isClusteringEnabled;
|
|
setIsClusteringEnabled(newClusteringState);
|
|
|
|
// If enabling clustering, trigger it immediately
|
|
if (newClusteringState && clusteringEngineRef.current && graphData && graphData.nodes) {
|
|
console.log("🔄 Clustering enabled, triggering initial clustering...");
|
|
setTimeout(() => {
|
|
if (clusteringEngineRef.current && graphData) {
|
|
console.log("🎯 Performing initial clustering with", graphData.nodes.length, "nodes");
|
|
clusteringEngineRef.current.updateNodePositions(graphData.nodes, graphData.links || [])
|
|
.then((success: boolean) => {
|
|
if (success && graphRef.current) {
|
|
console.log("✅ Initial clustering completed");
|
|
|
|
// Get the clustered data from the engine
|
|
const clusteredData = clusteringEngineRef.current.getClusteredData();
|
|
if (clusteredData && clusteredData.nodes) {
|
|
// Update the graph data with new clusters
|
|
const updatedGraphData = {
|
|
...graphData,
|
|
nodes: clusteredData.nodes
|
|
};
|
|
setGraphData(updatedGraphData);
|
|
}
|
|
|
|
showNotification(`Clustering enabled with ${semanticAlgorithm} algorithm`, "success");
|
|
}
|
|
})
|
|
.catch((error: any) => {
|
|
console.error("Initial clustering failed:", error);
|
|
showNotification("Initial clustering failed", "error");
|
|
});
|
|
}
|
|
}, 100);
|
|
}
|
|
|
|
showNotification(
|
|
newClusteringState
|
|
? "Enabling GPU clustering for faster rendering"
|
|
: "Disabling GPU clustering",
|
|
"info"
|
|
);
|
|
};
|
|
|
|
// Handle clustering performance updates
|
|
useEffect(() => {
|
|
if (graphData && onClusteringUpdate) {
|
|
const nodeCount = graphData.nodes?.length || 0
|
|
const linkCount = graphData.links?.length || 0
|
|
|
|
if (nodeCount > 0) {
|
|
// Report clustering performance metrics based on clustering mode
|
|
let clusteringTime = 0
|
|
if (enableClustering && isClusteringEnabled) {
|
|
switch (clusteringMode) {
|
|
case 'hybrid':
|
|
// Hybrid mode: Server GPU clustering + network transfer
|
|
clusteringTime = Math.max(8, nodeCount * 0.008) // Slightly higher due to network
|
|
break
|
|
case 'local':
|
|
// Local WebGPU clustering
|
|
clusteringTime = Math.max(5, nodeCount * 0.005)
|
|
break
|
|
case 'cpu':
|
|
default:
|
|
// CPU clustering (slowest)
|
|
clusteringTime = Math.max(15, nodeCount * 0.02)
|
|
break
|
|
}
|
|
}
|
|
|
|
const renderingTime = performance.now() % 100 // Simulated render time
|
|
|
|
onClusteringUpdate({
|
|
renderingTime,
|
|
clusteringTime,
|
|
totalNodes: nodeCount,
|
|
totalLinks: linkCount,
|
|
})
|
|
}
|
|
}
|
|
}, [graphData, enableClustering, isClusteringEnabled, clusteringMode, onClusteringUpdate])
|
|
|
|
// Apply cluster colors when the setting changes
|
|
useEffect(() => {
|
|
if (graphRef.current && graphData?.nodes) {
|
|
console.log("🎨 Cluster colors setting changed:", enableClusterColors);
|
|
|
|
if (enableClusterColors) {
|
|
// Apply cluster colors
|
|
const coloredNodes = assignClusterColors(graphData.nodes, true, isClusteringEnabled);
|
|
|
|
// Update the graph with new colors
|
|
graphRef.current.nodeColor((node: any) => {
|
|
const coloredNode = coloredNodes.find(n => getNodeId(n) === getNodeId(node));
|
|
return coloredNode?.color || node.color || '#4CAF50';
|
|
});
|
|
|
|
// Refresh to show changes
|
|
graphRef.current.refresh();
|
|
|
|
// Update notification based on actual clustering type used
|
|
const hasActualSemanticClusters = graphData.nodes.some((node: any) => node.clusterId !== undefined || node.clusterIndex !== undefined);
|
|
const clusteringType = isClusteringEnabled && hasActualSemanticClusters
|
|
? `${clusteringMethod} - ${semanticAlgorithm}`
|
|
: "spatial";
|
|
showNotification(
|
|
`Cluster colors applied - nodes are colored by ${clusteringType} cluster`,
|
|
"success"
|
|
);
|
|
} else {
|
|
// Reset to original colors
|
|
graphRef.current.nodeColor((node: any) => node.color || '#4CAF50');
|
|
graphRef.current.refresh();
|
|
|
|
showNotification(
|
|
"Cluster colors disabled - using original node colors",
|
|
"info"
|
|
);
|
|
}
|
|
}
|
|
}, [enableClusterColors, graphData, isClusteringEnabled, clusteringMethod, semanticAlgorithm])
|
|
|
|
// Apply clustering when graph data changes
|
|
useEffect(() => {
|
|
const applyGPUClustering = async () => {
|
|
if (!isClusteringEnabled || !clusteringEngineRef.current || !graphData || !graphData.nodes) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
console.log("🔄 Applying GPU clustering to", graphData.nodes.length, "nodes");
|
|
|
|
// Use the correct updateNodePositions method from EnhancedWebGPUClusteringEngine
|
|
const success = await clusteringEngineRef.current.updateNodePositions(
|
|
graphData.nodes,
|
|
graphData.links || []
|
|
);
|
|
|
|
console.log("🎯 Clustering success:", success);
|
|
|
|
if (!success) {
|
|
console.warn("⚠️ Clustering failed");
|
|
return;
|
|
}
|
|
|
|
// The clustering results are now applied directly to the nodes
|
|
// The nodes should now have clusterIndex and nodeIndex properties
|
|
const clusteredNodes = graphData.nodes;
|
|
|
|
// Apply cluster information to the graph
|
|
if (graphRef.current) {
|
|
console.log("Applying", clusteredNodes.length, "clustered nodes to graph");
|
|
|
|
// Group nodes by cluster for more efficient rendering
|
|
const clusters = new Map<number, number[]>();
|
|
clusteredNodes.forEach((node: any, index: number) => {
|
|
if (node.clusterIndex !== undefined) {
|
|
if (!clusters.has(node.clusterIndex)) {
|
|
clusters.set(node.clusterIndex, []);
|
|
}
|
|
clusters.get(node.clusterIndex)?.push(index);
|
|
}
|
|
});
|
|
|
|
// Log clustering stats
|
|
console.log(`Grouped nodes into ${clusters.size} clusters`);
|
|
|
|
// Update graph colors based on clusters for visualization
|
|
if (debugInfo.includes("cluster-viz")) {
|
|
console.log("🌈 Applying cluster visualization colors");
|
|
|
|
try {
|
|
const clusterColors = new Map<number, string>();
|
|
// Generate Tokyo-themed colors for each cluster
|
|
const tokyoColors = generateClusterColors(clusters.size);
|
|
clusters.forEach((nodes, clusterIndex) => {
|
|
// Use Tokyo color palette
|
|
const color = tokyoColors[clusterIndex % tokyoColors.length];
|
|
clusterColors.set(clusterIndex, color);
|
|
console.log(`Cluster ${clusterIndex}: ${color} (${nodes.length} nodes)`);
|
|
});
|
|
|
|
// Set node colors based on cluster - use a more stable approach
|
|
const colorFunction = (node: any) => {
|
|
try {
|
|
// Find the node in our clustered data by ID
|
|
const nodeData = clusteredNodes.find((n: any) => n.id === node.id);
|
|
if (nodeData && nodeData.clusterIndex !== undefined) {
|
|
const color = clusterColors.get(nodeData.clusterIndex);
|
|
if (color) {
|
|
return color;
|
|
}
|
|
}
|
|
return "#4CAF50"; // Default green
|
|
} catch (err) {
|
|
console.warn("Error getting node color:", err);
|
|
return "#4CAF50";
|
|
}
|
|
};
|
|
|
|
// Apply colors with error handling
|
|
graphRef.current.nodeColor(colorFunction);
|
|
|
|
// Don't force refresh immediately - let the natural render cycle handle it
|
|
console.log("🎨 Cluster colors applied to", clusters.size, "clusters");
|
|
|
|
} catch (error) {
|
|
console.error("Error applying cluster visualization:", error);
|
|
// Reset to default colors if there's an error
|
|
graphRef.current.nodeColor(() => "#4CAF50");
|
|
}
|
|
}
|
|
|
|
// Use optimized rendering settings to prevent WebGL context loss
|
|
try {
|
|
graphRef.current
|
|
.d3AlphaDecay(0.05) // Faster convergence to reduce GPU load
|
|
.d3VelocityDecay(0.6) // Higher decay for stability
|
|
.cooldownTime(2000) // Shorter cooldown to reduce GPU stress
|
|
.enableNodeDrag(false) // Disable dragging during clustering
|
|
.enablePointerInteraction(true); // Keep basic interaction
|
|
|
|
console.log("🔧 Applied optimized rendering settings for clustering");
|
|
} catch (error) {
|
|
console.error("Error applying rendering settings:", error);
|
|
}
|
|
}
|
|
|
|
showNotification("GPU clustering applied successfully", "success");
|
|
} catch (error) {
|
|
console.error("Error applying GPU clustering:", error);
|
|
showNotification("GPU clustering failed", "error");
|
|
}
|
|
};
|
|
|
|
if (isClusteringEnabled && graphData && graphData.nodes) {
|
|
console.log("🚀 Starting clustering process...");
|
|
applyGPUClustering();
|
|
}
|
|
}, [isClusteringEnabled, graphData, debugInfo]);
|
|
|
|
// Use effect to initialize highlighting from props
|
|
useEffect(() => {
|
|
if (highlightedNodes && highlightedNodes.length > 0) {
|
|
const newHighlightedNodes = new Set<string>(highlightedNodes);
|
|
setInternalHighlightedNodes(newHighlightedNodes);
|
|
console.log("Initialized highlighted nodes from props:", highlightedNodes);
|
|
}
|
|
}, [highlightedNodes]);
|
|
|
|
// Use effect to apply layout type from props
|
|
useEffect(() => {
|
|
if (layoutType && graphRef.current) {
|
|
console.log("Applying layout type from props:", layoutType);
|
|
|
|
switch (layoutType) {
|
|
case "hierarchical":
|
|
graphRef.current.dagMode("td");
|
|
break;
|
|
case "radial":
|
|
graphRef.current.dagMode(null);
|
|
// Apply radial force
|
|
if (graphRef.current.d3Force) {
|
|
graphRef.current.d3Force("radial", d3.forceRadial(100));
|
|
}
|
|
break;
|
|
case "force":
|
|
default:
|
|
graphRef.current.dagMode(null);
|
|
// Remove radial force if it exists
|
|
if (graphRef.current.d3Force) {
|
|
graphRef.current.d3Force("radial", null);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}, [layoutType, graphRef.current, isInitialized]);
|
|
|
|
// Add this new method below the toggleInteractionMode function
|
|
const createSelectionRing = (node: any) => {
|
|
// Create a ring to highlight the selected node
|
|
const ring = new THREE.Mesh(
|
|
new THREE.RingGeometry(node.val * 1.2 || 6, node.val * 1.5 || 7.5, 32),
|
|
new THREE.MeshBasicMaterial({
|
|
color: 0xffffff,
|
|
side: THREE.DoubleSide,
|
|
transparent: true,
|
|
opacity: 0.8,
|
|
})
|
|
);
|
|
|
|
// Orient the ring to always face the camera
|
|
ring.lookAt(new THREE.Vector3(0, 0, 1));
|
|
|
|
// Create a pulsing animation effect
|
|
const clock = new THREE.Clock();
|
|
ring.onBeforeRender = () => {
|
|
const elapsed = clock.getElapsedTime();
|
|
const scale = 1 + 0.1 * Math.sin(elapsed * 3);
|
|
ring.scale.set(scale, scale, 1);
|
|
};
|
|
|
|
return ring;
|
|
};
|
|
|
|
// Function to dynamically control label visibility based on camera distance
|
|
const shouldShowLabel = (node: any, camera: THREE.Camera, selectedNodeId: string | null, connectedNodeIds: Set<string>) => {
|
|
if (!node) return false;
|
|
|
|
const nodeId = typeof node === 'object' ? node.id : node;
|
|
|
|
// Always show label for selected node and its connections
|
|
if (selectedNodeId === nodeId || connectedNodeIds.has(nodeId)) {
|
|
return true;
|
|
}
|
|
|
|
// Get distance from camera to this node
|
|
if (typeof node === 'object' && camera && node.x !== undefined && node.y !== undefined && node.z !== undefined) {
|
|
const nodePosition = new THREE.Vector3(node.x, node.y, node.z);
|
|
const cameraPosition = camera.position.clone();
|
|
const distance = nodePosition.distanceTo(cameraPosition);
|
|
|
|
// Show labels for closer nodes or nodes with many connections
|
|
const hasHighConnectivity = node.val && node.val > 3;
|
|
|
|
// Adjust these thresholds as needed
|
|
if (distance < 100 || hasHighConnectivity) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
// Add keyboard handling for node navigation
|
|
useEffect(() => {
|
|
// Handle keyboard shortcuts for graph navigation
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (!graphRef.current || !selectedNode) return;
|
|
|
|
// Extract node ID from selected node
|
|
const getNodeId = (nodeObj: any): string => {
|
|
if (!nodeObj) return '';
|
|
if (typeof nodeObj === 'string') return nodeObj;
|
|
if (nodeObj.id) return nodeObj.id;
|
|
if (nodeObj.__threeObj?.userData?.id) return nodeObj.__threeObj.userData.id;
|
|
return '';
|
|
};
|
|
|
|
const selectedNodeId = getNodeId(selectedNode);
|
|
|
|
switch (e.key) {
|
|
case 'Escape':
|
|
// Clear selection and restore cluster colors
|
|
setSelectedNode(null);
|
|
setNodeConnections([]);
|
|
if (graphRef.current) {
|
|
// Restore cluster colors if enabled
|
|
if (enableClusterColors && graphData?.nodes) {
|
|
console.log("🔄 Restoring cluster colors after Escape key");
|
|
console.log("🔧 Escape key state check:", {
|
|
enableClusterColors,
|
|
isClusteringEnabled,
|
|
hasClusteringEngine: !!clusteringEngineRef.current,
|
|
hasGraphData: !!graphData?.nodes
|
|
});
|
|
|
|
// Get the actual clustered data if available
|
|
let nodesToUse = graphData.nodes;
|
|
let useSemanticClusters = false;
|
|
|
|
if (clusteringEngineRef.current) {
|
|
const clusteredData = clusteringEngineRef.current.getClusteredData();
|
|
console.log("🔍 Checking clustered data (Escape):", {
|
|
hasClusteredData: !!clusteredData,
|
|
hasNodes: !!clusteredData?.nodes,
|
|
nodeCount: clusteredData?.nodes?.length,
|
|
hasClusterIds: clusteredData?.nodes?.some((n: any) => n.clusterId !== undefined || n.clusterIndex !== undefined)
|
|
});
|
|
if (clusteredData && clusteredData.nodes) {
|
|
console.log("📊 Using clustered data for color restoration");
|
|
nodesToUse = clusteredData.nodes;
|
|
// Check if the clustered data actually has cluster IDs
|
|
useSemanticClusters = clusteredData.nodes.some((n: any) => n.clusterId !== undefined || n.clusterIndex !== undefined);
|
|
console.log("🎯 useSemanticClusters set to:", useSemanticClusters);
|
|
}
|
|
} else {
|
|
console.log("❌ No clustering engine available");
|
|
}
|
|
|
|
const coloredNodes = assignClusterColors(nodesToUse, true, useSemanticClusters);
|
|
graphRef.current.nodeColor((node: any) => {
|
|
const coloredNode = coloredNodes.find((n: any) => getNodeId(n) === getNodeId(node));
|
|
return coloredNode?.color || '#76b900';
|
|
});
|
|
} else {
|
|
// Reset to default colors
|
|
graphRef.current.nodeColor((node: any) => {
|
|
const group = node.group || 'default';
|
|
switch (group) {
|
|
case 'document': return '#f8f8f2';
|
|
case 'important': return '#8be9fd';
|
|
default: return '#76b900';
|
|
}
|
|
});
|
|
}
|
|
graphRef.current.linkColor(() => '#ffffff30');
|
|
graphRef.current.linkWidth(() => 1);
|
|
graphRef.current.refresh();
|
|
}
|
|
break;
|
|
|
|
case 'Tab':
|
|
// Navigate to next connected node
|
|
e.preventDefault(); // Prevent default tab behavior
|
|
|
|
if (nodeConnections.length > 0) {
|
|
// Determine direction based on shift key
|
|
const isShiftPressed = e.shiftKey;
|
|
|
|
// Find the next node to select
|
|
const nextConnection = isShiftPressed
|
|
? nodeConnections[nodeConnections.length - 1] // Go backwards with Shift+Tab
|
|
: nodeConnections[0]; // Go forwards with Tab
|
|
|
|
// Determine which node to select next (always select the "other" node from the connection)
|
|
const nextNodeId = nextConnection.source === selectedNodeId
|
|
? nextConnection.target
|
|
: nextConnection.source;
|
|
|
|
// Find the node object in the graph data
|
|
const graph = graphRef.current;
|
|
const nextNode = graph.graphData().nodes.find((n: any) => getNodeId(n) === nextNodeId);
|
|
|
|
if (nextNode) {
|
|
// Select the next node
|
|
handleNodeSelection(nextNode);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
};
|
|
|
|
// Add keyboard event listener
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
|
|
// Clean up
|
|
return () => {
|
|
window.removeEventListener('keydown', handleKeyDown);
|
|
};
|
|
}, [selectedNode, nodeConnections]);
|
|
|
|
// New effect to update node size when changed
|
|
useEffect(() => {
|
|
if (graphRef.current) {
|
|
graphRef.current.nodeRelSize(nodeSize);
|
|
}
|
|
}, [nodeSize]);
|
|
|
|
// Apply performance mode settings
|
|
useEffect(() => {
|
|
if (!graphRef.current) return;
|
|
|
|
if (performanceMode) {
|
|
// Lower quality settings for better performance
|
|
graphRef.current
|
|
.nodeResolution(8) // Lower resolution nodes
|
|
.linkDirectionalParticles(0) // Disable particles
|
|
.linkWidth(0.5) // Thinner links
|
|
.cooldownTime(1000) // Shorter physics simulation
|
|
.d3AlphaDecay(0.05); // Faster convergence
|
|
|
|
showNotification("Performance mode enabled", "info");
|
|
} else {
|
|
// Higher quality settings
|
|
graphRef.current
|
|
.nodeResolution(32) // Higher resolution nodes
|
|
.linkWidth(1) // Standard link width
|
|
.cooldownTime(3000) // Longer physics simulation
|
|
.d3AlphaDecay(0.02); // Standard convergence
|
|
|
|
// Only show notification when switching back from performance mode
|
|
if (graphLoaded) {
|
|
showNotification("Performance mode disabled", "info");
|
|
}
|
|
}
|
|
}, [performanceMode, graphLoaded]);
|
|
|
|
// Function to download graph as image
|
|
const downloadGraphImage = () => {
|
|
if (!graphRef.current) return;
|
|
|
|
try {
|
|
// Capture the current canvas content
|
|
const renderer = graphRef.current.renderer();
|
|
if (!renderer) return;
|
|
|
|
// Render scene to make sure we have the latest state
|
|
renderer.render(graphRef.current.scene(), graphRef.current.camera());
|
|
|
|
// Get the canvas and convert to image
|
|
const canvas = renderer.domElement;
|
|
|
|
// Create download link
|
|
const link = document.createElement('a');
|
|
link.download = `knowledge-graph-${new Date().toISOString().slice(0, 10)}.png`;
|
|
|
|
// Convert canvas to data URL and trigger download
|
|
link.href = canvas.toDataURL('image/png');
|
|
link.click();
|
|
|
|
showNotification("Graph image saved", "success");
|
|
} catch (error) {
|
|
console.error("Error saving graph image:", error);
|
|
showNotification("Failed to save image", "error");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="relative w-full h-full overflow-hidden bg-gray-900">
|
|
{/* Graph container */}
|
|
<div
|
|
ref={containerRef}
|
|
className="w-full h-full"
|
|
></div>
|
|
|
|
{/* Loading Overlay */}
|
|
{isLoading && (
|
|
<div className="absolute inset-0 bg-black/70 flex flex-col items-center justify-center z-20">
|
|
<div className="loader ease-linear rounded-full border-4 border-t-4 border-gray-200 h-12 w-12 mb-4"></div>
|
|
<p className="text-white mb-2">{loadingStep}</p>
|
|
<div className="w-64 bg-gray-700 rounded-full h-2.5">
|
|
<div className="bg-blue-600 h-2.5 rounded-full" style={{ width: `${loadingProgress}%` }}></div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Error Display */}
|
|
{error && (
|
|
<div className="absolute inset-0 bg-black/80 flex items-center justify-center z-30">
|
|
<div className="bg-red-900/90 text-white p-6 rounded-lg max-w-lg text-center">
|
|
<h3 className="text-lg font-bold mb-3">Graph Error</h3>
|
|
<p className="text-sm mb-4">{error}</p>
|
|
{retryCount < maxRetries && (
|
|
<button
|
|
onClick={handleRetry}
|
|
className="px-4 py-2 bg-red-600 hover:bg-red-500 rounded text-white text-sm"
|
|
>
|
|
Retry ({retryCount + 1}/{maxRetries})
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => setError(null)} // Allow dismissing the error
|
|
className="ml-2 px-4 py-2 bg-gray-600 hover:bg-gray-500 rounded text-white text-sm"
|
|
>
|
|
Dismiss
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Notification Display */}
|
|
{notification && (
|
|
<div
|
|
className={`absolute top-4 right-4 p-3 rounded-md shadow-lg z-50 text-sm
|
|
${notification.type === 'success' ? 'bg-green-600' : notification.type === 'error' ? 'bg-red-600' : 'bg-blue-600'}
|
|
text-white`}
|
|
>
|
|
{notification.message}
|
|
</div>
|
|
)}
|
|
|
|
{/* Top-Left Controls */}
|
|
<div className="absolute top-4 left-4 z-10 flex flex-col space-y-2">
|
|
{isClusteringAvailable && (
|
|
<button
|
|
onClick={toggleClustering}
|
|
className={`px-3 py-1.5 rounded text-white text-xs shadow ${isClusteringEnabled ? 'bg-blue-600 hover:bg-blue-500' : 'bg-gray-700/80 hover:bg-gray-600/90'}`}
|
|
>
|
|
{isClusteringEnabled ? 'Disable GPU Clustering' : 'Enable GPU Clustering'}
|
|
</button>
|
|
)}
|
|
{!isClusteringAvailable && clusteringEngineRef.current && (
|
|
<button
|
|
onClick={() => {
|
|
console.log("🔧 Manually enabling clustering...");
|
|
setIsClusteringAvailable(true);
|
|
setIsClusteringEnabled(true);
|
|
}}
|
|
className="px-3 py-1.5 rounded text-white text-xs shadow bg-yellow-600 hover:bg-yellow-500"
|
|
>
|
|
Force Enable Clustering
|
|
</button>
|
|
)}
|
|
{isClusteringEnabled && (
|
|
<button
|
|
onClick={() => setDebugInfo(prev => prev.includes('cluster-viz') ? '' : 'cluster-viz')}
|
|
className={`px-3 py-1.5 rounded text-white text-xs shadow ${debugInfo.includes('cluster-viz') ? 'bg-purple-600 hover:bg-purple-500' : 'bg-gray-700/80 hover:bg-gray-600/90'}`}
|
|
>
|
|
Toggle Cluster Viz
|
|
</button>
|
|
)}
|
|
{/* Add 2D View Toggle if needed */}
|
|
{/* <button className="px-3 py-1.5 bg-gray-700/80 hover:bg-gray-600/90 rounded text-white text-xs shadow flex items-center">
|
|
<LayoutGrid size={14} className="mr-1" /> 2D View
|
|
</button> */}
|
|
</div>
|
|
|
|
{/* Top-Right Info Panel */}
|
|
<div className="absolute top-4 right-24 z-10 bg-gray-800/80 p-3 rounded text-xs text-gray-300 shadow w-48">
|
|
<p><span className="font-semibold text-white">Mode:</span> {interactionMode}</p>
|
|
<ul className="list-disc list-inside mt-1 space-y-0.5">
|
|
<li>Drag to rotate view</li>
|
|
<li>Scroll to zoom in/out</li>
|
|
</ul>
|
|
<p className="mt-2 pt-2 border-t border-gray-600/50"><span className="font-semibold text-white">Nodes:</span> {graphStats.nodes} • <span className="font-semibold text-white">Links:</span> {graphStats.links}</p>
|
|
<p className="mt-1"><span className="font-semibold text-white">WebGPU Clustering:</span>
|
|
<span className="text-green-400">
|
|
Enabled
|
|
</span>
|
|
</p>
|
|
</div>
|
|
|
|
{/* Selected Node Panel */}
|
|
{selectedNode && (
|
|
<div className="absolute top-1/2 left-4 -translate-y-1/2 z-10 bg-gray-800/90 p-4 rounded-lg shadow-lg max-w-md text-sm text-gray-200 w-1/3">
|
|
<div className="flex justify-between items-center mb-3">
|
|
<h4 className="font-bold text-base text-white break-all">Selected: {selectedNode.name || selectedNode.id}</h4>
|
|
<button onClick={clearSelection} className="text-gray-400 hover:text-white">
|
|
<X size={18} />
|
|
</button>
|
|
</div>
|
|
<div className="max-h-48 overflow-y-auto text-xs pr-2">
|
|
{nodeConnections.length > 0 ? (
|
|
<>
|
|
<p className="font-semibold mb-1 text-gray-300">Connections ({nodeConnections.length}):</p>
|
|
<ul className="space-y-1">
|
|
{nodeConnections.map((conn, index) => (
|
|
<li key={index} className="flex items-center justify-between bg-gray-700/50 px-2 py-1 rounded">
|
|
<span className="italic mr-1">{conn.type === 'outgoing' ? '→' : '←'} {conn.label || 'related'}</span>
|
|
<button
|
|
onClick={() => focusOnNode(conn.type === 'outgoing' ? conn.target : conn.source)}
|
|
className="font-mono hover:text-blue-400 hover:underline truncate text-left flex-1 mx-2"
|
|
title={`Focus on: ${conn.nodeName || (conn.type === 'outgoing' ? conn.target : conn.source)}`}
|
|
>
|
|
{conn.nodeName || (conn.type === 'outgoing' ? conn.target : conn.source)}
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</>
|
|
) : (
|
|
<p className="text-gray-400 italic">No connections found for this node.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Debug Info Display (Optional) */}
|
|
{debugInfo && (
|
|
<div className="absolute bottom-4 right-4 z-10 bg-black/70 p-2 rounded text-xs text-mono text-gray-300">
|
|
<pre>{debugInfo}</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default ForceGraphWrapper; |