- 新增 spreadsheet-preview 组件及 xlsx 解析工具,支持产物中的表格文件预览 - 优化知识图谱画布、详情面板及样式 - 调整 Agent 详情面板、聊天与消息列表交互 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
900 lines
29 KiB
TypeScript
900 lines
29 KiB
TypeScript
import {
|
|
forwardRef,
|
|
useCallback,
|
|
useEffect,
|
|
useImperativeHandle,
|
|
useMemo,
|
|
useRef,
|
|
type ReactNode,
|
|
} from "react";
|
|
|
|
import type {
|
|
GraphEdge,
|
|
GraphNode,
|
|
LayoutType,
|
|
SimEdge,
|
|
SimNode,
|
|
TooltipState,
|
|
Transform,
|
|
} from "./graphTypes";
|
|
import {
|
|
applyCircularLayout,
|
|
ForceSimulation,
|
|
getEdgeId,
|
|
getEdgeInfo,
|
|
getEdgeLabel,
|
|
getLinkDistance,
|
|
getNodeKind,
|
|
getNodeRadius,
|
|
getNodeTitle,
|
|
NODE_KIND_META,
|
|
seedPositions,
|
|
truncateLabel,
|
|
} from "./graphUtils";
|
|
|
|
const MIN_ZOOM = 0.15;
|
|
const MAX_ZOOM = 4;
|
|
|
|
/** Canvas-drawn colors per app theme (CSS variables don't reach the 2D context). */
|
|
const CANVAS_PALETTE = {
|
|
dark: {
|
|
exportBg: "#0b1020",
|
|
labelHalo: "rgba(2, 6, 23, 0.9)",
|
|
nodeRim: "rgba(2, 6, 23, 0.55)",
|
|
rootRim: "#e0f2fe",
|
|
expandedRim: "#7dd3fc",
|
|
rootRing: "rgba(125, 211, 252, 0.9)",
|
|
expandedDot: "#7dd3fc",
|
|
expandedDotRim: "rgba(2, 6, 23, 0.8)",
|
|
selectionRing: "#ffffff",
|
|
hoverRing: "rgba(255, 255, 255, 0.85)",
|
|
pin: "rgba(255, 255, 255, 0.8)",
|
|
labelActive: "#ffffff",
|
|
label: "#e2e8f0",
|
|
minimapViewport: "rgba(56, 189, 248, 0.8)",
|
|
},
|
|
light: {
|
|
exportBg: "#f8fafc",
|
|
labelHalo: "rgba(255, 255, 255, 0.92)",
|
|
nodeRim: "rgba(255, 255, 255, 0.9)",
|
|
rootRim: "#075985",
|
|
expandedRim: "#0284c7",
|
|
rootRing: "rgba(2, 132, 199, 0.85)",
|
|
expandedDot: "#0284c7",
|
|
expandedDotRim: "rgba(255, 255, 255, 0.9)",
|
|
selectionRing: "#0f172a",
|
|
hoverRing: "rgba(15, 23, 42, 0.7)",
|
|
pin: "rgba(15, 23, 42, 0.6)",
|
|
labelActive: "#0f172a",
|
|
label: "#334155",
|
|
minimapViewport: "rgba(2, 132, 199, 0.8)",
|
|
},
|
|
} as const;
|
|
|
|
export interface GraphCanvasHandle {
|
|
zoomIn: () => void;
|
|
zoomOut: () => void;
|
|
fitView: () => void;
|
|
relayout: () => void;
|
|
focusNode: (id: string) => void;
|
|
exportImage: () => void;
|
|
}
|
|
|
|
interface GraphCanvasProps {
|
|
nodes: GraphNode[];
|
|
edges: GraphEdge[];
|
|
/** Degrees computed on the FULL graph so node sizes stay stable under filters. */
|
|
degrees: Map<string, number>;
|
|
selectedNodeId: string | null;
|
|
/** null = no active search; otherwise the set of matching node ids. */
|
|
searchMatchIds: Set<string> | null;
|
|
layoutType: LayoutType;
|
|
showLabels: boolean;
|
|
/** App theme; drives the canvas-drawn colors (labels, rims, rings). */
|
|
theme: "light" | "dark";
|
|
onSelectNode: (id: string | null) => void;
|
|
onTooltip: (tooltip: TooltipState | null) => void;
|
|
onZoomChange: (k: number) => void;
|
|
/**
|
|
* Explore-mode rendering context; null/undefined renders the normal full view.
|
|
* preview = no root picked yet: nodes are shown faded as pickable起点 candidates.
|
|
*/
|
|
explore?: {
|
|
preview: boolean;
|
|
rootId: string | null;
|
|
nodeDepthMap: Map<string, number>;
|
|
expandedNodeIds: Set<string>;
|
|
} | null;
|
|
/** Floating pills rendered top-left over the canvas. */
|
|
toolbar?: ReactNode;
|
|
/** Small floating hint rendered bottom-center over the canvas. */
|
|
hint?: ReactNode;
|
|
/** Centered overlay (empty / loading states). */
|
|
overlay?: ReactNode;
|
|
}
|
|
|
|
interface DragState {
|
|
pointerId: number;
|
|
node: SimNode | null;
|
|
startX: number;
|
|
startY: number;
|
|
originX: number;
|
|
originY: number;
|
|
moved: boolean;
|
|
}
|
|
|
|
const easeCubicOut = (t: number) => 1 - Math.pow(1 - t, 3);
|
|
|
|
export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
|
function GraphCanvas(props, ref) {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const minimapRef = useRef<HTMLCanvasElement>(null);
|
|
|
|
const simRef = useRef<ForceSimulation | null>(null);
|
|
const nodeMapRef = useRef<Map<string, SimNode>>(new Map());
|
|
const adjacencyRef = useRef<Map<string, Set<string>>>(new Map());
|
|
const labelThresholdRef = useRef(3);
|
|
|
|
const transformRef = useRef<Transform>({ x: 0, y: 0, k: 0 });
|
|
const sizeRef = useRef({ w: 0, h: 0 });
|
|
const hoverRef = useRef<SimNode | null>(null);
|
|
const dragRef = useRef<DragState | null>(null);
|
|
const animRef = useRef<{
|
|
from: Transform;
|
|
to: Transform;
|
|
start: number;
|
|
duration: number;
|
|
} | null>(null);
|
|
const needsDrawRef = useRef(true);
|
|
const autoFitRef = useRef(false);
|
|
const rafRef = useRef(0);
|
|
|
|
// Latest props readable from the rAF loop / event handlers.
|
|
const propsRef = useRef(props);
|
|
propsRef.current = props;
|
|
|
|
const requestDraw = useCallback(() => {
|
|
needsDrawRef.current = true;
|
|
}, []);
|
|
|
|
// ---------------------------------------------------------------- build
|
|
|
|
useEffect(() => {
|
|
const previous = nodeMapRef.current;
|
|
const simNodes: SimNode[] = props.nodes.map((n) => {
|
|
const degree = props.degrees.get(n.id) ?? 0;
|
|
const kind = getNodeKind(n);
|
|
const old = previous.get(n.id);
|
|
return {
|
|
id: n.id,
|
|
node: n,
|
|
kind,
|
|
degree,
|
|
radius: getNodeRadius(degree),
|
|
color: NODE_KIND_META[kind].color,
|
|
x: old?.x ?? Number.NaN,
|
|
y: old?.y ?? Number.NaN,
|
|
vx: 0,
|
|
vy: 0,
|
|
fx: old?.fx ?? null,
|
|
fy: old?.fy ?? null,
|
|
};
|
|
});
|
|
|
|
const fresh = simNodes.filter((n) => Number.isNaN(n.x));
|
|
if (fresh.length === simNodes.length) {
|
|
seedPositions(simNodes);
|
|
} else {
|
|
// Filters re-enabled some nodes: spiral the newcomers in near the center.
|
|
fresh.forEach((n, i) => {
|
|
const r = 60 + 16 * Math.sqrt(i + 1);
|
|
const angle = i * 2.3999632297;
|
|
n.x = r * Math.cos(angle);
|
|
n.y = r * Math.sin(angle);
|
|
});
|
|
}
|
|
|
|
const byId = new Map(simNodes.map((n) => [n.id, n]));
|
|
const simEdges: SimEdge[] = [];
|
|
const adjacency = new Map<string, Set<string>>();
|
|
for (const e of props.edges) {
|
|
const source = byId.get(e.source);
|
|
const target = byId.get(e.target);
|
|
if (!source || !target) continue;
|
|
const label = getEdgeLabel(e);
|
|
simEdges.push({
|
|
id: getEdgeId(e),
|
|
source,
|
|
target,
|
|
label,
|
|
info: getEdgeInfo(label),
|
|
weight: e.weight ?? 1,
|
|
distance: getLinkDistance(label) + source.radius + target.radius,
|
|
});
|
|
if (!adjacency.has(source.id)) adjacency.set(source.id, new Set());
|
|
if (!adjacency.has(target.id)) adjacency.set(target.id, new Set());
|
|
adjacency.get(source.id)!.add(target.id);
|
|
adjacency.get(target.id)!.add(source.id);
|
|
}
|
|
|
|
// Label budget: always label hubs; ~20 labels max by default.
|
|
const sortedDegrees = simNodes.map((n) => n.degree).sort((a, b) => b - a);
|
|
labelThresholdRef.current = Math.max(
|
|
2,
|
|
sortedDegrees[Math.min(19, sortedDegrees.length - 1)] ?? 2,
|
|
);
|
|
|
|
nodeMapRef.current = byId;
|
|
adjacencyRef.current = adjacency;
|
|
const sim = new ForceSimulation(simNodes, simEdges);
|
|
if (props.layoutType === "circular") {
|
|
applyCircularLayout(simNodes);
|
|
sim.alpha = 0;
|
|
} else {
|
|
sim.alpha = previous.size === 0 ? 1 : 0.5;
|
|
}
|
|
simRef.current = sim;
|
|
if (previous.size === 0) autoFitRef.current = false;
|
|
requestDraw();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [props.nodes, props.edges, props.degrees]);
|
|
|
|
// Layout switching.
|
|
const layoutRef = useRef(props.layoutType);
|
|
useEffect(() => {
|
|
if (layoutRef.current === props.layoutType) return;
|
|
layoutRef.current = props.layoutType;
|
|
const sim = simRef.current;
|
|
if (!sim) return;
|
|
if (props.layoutType === "circular") {
|
|
applyCircularLayout(sim.nodes);
|
|
sim.alpha = 0;
|
|
} else {
|
|
for (const n of sim.nodes) {
|
|
n.fx = null;
|
|
n.fy = null;
|
|
}
|
|
sim.reheat(0.9);
|
|
}
|
|
autoFitRef.current = false;
|
|
requestDraw();
|
|
}, [props.layoutType, requestDraw]);
|
|
|
|
// Redraw on highlight-affecting prop changes.
|
|
useEffect(() => {
|
|
requestDraw();
|
|
}, [
|
|
props.selectedNodeId,
|
|
props.searchMatchIds,
|
|
props.showLabels,
|
|
props.explore,
|
|
props.theme,
|
|
requestDraw,
|
|
]);
|
|
|
|
// ------------------------------------------------------------ transform
|
|
|
|
const clampZoom = (k: number) => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, k));
|
|
|
|
const setTransform = useCallback((t: Transform) => {
|
|
transformRef.current = t;
|
|
propsRef.current.onZoomChange(t.k);
|
|
needsDrawRef.current = true;
|
|
}, []);
|
|
|
|
const animateTo = useCallback((to: Transform, duration = 350) => {
|
|
animRef.current = {
|
|
from: { ...transformRef.current },
|
|
to,
|
|
start: performance.now(),
|
|
duration,
|
|
};
|
|
needsDrawRef.current = true;
|
|
}, []);
|
|
|
|
const zoomBy = useCallback(
|
|
(factor: number, cx?: number, cy?: number) => {
|
|
const t = transformRef.current;
|
|
const { w, h } = sizeRef.current;
|
|
const px = cx ?? w / 2;
|
|
const py = cy ?? h / 2;
|
|
const k = clampZoom(t.k * factor);
|
|
const wx = (px - t.x) / t.k;
|
|
const wy = (py - t.y) / t.k;
|
|
setTransform({ x: px - wx * k, y: py - wy * k, k });
|
|
},
|
|
[setTransform],
|
|
);
|
|
|
|
const fitView = useCallback(
|
|
(animated = true) => {
|
|
const sim = simRef.current;
|
|
const { w, h } = sizeRef.current;
|
|
if (!sim || !sim.nodes.length || !w || !h) return;
|
|
let minX = Infinity;
|
|
let minY = Infinity;
|
|
let maxX = -Infinity;
|
|
let maxY = -Infinity;
|
|
for (const n of sim.nodes) {
|
|
minX = Math.min(minX, n.x - n.radius);
|
|
minY = Math.min(minY, n.y - n.radius);
|
|
maxX = Math.max(maxX, n.x + n.radius);
|
|
maxY = Math.max(maxY, n.y + n.radius);
|
|
}
|
|
const pad = 70;
|
|
const bw = Math.max(maxX - minX, 1);
|
|
const bh = Math.max(maxY - minY, 1);
|
|
const k = clampZoom(Math.min((w - pad * 2) / bw, (h - pad * 2) / bh, 2.2));
|
|
const cx = (minX + maxX) / 2;
|
|
const cy = (minY + maxY) / 2;
|
|
const target = { x: w / 2 - cx * k, y: h / 2 - cy * k, k };
|
|
if (animated) animateTo(target);
|
|
else setTransform(target);
|
|
},
|
|
[animateTo, setTransform],
|
|
);
|
|
|
|
const focusNode = useCallback(
|
|
(id: string) => {
|
|
const node = nodeMapRef.current.get(id);
|
|
const { w, h } = sizeRef.current;
|
|
if (!node || !w || !h) return;
|
|
const k = clampZoom(Math.max(transformRef.current.k, 1.4));
|
|
animateTo({ x: w / 2 - node.x * k, y: h / 2 - node.y * k, k });
|
|
},
|
|
[animateTo],
|
|
);
|
|
|
|
const relayout = useCallback(() => {
|
|
const sim = simRef.current;
|
|
if (!sim) return;
|
|
if (layoutRef.current === "circular") {
|
|
applyCircularLayout(sim.nodes);
|
|
sim.alpha = 0;
|
|
fitView(true);
|
|
} else {
|
|
seedPositions(sim.nodes);
|
|
sim.alpha = 1;
|
|
autoFitRef.current = false;
|
|
}
|
|
requestDraw();
|
|
}, [fitView, requestDraw]);
|
|
|
|
const exportImage = useCallback(() => {
|
|
const main = canvasRef.current;
|
|
if (!main) return;
|
|
const out = document.createElement("canvas");
|
|
out.width = main.width;
|
|
out.height = main.height;
|
|
const ctx = out.getContext("2d");
|
|
if (!ctx) return;
|
|
ctx.fillStyle = CANVAS_PALETTE[propsRef.current.theme].exportBg;
|
|
ctx.fillRect(0, 0, out.width, out.height);
|
|
ctx.drawImage(main, 0, 0);
|
|
out.toBlob((blob) => {
|
|
if (!blob) return;
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = "knowledge-graph.png";
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}, "image/png");
|
|
}, []);
|
|
|
|
useImperativeHandle(
|
|
ref,
|
|
() => ({
|
|
zoomIn: () => zoomBy(1.25),
|
|
zoomOut: () => zoomBy(1 / 1.25),
|
|
fitView: () => fitView(true),
|
|
relayout,
|
|
focusNode,
|
|
exportImage,
|
|
}),
|
|
[zoomBy, fitView, relayout, focusNode, exportImage],
|
|
);
|
|
|
|
// ----------------------------------------------------------------- draw
|
|
|
|
const draw = useCallback(() => {
|
|
const canvas = canvasRef.current;
|
|
const sim = simRef.current;
|
|
if (!canvas || !sim) return;
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) return;
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const { w, h } = sizeRef.current;
|
|
const t = transformRef.current;
|
|
const p = propsRef.current;
|
|
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
ctx.clearRect(0, 0, w, h);
|
|
ctx.translate(t.x, t.y);
|
|
ctx.scale(t.k, t.k);
|
|
|
|
const hover = hoverRef.current;
|
|
const activeId = hover?.id ?? p.selectedNodeId;
|
|
const neighbors = activeId ? adjacencyRef.current.get(activeId) : undefined;
|
|
const search = p.searchMatchIds;
|
|
const k = t.k || 1;
|
|
const explore = p.explore;
|
|
const palette = CANVAS_PALETTE[p.theme];
|
|
|
|
// ---- edges
|
|
for (const e of sim.edges) {
|
|
const touchesActive =
|
|
activeId !== null &&
|
|
(e.source.id === activeId || e.target.id === activeId);
|
|
let alpha: number;
|
|
if (activeId) alpha = touchesActive ? 0.95 : 0.07;
|
|
else if (search)
|
|
alpha = search.has(e.source.id) || search.has(e.target.id) ? 0.75 : 0.06;
|
|
else alpha = 0.38;
|
|
|
|
if (explore && !explore.preview) {
|
|
const sourceDepth = explore.nodeDepthMap.get(e.source.id) ?? 0;
|
|
const targetDepth = explore.nodeDepthMap.get(e.target.id) ?? 0;
|
|
const edgeDepth = Math.max(sourceDepth, targetDepth);
|
|
alpha *= Math.max(0.48, 1 - edgeDepth * 0.12);
|
|
}
|
|
|
|
ctx.globalAlpha = alpha;
|
|
ctx.strokeStyle = e.info.color;
|
|
ctx.lineWidth = (touchesActive ? 2.4 : 1.2) / k;
|
|
ctx.setLineDash(e.info.dashed ? [6 / k, 5 / k] : []);
|
|
ctx.beginPath();
|
|
ctx.moveTo(e.source.x, e.source.y);
|
|
ctx.lineTo(e.target.x, e.target.y);
|
|
ctx.stroke();
|
|
|
|
// Relation label at the midpoint while a node is focused.
|
|
if (touchesActive && k >= 0.55) {
|
|
const mx = (e.source.x + e.target.x) / 2;
|
|
const my = (e.source.y + e.target.y) / 2;
|
|
ctx.setLineDash([]);
|
|
ctx.font = `${10.5 / k}px "Noto Sans SC", system-ui, sans-serif`;
|
|
ctx.textAlign = "center";
|
|
ctx.textBaseline = "middle";
|
|
ctx.lineWidth = 3 / k;
|
|
ctx.strokeStyle = palette.labelHalo;
|
|
ctx.strokeText(e.info.zh, mx, my);
|
|
ctx.fillStyle = e.info.color;
|
|
ctx.fillText(e.info.zh, mx, my);
|
|
}
|
|
}
|
|
ctx.setLineDash([]);
|
|
|
|
// ---- nodes
|
|
const labelThreshold = labelThresholdRef.current;
|
|
for (const n of sim.nodes) {
|
|
const isActive = n.id === activeId;
|
|
const isSelected = n.id === p.selectedNodeId;
|
|
const isNeighbor = neighbors?.has(n.id) ?? false;
|
|
const depth = explore?.nodeDepthMap.get(n.id);
|
|
const isExploreRoot = explore?.rootId === n.id;
|
|
const isExpanded = explore?.expandedNodeIds.has(n.id) ?? false;
|
|
let alpha: number;
|
|
if (activeId) alpha = isActive || isNeighbor ? 1 : 0.14;
|
|
else if (search) alpha = search.has(n.id) ? 1 : 0.14;
|
|
else alpha = 1;
|
|
|
|
let baseRadius = n.radius;
|
|
let labelByExplore = false;
|
|
let strokeWidth = 1.4 / k;
|
|
let strokeColor: string = palette.nodeRim;
|
|
let fillColor = n.color;
|
|
if (explore?.preview) {
|
|
alpha *= 0.42;
|
|
baseRadius = Math.max(8, n.radius * 0.86);
|
|
labelByExplore = isActive || isSelected;
|
|
} else if (explore && depth != null) {
|
|
baseRadius = depth === 0 ? 34 : Math.max(14, 28 - depth * 4);
|
|
alpha *= Math.max(0.45, 1 - depth * 0.12);
|
|
labelByExplore = depth <= 2 || isActive || isSelected;
|
|
strokeWidth = (depth === 0 ? 4 : isExpanded ? 3 : 1.5) / k;
|
|
strokeColor =
|
|
depth === 0
|
|
? palette.rootRim
|
|
: isExpanded
|
|
? palette.expandedRim
|
|
: palette.nodeRim;
|
|
if (isExploreRoot) fillColor = "#38bdf8";
|
|
}
|
|
|
|
const r = baseRadius * (n.id === hover?.id ? 1.15 : 1);
|
|
ctx.globalAlpha = alpha;
|
|
|
|
// Glow for hubs and the active node.
|
|
if (
|
|
alpha > 0.5 &&
|
|
(isActive || isSelected || isExploreRoot || isExpanded || n.degree >= labelThreshold)
|
|
) {
|
|
ctx.shadowColor = fillColor;
|
|
ctx.shadowBlur = isActive || isSelected || isExploreRoot ? 24 : 12;
|
|
} else {
|
|
ctx.shadowBlur = 0;
|
|
}
|
|
|
|
ctx.beginPath();
|
|
ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
|
|
ctx.fillStyle = fillColor;
|
|
ctx.fill();
|
|
ctx.shadowBlur = 0;
|
|
|
|
// Rim.
|
|
ctx.lineWidth = strokeWidth;
|
|
ctx.strokeStyle = strokeColor;
|
|
ctx.stroke();
|
|
|
|
if (isExploreRoot) {
|
|
ctx.beginPath();
|
|
ctx.arc(n.x, n.y, r + 8 / k, 0, Math.PI * 2);
|
|
ctx.lineWidth = 1.4 / k;
|
|
ctx.strokeStyle = palette.rootRing;
|
|
ctx.stroke();
|
|
} else if (isExpanded) {
|
|
ctx.beginPath();
|
|
ctx.arc(n.x + r * 0.58, n.y - r * 0.58, Math.max(4 / k, 3), 0, Math.PI * 2);
|
|
ctx.fillStyle = palette.expandedDot;
|
|
ctx.fill();
|
|
ctx.lineWidth = 1 / k;
|
|
ctx.strokeStyle = palette.expandedDotRim;
|
|
ctx.stroke();
|
|
}
|
|
|
|
// Selection: double ring.
|
|
if (isSelected) {
|
|
ctx.beginPath();
|
|
ctx.arc(n.x, n.y, r + 4 / k, 0, Math.PI * 2);
|
|
ctx.lineWidth = 2 / k;
|
|
ctx.strokeStyle = palette.selectionRing;
|
|
ctx.stroke();
|
|
ctx.beginPath();
|
|
ctx.arc(n.x, n.y, r + 8 / k, 0, Math.PI * 2);
|
|
ctx.lineWidth = 1.2 / k;
|
|
ctx.strokeStyle = n.color;
|
|
ctx.stroke();
|
|
} else if (n.id === hover?.id) {
|
|
ctx.beginPath();
|
|
ctx.arc(n.x, n.y, r + 3.5 / k, 0, Math.PI * 2);
|
|
ctx.lineWidth = 1.6 / k;
|
|
ctx.strokeStyle = palette.hoverRing;
|
|
ctx.stroke();
|
|
}
|
|
|
|
// Pin marker for dragged-and-fixed nodes.
|
|
if (n.fx != null && !isSelected) {
|
|
ctx.beginPath();
|
|
ctx.arc(n.x, n.y, Math.max(r * 0.28, 2 / k), 0, Math.PI * 2);
|
|
ctx.fillStyle = palette.pin;
|
|
ctx.fill();
|
|
}
|
|
|
|
// Label.
|
|
const wantsLabel =
|
|
isActive ||
|
|
isSelected ||
|
|
isNeighbor ||
|
|
labelByExplore ||
|
|
(search?.has(n.id) ?? false) ||
|
|
(p.showLabels && n.degree >= labelThreshold && k >= 0.45);
|
|
if (wantsLabel && alpha > 0.5) {
|
|
const text = truncateLabel(getNodeTitle(n.node), isActive ? 26 : 14);
|
|
const fontSize = (isActive || isSelected ? 13 : 12) / k;
|
|
ctx.font = `600 ${fontSize}px "Noto Sans SC", system-ui, sans-serif`;
|
|
ctx.textAlign = "center";
|
|
ctx.textBaseline = "top";
|
|
ctx.lineWidth = 3.5 / k;
|
|
ctx.strokeStyle = palette.labelHalo;
|
|
ctx.strokeText(text, n.x, n.y + r + 5 / k);
|
|
ctx.fillStyle = isActive || isSelected ? palette.labelActive : palette.label;
|
|
ctx.fillText(text, n.x, n.y + r + 5 / k);
|
|
}
|
|
}
|
|
ctx.globalAlpha = 1;
|
|
|
|
drawMinimap();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
const drawMinimap = useCallback(() => {
|
|
const minimap = minimapRef.current;
|
|
const sim = simRef.current;
|
|
if (!minimap || !sim || !sim.nodes.length) return;
|
|
const ctx = minimap.getContext("2d");
|
|
if (!ctx) return;
|
|
const mw = minimap.width;
|
|
const mh = minimap.height;
|
|
ctx.clearRect(0, 0, mw, mh);
|
|
|
|
let minX = Infinity;
|
|
let minY = Infinity;
|
|
let maxX = -Infinity;
|
|
let maxY = -Infinity;
|
|
for (const n of sim.nodes) {
|
|
minX = Math.min(minX, n.x);
|
|
minY = Math.min(minY, n.y);
|
|
maxX = Math.max(maxX, n.x);
|
|
maxY = Math.max(maxY, n.y);
|
|
}
|
|
const pad = 10;
|
|
const scale = Math.min(
|
|
(mw - pad * 2) / Math.max(maxX - minX, 1),
|
|
(mh - pad * 2) / Math.max(maxY - minY, 1),
|
|
);
|
|
const ox = (mw - (maxX - minX) * scale) / 2 - minX * scale;
|
|
const oy = (mh - (maxY - minY) * scale) / 2 - minY * scale;
|
|
|
|
for (const n of sim.nodes) {
|
|
ctx.beginPath();
|
|
ctx.arc(n.x * scale + ox, n.y * scale + oy, 1.6, 0, Math.PI * 2);
|
|
ctx.fillStyle = n.color;
|
|
ctx.globalAlpha = 0.9;
|
|
ctx.fill();
|
|
}
|
|
ctx.globalAlpha = 1;
|
|
|
|
// Viewport rectangle.
|
|
const t = transformRef.current;
|
|
const { w, h } = sizeRef.current;
|
|
if (t.k > 0 && w && h) {
|
|
const x0 = ((0 - t.x) / t.k) * scale + ox;
|
|
const y0 = ((0 - t.y) / t.k) * scale + oy;
|
|
const x1 = ((w - t.x) / t.k) * scale + ox;
|
|
const y1 = ((h - t.y) / t.k) * scale + oy;
|
|
ctx.strokeStyle = CANVAS_PALETTE[propsRef.current.theme].minimapViewport;
|
|
ctx.lineWidth = 1;
|
|
ctx.strokeRect(x0, y0, x1 - x0, y1 - y0);
|
|
}
|
|
}, []);
|
|
|
|
// ----------------------------------------------------------------- loop
|
|
|
|
useEffect(() => {
|
|
const loop = () => {
|
|
const sim = simRef.current;
|
|
let active = false;
|
|
|
|
const anim = animRef.current;
|
|
if (anim) {
|
|
const progress = Math.min(
|
|
(performance.now() - anim.start) / anim.duration,
|
|
1,
|
|
);
|
|
const e = easeCubicOut(progress);
|
|
transformRef.current = {
|
|
x: anim.from.x + (anim.to.x - anim.from.x) * e,
|
|
y: anim.from.y + (anim.to.y - anim.from.y) * e,
|
|
k: anim.from.k + (anim.to.k - anim.from.k) * e,
|
|
};
|
|
if (progress >= 1) {
|
|
animRef.current = null;
|
|
propsRef.current.onZoomChange(transformRef.current.k);
|
|
}
|
|
active = true;
|
|
}
|
|
|
|
if (sim && layoutRef.current === "force" && sim.tick()) {
|
|
active = true;
|
|
// Auto-fit once after the initial layout settles.
|
|
if (!autoFitRef.current && sim.alpha < 0.3) {
|
|
autoFitRef.current = true;
|
|
fitView(true);
|
|
}
|
|
} else if (sim && !autoFitRef.current && sim.nodes.length) {
|
|
autoFitRef.current = true;
|
|
fitView(false);
|
|
}
|
|
|
|
if (active || needsDrawRef.current) {
|
|
needsDrawRef.current = false;
|
|
draw();
|
|
}
|
|
rafRef.current = requestAnimationFrame(loop);
|
|
};
|
|
rafRef.current = requestAnimationFrame(loop);
|
|
return () => cancelAnimationFrame(rafRef.current);
|
|
}, [draw, fitView]);
|
|
|
|
// --------------------------------------------------------------- resize
|
|
|
|
useEffect(() => {
|
|
const container = containerRef.current;
|
|
const canvas = canvasRef.current;
|
|
if (!container || !canvas) return;
|
|
let timer: number | undefined;
|
|
const apply = () => {
|
|
const rect = container.getBoundingClientRect();
|
|
const dpr = window.devicePixelRatio || 1;
|
|
sizeRef.current = { w: rect.width, h: rect.height };
|
|
canvas.width = Math.max(1, Math.round(rect.width * dpr));
|
|
canvas.height = Math.max(1, Math.round(rect.height * dpr));
|
|
if (transformRef.current.k === 0) {
|
|
setTransform({ x: rect.width / 2, y: rect.height / 2, k: 0.9 });
|
|
}
|
|
requestDraw();
|
|
};
|
|
apply();
|
|
const observer = new ResizeObserver(() => {
|
|
window.clearTimeout(timer);
|
|
timer = window.setTimeout(apply, 120);
|
|
});
|
|
observer.observe(container);
|
|
return () => {
|
|
window.clearTimeout(timer);
|
|
observer.disconnect();
|
|
};
|
|
}, [requestDraw, setTransform]);
|
|
|
|
// --------------------------------------------------------------- events
|
|
|
|
const screenToWorld = (sx: number, sy: number) => {
|
|
const t = transformRef.current;
|
|
return { x: (sx - t.x) / t.k, y: (sy - t.y) / t.k };
|
|
};
|
|
|
|
const hitTest = (wx: number, wy: number): SimNode | null => {
|
|
const sim = simRef.current;
|
|
if (!sim) return null;
|
|
// Topmost (drawn last) first.
|
|
for (let i = sim.nodes.length - 1; i >= 0; i--) {
|
|
const n = sim.nodes[i];
|
|
const dx = wx - n.x;
|
|
const dy = wy - n.y;
|
|
const r = n.radius + 3 / (transformRef.current.k || 1);
|
|
if (dx * dx + dy * dy <= r * r) return n;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const localPoint = (e: { clientX: number; clientY: number }) => {
|
|
const rect = canvasRef.current!.getBoundingClientRect();
|
|
return { sx: e.clientX - rect.left, sy: e.clientY - rect.top };
|
|
};
|
|
|
|
const setHover = (node: SimNode | null, e?: React.PointerEvent) => {
|
|
if (hoverRef.current?.id !== node?.id) {
|
|
hoverRef.current = node;
|
|
requestDraw();
|
|
}
|
|
const canvas = canvasRef.current;
|
|
if (canvas) canvas.style.cursor = node ? "pointer" : "grab";
|
|
if (node && e) {
|
|
propsRef.current.onTooltip({
|
|
x: e.clientX,
|
|
y: e.clientY,
|
|
node: node.node,
|
|
kind: node.kind,
|
|
degree: node.degree,
|
|
});
|
|
} else {
|
|
propsRef.current.onTooltip(null);
|
|
}
|
|
};
|
|
|
|
const handlePointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
|
if (e.button !== 0) return;
|
|
const { sx, sy } = localPoint(e);
|
|
const { x: wx, y: wy } = screenToWorld(sx, sy);
|
|
const node = hitTest(wx, wy);
|
|
animRef.current = null;
|
|
dragRef.current = {
|
|
pointerId: e.pointerId,
|
|
node,
|
|
startX: sx,
|
|
startY: sy,
|
|
originX: transformRef.current.x,
|
|
originY: transformRef.current.y,
|
|
moved: false,
|
|
};
|
|
if (node) {
|
|
node.fx = node.x;
|
|
node.fy = node.y;
|
|
}
|
|
e.currentTarget.setPointerCapture(e.pointerId);
|
|
};
|
|
|
|
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
|
const { sx, sy } = localPoint(e);
|
|
const drag = dragRef.current;
|
|
if (drag && drag.pointerId === e.pointerId) {
|
|
const dx = sx - drag.startX;
|
|
const dy = sy - drag.startY;
|
|
if (Math.abs(dx) + Math.abs(dy) > 3) drag.moved = true;
|
|
if (drag.node) {
|
|
const world = screenToWorld(sx, sy);
|
|
drag.node.fx = world.x;
|
|
drag.node.fy = world.y;
|
|
if (layoutRef.current === "force") simRef.current?.reheat(0.3);
|
|
propsRef.current.onTooltip(null);
|
|
} else {
|
|
transformRef.current = {
|
|
...transformRef.current,
|
|
x: drag.originX + dx,
|
|
y: drag.originY + dy,
|
|
};
|
|
}
|
|
requestDraw();
|
|
return;
|
|
}
|
|
const world = screenToWorld(sx, sy);
|
|
setHover(hitTest(world.x, world.y), e);
|
|
};
|
|
|
|
const handlePointerUp = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
|
const drag = dragRef.current;
|
|
if (!drag || drag.pointerId !== e.pointerId) return;
|
|
dragRef.current = null;
|
|
if (!drag.moved) {
|
|
// Plain click: select node / clear selection on empty space.
|
|
if (drag.node) {
|
|
drag.node.fx = null;
|
|
drag.node.fy = null;
|
|
propsRef.current.onSelectNode(drag.node.id);
|
|
} else {
|
|
propsRef.current.onSelectNode(null);
|
|
}
|
|
requestDraw();
|
|
}
|
|
// A real drag leaves the node pinned at its drop position.
|
|
};
|
|
|
|
const handleDoubleClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
|
const { sx, sy } = localPoint(e);
|
|
const world = screenToWorld(sx, sy);
|
|
const node = hitTest(world.x, world.y);
|
|
if (node) {
|
|
propsRef.current.onSelectNode(node.id);
|
|
focusNode(node.id);
|
|
}
|
|
};
|
|
|
|
const handlePointerLeave = () => {
|
|
setHover(null);
|
|
};
|
|
|
|
// Wheel zoom needs a non-passive listener to preventDefault.
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
const onWheel = (e: WheelEvent) => {
|
|
e.preventDefault();
|
|
const rect = canvas.getBoundingClientRect();
|
|
zoomBy(
|
|
Math.exp(-e.deltaY * 0.0012),
|
|
e.clientX - rect.left,
|
|
e.clientY - rect.top,
|
|
);
|
|
};
|
|
canvas.addEventListener("wheel", onWheel, { passive: false });
|
|
return () => canvas.removeEventListener("wheel", onWheel);
|
|
}, [zoomBy]);
|
|
|
|
const minimapVisible = useMemo(() => props.nodes.length > 0, [props.nodes]);
|
|
|
|
return (
|
|
<div className="kg-canvas-wrap" ref={containerRef}>
|
|
<canvas
|
|
ref={canvasRef}
|
|
className="kg-canvas"
|
|
onPointerDown={handlePointerDown}
|
|
onPointerMove={handlePointerMove}
|
|
onPointerUp={handlePointerUp}
|
|
onPointerLeave={handlePointerLeave}
|
|
onDoubleClick={handleDoubleClick}
|
|
/>
|
|
{props.toolbar ? (
|
|
<div className="kg-canvas-toolbar">{props.toolbar}</div>
|
|
) : null}
|
|
{props.hint}
|
|
{minimapVisible ? (
|
|
<canvas ref={minimapRef} className="kg-minimap" width={180} height={120} />
|
|
) : null}
|
|
{props.overlay}
|
|
</div>
|
|
);
|
|
},
|
|
);
|