- graphTypes.ts (39 行) — GraphNode/GraphEdge/GraphConfig/NodePosition/HoverState - graphLayout.ts (41 行) — computeCircularLayout 环形布局算法 - graphRenderer.ts (293 行) — Canvas 绘制函数 + 常量 + helper - PluginGraphPage.tsx (758 行) — 组件壳:state/effects/event handlers/JSX
759 lines
24 KiB
TypeScript
759 lines
24 KiB
TypeScript
import { useEffect, useState, useRef, useCallback } from 'react';
|
|
import { useParams } from 'react-router-dom';
|
|
import {
|
|
Card,
|
|
Select,
|
|
Space,
|
|
Empty,
|
|
Spin,
|
|
Statistic,
|
|
Row,
|
|
Col,
|
|
Tag,
|
|
Tooltip,
|
|
message,
|
|
theme,
|
|
Typography,
|
|
Divider,
|
|
Badge,
|
|
Flex,
|
|
} from 'antd';
|
|
import {
|
|
ApartmentOutlined,
|
|
TeamOutlined,
|
|
NodeIndexOutlined,
|
|
AimOutlined,
|
|
InfoCircleOutlined,
|
|
ReloadOutlined,
|
|
} from '@ant-design/icons';
|
|
import { listPluginData } from '../api/pluginData';
|
|
import {
|
|
getPluginSchema,
|
|
type PluginFieldSchema,
|
|
type PluginSchemaResponse,
|
|
} from '../api/plugins';
|
|
|
|
import type { GraphNode, GraphEdge, GraphConfig, NodePosition, HoverState } from './graph/graphTypes';
|
|
import { computeCircularLayout } from './graph/graphLayout';
|
|
import {
|
|
RELATIONSHIP_COLORS,
|
|
NODE_HOVER_SCALE,
|
|
getRelColor,
|
|
getEdgeTypeLabel,
|
|
getNodeDegree,
|
|
degreeToRadius,
|
|
drawCurvedEdge,
|
|
drawNode,
|
|
drawEdgeLabel,
|
|
drawNodeLabel,
|
|
} from './graph/graphRenderer';
|
|
|
|
const { Text } = Typography;
|
|
|
|
/**
|
|
* 插件关系图谱页面 — 通过路由参数自加载 schema
|
|
* 路由: /plugins/:pluginId/graph/:entityName
|
|
*/
|
|
export function PluginGraphPage() {
|
|
const { pluginId, entityName } = useParams<{ pluginId: string; entityName: string }>();
|
|
const { token } = theme.useToken();
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const animFrameRef = useRef<number>(0);
|
|
const nodePositionsRef = useRef<Map<string, NodePosition>>(new Map());
|
|
const visibleNodesRef = useRef<GraphNode[]>([]);
|
|
const visibleEdgesRef = useRef<GraphEdge[]>([]);
|
|
|
|
const [customers, setCustomers] = useState<GraphNode[]>([]);
|
|
const [relationships, setRelationships] = useState<GraphEdge[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [selectedCenter, setSelectedCenter] = useState<string | null>(null);
|
|
const [relTypes, setRelTypes] = useState<string[]>([]);
|
|
const [relFilter, setRelFilter] = useState<string | undefined>();
|
|
const [graphConfig, setGraphConfig] = useState<GraphConfig | null>(null);
|
|
const [fields, setFields] = useState<PluginFieldSchema[]>([]);
|
|
const [hoverState, setHoverState] = useState<HoverState>({ nodeId: null, x: 0, y: 0 });
|
|
const [canvasSize, setCanvasSize] = useState({ width: 800, height: 600 });
|
|
|
|
// ── Computed stats ──
|
|
|
|
const filteredRels = relFilter
|
|
? relationships.filter((r) => r.label === relFilter)
|
|
: relationships;
|
|
|
|
const visibleEdges = selectedCenter
|
|
? filteredRels.filter((r) => r.source === selectedCenter || r.target === selectedCenter)
|
|
: filteredRels;
|
|
|
|
const visibleNodeIds = new Set<string>();
|
|
if (selectedCenter) {
|
|
visibleNodeIds.add(selectedCenter);
|
|
for (const e of visibleEdges) {
|
|
visibleNodeIds.add(e.source);
|
|
visibleNodeIds.add(e.target);
|
|
}
|
|
}
|
|
const visibleNodes = selectedCenter
|
|
? customers.filter((n) => visibleNodeIds.has(n.id))
|
|
: customers;
|
|
|
|
const centerNode = customers.find((c) => c.id === selectedCenter);
|
|
const centerDegree = selectedCenter ? getNodeDegree(selectedCenter, visibleEdges) : 0;
|
|
|
|
// ── Schema loading ──
|
|
|
|
useEffect(() => {
|
|
if (!pluginId || !entityName) return;
|
|
const abortController = new AbortController();
|
|
|
|
async function loadSchema() {
|
|
try {
|
|
const schema: PluginSchemaResponse = await getPluginSchema(pluginId!);
|
|
if (abortController.signal.aborted) return;
|
|
|
|
const pages = schema.ui?.pages || [];
|
|
const graphPage = pages.find(
|
|
(p): p is typeof p & GraphConfig & { type: 'graph' } =>
|
|
p.type === 'graph' && p.entity === entityName,
|
|
);
|
|
if (graphPage) {
|
|
setGraphConfig({
|
|
entity: graphPage.entity,
|
|
relationshipEntity: graphPage.relationship_entity,
|
|
sourceField: graphPage.source_field,
|
|
targetField: graphPage.target_field,
|
|
edgeLabelField: graphPage.edge_label_field,
|
|
nodeLabelField: graphPage.node_label_field,
|
|
});
|
|
}
|
|
|
|
const entity = schema.entities?.find((e) => e.name === entityName);
|
|
if (entity) setFields(entity.fields);
|
|
} catch {
|
|
message.warning('Schema 加载失败,部分功能不可用');
|
|
}
|
|
}
|
|
|
|
loadSchema();
|
|
return () => abortController.abort();
|
|
}, [pluginId, entityName]);
|
|
|
|
// ── Data loading ──
|
|
|
|
useEffect(() => {
|
|
if (!pluginId || !graphConfig) return;
|
|
const abortController = new AbortController();
|
|
const gc = graphConfig;
|
|
const labelField = fields.find((f) => f.name === gc.nodeLabelField)?.name || fields[1]?.name || 'name';
|
|
|
|
async function loadData() {
|
|
setLoading(true);
|
|
try {
|
|
let allCustomers: GraphNode[] = [];
|
|
let page = 1;
|
|
let hasMore = true;
|
|
while (hasMore) {
|
|
if (abortController.signal.aborted) return;
|
|
const result = await listPluginData(pluginId!, gc.entity, page, 100);
|
|
allCustomers = [
|
|
...allCustomers,
|
|
...result.data.map((r) => ({
|
|
id: r.id,
|
|
label: String(r.data[labelField] || '未命名'),
|
|
data: r.data,
|
|
})),
|
|
];
|
|
hasMore = result.data.length === 100 && allCustomers.length < result.total;
|
|
page++;
|
|
}
|
|
if (abortController.signal.aborted) return;
|
|
setCustomers(allCustomers);
|
|
|
|
let allRels: GraphEdge[] = [];
|
|
page = 1;
|
|
hasMore = true;
|
|
const types = new Set<string>();
|
|
while (hasMore) {
|
|
if (abortController.signal.aborted) return;
|
|
const result = await listPluginData(pluginId!, gc.relationshipEntity, page, 100);
|
|
for (const r of result.data) {
|
|
const relType = String(r.data[gc.edgeLabelField] || '');
|
|
types.add(relType);
|
|
allRels.push({
|
|
source: String(r.data[gc.sourceField] || ''),
|
|
target: String(r.data[gc.targetField] || ''),
|
|
label: relType,
|
|
});
|
|
}
|
|
hasMore = result.data.length === 100 && allRels.length < result.total;
|
|
page++;
|
|
}
|
|
if (abortController.signal.aborted) return;
|
|
setRelationships(allRels);
|
|
setRelTypes(Array.from(types));
|
|
} catch {
|
|
message.warning('数据加载失败');
|
|
}
|
|
if (!abortController.signal.aborted) setLoading(false);
|
|
}
|
|
|
|
loadData();
|
|
return () => abortController.abort();
|
|
}, [pluginId, graphConfig, fields]);
|
|
|
|
// ── Canvas resize observer ──
|
|
|
|
useEffect(() => {
|
|
const container = containerRef.current;
|
|
if (!container) return;
|
|
|
|
const observer = new ResizeObserver((entries) => {
|
|
for (const entry of entries) {
|
|
const { width } = entry.contentRect;
|
|
if (width > 0) {
|
|
setCanvasSize({ width, height: Math.max(500, Math.min(700, width * 0.65)) });
|
|
}
|
|
}
|
|
});
|
|
|
|
observer.observe(container);
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
|
|
// ── Update refs for animation loop ──
|
|
|
|
useEffect(() => {
|
|
visibleNodesRef.current = visibleNodes;
|
|
visibleEdgesRef.current = visibleEdges;
|
|
}, [visibleNodes, visibleEdges]);
|
|
|
|
// ── Main canvas drawing with requestAnimationFrame ──
|
|
|
|
const drawGraph = useCallback(() => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return;
|
|
|
|
const nodes = visibleNodesRef.current;
|
|
const edges = visibleEdgesRef.current;
|
|
|
|
const width = canvasSize.width;
|
|
const height = canvasSize.height;
|
|
|
|
// High DPI support
|
|
const dpr = window.devicePixelRatio || 1;
|
|
canvas.width = width * dpr;
|
|
canvas.height = height * dpr;
|
|
canvas.style.width = `${width}px`;
|
|
canvas.style.height = `${height}px`;
|
|
ctx.scale(dpr, dpr);
|
|
|
|
// Theme-aware colors
|
|
const textColor = token.colorText;
|
|
const bgColor = token.colorBgContainer;
|
|
|
|
// Clear canvas
|
|
ctx.clearRect(0, 0, width, height);
|
|
|
|
// Background
|
|
ctx.fillStyle = bgColor;
|
|
ctx.fillRect(0, 0, width, height);
|
|
|
|
if (nodes.length === 0) return;
|
|
|
|
// Compute layout
|
|
const centerX = width / 2;
|
|
const centerY = height / 2;
|
|
const radius = Math.min(width, height) * 0.36;
|
|
|
|
const positions = computeCircularLayout(nodes, centerX, centerY, radius);
|
|
nodePositionsRef.current = positions;
|
|
|
|
// Precompute degrees for node sizing
|
|
const degreeMap = new Map<string, number>();
|
|
for (const node of nodes) {
|
|
degreeMap.set(node.id, getNodeDegree(node.id, edges));
|
|
}
|
|
|
|
// ── Draw edges first (behind nodes) ──
|
|
|
|
for (const edge of edges) {
|
|
const from = positions.get(edge.source);
|
|
const to = positions.get(edge.target);
|
|
if (!from || !to) continue;
|
|
|
|
const colors = getRelColor(edge.label);
|
|
const isHighlighted =
|
|
hoverState.nodeId === edge.source || hoverState.nodeId === edge.target;
|
|
const alpha = hoverState.nodeId ? (isHighlighted ? 1 : 0.15) : 0.7;
|
|
const lw = isHighlighted ? 2.5 : 1.5;
|
|
|
|
const labelPos = drawCurvedEdge(
|
|
ctx, from.x, from.y, to.x, to.y,
|
|
colors.base, lw, isHighlighted, alpha,
|
|
);
|
|
|
|
// Edge label
|
|
if (edge.label && labelPos) {
|
|
const labelAlpha = hoverState.nodeId ? (isHighlighted ? 1 : 0.1) : 0.9;
|
|
drawEdgeLabel(ctx, labelPos.labelX, labelPos.labelY - 10, edge.label, colors.base, labelAlpha);
|
|
}
|
|
}
|
|
|
|
// ── Draw nodes ──
|
|
|
|
for (const node of nodes) {
|
|
const pos = positions.get(node.id);
|
|
if (!pos) continue;
|
|
|
|
const isCenter = node.id === selectedCenter;
|
|
const isHovered = node.id === hoverState.nodeId;
|
|
const degree = degreeMap.get(node.id) || 0;
|
|
const r = degreeToRadius(degree, isCenter);
|
|
|
|
// Determine node color from its most common edge type, or default palette
|
|
let nodeColorBase = '#4F46E5';
|
|
let nodeColorLight = '#818CF8';
|
|
let nodeColorGlow = 'rgba(79,70,229,0.3)';
|
|
|
|
if (isCenter) {
|
|
const firstEdge = edges.find((e) => e.source === node.id || e.target === node.id);
|
|
if (firstEdge) {
|
|
const rc = getRelColor(firstEdge.label);
|
|
nodeColorBase = rc.base;
|
|
nodeColorLight = rc.light;
|
|
nodeColorGlow = rc.glow;
|
|
}
|
|
} else {
|
|
const idx = nodes.indexOf(node);
|
|
const palette = Object.values(RELATIONSHIP_COLORS);
|
|
const pick = palette[idx % palette.length];
|
|
nodeColorBase = pick.base;
|
|
nodeColorLight = pick.light;
|
|
nodeColorGlow = pick.glow;
|
|
}
|
|
|
|
const nodeAlpha = hoverState.nodeId
|
|
? (isHovered || (hoverState.nodeId && edges.some(
|
|
(e) => (e.source === hoverState.nodeId && e.target === node.id) ||
|
|
(e.target === hoverState.nodeId && e.source === node.id),
|
|
)) ? 1 : 0.2)
|
|
: 1;
|
|
|
|
drawNode(ctx, pos.x, pos.y, r, nodeColorBase, nodeColorLight, nodeColorGlow, isCenter, isHovered, nodeAlpha);
|
|
drawNodeLabel(ctx, pos.x, pos.y, r, node.label, textColor, isCenter, isHovered);
|
|
}
|
|
|
|
// ── Hover tooltip ──
|
|
|
|
if (hoverState.nodeId) {
|
|
const hoveredNode = nodes.find((n) => n.id === hoverState.nodeId);
|
|
if (hoveredNode) {
|
|
const degree = degreeMap.get(hoverState.nodeId) || 0;
|
|
const tooltipText = `${hoveredNode.label} (${degree} 条关系)`;
|
|
ctx.save();
|
|
ctx.font = '12px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
|
const metrics = ctx.measureText(tooltipText);
|
|
const tw = metrics.width + 16;
|
|
const th = 28;
|
|
const tx = hoverState.x - tw / 2;
|
|
const ty = hoverState.y - 40;
|
|
|
|
ctx.fillStyle = token.colorBgElevated;
|
|
ctx.shadowColor = 'rgba(0,0,0,0.15)';
|
|
ctx.shadowBlur = 8;
|
|
ctx.beginPath();
|
|
ctx.roundRect(tx, ty, tw, th, 6);
|
|
ctx.fill();
|
|
|
|
ctx.shadowBlur = 0;
|
|
ctx.fillStyle = token.colorText;
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(tooltipText, hoverState.x, ty + th / 2);
|
|
ctx.restore();
|
|
}
|
|
}
|
|
}, [canvasSize, selectedCenter, hoverState, token]);
|
|
|
|
// ── Animation loop ──
|
|
|
|
useEffect(() => {
|
|
const animate = () => {
|
|
drawGraph();
|
|
animFrameRef.current = requestAnimationFrame(animate);
|
|
};
|
|
animFrameRef.current = requestAnimationFrame(animate);
|
|
return () => cancelAnimationFrame(animFrameRef.current);
|
|
}, [drawGraph]);
|
|
|
|
// ── Mouse interaction handlers ──
|
|
|
|
const handleCanvasMouseMove = useCallback(
|
|
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
|
|
const rect = canvas.getBoundingClientRect();
|
|
const x = e.clientX - rect.left;
|
|
const y = e.clientY - rect.top;
|
|
const positions = nodePositionsRef.current;
|
|
const nodes = visibleNodesRef.current;
|
|
const edges = visibleEdgesRef.current;
|
|
|
|
let foundId: string | null = null;
|
|
for (const node of nodes) {
|
|
const pos = positions.get(node.id);
|
|
if (!pos) continue;
|
|
const degree = getNodeDegree(node.id, edges);
|
|
const r = degreeToRadius(degree, node.id === selectedCenter) * NODE_HOVER_SCALE;
|
|
const dx = x - pos.x;
|
|
const dy = y - pos.y;
|
|
if (dx * dx + dy * dy < r * r) {
|
|
foundId = node.id;
|
|
break;
|
|
}
|
|
}
|
|
|
|
canvas.style.cursor = foundId ? 'pointer' : 'default';
|
|
setHoverState((prev) => {
|
|
if (prev.nodeId === foundId) return prev;
|
|
return { nodeId: foundId, x, y };
|
|
});
|
|
|
|
if (foundId) {
|
|
setHoverState({ nodeId: foundId, x, y });
|
|
}
|
|
},
|
|
[selectedCenter],
|
|
);
|
|
|
|
const handleCanvasMouseLeave = useCallback(() => {
|
|
setHoverState({ nodeId: null, x: 0, y: 0 });
|
|
}, []);
|
|
|
|
const handleCanvasClick = useCallback(
|
|
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
|
|
const rect = canvas.getBoundingClientRect();
|
|
const x = e.clientX - rect.left;
|
|
const y = e.clientY - rect.top;
|
|
const positions = nodePositionsRef.current;
|
|
const nodes = visibleNodesRef.current;
|
|
const edges = visibleEdgesRef.current;
|
|
|
|
for (const node of nodes) {
|
|
const pos = positions.get(node.id);
|
|
if (!pos) continue;
|
|
const degree = getNodeDegree(node.id, edges);
|
|
const r = degreeToRadius(degree, node.id === selectedCenter);
|
|
const dx = x - pos.x;
|
|
const dy = y - pos.y;
|
|
if (dx * dx + dy * dy < r * r) {
|
|
setSelectedCenter((prev) => (prev === node.id ? null : node.id));
|
|
return;
|
|
}
|
|
}
|
|
},
|
|
[selectedCenter],
|
|
);
|
|
|
|
// ── Legend data ──
|
|
|
|
const legendItems = relTypes.map((type) => ({
|
|
label: getEdgeTypeLabel(type),
|
|
rawLabel: type,
|
|
color: getRelColor(type).base,
|
|
count: relationships.filter((r) => r.label === type).length,
|
|
}));
|
|
|
|
// ── Render ──
|
|
|
|
if (loading) {
|
|
return (
|
|
<div style={{ padding: 24, textAlign: 'center' }}>
|
|
<Spin size="large" tip="加载图谱数据中..." />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div style={{ padding: 24 }}>
|
|
{/* Stats Row */}
|
|
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
|
<Col xs={24} sm={8}>
|
|
<Card
|
|
size="small"
|
|
style={{ borderLeft: `3px solid ${token.colorPrimary}` }}
|
|
>
|
|
<Statistic
|
|
title={
|
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
|
<TeamOutlined style={{ marginRight: 4 }} />
|
|
客户总数
|
|
</Text>
|
|
}
|
|
value={customers.length}
|
|
valueStyle={{ color: token.colorPrimary, fontWeight: 600 }}
|
|
/>
|
|
</Card>
|
|
</Col>
|
|
<Col xs={24} sm={8}>
|
|
<Card
|
|
size="small"
|
|
style={{ borderLeft: `3px solid ${token.colorSuccess}` }}
|
|
>
|
|
<Statistic
|
|
title={
|
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
|
<NodeIndexOutlined style={{ marginRight: 4 }} />
|
|
关系总数
|
|
</Text>
|
|
}
|
|
value={relationships.length}
|
|
valueStyle={{ color: token.colorSuccess, fontWeight: 600 }}
|
|
/>
|
|
</Card>
|
|
</Col>
|
|
<Col xs={24} sm={8}>
|
|
<Card
|
|
size="small"
|
|
style={{ borderLeft: `3px solid ${token.colorWarning}` }}
|
|
>
|
|
<Statistic
|
|
title={
|
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
|
<AimOutlined style={{ marginRight: 4 }} />
|
|
当前中心
|
|
</Text>
|
|
}
|
|
value={centerNode?.label || '未选择'}
|
|
valueStyle={{
|
|
fontSize: 20,
|
|
color: centerNode ? token.colorWarning : token.colorTextDisabled,
|
|
fontWeight: 600,
|
|
}}
|
|
/>
|
|
{selectedCenter && (
|
|
<Text type="secondary" style={{ fontSize: 11 }}>
|
|
{centerDegree} 条直接关系
|
|
</Text>
|
|
)}
|
|
</Card>
|
|
</Col>
|
|
</Row>
|
|
|
|
{/* Main Graph Card */}
|
|
<Card
|
|
title={
|
|
<Space>
|
|
<ApartmentOutlined />
|
|
<span>客户关系图谱</span>
|
|
{relFilter && (
|
|
<Tag
|
|
color="blue"
|
|
closable
|
|
onClose={() => setRelFilter(undefined)}
|
|
>
|
|
{getEdgeTypeLabel(relFilter)}
|
|
</Tag>
|
|
)}
|
|
</Space>
|
|
}
|
|
size="small"
|
|
extra={
|
|
<Space wrap>
|
|
<Select
|
|
placeholder="筛选关系类型"
|
|
allowClear
|
|
style={{ width: 150 }}
|
|
value={relFilter}
|
|
options={relTypes.map((t) => ({
|
|
label: (
|
|
<Space>
|
|
<span
|
|
style={{
|
|
display: 'inline-block',
|
|
width: 10,
|
|
height: 10,
|
|
borderRadius: '50%',
|
|
backgroundColor: getRelColor(t).base,
|
|
}}
|
|
/>
|
|
{getEdgeTypeLabel(t)}
|
|
<Text type="secondary" style={{ fontSize: 11 }}>
|
|
({relationships.filter((r) => r.label === t).length})
|
|
</Text>
|
|
</Space>
|
|
),
|
|
value: t,
|
|
}))}
|
|
onChange={(v) => setRelFilter(v)}
|
|
/>
|
|
<Select
|
|
placeholder="选择中心客户"
|
|
allowClear
|
|
showSearch
|
|
style={{ width: 200 }}
|
|
optionFilterProp="label"
|
|
value={selectedCenter || undefined}
|
|
options={customers.map((c) => ({
|
|
label: c.label,
|
|
value: c.id,
|
|
}))}
|
|
onChange={(v) => setSelectedCenter(v || null)}
|
|
/>
|
|
</Space>
|
|
}
|
|
>
|
|
{customers.length === 0 ? (
|
|
<Empty
|
|
description="暂无客户数据"
|
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
|
/>
|
|
) : (
|
|
<div ref={containerRef} style={{ position: 'relative' }}>
|
|
<canvas
|
|
ref={canvasRef}
|
|
onMouseMove={handleCanvasMouseMove}
|
|
onMouseLeave={handleCanvasMouseLeave}
|
|
onClick={handleCanvasClick}
|
|
style={{
|
|
width: '100%',
|
|
height: canvasSize.height,
|
|
borderRadius: 8,
|
|
border: `1px solid ${token.colorBorderSecondary}`,
|
|
display: 'block',
|
|
}}
|
|
/>
|
|
|
|
{/* Legend overlay */}
|
|
{legendItems.length > 0 && (
|
|
<div
|
|
style={{
|
|
position: 'absolute',
|
|
bottom: 12,
|
|
left: 12,
|
|
background: token.colorBgElevated,
|
|
border: `1px solid ${token.colorBorderSecondary}`,
|
|
borderRadius: 8,
|
|
padding: '8px 12px',
|
|
boxShadow: token.boxShadowSecondary,
|
|
maxWidth: 220,
|
|
}}
|
|
>
|
|
<Text
|
|
strong
|
|
style={{ fontSize: 11, color: token.colorTextSecondary, display: 'block', marginBottom: 4 }}
|
|
>
|
|
关系类型图例
|
|
</Text>
|
|
<Flex wrap="wrap" gap={6}>
|
|
{legendItems.map((item) => (
|
|
<Tag
|
|
key={item.rawLabel}
|
|
color={item.color}
|
|
style={{
|
|
margin: 0,
|
|
fontSize: 11,
|
|
cursor: relFilter === item.rawLabel ? 'default' : 'pointer',
|
|
opacity: relFilter && relFilter !== item.rawLabel ? 0.4 : 1,
|
|
}}
|
|
onClick={() => {
|
|
setRelFilter((prev) =>
|
|
prev === item.rawLabel ? undefined : item.rawLabel,
|
|
);
|
|
}}
|
|
>
|
|
{item.label} ({item.count})
|
|
</Tag>
|
|
))}
|
|
</Flex>
|
|
</div>
|
|
)}
|
|
|
|
{/* Info overlay */}
|
|
{hoverState.nodeId && (
|
|
<div
|
|
style={{
|
|
position: 'absolute',
|
|
top: 12,
|
|
right: 12,
|
|
background: token.colorBgElevated,
|
|
border: `1px solid ${token.colorBorderSecondary}`,
|
|
borderRadius: 8,
|
|
padding: '8px 12px',
|
|
boxShadow: token.boxShadowSecondary,
|
|
maxWidth: 280,
|
|
transition: 'opacity 0.15s ease',
|
|
}}
|
|
>
|
|
<Space direction="vertical" size={4}>
|
|
<Text strong>
|
|
{visibleNodes.find((n) => n.id === hoverState.nodeId)?.label}
|
|
</Text>
|
|
<Text type="secondary" style={{ fontSize: 11 }}>
|
|
<InfoCircleOutlined style={{ marginRight: 4 }} />
|
|
点击节点设为中心 / 再次点击取消
|
|
</Text>
|
|
</Space>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</Card>
|
|
|
|
{/* Selected node detail panel */}
|
|
{selectedCenter && centerNode && (
|
|
<Card
|
|
size="small"
|
|
style={{ marginTop: 16 }}
|
|
title={
|
|
<Space>
|
|
<Badge color={token.colorPrimary} />
|
|
<Text strong>{centerNode.label}</Text>
|
|
<Text type="secondary">— 详细信息</Text>
|
|
</Space>
|
|
}
|
|
extra={
|
|
<Tooltip title="取消选中">
|
|
<Text
|
|
type="secondary"
|
|
style={{ cursor: 'pointer', fontSize: 12 }}
|
|
onClick={() => setSelectedCenter(null)}
|
|
>
|
|
<ReloadOutlined style={{ marginRight: 4 }} />
|
|
重置视图
|
|
</Text>
|
|
</Tooltip>
|
|
}
|
|
>
|
|
<Row gutter={[16, 12]}>
|
|
{Object.entries(centerNode.data).map(([key, value]) => {
|
|
if (value == null || value === '') return null;
|
|
const fieldSchema = fields.find((f) => f.name === key);
|
|
const displayName = fieldSchema?.display_name || key;
|
|
return (
|
|
<Col xs={12} sm={8} md={6} key={key}>
|
|
<Text type="secondary" style={{ fontSize: 11, display: 'block' }}>
|
|
{displayName}
|
|
</Text>
|
|
<Text style={{ fontSize: 13 }}>{String(value)}</Text>
|
|
</Col>
|
|
);
|
|
})}
|
|
</Row>
|
|
<Divider style={{ margin: '12px 0 8px' }} />
|
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
|
直接关系: {centerDegree} 条 —
|
|
显示 {visibleNodes.length} 个节点、{visibleEdges.length} 条边
|
|
</Text>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|