feat: 激活节点时聚焦节点
This commit is contained in:
@@ -1,10 +1,15 @@
|
|||||||
import { useEffect, useRef, useMemo } from 'react';
|
import { useEffect, useRef, useMemo } from "react";
|
||||||
import { Graph } from '@antv/g6';
|
import { Graph, NodeEvent, CanvasEvent } from "@antv/g6";
|
||||||
import type { GraphData, Node, Edge, GraphLayoutConfig } from '../../types/graph';
|
import type {
|
||||||
import { getNodeColors } from '../../config/colors';
|
GraphData,
|
||||||
import { FORCE_LAYOUT_CONFIG, GRAPH_BEHAVIORS } from '../../config/layout';
|
Node,
|
||||||
import { GRAPH_DEFAULT_HEIGHT } from '../../config/constants';
|
Edge,
|
||||||
import { getNodeStyle, getEdgeStyle } from '../../utils/graphStyle';
|
GraphLayoutConfig,
|
||||||
|
} from "../../types/graph";
|
||||||
|
import { getNodeColors } from "../../config/colors";
|
||||||
|
import { FORCE_LAYOUT_CONFIG } from "../../config/layout";
|
||||||
|
import { GRAPH_DEFAULT_HEIGHT } from "../../config/constants";
|
||||||
|
import { getNodeStyle, getEdgeStyle } from "../../utils/graphStyle";
|
||||||
|
|
||||||
interface GraphViewProps {
|
interface GraphViewProps {
|
||||||
data: GraphData;
|
data: GraphData;
|
||||||
@@ -28,10 +33,34 @@ export const GraphView = ({
|
|||||||
const cleanupRef = useRef<(() => void) | null>(null);
|
const cleanupRef = useRef<(() => void) | null>(null);
|
||||||
const isCancelledRef = useRef(false);
|
const isCancelledRef = useRef(false);
|
||||||
const renderPromiseRef = useRef<Promise<void> | null>(null);
|
const renderPromiseRef = useRef<Promise<void> | null>(null);
|
||||||
|
const selectedNodeIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const onNodeClickRef = useRef(onNodeClick);
|
const onNodeClickRef = useRef(onNodeClick);
|
||||||
|
|
||||||
const nodeMap = useMemo(() => new Map(data.nodes.map((node: Node) => [node.id, node])), [data.nodes]);
|
const nodeMap = useMemo(
|
||||||
|
() => new Map(data.nodes.map((node: Node) => [node.id, node])),
|
||||||
|
[data.nodes],
|
||||||
|
);
|
||||||
|
|
||||||
|
const findConnectedElements = (
|
||||||
|
nodeId: string,
|
||||||
|
): {
|
||||||
|
connectedNodeIds: Set<string>;
|
||||||
|
connectedEdgeIds: Set<string>;
|
||||||
|
} => {
|
||||||
|
const connectedNodeIds = new Set<string>([nodeId]);
|
||||||
|
const connectedEdgeIds = new Set<string>();
|
||||||
|
|
||||||
|
data.edges.forEach((edge: Edge) => {
|
||||||
|
if (edge.source === nodeId || edge.target === nodeId) {
|
||||||
|
connectedEdgeIds.add(edge.id);
|
||||||
|
connectedNodeIds.add(edge.source);
|
||||||
|
connectedNodeIds.add(edge.target);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { connectedNodeIds, connectedEdgeIds };
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onNodeClickRef.current = onNodeClick;
|
onNodeClickRef.current = onNodeClick;
|
||||||
@@ -65,10 +94,10 @@ export const GraphView = ({
|
|||||||
stroke: colors.stroke,
|
stroke: colors.stroke,
|
||||||
lineWidth: 2,
|
lineWidth: 2,
|
||||||
labelText: node.label,
|
labelText: node.label,
|
||||||
labelFill: '#2C3E50',
|
labelFill: "#2C3E50",
|
||||||
labelFontSize: 14,
|
labelFontSize: 14,
|
||||||
labelFontWeight: 'bold' as const,
|
labelFontWeight: "bold" as const,
|
||||||
labelPlacement: 'bottom' as const,
|
labelPlacement: "bottom" as const,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
@@ -86,9 +115,9 @@ export const GraphView = ({
|
|||||||
properties: edge.properties,
|
properties: edge.properties,
|
||||||
},
|
},
|
||||||
style: {
|
style: {
|
||||||
stroke: '#95A5A6',
|
stroke: "#95A5A6",
|
||||||
labelText: edge.label,
|
labelText: edge.label,
|
||||||
labelFill: '#666',
|
labelFill: "#666",
|
||||||
labelFontSize: 10,
|
labelFontSize: 10,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -101,76 +130,45 @@ export const GraphView = ({
|
|||||||
width: containerRef.current.clientWidth,
|
width: containerRef.current.clientWidth,
|
||||||
height: height,
|
height: height,
|
||||||
data: processedGraphData,
|
data: processedGraphData,
|
||||||
autoFit: 'view' as const,
|
autoFit: "view" as const,
|
||||||
node: {
|
node: {
|
||||||
type: 'circle',
|
type: "circle",
|
||||||
style: {
|
style: {
|
||||||
lineWidth: 2,
|
lineWidth: 2,
|
||||||
...getNodeStyle(),
|
...getNodeStyle(),
|
||||||
},
|
},
|
||||||
state: {
|
state: {
|
||||||
selected: {
|
selected: {
|
||||||
stroke: '#1890FF',
|
stroke: "#1890FF",
|
||||||
lineWidth: 4,
|
lineWidth: 4,
|
||||||
halo: true,
|
halo: true,
|
||||||
haloColor: 'rgba(24, 144, 255, 0.3)',
|
haloColor: "rgba(24, 144, 255, 0.3)",
|
||||||
haloLineWidth: 12,
|
haloLineWidth: 12,
|
||||||
},
|
},
|
||||||
|
inactive: {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
edge: {
|
edge: {
|
||||||
type: 'line',
|
type: "line",
|
||||||
style: {
|
style: {
|
||||||
endArrow: true,
|
endArrow: true,
|
||||||
...getEdgeStyle(nodeMap),
|
...getEdgeStyle(nodeMap),
|
||||||
},
|
},
|
||||||
state: {
|
state: {
|
||||||
selected: {
|
selected: {
|
||||||
stroke: '#1890FF',
|
stroke: "#1890FF",
|
||||||
|
},
|
||||||
|
inactive: {
|
||||||
|
opacity: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
layout: {
|
layout: {
|
||||||
...FORCE_LAYOUT_CONFIG,
|
...FORCE_LAYOUT_CONFIG,
|
||||||
},
|
},
|
||||||
behaviors: [
|
behaviors: ["drag-canvas", "zoom-canvas", "drag-element-force"],
|
||||||
...GRAPH_BEHAVIORS,
|
|
||||||
{
|
|
||||||
type: 'click-select',
|
|
||||||
key: 'click-select',
|
|
||||||
degree: 0,
|
|
||||||
state: 'selected',
|
|
||||||
multiple: false,
|
|
||||||
animation: true,
|
|
||||||
onClick: (event: any) => {
|
|
||||||
console.log('[GraphView] Node click event triggered', event);
|
|
||||||
console.log('[GraphView] event.target:', event.target);
|
|
||||||
console.log('[GraphView] onNodeClickRef.current:', onNodeClickRef.current);
|
|
||||||
|
|
||||||
if (event.target && event.target.id) {
|
|
||||||
const nodeId = event.target.id;
|
|
||||||
console.log('[GraphView] Looking for node with id:', nodeId);
|
|
||||||
console.log('[GraphView] Available nodes:', data.nodes.map((n: Node) => n.id));
|
|
||||||
|
|
||||||
const node = data.nodes.find((n: Node) => n.id === nodeId);
|
|
||||||
console.log('[GraphView] Found node:', nodeId, node);
|
|
||||||
|
|
||||||
if (node && onNodeClickRef.current) {
|
|
||||||
console.log('[GraphView] Calling onNodeClick callback with node:', node);
|
|
||||||
onNodeClickRef.current(node);
|
|
||||||
} else {
|
|
||||||
console.log('[GraphView] Failed: node not found or callback not available');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'drag-element-force',
|
|
||||||
key: 'drag-element-force',
|
|
||||||
state: 'selected',
|
|
||||||
fixed: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const graph = new Graph(graphConfig);
|
const graph = new Graph(graphConfig);
|
||||||
@@ -183,29 +181,109 @@ export const GraphView = ({
|
|||||||
|
|
||||||
renderPromiseRef.current = graph.render().catch((renderError) => {
|
renderPromiseRef.current = graph.render().catch((renderError) => {
|
||||||
if (!isCancelledRef.current) {
|
if (!isCancelledRef.current) {
|
||||||
console.error('Graph render error:', renderError);
|
console.error("Graph render error:", renderError);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
graph.on(NodeEvent.CLICK, (event: any) => {
|
||||||
|
console.log("[GraphView] Node click event", event);
|
||||||
|
|
||||||
|
if (event.target && event.target.id && event.targetType === "node") {
|
||||||
|
const nodeId = event.target.id;
|
||||||
|
const node = data.nodes.find((n: Node) => n.id === nodeId);
|
||||||
|
|
||||||
|
if (node && graph) {
|
||||||
|
const wasSelected = selectedNodeIdRef.current === nodeId;
|
||||||
|
|
||||||
|
if (wasSelected) {
|
||||||
|
selectedNodeIdRef.current = null;
|
||||||
|
|
||||||
|
const stateUpdates: Record<string, string[]> = {};
|
||||||
|
data.nodes.forEach((n: Node) => {
|
||||||
|
stateUpdates[n.id] = [];
|
||||||
|
});
|
||||||
|
data.edges.forEach((edge: Edge) => {
|
||||||
|
stateUpdates[edge.id] = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("[GraphView] Clearing all states:", stateUpdates);
|
||||||
|
graph.setElementState(stateUpdates).then(() => {
|
||||||
|
console.log("[GraphView] States cleared");
|
||||||
|
});
|
||||||
|
|
||||||
|
if (onNodeClickRef.current) {
|
||||||
|
onNodeClickRef.current(null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
selectedNodeIdRef.current = nodeId;
|
||||||
|
|
||||||
|
const { connectedNodeIds, connectedEdgeIds } =
|
||||||
|
findConnectedElements(nodeId);
|
||||||
|
|
||||||
|
const stateUpdates: Record<string, string[]> = {};
|
||||||
|
|
||||||
|
data.nodes.forEach((n: Node) => {
|
||||||
|
if (n.id === nodeId) {
|
||||||
|
stateUpdates[n.id] = ["selected"];
|
||||||
|
} else if (connectedNodeIds.has(n.id)) {
|
||||||
|
stateUpdates[n.id] = [];
|
||||||
|
} else {
|
||||||
|
stateUpdates[n.id] = ["inactive"];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
data.edges.forEach((edge: Edge) => {
|
||||||
|
if (connectedEdgeIds.has(edge.id)) {
|
||||||
|
stateUpdates[edge.id] = [];
|
||||||
|
} else {
|
||||||
|
stateUpdates[edge.id] = ["inactive"];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
graph.setElementState(stateUpdates);
|
||||||
|
|
||||||
|
if (onNodeClickRef.current) {
|
||||||
|
onNodeClickRef.current(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
graph.on(CanvasEvent.CLICK, (event: any) => {
|
||||||
|
if (event.targetType !== "canvas") {
|
||||||
|
console.log(
|
||||||
|
"[GraphView] Canvas click ignored - clicked on",
|
||||||
|
event.targetType,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[GraphView] Canvas click - deselecting node");
|
||||||
|
selectedNodeIdRef.current = null;
|
||||||
|
|
||||||
|
const stateUpdates: Record<string, string[]> = {};
|
||||||
|
data.nodes.forEach((node: Node) => {
|
||||||
|
stateUpdates[node.id] = [];
|
||||||
|
});
|
||||||
|
data.edges.forEach((edge: Edge) => {
|
||||||
|
stateUpdates[edge.id] = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("[GraphView] Clearing all states:", stateUpdates);
|
||||||
|
graph.setElementState(stateUpdates).then(() => {
|
||||||
|
console.log("[GraphView] States cleared");
|
||||||
|
});
|
||||||
|
|
||||||
// 画布点击事件(取消选择)
|
|
||||||
graph.on('canvas:click', () => {
|
|
||||||
console.log('[GraphView] Canvas click - deselecting node');
|
|
||||||
console.log('[GraphView] onNodeClickRef.current:', onNodeClickRef.current);
|
|
||||||
if (onNodeClickRef.current) {
|
if (onNodeClickRef.current) {
|
||||||
console.log('[GraphView] Calling onNodeClick with null');
|
|
||||||
onNodeClickRef.current(null);
|
onNodeClickRef.current(null);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 清理函数
|
// 清理函数
|
||||||
return () => {
|
return () => {
|
||||||
isCancelledRef.current = true;
|
isCancelledRef.current = true;
|
||||||
|
selectedNodeIdRef.current = null;
|
||||||
|
|
||||||
if (renderPromiseRef.current) {
|
if (renderPromiseRef.current) {
|
||||||
renderPromiseRef.current.catch(() => {});
|
renderPromiseRef.current.catch(() => {});
|
||||||
@@ -213,7 +291,8 @@ export const GraphView = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (graphRef.current) {
|
if (graphRef.current) {
|
||||||
graphRef.current.off('canvas:click');
|
graphRef.current.off(NodeEvent.CLICK);
|
||||||
|
graphRef.current.off(CanvasEvent.CLICK);
|
||||||
graphRef.current.destroy();
|
graphRef.current.destroy();
|
||||||
graphRef.current = null;
|
graphRef.current = null;
|
||||||
}
|
}
|
||||||
@@ -228,16 +307,22 @@ export const GraphView = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('resize', handleResize);
|
window.addEventListener("resize", handleResize);
|
||||||
return () => window.removeEventListener('resize', handleResize);
|
return () => window.removeEventListener("resize", handleResize);
|
||||||
}, [height]);
|
}, [height]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full h-full">
|
<div className="relative w-full h-full">
|
||||||
<div ref={containerRef} className="w-full h-full" style={{ height: `${height}px` }} />
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="w-full h-full"
|
||||||
|
style={{ height: `${height}px` }}
|
||||||
|
/>
|
||||||
<div className="absolute bottom-4 right-4 bg-white/90 backdrop-blur-sm rounded-lg shadow-lg p-3">
|
<div className="absolute bottom-4 right-4 bg-white/90 backdrop-blur-sm rounded-lg shadow-lg p-3">
|
||||||
<div className="text-xs text-gray-600 mb-2">布局: {layout.type}</div>
|
<div className="text-xs text-gray-600 mb-2">布局: {layout.type}</div>
|
||||||
<div className="text-xs text-gray-600">节点: {data.nodes.length} | 边: {data.edges.length}</div>
|
<div className="text-xs text-gray-600">
|
||||||
|
节点: {data.nodes.length} | 边: {data.edges.length}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// API 相关常量
|
// API 相关常量
|
||||||
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3001/api';
|
export const API_BASE_URL =
|
||||||
|
import.meta.env.VITE_API_BASE_URL || "http://localhost:3001/api";
|
||||||
export const API_TIMEOUT = 10000;
|
export const API_TIMEOUT = 10000;
|
||||||
|
|
||||||
// 图谱渲染配置
|
// 图谱渲染配置
|
||||||
@@ -11,9 +12,9 @@ export const NODE_SIZE_MIN = 10;
|
|||||||
export const NODE_SIZE_MAX = 60;
|
export const NODE_SIZE_MAX = 60;
|
||||||
|
|
||||||
// 边宽度配置
|
// 边宽度配置
|
||||||
export const DEFAULT_EDGE_WIDTH = 1.5;
|
export const DEFAULT_EDGE_WIDTH = 0.8;
|
||||||
export const EDGE_WIDTH_MIN = 1;
|
export const EDGE_WIDTH_MIN = 0.3;
|
||||||
export const EDGE_WIDTH_MAX = 2;
|
export const EDGE_WIDTH_MAX = 2.0;
|
||||||
|
|
||||||
// Importance 配置
|
// Importance 配置
|
||||||
export const IMPORTANCE_DEFAULT = 0.8;
|
export const IMPORTANCE_DEFAULT = 0.8;
|
||||||
|
|||||||
Reference in New Issue
Block a user