1073 lines
36 KiB
TypeScript
1073 lines
36 KiB
TypeScript
/**
|
|
* GraphView — main orchestrator with UNIFIED RAF loop.
|
|
*
|
|
* ARCHITECTURE: One RAF loop that:
|
|
* 1. Ticks d3-force simulation (updates node positions in refs)
|
|
* 2. Updates particles and effects (in refs)
|
|
* 3. Calls canvasRef.draw() imperatively (no React re-renders)
|
|
*
|
|
* React useState ONLY for: selectedNodeId, filters (user-facing UI state).
|
|
* ALL animation state (positions, particles, effects, time) lives in refs.
|
|
*/
|
|
|
|
import { useState, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
|
|
import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom';
|
|
import type { GraphDataPort } from '../ports/GraphDataPort';
|
|
import type { GraphEventPort } from '../ports/GraphEventPort';
|
|
import type { GraphConfigPort } from '../ports/GraphConfigPort';
|
|
import type { GraphEdge, GraphNode, GraphOwnerSlotAssignment } from '../ports/types';
|
|
import type { StableRect } from '../layout/stableSlots';
|
|
import { GraphCanvas, type GraphCanvasHandle } from './GraphCanvas';
|
|
import { GraphControls, type GraphFilterState } from './GraphControls';
|
|
import { GraphOverlay } from './GraphOverlay';
|
|
import { GraphEdgeOverlay } from './GraphEdgeOverlay';
|
|
import { buildFocusState } from './buildFocusState';
|
|
import type { TransientHandoffCard } from './transientHandoffs';
|
|
import { useGraphSimulation } from '../hooks/useGraphSimulation';
|
|
import { useGraphCamera } from '../hooks/useGraphCamera';
|
|
import { useGraphInteraction } from '../hooks/useGraphInteraction';
|
|
import {
|
|
collectInteractiveEdgesInViewport,
|
|
findEdgeAt,
|
|
findNodeAt,
|
|
getEdgeMidpoint,
|
|
} from '../canvas/hit-detection';
|
|
import { ANIM, ANIM_SPEED } from '../constants/canvas-constants';
|
|
import { getLaunchAnchorScreenPlacement as buildLaunchAnchorScreenPlacement } from '../layout/launchAnchor';
|
|
|
|
export interface GraphViewProps {
|
|
data: GraphDataPort;
|
|
events?: GraphEventPort;
|
|
config?: Partial<GraphConfigPort>;
|
|
className?: string;
|
|
suspendAnimation?: boolean;
|
|
onRequestClose?: () => void;
|
|
onRequestPinAsTab?: () => void;
|
|
onRequestFullscreen?: () => void;
|
|
isSurfaceActive?: boolean;
|
|
onOpenTeamPage?: () => void;
|
|
onCreateTask?: () => void;
|
|
onToggleSidebar?: () => void;
|
|
isSidebarVisible?: boolean;
|
|
renderTopToolbarContent?: () => React.ReactNode;
|
|
onOwnerSlotDrop?: (payload: {
|
|
nodeId: string;
|
|
assignment: GraphOwnerSlotAssignment;
|
|
displacedNodeId?: string;
|
|
displacedAssignment?: GraphOwnerSlotAssignment;
|
|
}) => void;
|
|
/** Custom overlay renderer — replaces built-in GraphOverlay. Allows host app to reuse its own components. */
|
|
renderOverlay?: (props: {
|
|
node: GraphNode;
|
|
screenPos: { x: number; y: number };
|
|
onClose: () => void;
|
|
}) => React.ReactNode;
|
|
renderEdgeOverlay?: (props: {
|
|
edge: GraphEdge;
|
|
sourceNode: GraphNode | undefined;
|
|
targetNode: GraphNode | undefined;
|
|
onClose: () => void;
|
|
onSelectNode: (nodeId: string) => void;
|
|
}) => React.ReactNode;
|
|
renderHud?: (props: {
|
|
getLaunchAnchorScreenPlacement: (
|
|
leadNodeId: string,
|
|
) => { x: number; y: number; scale: number; visible: boolean } | null;
|
|
getActivityWorldRect: (ownerNodeId: string) => StableRect | null;
|
|
getTransientHandoffSnapshot: (options?: {
|
|
focusNodeIds?: ReadonlySet<string> | null;
|
|
focusEdgeIds?: ReadonlySet<string> | null;
|
|
}) => { cards: TransientHandoffCard[]; time: number };
|
|
getCameraZoom: () => number;
|
|
worldToScreen: (x: number, y: number) => { x: number; y: number };
|
|
getNodeWorldPosition: (nodeId: string) => { x: number; y: number } | null;
|
|
getViewportSize: () => { width: number; height: number };
|
|
focusNodeIds: ReadonlySet<string> | null;
|
|
focusEdgeIds: ReadonlySet<string> | null;
|
|
}) => React.ReactNode;
|
|
}
|
|
|
|
export function GraphView({
|
|
data,
|
|
events,
|
|
config,
|
|
className,
|
|
suspendAnimation = false,
|
|
onRequestClose,
|
|
onRequestPinAsTab,
|
|
onRequestFullscreen,
|
|
isSurfaceActive = true,
|
|
onOpenTeamPage,
|
|
onCreateTask,
|
|
onToggleSidebar,
|
|
isSidebarVisible = true,
|
|
renderTopToolbarContent,
|
|
onOwnerSlotDrop,
|
|
renderOverlay,
|
|
renderEdgeOverlay,
|
|
renderHud,
|
|
}: GraphViewProps): React.JSX.Element {
|
|
// ─── React state (user-facing only) ─────────────────────────────────────
|
|
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
|
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
|
const [interactionLocked, setInteractionLocked] = useState(false);
|
|
const [filters, setFilters] = useState<GraphFilterState>({
|
|
showTasks: config?.showTasks ?? true,
|
|
showProcesses: config?.showProcesses ?? true,
|
|
showEdges: true,
|
|
paused: !(config?.animationEnabled ?? true),
|
|
});
|
|
const effectivePaused = filters.paused || suspendAnimation;
|
|
|
|
// Ref mirror of selectedNodeId — read by RAF loop to avoid recreating animate on selection change
|
|
const selectedNodeIdRef = useRef<string | null>(null);
|
|
selectedNodeIdRef.current = selectedNodeId;
|
|
const selectedEdgeIdRef = useRef<string | null>(null);
|
|
selectedEdgeIdRef.current = selectedEdgeId;
|
|
const hoveredEdgeIdRef = useRef<string | null>(null);
|
|
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const canvasHandle = useRef<GraphCanvasHandle>(null);
|
|
const overlayRef = useRef<HTMLDivElement>(null);
|
|
const rafRef = useRef(0);
|
|
const lastTimeRef = useRef(0);
|
|
const runningRef = useRef(false);
|
|
const hasAutoFit = useRef(false);
|
|
const allowAutoFitRef = useRef(true);
|
|
const nodeMapRef = useRef(new Map<string, GraphNode>());
|
|
const nodeMapNodesRef = useRef<GraphNode[] | null>(null);
|
|
const dragPreviewRef = useRef<{
|
|
nodeId: string;
|
|
x: number;
|
|
y: number;
|
|
color?: string | null;
|
|
} | null>(null);
|
|
const selectionLockRef = useRef<{ userSelect: string; webkitUserSelect: string } | null>(null);
|
|
const activePrimaryInteractionRef = useRef(false);
|
|
|
|
// ─── Hooks ──────────────────────────────────────────────────────────────
|
|
const simulation = useGraphSimulation();
|
|
const camera = useGraphCamera();
|
|
const interaction = useGraphInteraction(
|
|
useCallback(
|
|
(nodeId: string, x: number, y: number) => {
|
|
simulation.setNodePosition(nodeId, x, y);
|
|
},
|
|
[simulation]
|
|
)
|
|
);
|
|
|
|
// Stable refs for RAF loop (avoid recreating animate on hook identity change)
|
|
const simulationRef = useRef(simulation);
|
|
simulationRef.current = simulation;
|
|
const cameraRef = useRef(camera);
|
|
cameraRef.current = camera;
|
|
const interactionRef = useRef(interaction);
|
|
interactionRef.current = interaction;
|
|
const processActivePointerMoveRef = useRef<((clientX: number, clientY: number) => boolean) | null>(
|
|
null
|
|
);
|
|
const completePointerInteractionRef = useRef<((clientX: number, clientY: number) => void) | null>(
|
|
null
|
|
);
|
|
|
|
const getVisibleNodes = useCallback(
|
|
(nodes: GraphNode[]): GraphNode[] =>
|
|
nodes.filter((node) => {
|
|
if (node.kind === 'task' && !filters.showTasks) return false;
|
|
if (node.kind === 'process' && !filters.showProcesses) return false;
|
|
return true;
|
|
}),
|
|
[filters.showProcesses, filters.showTasks]
|
|
);
|
|
|
|
const getVisibleEdges = useCallback(
|
|
(edges: GraphEdge[], visibleNodeIds: ReadonlySet<string>): GraphEdge[] =>
|
|
edges.filter((edge) => {
|
|
if (!filters.showEdges && edge.type !== 'parent-child') {
|
|
return false;
|
|
}
|
|
return visibleNodeIds.has(edge.source) && visibleNodeIds.has(edge.target);
|
|
}),
|
|
[filters.showEdges]
|
|
);
|
|
|
|
// ─── Sync data from adapter → simulation ────────────────────────────────
|
|
useEffect(() => {
|
|
simulation.updateData(data.nodes, data.edges, data.particles, data.teamName, data.layout);
|
|
}, [data, simulation]);
|
|
|
|
// ─── UNIFIED RAF LOOP: tick simulation + draw canvas ────────────────────
|
|
const focusState = useMemo(
|
|
() => buildFocusState(selectedNodeId, selectedEdgeId, data.nodes, data.edges),
|
|
[selectedEdgeId, selectedNodeId, data.edges, data.nodes]
|
|
);
|
|
|
|
const getNodeMap = useCallback((nodes: GraphNode[]): Map<string, GraphNode> => {
|
|
if (nodeMapNodesRef.current === nodes) {
|
|
return nodeMapRef.current;
|
|
}
|
|
const nodeMap = nodeMapRef.current;
|
|
nodeMap.clear();
|
|
for (const node of nodes) {
|
|
nodeMap.set(node.id, node);
|
|
}
|
|
nodeMapNodesRef.current = nodes;
|
|
return nodeMap;
|
|
}, []);
|
|
|
|
const getInteractiveEdges = useCallback(
|
|
(canvas: HTMLCanvasElement, nodes: GraphNode[], edges: GraphEdge[]): GraphEdge[] => {
|
|
const nodeMap = getNodeMap(nodes);
|
|
const rect = canvas.getBoundingClientRect();
|
|
const transform = camera.transformRef.current;
|
|
const bounds = {
|
|
left: -transform.x / transform.zoom,
|
|
top: -transform.y / transform.zoom,
|
|
right: (rect.width - transform.x) / transform.zoom,
|
|
bottom: (rect.height - transform.y) / transform.zoom,
|
|
};
|
|
return collectInteractiveEdgesInViewport(edges, nodeMap, bounds);
|
|
},
|
|
[camera.transformRef, getNodeMap]
|
|
);
|
|
const getViewportSize = useCallback(() => {
|
|
const container = containerRef.current;
|
|
return {
|
|
width: container?.clientWidth ?? 0,
|
|
height: container?.clientHeight ?? 0,
|
|
};
|
|
}, []);
|
|
const getLaunchAnchorScreenPlacement = useCallback((leadNodeId: string) => {
|
|
const anchor = simulationRef.current.getLaunchAnchorWorldPosition(leadNodeId);
|
|
if (!anchor) {
|
|
return null;
|
|
}
|
|
const viewport = getViewportSize();
|
|
if (viewport.width <= 0 || viewport.height <= 0) {
|
|
return null;
|
|
}
|
|
const transform = cameraRef.current.transformRef.current;
|
|
return buildLaunchAnchorScreenPlacement({
|
|
anchorX: anchor.x,
|
|
anchorY: anchor.y,
|
|
cameraX: transform.x,
|
|
cameraY: transform.y,
|
|
zoom: transform.zoom,
|
|
viewportWidth: viewport.width,
|
|
viewportHeight: viewport.height,
|
|
});
|
|
}, [getViewportSize]);
|
|
const getCameraZoom = useCallback(() => cameraRef.current.transformRef.current.zoom, []);
|
|
const getActivityWorldRect = useCallback(
|
|
(ownerNodeId: string) => simulationRef.current.getActivityWorldRect(ownerNodeId),
|
|
[]
|
|
);
|
|
const getTransientHandoffSnapshot = useCallback(
|
|
(options?: {
|
|
focusNodeIds?: ReadonlySet<string> | null;
|
|
focusEdgeIds?: ReadonlySet<string> | null;
|
|
}) =>
|
|
canvasHandle.current?.getTransientHandoffSnapshot(options) ?? {
|
|
cards: [],
|
|
time: 0,
|
|
},
|
|
[]
|
|
);
|
|
const getNodeWorldPosition = useCallback((nodeId: string) => {
|
|
const node = simulationRef.current.stateRef.current.nodes.find((candidate) => candidate.id === nodeId);
|
|
if (node?.x == null || node?.y == null) {
|
|
return null;
|
|
}
|
|
return { x: node.x, y: node.y };
|
|
}, []);
|
|
|
|
const setInteractionSelectionDisabled = useCallback((disabled: boolean) => {
|
|
if (typeof document === 'undefined') {
|
|
return;
|
|
}
|
|
const bodyStyle = document.body.style;
|
|
if (disabled) {
|
|
if (!selectionLockRef.current) {
|
|
selectionLockRef.current = {
|
|
userSelect: bodyStyle.userSelect,
|
|
webkitUserSelect: bodyStyle.webkitUserSelect,
|
|
};
|
|
}
|
|
bodyStyle.userSelect = 'none';
|
|
bodyStyle.webkitUserSelect = 'none';
|
|
return;
|
|
}
|
|
if (!selectionLockRef.current) {
|
|
return;
|
|
}
|
|
bodyStyle.userSelect = selectionLockRef.current.userSelect;
|
|
bodyStyle.webkitUserSelect = selectionLockRef.current.webkitUserSelect;
|
|
selectionLockRef.current = null;
|
|
}, []);
|
|
|
|
const setInteractionGuards = useCallback(
|
|
(active: boolean) => {
|
|
activePrimaryInteractionRef.current = active;
|
|
setInteractionLocked(active);
|
|
setInteractionSelectionDisabled(active);
|
|
},
|
|
[setInteractionSelectionDisabled]
|
|
);
|
|
|
|
const animate = useCallback(() => {
|
|
if (!runningRef.current) return;
|
|
|
|
const now = performance.now() / 1000;
|
|
const dt = Math.min(
|
|
lastTimeRef.current > 0 ? now - lastTimeRef.current : ANIM_SPEED.defaultDeltaTime,
|
|
ANIM_SPEED.maxDeltaTime
|
|
);
|
|
lastTimeRef.current = now;
|
|
|
|
// 1. Tick simulation
|
|
simulationRef.current.tick(dt);
|
|
|
|
// 2. Update camera inertia
|
|
cameraRef.current.updateInertia();
|
|
|
|
// 3. Draw every frame: background stars and shooting stars need continuous motion.
|
|
const state = simulationRef.current.stateRef.current;
|
|
const visibleNodes = getVisibleNodes(state.nodes);
|
|
const visibleNodeIds = new Set(visibleNodes.map((node) => node.id));
|
|
const visibleEdges = getVisibleEdges(state.edges, visibleNodeIds);
|
|
|
|
// 4. Draw canvas imperatively (NO React re-render)
|
|
canvasHandle.current?.draw({
|
|
teamName: data.teamName,
|
|
nodes: visibleNodes,
|
|
edges: visibleEdges,
|
|
particles: state.particles,
|
|
effects: state.effects,
|
|
time: state.time,
|
|
camera: cameraRef.current.transformRef.current,
|
|
selectedNodeId: selectedNodeIdRef.current,
|
|
hoveredNodeId: interaction.hoveredNodeId.current,
|
|
selectedEdgeId: selectedEdgeIdRef.current,
|
|
hoveredEdgeId: hoveredEdgeIdRef.current,
|
|
focusNodeIds: focusState.focusNodeIds,
|
|
focusEdgeIds: focusState.focusEdgeIds,
|
|
dragPreview: dragPreviewRef.current,
|
|
});
|
|
|
|
rafRef.current = requestAnimationFrame(animate);
|
|
}, [
|
|
data.teamName,
|
|
focusState.focusEdgeIds,
|
|
focusState.focusNodeIds,
|
|
getVisibleEdges,
|
|
getVisibleNodes,
|
|
interaction.hoveredNodeId,
|
|
]);
|
|
|
|
// Start/stop RAF
|
|
useEffect(() => {
|
|
if (!effectivePaused) {
|
|
runningRef.current = true;
|
|
lastTimeRef.current = 0;
|
|
rafRef.current = requestAnimationFrame(animate);
|
|
} else {
|
|
runningRef.current = false;
|
|
cancelAnimationFrame(rafRef.current);
|
|
}
|
|
return () => {
|
|
runningRef.current = false;
|
|
cancelAnimationFrame(rafRef.current);
|
|
};
|
|
}, [effectivePaused, animate]);
|
|
|
|
const fitGraphToViewport = useCallback(() => {
|
|
const el = containerRef.current;
|
|
if (!el || data.nodes.length === 0) return;
|
|
camera.zoomToFit(
|
|
simulation.stateRef.current.nodes,
|
|
el.clientWidth,
|
|
el.clientHeight,
|
|
simulation.getExtraWorldBounds()
|
|
);
|
|
}, [camera, data.nodes.length, simulation]);
|
|
|
|
// ─── Auto-fit: until first user interaction, also react to container resizes ─────
|
|
useEffect(() => {
|
|
if (data.nodes.length === 0) {
|
|
hasAutoFit.current = false;
|
|
allowAutoFitRef.current = true;
|
|
return;
|
|
}
|
|
|
|
if (!hasAutoFit.current) {
|
|
hasAutoFit.current = true;
|
|
fitGraphToViewport();
|
|
|
|
const raf1 = requestAnimationFrame(() => {
|
|
fitGraphToViewport();
|
|
requestAnimationFrame(() => {
|
|
fitGraphToViewport();
|
|
});
|
|
});
|
|
|
|
return () => cancelAnimationFrame(raf1);
|
|
}
|
|
}, [data.nodes.length, fitGraphToViewport]);
|
|
|
|
useEffect(() => {
|
|
const el = containerRef.current;
|
|
if (!el || data.nodes.length === 0) return;
|
|
|
|
let frame = 0;
|
|
const observer = new ResizeObserver(() => {
|
|
if (!allowAutoFitRef.current) return;
|
|
cancelAnimationFrame(frame);
|
|
frame = requestAnimationFrame(() => {
|
|
fitGraphToViewport();
|
|
});
|
|
});
|
|
|
|
observer.observe(el);
|
|
return () => {
|
|
observer.disconnect();
|
|
cancelAnimationFrame(frame);
|
|
};
|
|
}, [data.nodes.length, fitGraphToViewport]);
|
|
|
|
const markUserInteracted = useCallback(() => {
|
|
allowAutoFitRef.current = false;
|
|
}, []);
|
|
|
|
useLayoutEffect(() => {
|
|
if (isSurfaceActive) {
|
|
return;
|
|
}
|
|
interactionRef.current.handleMouseUp();
|
|
simulationRef.current.clearTransientOwnerPositions();
|
|
dragPreviewRef.current = null;
|
|
isPanningRef.current = false;
|
|
edgeMouseDownRef.current = null;
|
|
setInteractionGuards(false);
|
|
}, [isSurfaceActive, setInteractionGuards]);
|
|
|
|
const handleWheel = useCallback(
|
|
(e: WheelEvent) => {
|
|
markUserInteracted();
|
|
camera.handleWheel(e);
|
|
},
|
|
[camera, markUserInteracted]
|
|
);
|
|
|
|
// ─── Mouse handlers (Figma-style: drag empty space = pan, drag node = move) ─
|
|
const isPanningRef = useRef(false);
|
|
const edgeMouseDownRef = useRef<{
|
|
id: string;
|
|
worldX: number;
|
|
worldY: number;
|
|
clientX: number;
|
|
clientY: number;
|
|
} | null>(null);
|
|
|
|
const handleMouseDown = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
if (e.button !== 0) return; // only left click
|
|
e.preventDefault();
|
|
dragPreviewRef.current = null;
|
|
setInteractionGuards(true);
|
|
|
|
const canvas = canvasHandle.current?.getCanvas();
|
|
if (!canvas) {
|
|
setInteractionGuards(false);
|
|
return;
|
|
}
|
|
const rect = canvas.getBoundingClientRect();
|
|
const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top);
|
|
const nodes = getVisibleNodes(simulation.stateRef.current.nodes);
|
|
const visibleNodeIds = new Set(nodes.map((node) => node.id));
|
|
const edges = getVisibleEdges(simulation.stateRef.current.edges, visibleNodeIds);
|
|
const nodeMap = getNodeMap(nodes);
|
|
const interactiveEdges = getInteractiveEdges(canvas, nodes, edges);
|
|
|
|
// Check if we hit a node
|
|
interaction.handleMouseDown(world.x, world.y, nodes);
|
|
|
|
// Hit a node (draggable or clickable) → don't pan
|
|
const hitNode = findNodeAt(world.x, world.y, nodes);
|
|
if (hitNode) {
|
|
markUserInteracted();
|
|
isPanningRef.current = false;
|
|
edgeMouseDownRef.current = null;
|
|
hoveredEdgeIdRef.current = null;
|
|
} else {
|
|
const hitEdge = findEdgeAt(world.x, world.y, interactiveEdges, nodeMap);
|
|
if (hitEdge) {
|
|
markUserInteracted();
|
|
isPanningRef.current = false;
|
|
edgeMouseDownRef.current = {
|
|
id: hitEdge,
|
|
worldX: world.x,
|
|
worldY: world.y,
|
|
clientX: e.clientX,
|
|
clientY: e.clientY,
|
|
};
|
|
hoveredEdgeIdRef.current = hitEdge;
|
|
} else {
|
|
// Hit empty space → pan
|
|
markUserInteracted();
|
|
isPanningRef.current = true;
|
|
edgeMouseDownRef.current = null;
|
|
hoveredEdgeIdRef.current = null;
|
|
camera.handlePanStart(e.clientX, e.clientY);
|
|
}
|
|
}
|
|
},
|
|
[
|
|
camera,
|
|
getInteractiveEdges,
|
|
getNodeMap,
|
|
getVisibleEdges,
|
|
getVisibleNodes,
|
|
interaction,
|
|
markUserInteracted,
|
|
setInteractionGuards,
|
|
simulation.stateRef,
|
|
]
|
|
);
|
|
|
|
const processActivePointerMove = useCallback(
|
|
(clientX: number, clientY: number) => {
|
|
if (isPanningRef.current) {
|
|
if (typeof document !== 'undefined') {
|
|
document.getSelection()?.removeAllRanges();
|
|
}
|
|
camera.handlePanMove(clientX, clientY);
|
|
return true;
|
|
}
|
|
|
|
const edgeMouseDown = edgeMouseDownRef.current;
|
|
if (
|
|
edgeMouseDown &&
|
|
!interaction.dragNodeId.current &&
|
|
!interaction.isDragging.current
|
|
) {
|
|
const dx = clientX - edgeMouseDown.clientX;
|
|
const dy = clientY - edgeMouseDown.clientY;
|
|
if (dx * dx + dy * dy > ANIM.dragThresholdPx * ANIM.dragThresholdPx) {
|
|
if (typeof document !== 'undefined') {
|
|
document.getSelection()?.removeAllRanges();
|
|
}
|
|
hoveredEdgeIdRef.current = null;
|
|
edgeMouseDownRef.current = null;
|
|
isPanningRef.current = true;
|
|
camera.handlePanStart(edgeMouseDown.clientX, edgeMouseDown.clientY);
|
|
camera.handlePanMove(clientX, clientY);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
if (
|
|
!activePrimaryInteractionRef.current &&
|
|
!interaction.dragNodeId.current &&
|
|
!interaction.isDragging.current
|
|
) {
|
|
dragPreviewRef.current = null;
|
|
return false;
|
|
}
|
|
|
|
const canvas = canvasHandle.current?.getCanvas();
|
|
if (!canvas) {
|
|
dragPreviewRef.current = null;
|
|
return false;
|
|
}
|
|
|
|
const rect = canvas.getBoundingClientRect();
|
|
const world = camera.screenToWorld(clientX - rect.left, clientY - rect.top);
|
|
interaction.handleMouseMove(world.x, world.y, getVisibleNodes(simulation.stateRef.current.nodes));
|
|
|
|
const draggedNodeId = interaction.dragNodeId.current;
|
|
if (interaction.isDragging.current && draggedNodeId) {
|
|
if (typeof document !== 'undefined') {
|
|
document.getSelection()?.removeAllRanges();
|
|
}
|
|
const draggedNode = simulation.stateRef.current.nodes.find((node) => node.id === draggedNodeId);
|
|
if (draggedNode?.kind === 'member') {
|
|
const nearest = simulation.resolveNearestOwnerSlot(draggedNodeId, world.x, world.y);
|
|
if (nearest) {
|
|
dragPreviewRef.current = {
|
|
nodeId: draggedNodeId,
|
|
x: nearest.previewOwnerX,
|
|
y: nearest.previewOwnerY,
|
|
color: draggedNode.color,
|
|
};
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
dragPreviewRef.current = null;
|
|
return true;
|
|
},
|
|
[camera, getVisibleNodes, interaction, simulation]
|
|
);
|
|
|
|
const completePointerInteraction = useCallback(
|
|
(clientX: number, clientY: number) => {
|
|
const draggedNodeId = interaction.dragNodeId.current;
|
|
const wasDragging = interaction.isDragging.current;
|
|
|
|
if (isPanningRef.current) {
|
|
camera.handlePanEnd();
|
|
isPanningRef.current = false;
|
|
setInteractionGuards(false);
|
|
dragPreviewRef.current = null;
|
|
setSelectedNodeId(null);
|
|
setSelectedEdgeId(null);
|
|
edgeMouseDownRef.current = null;
|
|
interaction.handleMouseUp();
|
|
return;
|
|
}
|
|
|
|
const clickedId = interaction.handleMouseUp();
|
|
if (wasDragging && draggedNodeId) {
|
|
setInteractionGuards(false);
|
|
const draggedNode = simulation.stateRef.current.nodes.find((node) => node.id === draggedNodeId);
|
|
if (draggedNode?.kind === 'member' && draggedNode.x != null && draggedNode.y != null) {
|
|
const nearest = simulation.resolveNearestOwnerSlot(
|
|
draggedNodeId,
|
|
draggedNode.x,
|
|
draggedNode.y
|
|
);
|
|
if (nearest) {
|
|
onOwnerSlotDrop?.({
|
|
nodeId: draggedNodeId,
|
|
assignment: nearest.assignment,
|
|
displacedNodeId: nearest.displacedOwnerId,
|
|
displacedAssignment: nearest.displacedAssignment,
|
|
});
|
|
requestAnimationFrame(() => {
|
|
simulation.clearNodePosition(draggedNodeId);
|
|
});
|
|
dragPreviewRef.current = null;
|
|
edgeMouseDownRef.current = null;
|
|
return;
|
|
}
|
|
}
|
|
simulation.clearNodePosition(draggedNodeId);
|
|
dragPreviewRef.current = null;
|
|
edgeMouseDownRef.current = null;
|
|
return;
|
|
}
|
|
|
|
setInteractionGuards(false);
|
|
if (clickedId) {
|
|
setSelectedNodeId(clickedId);
|
|
setSelectedEdgeId(null);
|
|
const node = simulation.stateRef.current.nodes.find((n) => n.id === clickedId);
|
|
if (node) events?.onNodeClick?.(node.domainRef);
|
|
} else {
|
|
const canvas = canvasHandle.current?.getCanvas();
|
|
let clickedEdgeId: string | null = null;
|
|
if (canvas && edgeMouseDownRef.current && !interaction.isDragging.current) {
|
|
const rect = canvas.getBoundingClientRect();
|
|
const world = camera.screenToWorld(clientX - rect.left, clientY - rect.top);
|
|
const dx = world.x - edgeMouseDownRef.current.worldX;
|
|
const dy = world.y - edgeMouseDownRef.current.worldY;
|
|
if (dx * dx + dy * dy <= 25) {
|
|
clickedEdgeId = edgeMouseDownRef.current.id;
|
|
}
|
|
}
|
|
edgeMouseDownRef.current = null;
|
|
|
|
if (clickedEdgeId) {
|
|
setSelectedNodeId(null);
|
|
setSelectedEdgeId(clickedEdgeId);
|
|
const edge = simulation.stateRef.current.edges.find(
|
|
(candidate) => candidate.id === clickedEdgeId
|
|
);
|
|
if (edge) {
|
|
events?.onEdgeClick?.(edge);
|
|
}
|
|
} else {
|
|
setSelectedNodeId(null);
|
|
setSelectedEdgeId(null);
|
|
}
|
|
if (!interaction.isDragging.current && !clickedEdgeId) {
|
|
events?.onBackgroundClick?.();
|
|
}
|
|
}
|
|
dragPreviewRef.current = null;
|
|
},
|
|
[camera, events, interaction, onOwnerSlotDrop, setInteractionGuards, simulation]
|
|
);
|
|
processActivePointerMoveRef.current = processActivePointerMove;
|
|
completePointerInteractionRef.current = completePointerInteraction;
|
|
|
|
const handleMouseMove = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
if (processActivePointerMove(e.clientX, e.clientY)) {
|
|
return;
|
|
}
|
|
|
|
dragPreviewRef.current = null;
|
|
|
|
const canvas = canvasHandle.current?.getCanvas();
|
|
if (!canvas) return;
|
|
const rect = canvas.getBoundingClientRect();
|
|
const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top);
|
|
const nodes = getVisibleNodes(simulation.stateRef.current.nodes);
|
|
const visibleNodeIds = new Set(nodes.map((node) => node.id));
|
|
const edges = getVisibleEdges(simulation.stateRef.current.edges, visibleNodeIds);
|
|
|
|
const hoveredNodeId = findNodeAt(world.x, world.y, nodes);
|
|
interaction.hoveredNodeId.current = hoveredNodeId;
|
|
|
|
if (hoveredNodeId) {
|
|
hoveredEdgeIdRef.current = null;
|
|
canvas.style.cursor = 'pointer';
|
|
return;
|
|
}
|
|
|
|
const nodeMap = getNodeMap(nodes);
|
|
const interactiveEdges = getInteractiveEdges(canvas, nodes, edges);
|
|
hoveredEdgeIdRef.current = findEdgeAt(world.x, world.y, interactiveEdges, nodeMap);
|
|
canvas.style.cursor = hoveredEdgeIdRef.current ? 'pointer' : 'grab';
|
|
},
|
|
[
|
|
camera,
|
|
getInteractiveEdges,
|
|
getNodeMap,
|
|
getVisibleEdges,
|
|
getVisibleNodes,
|
|
interaction,
|
|
processActivePointerMove,
|
|
simulation.stateRef,
|
|
]
|
|
);
|
|
|
|
const handleMouseUp = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
completePointerInteraction(e.clientX, e.clientY);
|
|
},
|
|
[completePointerInteraction]
|
|
);
|
|
|
|
useEffect(() => {
|
|
const handleWindowMouseMove = (event: MouseEvent): void => {
|
|
if (
|
|
!activePrimaryInteractionRef.current &&
|
|
!isPanningRef.current &&
|
|
!interactionRef.current.dragNodeId.current &&
|
|
!interactionRef.current.isDragging.current &&
|
|
!edgeMouseDownRef.current
|
|
) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
processActivePointerMoveRef.current?.(event.clientX, event.clientY);
|
|
};
|
|
|
|
const handleWindowMouseUp = (event: MouseEvent): void => {
|
|
if (
|
|
!activePrimaryInteractionRef.current &&
|
|
!isPanningRef.current &&
|
|
!interactionRef.current.dragNodeId.current &&
|
|
!interactionRef.current.isDragging.current &&
|
|
!edgeMouseDownRef.current
|
|
) {
|
|
setInteractionGuards(false);
|
|
return;
|
|
}
|
|
completePointerInteractionRef.current?.(event.clientX, event.clientY);
|
|
};
|
|
|
|
const clearInteraction = (): void => {
|
|
if (
|
|
!activePrimaryInteractionRef.current &&
|
|
!isPanningRef.current &&
|
|
!interactionRef.current.isDragging.current
|
|
) {
|
|
return;
|
|
}
|
|
interactionRef.current.handleMouseUp();
|
|
cameraRef.current.handlePanEnd();
|
|
isPanningRef.current = false;
|
|
edgeMouseDownRef.current = null;
|
|
dragPreviewRef.current = null;
|
|
setInteractionGuards(false);
|
|
};
|
|
|
|
window.addEventListener('mousemove', handleWindowMouseMove);
|
|
window.addEventListener('mouseup', handleWindowMouseUp);
|
|
window.addEventListener('blur', clearInteraction);
|
|
window.addEventListener('dragstart', clearInteraction);
|
|
return () => {
|
|
window.removeEventListener('mousemove', handleWindowMouseMove);
|
|
window.removeEventListener('mouseup', handleWindowMouseUp);
|
|
window.removeEventListener('blur', clearInteraction);
|
|
window.removeEventListener('dragstart', clearInteraction);
|
|
};
|
|
}, [setInteractionGuards]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
setInteractionGuards(false);
|
|
};
|
|
}, [setInteractionGuards]);
|
|
|
|
const handleDoubleClick = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
const canvas = canvasHandle.current?.getCanvas();
|
|
if (!canvas) return;
|
|
const rect = canvas.getBoundingClientRect();
|
|
const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top);
|
|
const nodeId = interaction.handleDoubleClick(
|
|
world.x,
|
|
world.y,
|
|
getVisibleNodes(simulation.stateRef.current.nodes)
|
|
);
|
|
if (nodeId) {
|
|
setSelectedEdgeId(null);
|
|
const node = simulation.stateRef.current.nodes.find((n) => n.id === nodeId);
|
|
if (node) {
|
|
// Unpin if pinned (toggle)
|
|
if (node.fx != null) {
|
|
node.fx = null;
|
|
node.fy = null;
|
|
}
|
|
events?.onNodeDoubleClick?.(node.domainRef);
|
|
}
|
|
}
|
|
},
|
|
[camera, events, getVisibleNodes, interaction, simulation.stateRef]
|
|
);
|
|
|
|
// ─── Keyboard ───────────────────────────────────────────────────────────
|
|
useEffect(() => {
|
|
const handler = (e: KeyboardEvent) => {
|
|
// Don't capture from inputs
|
|
const target = e.target as HTMLElement;
|
|
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
|
|
return;
|
|
|
|
if (e.key === 'Escape') {
|
|
if (selectedNodeId || selectedEdgeId) {
|
|
setSelectedNodeId(null);
|
|
setSelectedEdgeId(null);
|
|
} else {
|
|
onRequestClose?.();
|
|
}
|
|
}
|
|
if (e.key === 'f' || e.key === 'F') {
|
|
const el = containerRef.current;
|
|
if (el)
|
|
camera.zoomToFit(
|
|
simulation.stateRef.current.nodes,
|
|
el.clientWidth,
|
|
el.clientHeight,
|
|
simulation.getExtraWorldBounds()
|
|
);
|
|
}
|
|
if (e.key === ' ') {
|
|
e.preventDefault();
|
|
setFilters((f) => ({ ...f, paused: !f.paused }));
|
|
}
|
|
};
|
|
window.addEventListener('keydown', handler);
|
|
return () => window.removeEventListener('keydown', handler);
|
|
}, [selectedEdgeId, selectedNodeId, onRequestClose, camera, simulation.stateRef]);
|
|
|
|
// ─── Selected node for overlay ──────────────────────────────────────────
|
|
const selectedNode: GraphNode | null = selectedNodeId
|
|
? (simulation.stateRef.current.nodes.find((n) => n.id === selectedNodeId) ?? null)
|
|
: null;
|
|
const selectedEdge: GraphEdge | null = selectedEdgeId
|
|
? (simulation.stateRef.current.edges.find((edge) => edge.id === selectedEdgeId) ?? null)
|
|
: null;
|
|
const selectedEdgeNodeMap = useMemo(
|
|
() => getNodeMap(simulation.stateRef.current.nodes),
|
|
[data.nodes, getNodeMap, selectedEdgeId, simulation.stateRef]
|
|
);
|
|
|
|
useLayoutEffect(() => {
|
|
if ((!selectedNode && !selectedEdgeId) || !containerRef.current || !overlayRef.current) {
|
|
return;
|
|
}
|
|
|
|
const container = containerRef.current;
|
|
const floating = overlayRef.current;
|
|
|
|
const reference = {
|
|
getBoundingClientRect(): DOMRect {
|
|
const containerRect = container.getBoundingClientRect();
|
|
const screenPos = (() => {
|
|
if (selectedNode) {
|
|
return camera.worldToScreen(selectedNode.x ?? 0, selectedNode.y ?? 0);
|
|
}
|
|
if (selectedEdgeId) {
|
|
const currentNodes = simulation.stateRef.current.nodes;
|
|
const currentEdge = simulation.stateRef.current.edges.find(
|
|
(edge) => edge.id === selectedEdgeId
|
|
);
|
|
if (currentEdge) {
|
|
const nodeMap = getNodeMap(currentNodes);
|
|
const midpoint = getEdgeMidpoint(currentEdge, nodeMap);
|
|
if (midpoint) {
|
|
return camera.worldToScreen(midpoint.x, midpoint.y);
|
|
}
|
|
}
|
|
}
|
|
return camera.worldToScreen(0, 0);
|
|
})();
|
|
return DOMRect.fromRect({
|
|
x: containerRect.left + screenPos.x,
|
|
y: containerRect.top + screenPos.y,
|
|
width: 0,
|
|
height: 0,
|
|
});
|
|
},
|
|
};
|
|
|
|
const updatePosition = async (): Promise<void> => {
|
|
const { x, y } = await computePosition(reference, floating, {
|
|
strategy: 'fixed',
|
|
placement: 'right-start',
|
|
middleware: [
|
|
offset(16),
|
|
flip({
|
|
boundary: container,
|
|
padding: 12,
|
|
fallbackPlacements: ['left-start', 'bottom-start', 'top-start'],
|
|
}),
|
|
shift({
|
|
boundary: container,
|
|
padding: 12,
|
|
}),
|
|
],
|
|
});
|
|
|
|
floating.style.left = `${x}px`;
|
|
floating.style.top = `${y}px`;
|
|
};
|
|
|
|
const cleanup = autoUpdate(reference, floating, updatePosition, {
|
|
animationFrame: true,
|
|
});
|
|
|
|
void updatePosition();
|
|
|
|
return cleanup;
|
|
}, [camera, getNodeMap, selectedEdgeId, selectedNode, simulation.stateRef]);
|
|
|
|
// ─── Render ─────────────────────────────────────────────────────────────
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className={`relative h-full w-full overflow-hidden select-none ${className ?? ''}`}
|
|
>
|
|
<GraphCanvas
|
|
ref={canvasHandle}
|
|
showHexGrid={config?.showHexGrid ?? true}
|
|
showStarField={config?.showStarField ?? true}
|
|
bloomIntensity={config?.bloomIntensity ?? 0.6}
|
|
onWheel={handleWheel}
|
|
onMouseDown={handleMouseDown}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseUp={handleMouseUp}
|
|
onDoubleClick={handleDoubleClick}
|
|
/>
|
|
|
|
<GraphControls
|
|
filters={filters}
|
|
onFiltersChange={setFilters}
|
|
onZoomIn={() => {
|
|
markUserInteracted();
|
|
camera.zoomIn();
|
|
}}
|
|
onZoomOut={() => {
|
|
markUserInteracted();
|
|
camera.zoomOut();
|
|
}}
|
|
onZoomToFit={() => {
|
|
markUserInteracted();
|
|
const el = containerRef.current;
|
|
if (el)
|
|
camera.zoomToFit(
|
|
simulation.stateRef.current.nodes,
|
|
el.clientWidth,
|
|
el.clientHeight,
|
|
simulation.getExtraWorldBounds()
|
|
);
|
|
}}
|
|
onRequestClose={onRequestClose}
|
|
onRequestPinAsTab={onRequestPinAsTab}
|
|
onRequestFullscreen={onRequestFullscreen}
|
|
onOpenTeamPage={onOpenTeamPage}
|
|
onCreateTask={onCreateTask}
|
|
onToggleSidebar={onToggleSidebar}
|
|
isSidebarVisible={isSidebarVisible}
|
|
teamName={data.teamName}
|
|
teamColor={data.teamColor}
|
|
isAlive={data.isAlive}
|
|
topToolbarContent={renderTopToolbarContent?.()}
|
|
interactionLocked={interactionLocked}
|
|
/>
|
|
|
|
{renderHud ? (
|
|
<div className="pointer-events-none absolute inset-0 z-[5] overflow-hidden">
|
|
{renderHud({
|
|
getLaunchAnchorScreenPlacement,
|
|
getActivityWorldRect,
|
|
getTransientHandoffSnapshot,
|
|
getCameraZoom,
|
|
worldToScreen: camera.worldToScreen,
|
|
getNodeWorldPosition,
|
|
getViewportSize,
|
|
focusNodeIds: focusState.focusNodeIds,
|
|
focusEdgeIds: focusState.focusEdgeIds,
|
|
})}
|
|
</div>
|
|
) : null}
|
|
|
|
{(selectedNode || selectedEdge) && (
|
|
<div ref={overlayRef} className="pointer-events-auto fixed z-20">
|
|
{selectedNode ? (
|
|
renderOverlay ? (
|
|
renderOverlay({
|
|
node: selectedNode,
|
|
screenPos: camera.worldToScreen(selectedNode.x ?? 0, selectedNode.y ?? 0),
|
|
onClose: () => setSelectedNodeId(null),
|
|
})
|
|
) : (
|
|
<GraphOverlay
|
|
selectedNode={selectedNode}
|
|
events={events}
|
|
onDeselect={() => setSelectedNodeId(null)}
|
|
/>
|
|
)
|
|
) : selectedEdge ? (
|
|
renderEdgeOverlay ? (
|
|
renderEdgeOverlay({
|
|
edge: selectedEdge,
|
|
sourceNode: selectedEdgeNodeMap.get(selectedEdge.source),
|
|
targetNode: selectedEdgeNodeMap.get(selectedEdge.target),
|
|
onClose: () => setSelectedEdgeId(null),
|
|
onSelectNode: (nodeId: string) => {
|
|
setSelectedEdgeId(null);
|
|
setSelectedNodeId(nodeId);
|
|
},
|
|
})
|
|
) : (
|
|
<GraphEdgeOverlay
|
|
edge={selectedEdge}
|
|
sourceNode={selectedEdgeNodeMap.get(selectedEdge.source)}
|
|
targetNode={selectedEdgeNodeMap.get(selectedEdge.target)}
|
|
onClose={() => setSelectedEdgeId(null)}
|
|
/>
|
|
)
|
|
) : null}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|