|
import Graph, { UndirectedGraph } from 'graphology' |
|
import { useCallback, useEffect, useRef } from 'react' |
|
import { useTranslation } from 'react-i18next' |
|
import { errorMessage } from '@/lib/utils' |
|
import * as Constants from '@/lib/constants' |
|
import { useGraphStore, RawGraph, RawNodeType, RawEdgeType } from '@/stores/graph' |
|
import { toast } from 'sonner' |
|
import { queryGraphs } from '@/api/lightrag' |
|
import { useBackendState } from '@/stores/state' |
|
import { useSettingsStore } from '@/stores/settings' |
|
|
|
import seedrandom from 'seedrandom' |
|
|
|
const TYPE_SYNONYMS: Record<string, string> = { |
|
'unknown': 'unknown', |
|
'未知': 'unknown', |
|
'other': 'unknown', |
|
|
|
'category': 'category', |
|
'类别': 'category', |
|
'type': 'category', |
|
'分类': 'category', |
|
|
|
'organization': 'organization', |
|
'组织': 'organization', |
|
'org': 'organization', |
|
'company': 'organization', |
|
'公司': 'organization', |
|
'机构': 'organization', |
|
|
|
'event': 'event', |
|
'事件': 'event', |
|
'activity': 'event', |
|
'活动': 'event', |
|
|
|
'person': 'person', |
|
'人物': 'person', |
|
'people': 'person', |
|
'human': 'person', |
|
'人': 'person', |
|
|
|
'animal': 'animal', |
|
'动物': 'animal', |
|
'creature': 'animal', |
|
'生物': 'animal', |
|
|
|
'geo': 'geo', |
|
'地理': 'geo', |
|
'geography': 'geo', |
|
'地域': 'geo', |
|
|
|
'location': 'location', |
|
'地点': 'location', |
|
'place': 'location', |
|
'address': 'location', |
|
'位置': 'location', |
|
'地址': 'location', |
|
|
|
'technology': 'technology', |
|
'技术': 'technology', |
|
'tech': 'technology', |
|
'科技': 'technology', |
|
|
|
'equipment': 'equipment', |
|
'设备': 'equipment', |
|
'device': 'equipment', |
|
'装备': 'equipment', |
|
|
|
'weapon': 'weapon', |
|
'武器': 'weapon', |
|
'arms': 'weapon', |
|
'军火': 'weapon', |
|
|
|
'object': 'object', |
|
'物品': 'object', |
|
'stuff': 'object', |
|
'物体': 'object', |
|
|
|
'group': 'group', |
|
'群组': 'group', |
|
'community': 'group', |
|
'社区': 'group' |
|
}; |
|
|
|
|
|
const NODE_TYPE_COLORS: Record<string, string> = { |
|
'unknown': '#f4d371', |
|
'category': '#e3493b', |
|
'organization': '#0f705d', |
|
'event': '#00bfa0', |
|
'person': '#4169E1', |
|
'animal': '#84a3e1', |
|
'geo': '#ff99cc', |
|
'location': '#cf6d17', |
|
'technology': '#b300b3', |
|
'equipment': '#2F4F4F', |
|
'weapon': '#4421af', |
|
'object': '#00cc00', |
|
'group': '#0f558a', |
|
}; |
|
|
|
|
|
const EXTENDED_COLORS = [ |
|
'#5a2c6d', |
|
'#0000ff', |
|
'#cd071e', |
|
'#00CED1', |
|
'#9b3a31', |
|
'#b2e061', |
|
'#bd7ebe', |
|
'#6ef7b3', |
|
'#003366', |
|
'#DEB887', |
|
]; |
|
|
|
|
|
const getNodeColorByType = (nodeType: string | undefined): string => { |
|
|
|
const defaultColor = '#5D6D7E'; |
|
|
|
const normalizedType = nodeType ? nodeType.toLowerCase() : 'unknown'; |
|
const typeColorMap = useGraphStore.getState().typeColorMap; |
|
|
|
|
|
if (typeColorMap.has(normalizedType)) { |
|
return typeColorMap.get(normalizedType) || defaultColor; |
|
} |
|
|
|
const standardType = TYPE_SYNONYMS[normalizedType]; |
|
if (standardType) { |
|
const color = NODE_TYPE_COLORS[standardType]; |
|
|
|
const newMap = new Map(typeColorMap); |
|
newMap.set(normalizedType, color); |
|
useGraphStore.setState({ typeColorMap: newMap }); |
|
return color; |
|
} |
|
|
|
|
|
|
|
const usedExtendedColors = new Set( |
|
Array.from(typeColorMap.entries()) |
|
.filter(([, color]) => !Object.values(NODE_TYPE_COLORS).includes(color)) |
|
.map(([, color]) => color) |
|
); |
|
|
|
|
|
const unusedColor = EXTENDED_COLORS.find(color => !usedExtendedColors.has(color)); |
|
const newColor = unusedColor || defaultColor; |
|
|
|
|
|
const newMap = new Map(typeColorMap); |
|
newMap.set(normalizedType, newColor); |
|
useGraphStore.setState({ typeColorMap: newMap }); |
|
|
|
return newColor; |
|
}; |
|
|
|
|
|
const validateGraph = (graph: RawGraph) => { |
|
|
|
if (!graph) { |
|
console.log('Graph validation failed: graph is null'); |
|
return false; |
|
} |
|
|
|
|
|
if (!Array.isArray(graph.nodes) || !Array.isArray(graph.edges)) { |
|
console.log('Graph validation failed: nodes or edges is not an array'); |
|
return false; |
|
} |
|
|
|
|
|
if (graph.nodes.length === 0) { |
|
console.log('Graph validation failed: nodes array is empty'); |
|
return false; |
|
} |
|
|
|
|
|
for (const node of graph.nodes) { |
|
if (!node.id || !node.labels || !node.properties) { |
|
console.log('Graph validation failed: invalid node structure'); |
|
return false; |
|
} |
|
} |
|
|
|
|
|
for (const edge of graph.edges) { |
|
if (!edge.id || !edge.source || !edge.target) { |
|
console.log('Graph validation failed: invalid edge structure'); |
|
return false; |
|
} |
|
} |
|
|
|
|
|
for (const edge of graph.edges) { |
|
const source = graph.getNode(edge.source); |
|
const target = graph.getNode(edge.target); |
|
if (source == undefined || target == undefined) { |
|
console.log('Graph validation failed: edge references non-existent node'); |
|
return false; |
|
} |
|
} |
|
|
|
console.log('Graph validation passed'); |
|
return true; |
|
} |
|
|
|
export type NodeType = { |
|
x: number |
|
y: number |
|
label: string |
|
size: number |
|
color: string |
|
highlighted?: boolean |
|
} |
|
export type EdgeType = { |
|
label: string |
|
originalWeight?: number |
|
size?: number |
|
color?: string |
|
hidden?: boolean |
|
} |
|
|
|
const fetchGraph = async (label: string, maxDepth: number, maxNodes: number) => { |
|
let rawData: any = null; |
|
|
|
|
|
const lastSuccessfulQueryLabel = useGraphStore.getState().lastSuccessfulQueryLabel; |
|
if (!lastSuccessfulQueryLabel) { |
|
console.log('Last successful queryLabel is empty'); |
|
try { |
|
await useGraphStore.getState().fetchAllDatabaseLabels(); |
|
} catch (e) { |
|
console.error('Failed to fetch all database labels:', e); |
|
|
|
} |
|
} |
|
|
|
|
|
|
|
useGraphStore.getState().setLabelsFetchAttempted(true) |
|
|
|
|
|
const queryLabel = label || '*'; |
|
|
|
try { |
|
console.log(`Fetching graph label: ${queryLabel}, depth: ${maxDepth}, nodes: ${maxNodes}`); |
|
rawData = await queryGraphs(queryLabel, maxDepth, maxNodes); |
|
} catch (e) { |
|
useBackendState.getState().setErrorMessage(errorMessage(e), 'Query Graphs Error!'); |
|
return null; |
|
} |
|
|
|
let rawGraph = null; |
|
|
|
if (rawData) { |
|
const nodeIdMap: Record<string, number> = {} |
|
const edgeIdMap: Record<string, number> = {} |
|
|
|
for (let i = 0; i < rawData.nodes.length; i++) { |
|
const node = rawData.nodes[i] |
|
nodeIdMap[node.id] = i |
|
|
|
node.x = Math.random() |
|
node.y = Math.random() |
|
node.degree = 0 |
|
node.size = 10 |
|
} |
|
|
|
for (let i = 0; i < rawData.edges.length; i++) { |
|
const edge = rawData.edges[i] |
|
edgeIdMap[edge.id] = i |
|
|
|
const source = nodeIdMap[edge.source] |
|
const target = nodeIdMap[edge.target] |
|
if (source !== undefined && source !== undefined) { |
|
const sourceNode = rawData.nodes[source] |
|
const targetNode = rawData.nodes[target] |
|
if (!sourceNode) { |
|
console.error(`Source node ${edge.source} is undefined`) |
|
continue |
|
} |
|
if (!targetNode) { |
|
console.error(`Target node ${edge.target} is undefined`) |
|
continue |
|
} |
|
sourceNode.degree += 1 |
|
targetNode.degree += 1 |
|
} |
|
} |
|
|
|
|
|
let minDegree = Number.MAX_SAFE_INTEGER |
|
let maxDegree = 0 |
|
|
|
for (const node of rawData.nodes) { |
|
minDegree = Math.min(minDegree, node.degree) |
|
maxDegree = Math.max(maxDegree, node.degree) |
|
} |
|
const range = maxDegree - minDegree |
|
if (range > 0) { |
|
const scale = Constants.maxNodeSize - Constants.minNodeSize |
|
for (const node of rawData.nodes) { |
|
node.size = Math.round( |
|
Constants.minNodeSize + scale * Math.pow((node.degree - minDegree) / range, 0.5) |
|
) |
|
} |
|
} |
|
|
|
rawGraph = new RawGraph() |
|
rawGraph.nodes = rawData.nodes |
|
rawGraph.edges = rawData.edges |
|
rawGraph.nodeIdMap = nodeIdMap |
|
rawGraph.edgeIdMap = edgeIdMap |
|
|
|
if (!validateGraph(rawGraph)) { |
|
rawGraph = null |
|
console.warn('Invalid graph data') |
|
} |
|
console.log('Graph data loaded') |
|
} |
|
|
|
|
|
return { rawGraph, is_truncated: rawData.is_truncated } |
|
} |
|
|
|
|
|
const createSigmaGraph = (rawGraph: RawGraph | null) => { |
|
|
|
const minEdgeSize = useSettingsStore.getState().minEdgeSize |
|
const maxEdgeSize = useSettingsStore.getState().maxEdgeSize |
|
|
|
if (!rawGraph || !rawGraph.nodes.length) { |
|
console.log('No graph data available, skipping sigma graph creation'); |
|
return null; |
|
} |
|
|
|
|
|
const graph = new UndirectedGraph() |
|
|
|
|
|
for (const rawNode of rawGraph?.nodes ?? []) { |
|
|
|
seedrandom(rawNode.id + Date.now().toString(), { global: true }) |
|
const x = Math.random() |
|
const y = Math.random() |
|
|
|
graph.addNode(rawNode.id, { |
|
label: rawNode.labels.join(', '), |
|
color: rawNode.color, |
|
x: x, |
|
y: y, |
|
size: rawNode.size, |
|
|
|
borderColor: Constants.nodeBorderColor, |
|
borderSize: 0.2 |
|
}) |
|
} |
|
|
|
|
|
for (const rawEdge of rawGraph?.edges ?? []) { |
|
|
|
const weight = rawEdge.properties?.weight !== undefined ? Number(rawEdge.properties.weight) : 1 |
|
|
|
rawEdge.dynamicId = graph.addEdge(rawEdge.source, rawEdge.target, { |
|
label: rawEdge.properties?.keywords || undefined, |
|
size: weight, |
|
originalWeight: weight, |
|
type: 'curvedNoArrow' |
|
}) |
|
} |
|
|
|
|
|
let minWeight = Number.MAX_SAFE_INTEGER |
|
let maxWeight = 0 |
|
|
|
|
|
graph.forEachEdge(edge => { |
|
const weight = graph.getEdgeAttribute(edge, 'originalWeight') || 1 |
|
minWeight = Math.min(minWeight, weight) |
|
maxWeight = Math.max(maxWeight, weight) |
|
}) |
|
|
|
|
|
const weightRange = maxWeight - minWeight |
|
if (weightRange > 0) { |
|
const sizeScale = maxEdgeSize - minEdgeSize |
|
graph.forEachEdge(edge => { |
|
const weight = graph.getEdgeAttribute(edge, 'originalWeight') || 1 |
|
const scaledSize = minEdgeSize + sizeScale * Math.pow((weight - minWeight) / weightRange, 0.5) |
|
graph.setEdgeAttribute(edge, 'size', scaledSize) |
|
}) |
|
} else { |
|
|
|
graph.forEachEdge(edge => { |
|
graph.setEdgeAttribute(edge, 'size', minEdgeSize) |
|
}) |
|
} |
|
|
|
return graph |
|
} |
|
|
|
const useLightrangeGraph = () => { |
|
const { t } = useTranslation() |
|
const queryLabel = useSettingsStore.use.queryLabel() |
|
const rawGraph = useGraphStore.use.rawGraph() |
|
const sigmaGraph = useGraphStore.use.sigmaGraph() |
|
const maxQueryDepth = useSettingsStore.use.graphQueryMaxDepth() |
|
const maxNodes = useSettingsStore.use.graphMaxNodes() |
|
const isFetching = useGraphStore.use.isFetching() |
|
const nodeToExpand = useGraphStore.use.nodeToExpand() |
|
const nodeToPrune = useGraphStore.use.nodeToPrune() |
|
|
|
|
|
|
|
const dataLoadedRef = useRef(false) |
|
const initialLoadRef = useRef(false) |
|
|
|
const emptyDataHandledRef = useRef(false) |
|
|
|
const getNode = useCallback( |
|
(nodeId: string) => { |
|
return rawGraph?.getNode(nodeId) || null |
|
}, |
|
[rawGraph] |
|
) |
|
|
|
const getEdge = useCallback( |
|
(edgeId: string, dynamicId: boolean = true) => { |
|
return rawGraph?.getEdge(edgeId, dynamicId) || null |
|
}, |
|
[rawGraph] |
|
) |
|
|
|
|
|
const fetchInProgressRef = useRef(false) |
|
|
|
|
|
useEffect(() => { |
|
if (!queryLabel && (rawGraph !== null || sigmaGraph !== null)) { |
|
const state = useGraphStore.getState() |
|
state.reset() |
|
state.setGraphDataFetchAttempted(false) |
|
state.setLabelsFetchAttempted(false) |
|
dataLoadedRef.current = false |
|
initialLoadRef.current = false |
|
} |
|
}, [queryLabel, rawGraph, sigmaGraph]) |
|
|
|
|
|
useEffect(() => { |
|
|
|
if (fetchInProgressRef.current) { |
|
return |
|
} |
|
|
|
|
|
if (!queryLabel && emptyDataHandledRef.current) { |
|
return; |
|
} |
|
|
|
|
|
|
|
if (!isFetching && !useGraphStore.getState().graphDataFetchAttempted) { |
|
|
|
fetchInProgressRef.current = true |
|
useGraphStore.getState().setGraphDataFetchAttempted(true) |
|
|
|
const state = useGraphStore.getState() |
|
state.setIsFetching(true) |
|
|
|
|
|
state.clearSelection() |
|
if (state.sigmaGraph) { |
|
state.sigmaGraph.forEachNode((node) => { |
|
state.sigmaGraph?.setNodeAttribute(node, 'highlighted', false) |
|
}) |
|
} |
|
|
|
console.log('Preparing graph data...') |
|
|
|
|
|
const currentQueryLabel = queryLabel |
|
const currentMaxQueryDepth = maxQueryDepth |
|
const currentMaxNodes = maxNodes |
|
|
|
|
|
let dataPromise: Promise<{ rawGraph: RawGraph | null; is_truncated: boolean | undefined } | null>; |
|
|
|
|
|
if (currentQueryLabel) { |
|
dataPromise = fetchGraph(currentQueryLabel, currentMaxQueryDepth, currentMaxNodes); |
|
} else { |
|
|
|
console.log('Query label is empty, show empty graph') |
|
dataPromise = Promise.resolve({ rawGraph: null, is_truncated: false }); |
|
} |
|
|
|
|
|
dataPromise.then((result) => { |
|
const state = useGraphStore.getState() |
|
const data = result?.rawGraph; |
|
|
|
|
|
if (data && data.nodes) { |
|
data.nodes.forEach(node => { |
|
|
|
const nodeEntityType = node.properties?.entity_type as string | undefined; |
|
node.color = getNodeColorByType(nodeEntityType); |
|
}); |
|
} |
|
|
|
if (result?.is_truncated) { |
|
toast.info(t('graphPanel.dataIsTruncated', 'Graph data is truncated to Max Nodes')); |
|
} |
|
|
|
|
|
state.reset() |
|
|
|
|
|
if (!data || !data.nodes || data.nodes.length === 0) { |
|
|
|
const emptyGraph = new UndirectedGraph(); |
|
|
|
|
|
emptyGraph.addNode('empty-graph-node', { |
|
label: t('graphPanel.emptyGraph'), |
|
color: '#5D6D7E', |
|
x: 0.5, |
|
y: 0.5, |
|
size: 15, |
|
borderColor: Constants.nodeBorderColor, |
|
borderSize: 0.2 |
|
}); |
|
|
|
|
|
state.setSigmaGraph(emptyGraph); |
|
state.setRawGraph(null); |
|
|
|
|
|
state.setGraphIsEmpty(true); |
|
|
|
|
|
const errorMessage = useBackendState.getState().message; |
|
const isAuthError = errorMessage && errorMessage.includes('Authentication required'); |
|
|
|
|
|
if (!isAuthError && currentQueryLabel) { |
|
useSettingsStore.getState().setQueryLabel(''); |
|
} |
|
|
|
|
|
if (!isAuthError) { |
|
state.setLastSuccessfulQueryLabel(''); |
|
} else { |
|
console.log('Keep queryLabel for post-login reload'); |
|
} |
|
|
|
console.log(`Graph data is empty, created graph with empty graph node. Auth error: ${isAuthError}`); |
|
} else { |
|
|
|
const newSigmaGraph = createSigmaGraph(data); |
|
data.buildDynamicMap(); |
|
|
|
|
|
state.setSigmaGraph(newSigmaGraph); |
|
state.setRawGraph(data); |
|
state.setGraphIsEmpty(false); |
|
|
|
|
|
state.setLastSuccessfulQueryLabel(currentQueryLabel); |
|
|
|
|
|
state.setMoveToSelectedNode(true); |
|
} |
|
|
|
|
|
dataLoadedRef.current = true |
|
initialLoadRef.current = true |
|
fetchInProgressRef.current = false |
|
state.setIsFetching(false) |
|
|
|
|
|
if ((!data || !data.nodes || data.nodes.length === 0) && !currentQueryLabel) { |
|
emptyDataHandledRef.current = true; |
|
} |
|
}).catch((error) => { |
|
console.error('Error fetching graph data:', error) |
|
|
|
|
|
const state = useGraphStore.getState() |
|
state.setIsFetching(false) |
|
dataLoadedRef.current = false; |
|
fetchInProgressRef.current = false |
|
state.setGraphDataFetchAttempted(false) |
|
state.setLastSuccessfulQueryLabel('') |
|
}) |
|
} |
|
}, [queryLabel, maxQueryDepth, maxNodes, isFetching, t]) |
|
|
|
|
|
useEffect(() => { |
|
const handleNodeExpand = async (nodeId: string | null) => { |
|
if (!nodeId || !sigmaGraph || !rawGraph) return; |
|
|
|
try { |
|
|
|
const nodeToExpand = rawGraph.getNode(nodeId); |
|
if (!nodeToExpand) { |
|
console.error('Node not found:', nodeId); |
|
return; |
|
} |
|
|
|
|
|
const label = nodeToExpand.labels[0]; |
|
if (!label) { |
|
console.error('Node has no label:', nodeId); |
|
return; |
|
} |
|
|
|
|
|
const extendedGraph = await queryGraphs(label, 2, 1000); |
|
|
|
if (!extendedGraph || !extendedGraph.nodes || !extendedGraph.edges) { |
|
console.error('Failed to fetch extended graph'); |
|
return; |
|
} |
|
|
|
|
|
const processedNodes: RawNodeType[] = []; |
|
for (const node of extendedGraph.nodes) { |
|
|
|
seedrandom(node.id, { global: true }); |
|
const nodeEntityType = node.properties?.entity_type as string | undefined; |
|
const color = getNodeColorByType(nodeEntityType); |
|
|
|
|
|
processedNodes.push({ |
|
id: node.id, |
|
labels: node.labels, |
|
properties: node.properties, |
|
size: 10, |
|
x: Math.random(), |
|
y: Math.random(), |
|
color: color, |
|
degree: 0 |
|
}); |
|
} |
|
|
|
|
|
const processedEdges: RawEdgeType[] = []; |
|
for (const edge of extendedGraph.edges) { |
|
|
|
processedEdges.push({ |
|
id: edge.id, |
|
source: edge.source, |
|
target: edge.target, |
|
type: edge.type, |
|
properties: edge.properties, |
|
dynamicId: '' |
|
}); |
|
} |
|
|
|
|
|
const nodePositions: Record<string, {x: number, y: number}> = {}; |
|
sigmaGraph.forEachNode((node) => { |
|
nodePositions[node] = { |
|
x: sigmaGraph.getNodeAttribute(node, 'x'), |
|
y: sigmaGraph.getNodeAttribute(node, 'y') |
|
}; |
|
}); |
|
|
|
|
|
const existingNodeIds = new Set(sigmaGraph.nodes()); |
|
|
|
|
|
const nodesToAdd = new Set<string>(); |
|
const edgesToAdd = new Set<string>(); |
|
|
|
|
|
const minDegree = 1; |
|
let maxDegree = 0; |
|
|
|
|
|
let minWeight = Number.MAX_SAFE_INTEGER; |
|
let maxWeight = 0; |
|
|
|
|
|
sigmaGraph.forEachNode(node => { |
|
const degree = sigmaGraph.degree(node); |
|
maxDegree = Math.max(maxDegree, degree); |
|
}); |
|
|
|
|
|
sigmaGraph.forEachEdge(edge => { |
|
const weight = sigmaGraph.getEdgeAttribute(edge, 'originalWeight') || 1; |
|
minWeight = Math.min(minWeight, weight); |
|
maxWeight = Math.max(maxWeight, weight); |
|
}); |
|
|
|
|
|
for (const node of processedNodes) { |
|
|
|
if (existingNodeIds.has(node.id)) { |
|
continue; |
|
} |
|
|
|
|
|
const isConnected = processedEdges.some( |
|
edge => (edge.source === nodeId && edge.target === node.id) || |
|
(edge.target === nodeId && edge.source === node.id) |
|
); |
|
|
|
if (isConnected) { |
|
nodesToAdd.add(node.id); |
|
} |
|
} |
|
|
|
|
|
const nodeDegrees = new Map<string, number>(); |
|
const existingNodeDegreeIncrements = new Map<string, number>(); |
|
const nodesWithDiscardedEdges = new Set<string>(); |
|
|
|
for (const edge of processedEdges) { |
|
const sourceExists = existingNodeIds.has(edge.source) || nodesToAdd.has(edge.source); |
|
const targetExists = existingNodeIds.has(edge.target) || nodesToAdd.has(edge.target); |
|
|
|
if (sourceExists && targetExists) { |
|
edgesToAdd.add(edge.id); |
|
|
|
if (nodesToAdd.has(edge.source)) { |
|
nodeDegrees.set(edge.source, (nodeDegrees.get(edge.source) || 0) + 1); |
|
} else if (existingNodeIds.has(edge.source)) { |
|
|
|
existingNodeDegreeIncrements.set(edge.source, (existingNodeDegreeIncrements.get(edge.source) || 0) + 1); |
|
} |
|
|
|
if (nodesToAdd.has(edge.target)) { |
|
nodeDegrees.set(edge.target, (nodeDegrees.get(edge.target) || 0) + 1); |
|
} else if (existingNodeIds.has(edge.target)) { |
|
|
|
existingNodeDegreeIncrements.set(edge.target, (existingNodeDegreeIncrements.get(edge.target) || 0) + 1); |
|
} |
|
} else { |
|
|
|
if (sigmaGraph.hasNode(edge.source)) { |
|
nodesWithDiscardedEdges.add(edge.source); |
|
} else if (nodesToAdd.has(edge.source)) { |
|
nodesWithDiscardedEdges.add(edge.source); |
|
nodeDegrees.set(edge.source, (nodeDegrees.get(edge.source) || 0) + 1); |
|
} |
|
if (sigmaGraph.hasNode(edge.target)) { |
|
nodesWithDiscardedEdges.add(edge.target); |
|
} else if (nodesToAdd.has(edge.target)) { |
|
nodesWithDiscardedEdges.add(edge.target); |
|
nodeDegrees.set(edge.target, (nodeDegrees.get(edge.target) || 0) + 1); |
|
} |
|
} |
|
} |
|
|
|
|
|
const updateNodeSizes = ( |
|
sigmaGraph: UndirectedGraph, |
|
nodesWithDiscardedEdges: Set<string>, |
|
minDegree: number, |
|
maxDegree: number |
|
) => { |
|
|
|
const range = maxDegree - minDegree || 1; |
|
const scale = Constants.maxNodeSize - Constants.minNodeSize; |
|
|
|
|
|
for (const nodeId of nodesWithDiscardedEdges) { |
|
if (sigmaGraph.hasNode(nodeId)) { |
|
let newDegree = sigmaGraph.degree(nodeId); |
|
newDegree += 1; |
|
|
|
const limitedDegree = Math.min(newDegree, maxDegree + 1); |
|
|
|
const newSize = Math.round( |
|
Constants.minNodeSize + scale * Math.pow((limitedDegree - minDegree) / range, 0.5) |
|
); |
|
|
|
sigmaGraph.setNodeAttribute(nodeId, 'size', newSize); |
|
} |
|
} |
|
}; |
|
|
|
|
|
const updateEdgeSizes = ( |
|
sigmaGraph: UndirectedGraph, |
|
minWeight: number, |
|
maxWeight: number |
|
) => { |
|
|
|
const minEdgeSize = useSettingsStore.getState().minEdgeSize; |
|
const maxEdgeSize = useSettingsStore.getState().maxEdgeSize; |
|
const weightRange = maxWeight - minWeight || 1; |
|
const sizeScale = maxEdgeSize - minEdgeSize; |
|
|
|
sigmaGraph.forEachEdge(edge => { |
|
const weight = sigmaGraph.getEdgeAttribute(edge, 'originalWeight') || 1; |
|
const scaledSize = minEdgeSize + sizeScale * Math.pow((weight - minWeight) / weightRange, 0.5); |
|
sigmaGraph.setEdgeAttribute(edge, 'size', scaledSize); |
|
}); |
|
}; |
|
|
|
|
|
if (nodesToAdd.size === 0) { |
|
updateNodeSizes(sigmaGraph, nodesWithDiscardedEdges, minDegree, maxDegree); |
|
toast.info(t('graphPanel.propertiesView.node.noNewNodes')); |
|
return; |
|
} |
|
|
|
|
|
|
|
for (const [, degree] of nodeDegrees.entries()) { |
|
maxDegree = Math.max(maxDegree, degree); |
|
} |
|
|
|
|
|
for (const [nodeId, increment] of existingNodeDegreeIncrements.entries()) { |
|
const currentDegree = sigmaGraph.degree(nodeId); |
|
const projectedDegree = currentDegree + increment; |
|
maxDegree = Math.max(maxDegree, projectedDegree); |
|
} |
|
|
|
const range = maxDegree - minDegree || 1; |
|
const scale = Constants.maxNodeSize - Constants.minNodeSize; |
|
|
|
|
|
|
|
const cameraRatio = useGraphStore.getState().sigmaInstance?.getCamera().ratio || 1; |
|
const spreadFactor = Math.max( |
|
Math.sqrt(nodeToExpand.size) * 4, |
|
Math.sqrt(nodesToAdd.size) * 3 |
|
) / cameraRatio; |
|
seedrandom(Date.now().toString(), { global: true }); |
|
const randomAngle = Math.random() * 2 * Math.PI |
|
|
|
console.log('nodeSize:', nodeToExpand.size, 'nodesToAdd:', nodesToAdd.size); |
|
console.log('cameraRatio:', Math.round(cameraRatio*100)/100, 'spreadFactor:', Math.round(spreadFactor*100)/100); |
|
|
|
|
|
for (const nodeId of nodesToAdd) { |
|
const newNode = processedNodes.find(n => n.id === nodeId)!; |
|
const nodeDegree = nodeDegrees.get(nodeId) || 0; |
|
|
|
|
|
|
|
const limitedDegree = Math.min(nodeDegree, maxDegree + 1); |
|
const nodeSize = Math.round( |
|
Constants.minNodeSize + scale * Math.pow((limitedDegree - minDegree) / range, 0.5) |
|
); |
|
|
|
|
|
const angle = 2 * Math.PI * (Array.from(nodesToAdd).indexOf(nodeId) / nodesToAdd.size); |
|
|
|
|
|
const x = nodePositions[nodeId]?.x || |
|
(nodePositions[nodeToExpand.id].x + Math.cos(randomAngle + angle) * spreadFactor); |
|
const y = nodePositions[nodeId]?.y || |
|
(nodePositions[nodeToExpand.id].y + Math.sin(randomAngle + angle) * spreadFactor); |
|
|
|
|
|
sigmaGraph.addNode(nodeId, { |
|
label: newNode.labels.join(', '), |
|
color: newNode.color, |
|
x: x, |
|
y: y, |
|
size: nodeSize, |
|
borderColor: Constants.nodeBorderColor, |
|
borderSize: 0.2 |
|
}); |
|
|
|
|
|
if (!rawGraph.getNode(nodeId)) { |
|
|
|
newNode.size = nodeSize; |
|
newNode.x = x; |
|
newNode.y = y; |
|
newNode.degree = nodeDegree; |
|
|
|
|
|
rawGraph.nodes.push(newNode); |
|
|
|
rawGraph.nodeIdMap[nodeId] = rawGraph.nodes.length - 1; |
|
} |
|
} |
|
|
|
|
|
for (const edgeId of edgesToAdd) { |
|
const newEdge = processedEdges.find(e => e.id === edgeId)!; |
|
|
|
|
|
if (sigmaGraph.hasEdge(newEdge.source, newEdge.target)) { |
|
continue; |
|
} |
|
|
|
|
|
const weight = newEdge.properties?.weight !== undefined ? Number(newEdge.properties.weight) : 1; |
|
|
|
|
|
minWeight = Math.min(minWeight, weight); |
|
maxWeight = Math.max(maxWeight, weight); |
|
|
|
|
|
newEdge.dynamicId = sigmaGraph.addEdge(newEdge.source, newEdge.target, { |
|
label: newEdge.properties?.keywords || undefined, |
|
size: weight, |
|
originalWeight: weight, |
|
type: 'curvedNoArrow' |
|
}); |
|
|
|
|
|
if (!rawGraph.getEdge(newEdge.id, false)) { |
|
|
|
rawGraph.edges.push(newEdge); |
|
|
|
rawGraph.edgeIdMap[newEdge.id] = rawGraph.edges.length - 1; |
|
|
|
rawGraph.edgeDynamicIdMap[newEdge.dynamicId] = rawGraph.edges.length - 1; |
|
} else { |
|
console.error('Edge already exists in rawGraph:', newEdge.id); |
|
} |
|
} |
|
|
|
|
|
rawGraph.buildDynamicMap(); |
|
|
|
|
|
useGraphStore.getState().resetSearchEngine(); |
|
|
|
|
|
updateNodeSizes(sigmaGraph, nodesWithDiscardedEdges, minDegree, maxDegree); |
|
updateEdgeSizes(sigmaGraph, minWeight, maxWeight); |
|
|
|
|
|
if (sigmaGraph.hasNode(nodeId)) { |
|
const finalDegree = sigmaGraph.degree(nodeId); |
|
const limitedDegree = Math.min(finalDegree, maxDegree + 1); |
|
const newSize = Math.round( |
|
Constants.minNodeSize + scale * Math.pow((limitedDegree - minDegree) / range, 0.5) |
|
); |
|
sigmaGraph.setNodeAttribute(nodeId, 'size', newSize); |
|
nodeToExpand.size = newSize; |
|
nodeToExpand.degree = finalDegree; |
|
} |
|
|
|
} catch (error) { |
|
console.error('Error expanding node:', error); |
|
} |
|
}; |
|
|
|
|
|
if (nodeToExpand) { |
|
handleNodeExpand(nodeToExpand); |
|
|
|
window.setTimeout(() => { |
|
useGraphStore.getState().triggerNodeExpand(null); |
|
}, 0); |
|
} |
|
}, [nodeToExpand, sigmaGraph, rawGraph, t]); |
|
|
|
|
|
const getNodesThatWillBeDeleted = useCallback((nodeId: string, graph: UndirectedGraph) => { |
|
const nodesToDelete = new Set<string>([nodeId]); |
|
|
|
|
|
graph.forEachNode((node) => { |
|
if (node === nodeId) return; |
|
|
|
|
|
const neighbors = graph.neighbors(node); |
|
|
|
|
|
|
|
if (neighbors.length === 1 && neighbors[0] === nodeId) { |
|
nodesToDelete.add(node); |
|
} |
|
}); |
|
|
|
return nodesToDelete; |
|
}, []); |
|
|
|
|
|
useEffect(() => { |
|
const handleNodePrune = (nodeId: string | null) => { |
|
if (!nodeId || !sigmaGraph || !rawGraph) return; |
|
|
|
try { |
|
const state = useGraphStore.getState(); |
|
|
|
|
|
if (!sigmaGraph.hasNode(nodeId)) { |
|
console.error('Node not found:', nodeId); |
|
return; |
|
} |
|
|
|
|
|
const nodesToDelete = getNodesThatWillBeDeleted(nodeId, sigmaGraph); |
|
|
|
|
|
if (nodesToDelete.size === sigmaGraph.nodes().length) { |
|
toast.error(t('graphPanel.propertiesView.node.deleteAllNodesError')); |
|
return; |
|
} |
|
|
|
|
|
state.clearSelection(); |
|
|
|
|
|
for (const nodeToDelete of nodesToDelete) { |
|
|
|
sigmaGraph.dropNode(nodeToDelete); |
|
|
|
|
|
const nodeIndex = rawGraph.nodeIdMap[nodeToDelete]; |
|
if (nodeIndex !== undefined) { |
|
|
|
const edgesToRemove = rawGraph.edges.filter( |
|
edge => edge.source === nodeToDelete || edge.target === nodeToDelete |
|
); |
|
|
|
|
|
for (const edge of edgesToRemove) { |
|
const edgeIndex = rawGraph.edgeIdMap[edge.id]; |
|
if (edgeIndex !== undefined) { |
|
|
|
rawGraph.edges.splice(edgeIndex, 1); |
|
|
|
for (const [id, idx] of Object.entries(rawGraph.edgeIdMap)) { |
|
if (idx > edgeIndex) { |
|
rawGraph.edgeIdMap[id] = idx - 1; |
|
} |
|
} |
|
|
|
delete rawGraph.edgeIdMap[edge.id]; |
|
|
|
delete rawGraph.edgeDynamicIdMap[edge.dynamicId]; |
|
} |
|
} |
|
|
|
|
|
rawGraph.nodes.splice(nodeIndex, 1); |
|
|
|
|
|
for (const [id, idx] of Object.entries(rawGraph.nodeIdMap)) { |
|
if (idx > nodeIndex) { |
|
rawGraph.nodeIdMap[id] = idx - 1; |
|
} |
|
} |
|
|
|
|
|
delete rawGraph.nodeIdMap[nodeToDelete]; |
|
} |
|
} |
|
|
|
|
|
rawGraph.buildDynamicMap(); |
|
|
|
|
|
useGraphStore.getState().resetSearchEngine(); |
|
|
|
|
|
if (nodesToDelete.size > 1) { |
|
toast.info(t('graphPanel.propertiesView.node.nodesRemoved', { count: nodesToDelete.size })); |
|
} |
|
|
|
|
|
} catch (error) { |
|
console.error('Error pruning node:', error); |
|
} |
|
}; |
|
|
|
|
|
if (nodeToPrune) { |
|
handleNodePrune(nodeToPrune); |
|
|
|
window.setTimeout(() => { |
|
useGraphStore.getState().triggerNodePrune(null); |
|
}, 0); |
|
} |
|
}, [nodeToPrune, sigmaGraph, rawGraph, getNodesThatWillBeDeleted, t]); |
|
|
|
const lightrageGraph = useCallback(() => { |
|
|
|
if (sigmaGraph) { |
|
return sigmaGraph as Graph<NodeType, EdgeType> |
|
} |
|
|
|
|
|
console.log('Creating new Sigma graph instance') |
|
const graph = new UndirectedGraph() |
|
useGraphStore.getState().setSigmaGraph(graph) |
|
return graph as Graph<NodeType, EdgeType> |
|
}, [sigmaGraph]) |
|
|
|
return { lightrageGraph, getNode, getEdge } |
|
} |
|
|
|
export default useLightrangeGraph |
|
|