Files
md-files/.claude/skills/graph-viewer/code/GraphViewerCanvas.tsx
T
2026-05-31 20:25:41 +00:00

178 lines
4.9 KiB
TypeScript

import { useCallback, useRef } from "react";
import {
GraphCanvas as ReagraphCanvas,
type GraphCanvasRef,
type LayoutTypes,
} from "reagraph";
import type { Theme } from "reagraph";
import type { GraphData, GraphEdge, GraphNode, LayoutType } from "./types";
import { ZoomControls } from "./ZoomControls";
import { GraphViewerLegend } from "./GraphViewerLegend";
import { useCanvasResize } from "./useCanvasResize";
import type { LiteFeatureFlags } from "./types";
const LAYOUT_MAP: Record<LayoutType, LayoutTypes> = {
forceDirected2d: "forceDirected2d",
forceDirected3d: "forceDirected3d",
treeTd2d: "treeTd2d",
treeLr2d: "treeLr2d",
radialOut2d: "radialOut2d",
circular2d: "circular2d",
};
const DOUBLE_CLICK_MS = 300;
export interface GraphViewerCanvasProps {
reagraphNodes: Array<{
id: string;
label: string;
fill: string;
icon: string;
data: GraphNode;
}>;
reagraphEdges: Array<{
id: string;
source: string;
target: string;
label?: string;
fill?: string;
size?: number;
data: GraphEdge;
}>;
displayedGraphData: GraphData;
layout: LayoutType;
theme: Theme;
selections: string[];
selectedNode: GraphNode | null;
selectedEdge: GraphEdge | null;
features: LiteFeatureFlags;
selectedNodeType: string | null;
graphRef: React.RefObject<GraphCanvasRef | null>;
onNodeClick: (node: GraphNode) => void;
onEdgeClick: (edge: GraphEdge) => void;
onCanvasClick: () => void;
onExpandNode: (nodeId: string) => void;
onNodeTypeClick: (typeKey: string) => void;
onClearNodeTypeSelection: () => void;
className?: string;
}
export function GraphViewerCanvas({
reagraphNodes,
reagraphEdges,
displayedGraphData,
layout,
theme,
selections,
features,
selectedNodeType,
graphRef,
onNodeClick,
onEdgeClick,
onCanvasClick,
onExpandNode,
onNodeTypeClick,
onClearNodeTypeSelection,
className,
}: GraphViewerCanvasProps) {
const canvasContainerRef = useRef<HTMLDivElement>(null);
const lastClickedIdRef = useRef<string | null>(null);
const lastClickTimeRef = useRef(0);
const pendingClickRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useCanvasResize(canvasContainerRef, graphRef as React.RefObject<GraphCanvasRef>);
const handleNodeClick = useCallback(
(node: { id: string }) => {
const graphNode = displayedGraphData.nodes.find((n) => n.id === node.id);
if (!graphNode) return;
const now = Date.now();
const isDoubleClick =
lastClickedIdRef.current === node.id &&
now - lastClickTimeRef.current < DOUBLE_CLICK_MS;
lastClickedIdRef.current = node.id;
lastClickTimeRef.current = now;
if (isDoubleClick && features.enableNodeExpansion) {
if (pendingClickRef.current) {
clearTimeout(pendingClickRef.current);
pendingClickRef.current = null;
}
onExpandNode(node.id);
return;
}
pendingClickRef.current = setTimeout(() => {
pendingClickRef.current = null;
onNodeClick(graphNode);
}, DOUBLE_CLICK_MS);
},
[displayedGraphData.nodes, onNodeClick, onExpandNode, features.enableNodeExpansion]
);
const handleEdgeClick = useCallback(
(edge: { id: string }) => {
const graphEdge = displayedGraphData.connections.find((c) => c.id === edge.id);
if (graphEdge) {
onEdgeClick(graphEdge);
}
},
[displayedGraphData.connections, onEdgeClick]
);
const hasNodes = reagraphNodes.length > 0;
return (
<div
ref={canvasContainerRef}
className={`relative w-full h-full min-h-0 min-w-0 ${className ?? ""}`}
>
{hasNodes && (
<ReagraphCanvas
ref={graphRef}
nodes={reagraphNodes}
edges={reagraphEdges}
layoutType={LAYOUT_MAP[layout]}
theme={theme}
labelType="nodes"
edgeLabelPosition="natural"
edgeArrowPosition="end"
draggable
animated
onNodeClick={handleNodeClick}
onEdgeClick={handleEdgeClick}
onCanvasClick={onCanvasClick}
selections={selections}
cameraMode={layout.includes("3d") ? "rotate" : "pan"}
/>
)}
{!hasNodes && (
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground text-sm">
No nodes to display
</div>
)}
{features.enableZoomControls && hasNodes && (
<ZoomControls
onZoomIn={() => graphRef.current?.zoomIn()}
onZoomOut={() => graphRef.current?.zoomOut()}
onFitView={() => graphRef.current?.fitNodesInView()}
/>
)}
{features.enableLegend &&
displayedGraphData.nodeTypes.length > 0 && (
<GraphViewerLegend
nodeTypes={displayedGraphData.nodeTypes}
selectedNodeType={selectedNodeType}
onNodeTypeClick={onNodeTypeClick}
onClearSelection={onClearNodeTypeSelection}
/>
)}
</div>
);
}