- EntitySelect: 未使用的 searchFields 改为 _searchFields - PluginKanbanPage: DragEndEvent/DragStartEvent 改为 type import, lane_order 改为 optional - PluginDashboardPage: 添加 PluginPageSchema import, 移除未使用的 CHART_COLORS/palette/totalCount - PluginGraphPage: 移除未使用的 Title/textColor, 修复 hovered → hoverState
1080 lines
32 KiB
TypeScript
1080 lines
32 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';
|
|
|
|
const { Text } = Typography;
|
|
|
|
// ── Types ──
|
|
|
|
interface GraphNode {
|
|
id: string;
|
|
label: string;
|
|
data: Record<string, unknown>;
|
|
}
|
|
|
|
interface GraphEdge {
|
|
source: string;
|
|
target: string;
|
|
label: string;
|
|
}
|
|
|
|
interface GraphConfig {
|
|
entity: string;
|
|
relationshipEntity: string;
|
|
sourceField: string;
|
|
targetField: string;
|
|
edgeLabelField: string;
|
|
nodeLabelField: string;
|
|
}
|
|
|
|
interface NodePosition {
|
|
x: number;
|
|
y: number;
|
|
vx: number;
|
|
vy: number;
|
|
}
|
|
|
|
interface HoverState {
|
|
nodeId: string | null;
|
|
x: number;
|
|
y: number;
|
|
}
|
|
|
|
// ── Constants ──
|
|
|
|
const RELATIONSHIP_COLORS: Record<string, { base: string; light: string; glow: string }> = {
|
|
parent_child: { base: '#4F46E5', light: '#818CF8', glow: 'rgba(79,70,229,0.3)' },
|
|
sibling: { base: '#059669', light: '#34D399', glow: 'rgba(5,150,105,0.3)' },
|
|
partner: { base: '#D97706', light: '#FBBF24', glow: 'rgba(217,119,6,0.3)' },
|
|
supplier: { base: '#0891B2', light: '#22D3EE', glow: 'rgba(8,145,178,0.3)' },
|
|
competitor: { base: '#DC2626', light: '#F87171', glow: 'rgba(220,38,38,0.3)' },
|
|
};
|
|
|
|
const DEFAULT_REL_COLOR = { base: '#7C3AED', light: '#A78BFA', glow: 'rgba(124,58,237,0.3)' };
|
|
|
|
const REL_LABEL_MAP: Record<string, string> = {
|
|
parent_child: '母子',
|
|
sibling: '兄弟',
|
|
partner: '伙伴',
|
|
supplier: '供应商',
|
|
competitor: '竞争',
|
|
};
|
|
|
|
const NODE_BASE_RADIUS = 18;
|
|
const NODE_CENTER_RADIUS = 26;
|
|
const NODE_HOVER_SCALE = 1.3;
|
|
const LABEL_MAX_LENGTH = 8;
|
|
|
|
// ── Helpers ──
|
|
|
|
function getRelColor(label: string) {
|
|
return RELATIONSHIP_COLORS[label] || DEFAULT_REL_COLOR;
|
|
}
|
|
|
|
function getEdgeTypeLabel(label: string): string {
|
|
return REL_LABEL_MAP[label] || label;
|
|
}
|
|
|
|
function truncateLabel(label: string): string {
|
|
return label.length > LABEL_MAX_LENGTH
|
|
? label.slice(0, LABEL_MAX_LENGTH) + '...'
|
|
: label;
|
|
}
|
|
|
|
/** Compute color for a node based on its connection count (degree). */
|
|
function getNodeDegree(nodeId: string, edges: GraphEdge[]): number {
|
|
return edges.filter((e) => e.source === nodeId || e.target === nodeId).length;
|
|
}
|
|
|
|
/** Map a degree value to a node radius — more connections = larger node. */
|
|
function degreeToRadius(degree: number, isCenter: boolean): number {
|
|
const base = isCenter ? NODE_CENTER_RADIUS : NODE_BASE_RADIUS;
|
|
const bonus = Math.min(degree * 1.2, 10);
|
|
return base + bonus;
|
|
}
|
|
|
|
/** Calculate circular layout positions. */
|
|
function computeCircularLayout(
|
|
nodes: GraphNode[],
|
|
centerX: number,
|
|
centerY: number,
|
|
radius: number,
|
|
): Map<string, NodePosition> {
|
|
const positions = new Map<string, NodePosition>();
|
|
const count = nodes.length;
|
|
if (count === 0) return positions;
|
|
|
|
if (count === 1) {
|
|
positions.set(nodes[0].id, { x: centerX, y: centerY, vx: 0, vy: 0 });
|
|
return positions;
|
|
}
|
|
|
|
nodes.forEach((node, i) => {
|
|
const angle = (2 * Math.PI * i) / count - Math.PI / 2;
|
|
positions.set(node.id, {
|
|
x: centerX + radius * Math.cos(angle),
|
|
y: centerY + radius * Math.sin(angle),
|
|
vx: 0,
|
|
vy: 0,
|
|
});
|
|
});
|
|
|
|
return positions;
|
|
}
|
|
|
|
/** Draw a quadratic bezier curved edge with an arrowhead. */
|
|
function drawCurvedEdge(
|
|
ctx: CanvasRenderingContext2D,
|
|
fromX: number,
|
|
fromY: number,
|
|
toX: number,
|
|
toY: number,
|
|
color: string,
|
|
lineWidth: number,
|
|
highlighted: boolean,
|
|
alpha: number,
|
|
) {
|
|
const dx = toX - fromX;
|
|
const dy = toY - fromY;
|
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
if (dist < 1) return;
|
|
|
|
// Control point offset perpendicular to the edge midpoint
|
|
const curvature = Math.min(dist * 0.15, 30);
|
|
const midX = (fromX + toX) / 2;
|
|
const midY = (fromY + toY) / 2;
|
|
const nx = -dy / dist;
|
|
const ny = dx / dist;
|
|
const cpX = midX + nx * curvature;
|
|
const cpY = midY + ny * curvature;
|
|
|
|
ctx.save();
|
|
ctx.globalAlpha = alpha;
|
|
ctx.strokeStyle = color;
|
|
ctx.lineWidth = highlighted ? lineWidth + 1 : lineWidth;
|
|
|
|
if (highlighted) {
|
|
ctx.shadowColor = color;
|
|
ctx.shadowBlur = 8;
|
|
}
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(fromX, fromY);
|
|
ctx.quadraticCurveTo(cpX, cpY, toX, toY);
|
|
ctx.stroke();
|
|
|
|
// Arrowhead at target end
|
|
const t = 0.95;
|
|
const arrowT = t;
|
|
const tangentX = 2 * (1 - arrowT) * (cpX - fromX) + 2 * arrowT * (toX - cpX);
|
|
const tangentY = 2 * (1 - arrowT) * (cpY - fromY) + 2 * arrowT * (toY - cpY);
|
|
const tangentLen = Math.sqrt(tangentX * tangentX + tangentY * tangentY);
|
|
if (tangentLen < 1) {
|
|
ctx.restore();
|
|
return;
|
|
}
|
|
|
|
const arrowSize = highlighted ? 10 : 7;
|
|
const ax = tangentX / tangentLen;
|
|
const ay = tangentY / tangentLen;
|
|
|
|
// Point slightly before target to avoid overlapping the node circle
|
|
const endX = toX - ax * 4;
|
|
const endY = toY - ay * 4;
|
|
|
|
ctx.fillStyle = color;
|
|
ctx.beginPath();
|
|
ctx.moveTo(endX, endY);
|
|
ctx.lineTo(endX - arrowSize * ax + arrowSize * 0.4 * ay, endY - arrowSize * ay - arrowSize * 0.4 * ax);
|
|
ctx.lineTo(endX - arrowSize * ax - arrowSize * 0.4 * ay, endY - arrowSize * ay + arrowSize * 0.4 * ax);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
|
|
ctx.restore();
|
|
|
|
// Return the midpoint for label placement
|
|
return { labelX: cpX, labelY: cpY };
|
|
}
|
|
|
|
/** Draw a gradient-filled node circle with shadow. */
|
|
function drawNode(
|
|
ctx: CanvasRenderingContext2D,
|
|
x: number,
|
|
y: number,
|
|
radius: number,
|
|
color: string,
|
|
lightColor: string,
|
|
glowColor: string,
|
|
isCenter: boolean,
|
|
isHovered: boolean,
|
|
alpha: number,
|
|
) {
|
|
ctx.save();
|
|
ctx.globalAlpha = alpha;
|
|
|
|
const r = isHovered ? radius * NODE_HOVER_SCALE : radius;
|
|
|
|
// Outer glow / shadow
|
|
if (isCenter || isHovered) {
|
|
ctx.shadowColor = glowColor;
|
|
ctx.shadowBlur = isCenter ? 20 : 14;
|
|
} else {
|
|
ctx.shadowColor = 'rgba(0,0,0,0.12)';
|
|
ctx.shadowBlur = 6;
|
|
}
|
|
|
|
// Gradient fill
|
|
const gradient = ctx.createRadialGradient(x - r * 0.3, y - r * 0.3, 0, x, y, r);
|
|
if (isCenter) {
|
|
gradient.addColorStop(0, lightColor);
|
|
gradient.addColorStop(1, color);
|
|
} else {
|
|
gradient.addColorStop(0, 'rgba(255,255,255,0.9)');
|
|
gradient.addColorStop(1, 'rgba(255,255,255,0.4)');
|
|
}
|
|
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, r, 0, 2 * Math.PI);
|
|
ctx.fillStyle = gradient;
|
|
ctx.fill();
|
|
|
|
// Border
|
|
ctx.shadowColor = 'transparent';
|
|
ctx.shadowBlur = 0;
|
|
ctx.strokeStyle = isCenter ? color : lightColor;
|
|
ctx.lineWidth = isCenter ? 3 : 1.5;
|
|
if (isHovered) {
|
|
ctx.lineWidth += 1;
|
|
}
|
|
ctx.stroke();
|
|
|
|
// Inner highlight ring for center node
|
|
if (isCenter) {
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, r + 4, 0, 2 * Math.PI);
|
|
ctx.strokeStyle = glowColor;
|
|
ctx.lineWidth = 2;
|
|
ctx.setLineDash([4, 4]);
|
|
ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
}
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
/** Draw the edge label with background pill. */
|
|
function drawEdgeLabel(
|
|
ctx: CanvasRenderingContext2D,
|
|
x: number,
|
|
y: number,
|
|
label: string,
|
|
color: string,
|
|
alpha: number,
|
|
) {
|
|
ctx.save();
|
|
ctx.globalAlpha = alpha * 0.85;
|
|
|
|
const display = getEdgeTypeLabel(label);
|
|
ctx.font = '11px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
|
const metrics = ctx.measureText(display);
|
|
const textWidth = metrics.width;
|
|
const padding = 6;
|
|
const pillHeight = 18;
|
|
const pillRadius = 9;
|
|
|
|
// Background pill
|
|
const pillX = x - textWidth / 2 - padding;
|
|
const pillY = y - pillHeight / 2;
|
|
|
|
ctx.fillStyle = color + '18';
|
|
ctx.beginPath();
|
|
ctx.roundRect(pillX, pillY, textWidth + padding * 2, pillHeight, pillRadius);
|
|
ctx.fill();
|
|
|
|
ctx.strokeStyle = color + '40';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.roundRect(pillX, pillY, textWidth + padding * 2, pillHeight, pillRadius);
|
|
ctx.stroke();
|
|
|
|
// Text
|
|
ctx.fillStyle = color;
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(display, x, y);
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
/** Draw a node label below the node. */
|
|
function drawNodeLabel(
|
|
ctx: CanvasRenderingContext2D,
|
|
x: number,
|
|
y: number,
|
|
radius: number,
|
|
label: string,
|
|
textColor: string,
|
|
isCenter: boolean,
|
|
isHovered: boolean,
|
|
) {
|
|
ctx.save();
|
|
const display = truncateLabel(label);
|
|
const r = isHovered ? radius * NODE_HOVER_SCALE : radius;
|
|
const fontSize = isCenter ? 13 : isHovered ? 12 : 11;
|
|
const fontWeight = isCenter ? '600' : isHovered ? '500' : '400';
|
|
|
|
ctx.font = `${fontWeight} ${fontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`;
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'top';
|
|
ctx.fillStyle = textColor;
|
|
|
|
if (isCenter || isHovered) {
|
|
ctx.globalAlpha = 1;
|
|
} else {
|
|
ctx.globalAlpha = 0.7;
|
|
}
|
|
|
|
ctx.fillText(display, x, y + r + 8);
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* 插件关系图谱页面 — 通过路由参数自加载 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) {
|
|
// Use the first connected edge color for center node
|
|
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 {
|
|
// Assign color based on index for non-center nodes
|
|
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>
|
|
);
|
|
}
|