aboutsummaryrefslogtreecommitdiff
path: root/packages/ui/memory-graph/hooks
diff options
context:
space:
mode:
Diffstat (limited to 'packages/ui/memory-graph/hooks')
-rw-r--r--packages/ui/memory-graph/hooks/use-graph-data.ts304
-rw-r--r--packages/ui/memory-graph/hooks/use-graph-interactions.ts564
2 files changed, 0 insertions, 868 deletions
diff --git a/packages/ui/memory-graph/hooks/use-graph-data.ts b/packages/ui/memory-graph/hooks/use-graph-data.ts
deleted file mode 100644
index 3e9fa5cc..00000000
--- a/packages/ui/memory-graph/hooks/use-graph-data.ts
+++ /dev/null
@@ -1,304 +0,0 @@
-"use client";
-
-import {
- calculateSemanticSimilarity,
- getConnectionVisualProps,
- getMagicalConnectionColor,
-} from "@repo/lib/similarity";
-import { useMemo } from "react";
-import { colors, LAYOUT_CONSTANTS } from "../constants";
-import type {
- DocumentsResponse,
- DocumentWithMemories,
- GraphEdge,
- GraphNode,
- MemoryEntry,
- MemoryRelation,
-} from "../types";
-
-export function useGraphData(
- data: DocumentsResponse | null,
- selectedSpace: string,
- nodePositions: Map<string, { x: number; y: number }>,
- draggingNodeId: string | null,
-) {
- return useMemo(() => {
- if (!data?.documents) return { nodes: [], edges: [] };
-
- const allNodes: GraphNode[] = [];
- const allEdges: GraphEdge[] = [];
-
- // Filter documents that have memories in selected space
- const filteredDocuments = data.documents
- .map((doc) => ({
- ...doc,
- memoryEntries:
- selectedSpace === "all"
- ? doc.memoryEntries
- : doc.memoryEntries.filter(
- (memory) =>
- (memory.spaceContainerTag ?? memory.spaceId ?? "default") ===
- selectedSpace,
- ),
- }))
- .filter((doc) => doc.memoryEntries.length > 0);
-
- // Group documents by space for better clustering
- const documentsBySpace = new Map<string, typeof filteredDocuments>();
- filteredDocuments.forEach((doc) => {
- const docSpace =
- doc.memoryEntries[0]?.spaceContainerTag ??
- doc.memoryEntries[0]?.spaceId ??
- "default";
- if (!documentsBySpace.has(docSpace)) {
- documentsBySpace.set(docSpace, []);
- }
- const spaceDocsArr = documentsBySpace.get(docSpace);
- if (spaceDocsArr) {
- spaceDocsArr.push(doc);
- }
- });
-
- // Enhanced Layout with Space Separation
- const { centerX, centerY, clusterRadius, spaceSpacing, documentSpacing } =
- LAYOUT_CONSTANTS;
-
- /* 1. Build DOCUMENT nodes with space-aware clustering */
- const documentNodes: GraphNode[] = [];
- let spaceIndex = 0;
-
- documentsBySpace.forEach((spaceDocs) => {
- const spaceAngle = (spaceIndex / documentsBySpace.size) * Math.PI * 2;
- const spaceOffsetX = Math.cos(spaceAngle) * spaceSpacing;
- const spaceOffsetY = Math.sin(spaceAngle) * spaceSpacing;
- const spaceCenterX = centerX + spaceOffsetX;
- const spaceCenterY = centerY + spaceOffsetY;
-
- spaceDocs.forEach((doc, docIndex) => {
- // Create proper circular layout with concentric rings
- const docsPerRing = 6; // Start with 6 docs in inner ring
- let currentRing = 0;
- let docsInCurrentRing = docsPerRing;
- let totalDocsInPreviousRings = 0;
-
- // Find which ring this document belongs to
- while (totalDocsInPreviousRings + docsInCurrentRing <= docIndex) {
- totalDocsInPreviousRings += docsInCurrentRing;
- currentRing++;
- docsInCurrentRing = docsPerRing + currentRing * 4; // Each ring has more docs
- }
-
- // Position within the ring
- const positionInRing = docIndex - totalDocsInPreviousRings;
- const angleInRing = (positionInRing / docsInCurrentRing) * Math.PI * 2;
-
- // Radius increases significantly with each ring
- const baseRadius = documentSpacing * 0.8;
- const radius =
- currentRing === 0
- ? baseRadius
- : baseRadius + currentRing * documentSpacing * 1.2;
-
- const defaultX = spaceCenterX + Math.cos(angleInRing) * radius;
- const defaultY = spaceCenterY + Math.sin(angleInRing) * radius;
-
- const customPos = nodePositions.get(doc.id);
-
- documentNodes.push({
- id: doc.id,
- type: "document",
- x: customPos?.x ?? defaultX,
- y: customPos?.y ?? defaultY,
- data: doc,
- size: 58,
- color: colors.document.primary,
- isHovered: false,
- isDragging: draggingNodeId === doc.id,
- } satisfies GraphNode);
- });
-
- spaceIndex++;
- });
-
- /* 2. Gentle document collision avoidance with dampening */
- const minDocDist = LAYOUT_CONSTANTS.minDocDist;
-
- // Reduced iterations and gentler repulsion for smoother movement
- for (let iter = 0; iter < 2; iter++) {
- documentNodes.forEach((nodeA) => {
- documentNodes.forEach((nodeB) => {
- if (nodeA.id >= nodeB.id) return;
-
- // Only repel documents in the same space
- const spaceA =
- (nodeA.data as DocumentWithMemories).memoryEntries[0]
- ?.spaceContainerTag ??
- (nodeA.data as DocumentWithMemories).memoryEntries[0]?.spaceId ??
- "default";
- const spaceB =
- (nodeB.data as DocumentWithMemories).memoryEntries[0]
- ?.spaceContainerTag ??
- (nodeB.data as DocumentWithMemories).memoryEntries[0]?.spaceId ??
- "default";
-
- if (spaceA !== spaceB) return;
-
- const dx = nodeB.x - nodeA.x;
- const dy = nodeB.y - nodeA.y;
- const dist = Math.sqrt(dx * dx + dy * dy) || 1;
-
- if (dist < minDocDist) {
- // Much gentler push with dampening
- const push = (minDocDist - dist) / 8;
- const dampening = Math.max(0.1, Math.min(1, dist / minDocDist));
- const smoothPush = push * dampening * 0.5;
-
- const nx = dx / dist;
- const ny = dy / dist;
- nodeA.x -= nx * smoothPush;
- nodeA.y -= ny * smoothPush;
- nodeB.x += nx * smoothPush;
- nodeB.y += ny * smoothPush;
- }
- });
- });
- }
-
- allNodes.push(...documentNodes);
-
- /* 3. Add memories around documents WITH doc-memory connections */
- documentNodes.forEach((docNode) => {
- const memoryNodeMap = new Map<string, GraphNode>();
- const doc = docNode.data as DocumentWithMemories;
-
- doc.memoryEntries.forEach((memory, memIndex) => {
- const memoryId = `${memory.id}`;
- const customMemPos = nodePositions.get(memoryId);
-
- const clusterAngle =
- (memIndex / doc.memoryEntries.length) * Math.PI * 2;
- const variation = Math.sin(memIndex * 2.5) * 0.3 + 0.7;
- const distance = clusterRadius * variation;
-
- const seed =
- memIndex * 12345 + Number.parseInt(docNode.id.slice(0, 6), 36);
- const offsetX = Math.sin(seed) * 0.5 * 40;
- const offsetY = Math.cos(seed) * 0.5 * 40;
-
- const defaultMemX =
- docNode.x + Math.cos(clusterAngle) * distance + offsetX;
- const defaultMemY =
- docNode.y + Math.sin(clusterAngle) * distance + offsetY;
-
- if (!memoryNodeMap.has(memoryId)) {
- const memoryNode: GraphNode = {
- id: memoryId,
- type: "memory",
- x: customMemPos?.x ?? defaultMemX,
- y: customMemPos?.y ?? defaultMemY,
- data: memory,
- size: Math.max(
- 32,
- Math.min(48, (memory.memory?.length || 50) * 0.5),
- ),
- color: colors.memory.primary,
- isHovered: false,
- isDragging: draggingNodeId === memoryId,
- };
- memoryNodeMap.set(memoryId, memoryNode);
- allNodes.push(memoryNode);
- }
-
- // Create doc-memory edge with similarity
- allEdges.push({
- id: `edge-${docNode.id}-${memory.id}`,
- source: docNode.id,
- target: memoryId,
- similarity: 1,
- visualProps: getConnectionVisualProps(1),
- color: colors.connection.memory,
- edgeType: "doc-memory",
- });
- });
- });
-
- // Build mapping of memoryId -> nodeId for version chains
- const memNodeIdMap = new Map<string, string>();
- allNodes.forEach((n) => {
- if (n.type === "memory") {
- memNodeIdMap.set((n.data as MemoryEntry).id, n.id);
- }
- });
-
- // Add version-chain edges (old -> new)
- data.documents.forEach((doc) => {
- doc.memoryEntries.forEach((mem: MemoryEntry) => {
- // Support both new object structure and legacy array/single parent fields
- let parentRelations: Record<string, MemoryRelation> = {};
-
- if (
- mem.memoryRelations &&
- typeof mem.memoryRelations === "object" &&
- Object.keys(mem.memoryRelations).length > 0
- ) {
- parentRelations = mem.memoryRelations;
- } else if (mem.parentMemoryId) {
- parentRelations = {
- [mem.parentMemoryId]: "updates" as MemoryRelation,
- };
- }
- Object.entries(parentRelations).forEach(([pid, relationType]) => {
- const fromId = memNodeIdMap.get(pid);
- const toId = memNodeIdMap.get(mem.id);
- if (fromId && toId) {
- allEdges.push({
- id: `version-${fromId}-${toId}`,
- source: fromId,
- target: toId,
- similarity: 1,
- visualProps: {
- opacity: 0.8,
- thickness: 1,
- glow: 0,
- pulseDuration: 3000,
- },
- // choose color based on relation type
- color: colors.relations[relationType] ?? colors.relations.updates,
- edgeType: "version",
- relationType: relationType as MemoryRelation,
- });
- }
- });
- });
- });
-
- // Document-to-document similarity edges
- for (let i = 0; i < filteredDocuments.length; i++) {
- const docI = filteredDocuments[i];
- if (!docI) continue;
-
- for (let j = i + 1; j < filteredDocuments.length; j++) {
- const docJ = filteredDocuments[j];
- if (!docJ) continue;
-
- const sim = calculateSemanticSimilarity(
- docI.summaryEmbedding ? Array.from(docI.summaryEmbedding) : null,
- docJ.summaryEmbedding ? Array.from(docJ.summaryEmbedding) : null,
- );
- if (sim > 0.725) {
- allEdges.push({
- id: `doc-doc-${docI.id}-${docJ.id}`,
- source: docI.id,
- target: docJ.id,
- similarity: sim,
- visualProps: getConnectionVisualProps(sim),
- color: getMagicalConnectionColor(sim, 200),
- edgeType: "doc-doc",
- });
- }
- }
- }
-
- return { nodes: allNodes, edges: allEdges };
- }, [data, selectedSpace, nodePositions, draggingNodeId]);
-}
diff --git a/packages/ui/memory-graph/hooks/use-graph-interactions.ts b/packages/ui/memory-graph/hooks/use-graph-interactions.ts
deleted file mode 100644
index ec44e83e..00000000
--- a/packages/ui/memory-graph/hooks/use-graph-interactions.ts
+++ /dev/null
@@ -1,564 +0,0 @@
-"use client";
-
-import { useCallback, useRef, useState } from "react";
-import { GRAPH_SETTINGS } from "../constants";
-import type { GraphNode } from "../types";
-
-export function useGraphInteractions(
- variant: "console" | "consumer" = "console",
-) {
- const settings = GRAPH_SETTINGS[variant];
-
- const [panX, setPanX] = useState(settings.initialPanX);
- const [panY, setPanY] = useState(settings.initialPanY);
- const [zoom, setZoom] = useState(settings.initialZoom);
- const [isPanning, setIsPanning] = useState(false);
- const [panStart, setPanStart] = useState({ x: 0, y: 0 });
- const [hoveredNode, setHoveredNode] = useState<string | null>(null);
- const [selectedNode, setSelectedNode] = useState<string | null>(null);
- const [draggingNodeId, setDraggingNodeId] = useState<string | null>(null);
- const [dragStart, setDragStart] = useState({
- x: 0,
- y: 0,
- nodeX: 0,
- nodeY: 0,
- });
- const [nodePositions, setNodePositions] = useState<
- Map<string, { x: number; y: number }>
- >(new Map());
-
- // Touch gesture state
- const [touchState, setTouchState] = useState<{
- touches: { id: number; x: number; y: number }[];
- lastDistance: number;
- lastCenter: { x: number; y: number };
- isGesturing: boolean;
- }>({
- touches: [],
- lastDistance: 0,
- lastCenter: { x: 0, y: 0 },
- isGesturing: false,
- });
-
- // Animation state for smooth transitions
- const animationRef = useRef<number | null>(null);
- const [isAnimating, setIsAnimating] = useState(false);
-
- // Smooth animation helper
- const animateToViewState = useCallback(
- (
- targetPanX: number,
- targetPanY: number,
- targetZoom: number,
- duration = 300,
- ) => {
- if (animationRef.current) {
- cancelAnimationFrame(animationRef.current);
- }
-
- const startPanX = panX;
- const startPanY = panY;
- const startZoom = zoom;
- const startTime = Date.now();
-
- setIsAnimating(true);
-
- const animate = () => {
- const elapsed = Date.now() - startTime;
- const progress = Math.min(elapsed / duration, 1);
-
- // Ease out cubic function for smooth transitions
- const easeOut = 1 - (1 - progress) ** 3;
-
- const currentPanX = startPanX + (targetPanX - startPanX) * easeOut;
- const currentPanY = startPanY + (targetPanY - startPanY) * easeOut;
- const currentZoom = startZoom + (targetZoom - startZoom) * easeOut;
-
- setPanX(currentPanX);
- setPanY(currentPanY);
- setZoom(currentZoom);
-
- if (progress < 1) {
- animationRef.current = requestAnimationFrame(animate);
- } else {
- setIsAnimating(false);
- animationRef.current = null;
- }
- };
-
- animate();
- },
- [panX, panY, zoom],
- );
-
- // Node drag handlers
- const handleNodeDragStart = useCallback(
- (nodeId: string, e: React.MouseEvent, nodes?: GraphNode[]) => {
- const node = nodes?.find((n) => n.id === nodeId);
- if (!node) return;
-
- setDraggingNodeId(nodeId);
- setDragStart({
- x: e.clientX,
- y: e.clientY,
- nodeX: node.x,
- nodeY: node.y,
- });
- },
- [],
- );
-
- const handleNodeDragMove = useCallback(
- (e: React.MouseEvent) => {
- if (!draggingNodeId) return;
-
- const deltaX = (e.clientX - dragStart.x) / zoom;
- const deltaY = (e.clientY - dragStart.y) / zoom;
-
- const newX = dragStart.nodeX + deltaX;
- const newY = dragStart.nodeY + deltaY;
-
- setNodePositions((prev) =>
- new Map(prev).set(draggingNodeId, { x: newX, y: newY }),
- );
- },
- [draggingNodeId, dragStart, zoom],
- );
-
- const handleNodeDragEnd = useCallback(() => {
- setDraggingNodeId(null);
- }, []);
-
- // Pan handlers
- const handlePanStart = useCallback(
- (e: React.MouseEvent) => {
- setIsPanning(true);
- setPanStart({ x: e.clientX - panX, y: e.clientY - panY });
- },
- [panX, panY],
- );
-
- const handlePanMove = useCallback(
- (e: React.MouseEvent) => {
- if (!isPanning || draggingNodeId) return;
-
- const newPanX = e.clientX - panStart.x;
- const newPanY = e.clientY - panStart.y;
- setPanX(newPanX);
- setPanY(newPanY);
- },
- [isPanning, panStart, draggingNodeId],
- );
-
- const handlePanEnd = useCallback(() => {
- setIsPanning(false);
- }, []);
-
- // Zoom handlers
- const handleWheel = useCallback(
- (e: React.WheelEvent) => {
- // Always prevent default to stop browser navigation
- e.preventDefault();
- e.stopPropagation();
-
- // Handle horizontal scrolling (trackpad swipe) by converting to pan
- if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
- // Horizontal scroll - pan the graph instead of zooming
- const panDelta = e.deltaX * 0.5;
- setPanX((prev) => prev - panDelta);
- return;
- }
-
- // Vertical scroll - zoom behavior
- const delta = e.deltaY > 0 ? 0.97 : 1.03;
- const newZoom = Math.max(0.05, Math.min(3, zoom * delta));
-
- // Get mouse position relative to the viewport
- let mouseX = e.clientX;
- let mouseY = e.clientY;
-
- // Try to get the container bounds to make coordinates relative to the graph container
- const target = e.currentTarget;
- if (target && "getBoundingClientRect" in target) {
- const rect = target.getBoundingClientRect();
- mouseX = e.clientX - rect.left;
- mouseY = e.clientY - rect.top;
- }
-
- // Calculate the world position of the mouse cursor
- const worldX = (mouseX - panX) / zoom;
- const worldY = (mouseY - panY) / zoom;
-
- // Calculate new pan to keep the mouse position stationary
- const newPanX = mouseX - worldX * newZoom;
- const newPanY = mouseY - worldY * newZoom;
-
- setZoom(newZoom);
- setPanX(newPanX);
- setPanY(newPanY);
- },
- [zoom, panX, panY],
- );
-
- const zoomIn = useCallback(
- (centerX?: number, centerY?: number, animate = true) => {
- const zoomFactor = 1.2;
- const newZoom = Math.min(3, zoom * zoomFactor); // Increased max zoom to 3x
-
- if (centerX !== undefined && centerY !== undefined) {
- // Mouse-centered zoom for programmatic zoom in
- const worldX = (centerX - panX) / zoom;
- const worldY = (centerY - panY) / zoom;
- const newPanX = centerX - worldX * newZoom;
- const newPanY = centerY - worldY * newZoom;
-
- if (animate && !isAnimating) {
- animateToViewState(newPanX, newPanY, newZoom, 200);
- } else {
- setZoom(newZoom);
- setPanX(newPanX);
- setPanY(newPanY);
- }
- } else {
- if (animate && !isAnimating) {
- animateToViewState(panX, panY, newZoom, 200);
- } else {
- setZoom(newZoom);
- }
- }
- },
- [zoom, panX, panY, isAnimating, animateToViewState],
- );
-
- const zoomOut = useCallback(
- (centerX?: number, centerY?: number, animate = true) => {
- const zoomFactor = 0.8;
- const newZoom = Math.max(0.05, zoom * zoomFactor); // Decreased min zoom to 0.05x
-
- if (centerX !== undefined && centerY !== undefined) {
- // Mouse-centered zoom for programmatic zoom out
- const worldX = (centerX - panX) / zoom;
- const worldY = (centerY - panY) / zoom;
- const newPanX = centerX - worldX * newZoom;
- const newPanY = centerY - worldY * newZoom;
-
- if (animate && !isAnimating) {
- animateToViewState(newPanX, newPanY, newZoom, 200);
- } else {
- setZoom(newZoom);
- setPanX(newPanX);
- setPanY(newPanY);
- }
- } else {
- if (animate && !isAnimating) {
- animateToViewState(panX, panY, newZoom, 200);
- } else {
- setZoom(newZoom);
- }
- }
- },
- [zoom, panX, panY, isAnimating, animateToViewState],
- );
-
- const resetView = useCallback(() => {
- setPanX(settings.initialPanX);
- setPanY(settings.initialPanY);
- setZoom(settings.initialZoom);
- setNodePositions(new Map());
- }, [settings]);
-
- // Auto-fit graph to viewport
- const autoFitToViewport = useCallback(
- (
- nodes: GraphNode[],
- viewportWidth: number,
- viewportHeight: number,
- options?: { occludedRightPx?: number; animate?: boolean },
- ) => {
- if (nodes.length === 0) return;
-
- // Find the bounds of all nodes
- let minX = Number.POSITIVE_INFINITY;
- let maxX = Number.NEGATIVE_INFINITY;
- let minY = Number.POSITIVE_INFINITY;
- let maxY = Number.NEGATIVE_INFINITY;
-
- nodes.forEach((node) => {
- minX = Math.min(minX, node.x - node.size / 2);
- maxX = Math.max(maxX, node.x + node.size / 2);
- minY = Math.min(minY, node.y - node.size / 2);
- maxY = Math.max(maxY, node.y + node.size / 2);
- });
-
- // Calculate the center of the content
- const contentCenterX = (minX + maxX) / 2;
- const contentCenterY = (minY + maxY) / 2;
-
- // Calculate the size of the content
- const contentWidth = maxX - minX;
- const contentHeight = maxY - minY;
-
- // Add padding (20% on each side)
- const paddingFactor = 1.4;
- const paddedWidth = contentWidth * paddingFactor;
- const paddedHeight = contentHeight * paddingFactor;
-
- // Account for occluded area on the right (e.g., chat panel)
- const occludedRightPx = Math.max(0, options?.occludedRightPx ?? 0);
- const availableWidth = Math.max(1, viewportWidth - occludedRightPx);
-
- // Calculate the zoom needed to fit the content within available width
- const zoomX = availableWidth / paddedWidth;
- const zoomY = viewportHeight / paddedHeight;
- const newZoom = Math.min(Math.max(0.05, Math.min(zoomX, zoomY)), 3);
-
- // Calculate pan to center the content within available area
- const availableCenterX = availableWidth / 2;
- const newPanX = availableCenterX - contentCenterX * newZoom;
- const newPanY = viewportHeight / 2 - contentCenterY * newZoom;
-
- // Apply the new view (optional animation)
- if (options?.animate) {
- const steps = 8;
- const durationMs = 160; // snappy
- const intervalMs = Math.max(1, Math.floor(durationMs / steps));
- const startZoom = zoom;
- const startPanX = panX;
- const startPanY = panY;
- let i = 0;
- const ease = (t: number) => 1 - (1 - t) ** 2; // ease-out quad
- const timer = setInterval(() => {
- i++;
- const t = ease(i / steps);
- setZoom(startZoom + (newZoom - startZoom) * t);
- setPanX(startPanX + (newPanX - startPanX) * t);
- setPanY(startPanY + (newPanY - startPanY) * t);
- if (i >= steps) clearInterval(timer);
- }, intervalMs);
- } else {
- setZoom(newZoom);
- setPanX(newPanX);
- setPanY(newPanY);
- }
- },
- [zoom, panX, panY],
- );
-
- // Touch gesture handlers for mobile pinch-to-zoom
- const handleTouchStart = useCallback((e: React.TouchEvent) => {
- const touches = Array.from(e.touches).map((touch) => ({
- id: touch.identifier,
- x: touch.clientX,
- y: touch.clientY,
- }));
-
- if (touches.length >= 2) {
- // Start gesture with two or more fingers
- const touch1 = touches[0]!;
- const touch2 = touches[1]!;
-
- const distance = Math.sqrt(
- (touch2.x - touch1.x) ** 2 + (touch2.y - touch1.y) ** 2,
- );
-
- const center = {
- x: (touch1.x + touch2.x) / 2,
- y: (touch1.y + touch2.y) / 2,
- };
-
- setTouchState({
- touches,
- lastDistance: distance,
- lastCenter: center,
- isGesturing: true,
- });
- } else {
- setTouchState((prev) => ({ ...prev, touches, isGesturing: false }));
- }
- }, []);
-
- const handleTouchMove = useCallback(
- (e: React.TouchEvent) => {
- e.preventDefault();
-
- const touches = Array.from(e.touches).map((touch) => ({
- id: touch.identifier,
- x: touch.clientX,
- y: touch.clientY,
- }));
-
- if (touches.length >= 2 && touchState.isGesturing) {
- const touch1 = touches[0]!;
- const touch2 = touches[1]!;
-
- const distance = Math.sqrt(
- (touch2.x - touch1.x) ** 2 + (touch2.y - touch1.y) ** 2,
- );
-
- const center = {
- x: (touch1.x + touch2.x) / 2,
- y: (touch1.y + touch2.y) / 2,
- };
-
- // Calculate zoom change based on pinch distance change
- const distanceChange = distance / touchState.lastDistance;
- const newZoom = Math.max(0.05, Math.min(3, zoom * distanceChange));
-
- // Get canvas bounds for center calculation
- const canvas = e.currentTarget as HTMLElement;
- const rect = canvas.getBoundingClientRect();
- const centerX = center.x - rect.left;
- const centerY = center.y - rect.top;
-
- // Calculate the world position of the pinch center
- const worldX = (centerX - panX) / zoom;
- const worldY = (centerY - panY) / zoom;
-
- // Calculate new pan to keep the pinch center stationary
- const newPanX = centerX - worldX * newZoom;
- const newPanY = centerY - worldY * newZoom;
-
- // Calculate pan change based on center movement
- const centerDx = center.x - touchState.lastCenter.x;
- const centerDy = center.y - touchState.lastCenter.y;
-
- setZoom(newZoom);
- setPanX(newPanX + centerDx);
- setPanY(newPanY + centerDy);
-
- setTouchState({
- touches,
- lastDistance: distance,
- lastCenter: center,
- isGesturing: true,
- });
- } else if (touches.length === 1 && !touchState.isGesturing && isPanning) {
- // Single finger pan (only if not in gesture mode)
- const touch = touches[0]!;
- const newPanX = touch.x - panStart.x;
- const newPanY = touch.y - panStart.y;
- setPanX(newPanX);
- setPanY(newPanY);
- }
- },
- [touchState, zoom, panX, panY, isPanning, panStart],
- );
-
- const handleTouchEnd = useCallback((e: React.TouchEvent) => {
- const touches = Array.from(e.touches).map((touch) => ({
- id: touch.identifier,
- x: touch.clientX,
- y: touch.clientY,
- }));
-
- if (touches.length < 2) {
- setTouchState((prev) => ({ ...prev, touches, isGesturing: false }));
- } else {
- setTouchState((prev) => ({ ...prev, touches }));
- }
-
- if (touches.length === 0) {
- setIsPanning(false);
- }
- }, []);
-
- // Center viewport on a specific world position (with animation)
- const centerViewportOn = useCallback(
- (
- worldX: number,
- worldY: number,
- viewportWidth: number,
- viewportHeight: number,
- animate = true,
- ) => {
- const newPanX = viewportWidth / 2 - worldX * zoom;
- const newPanY = viewportHeight / 2 - worldY * zoom;
-
- if (animate && !isAnimating) {
- animateToViewState(newPanX, newPanY, zoom, 400);
- } else {
- setPanX(newPanX);
- setPanY(newPanY);
- }
- },
- [zoom, isAnimating, animateToViewState],
- );
-
- // Node interaction handlers
- const handleNodeHover = useCallback((nodeId: string | null) => {
- setHoveredNode(nodeId);
- }, []);
-
- const handleNodeClick = useCallback(
- (nodeId: string) => {
- setSelectedNode(selectedNode === nodeId ? null : nodeId);
- },
- [selectedNode],
- );
-
- const handleDoubleClick = useCallback(
- (e: React.MouseEvent) => {
- // Calculate new zoom (zoom in by 1.5x)
- const zoomFactor = 1.5;
- const newZoom = Math.min(3, zoom * zoomFactor);
-
- // Get mouse position relative to the container
- let mouseX = e.clientX;
- let mouseY = e.clientY;
-
- // Try to get the container bounds to make coordinates relative to the graph container
- const target = e.currentTarget;
- if (target && "getBoundingClientRect" in target) {
- const rect = target.getBoundingClientRect();
- mouseX = e.clientX - rect.left;
- mouseY = e.clientY - rect.top;
- }
-
- // Calculate the world position of the clicked point
- const worldX = (mouseX - panX) / zoom;
- const worldY = (mouseY - panY) / zoom;
-
- // Calculate new pan to keep the clicked point in the same screen position
- const newPanX = mouseX - worldX * newZoom;
- const newPanY = mouseY - worldY * newZoom;
-
- setZoom(newZoom);
- setPanX(newPanX);
- setPanY(newPanY);
- },
- [zoom, panX, panY],
- );
-
- return {
- // State
- panX,
- panY,
- zoom,
- hoveredNode,
- selectedNode,
- draggingNodeId,
- nodePositions,
- // Handlers
- handlePanStart,
- handlePanMove,
- handlePanEnd,
- handleWheel,
- handleNodeHover,
- handleNodeClick,
- handleNodeDragStart,
- handleNodeDragMove,
- handleNodeDragEnd,
- handleDoubleClick,
- // Touch handlers
- handleTouchStart,
- handleTouchMove,
- handleTouchEnd,
- // Controls
- zoomIn,
- zoomOut,
- resetView,
- autoFitToViewport,
- centerViewportOn,
- setSelectedNode,
- };
-}