- Toolbar moved to top-10 (below macOS system buttons) - Lucide icons for all buttons (Tasks, Proc, Edges, Play/Pause, Zoom, Pin, Close) - Toggle buttons show active state with highlight background - Team name + alive indicator uses team color from config - Grouped buttons in glass panels with backdrop-blur - Removed raw text-only buttons (was: "−", "⊡", "+", "⊞ Pin", "✕")
343 lines
13 KiB
TypeScript
343 lines
13 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, useRef } from 'react';
|
|
import type { GraphDataPort } from '../ports/GraphDataPort';
|
|
import type { GraphEventPort } from '../ports/GraphEventPort';
|
|
import type { GraphConfigPort } from '../ports/GraphConfigPort';
|
|
import type { GraphNode } from '../ports/types';
|
|
import { GraphCanvas, type GraphCanvasHandle, type GraphDrawState } from './GraphCanvas';
|
|
import { GraphControls, type GraphFilterState } from './GraphControls';
|
|
import { GraphOverlay } from './GraphOverlay';
|
|
import { useGraphSimulation } from '../hooks/useGraphSimulation';
|
|
import { useGraphCamera } from '../hooks/useGraphCamera';
|
|
import { useGraphInteraction } from '../hooks/useGraphInteraction';
|
|
import { findNodeAt } from '../canvas/hit-detection';
|
|
import { ANIM_SPEED } from '../constants/canvas-constants';
|
|
|
|
export interface GraphViewProps {
|
|
data: GraphDataPort;
|
|
events?: GraphEventPort;
|
|
config?: Partial<GraphConfigPort>;
|
|
className?: string;
|
|
onRequestClose?: () => void;
|
|
onRequestPinAsTab?: () => void;
|
|
}
|
|
|
|
export function GraphView({
|
|
data,
|
|
events,
|
|
config,
|
|
className,
|
|
onRequestClose,
|
|
onRequestPinAsTab,
|
|
}: GraphViewProps): React.JSX.Element {
|
|
// ─── React state (user-facing only) ─────────────────────────────────────
|
|
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
|
const [filters, setFilters] = useState<GraphFilterState>({
|
|
showTasks: config?.showTasks ?? true,
|
|
showProcesses: config?.showProcesses ?? true,
|
|
showEdges: true,
|
|
paused: !(config?.animationEnabled ?? true),
|
|
});
|
|
|
|
// 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 containerRef = useRef<HTMLDivElement>(null);
|
|
const canvasHandle = useRef<GraphCanvasHandle>(null);
|
|
const rafRef = useRef(0);
|
|
const lastTimeRef = useRef(0);
|
|
const runningRef = useRef(false);
|
|
|
|
// ─── Hooks ──────────────────────────────────────────────────────────────
|
|
const simulation = useGraphSimulation();
|
|
const camera = useGraphCamera();
|
|
|
|
// 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 interaction = useGraphInteraction(
|
|
useCallback((nodeId: string, x: number, y: number) => {
|
|
const state = simulation.stateRef.current;
|
|
const node = state.nodes.find((n) => n.id === nodeId);
|
|
if (node) {
|
|
node.fx = x;
|
|
node.fy = y;
|
|
node.x = x;
|
|
node.y = y;
|
|
}
|
|
}, [simulation.stateRef]),
|
|
);
|
|
|
|
// ─── Sync data from adapter → simulation ────────────────────────────────
|
|
useEffect(() => {
|
|
const filteredNodes = data.nodes.filter((n) => {
|
|
if (n.kind === 'task' && !filters.showTasks) return false;
|
|
if (n.kind === 'process' && !filters.showProcesses) return false;
|
|
return true;
|
|
});
|
|
const filteredEdges = filters.showEdges
|
|
? data.edges
|
|
: data.edges.filter((e) => e.type === 'parent-child');
|
|
simulation.updateData(filteredNodes, filteredEdges, data.particles);
|
|
}, [data, filters.showTasks, filters.showProcesses, filters.showEdges, simulation]);
|
|
|
|
// ─── UNIFIED RAF LOOP: tick simulation + draw canvas ────────────────────
|
|
const idleFrameSkip = useRef(0);
|
|
|
|
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. Adaptive frame rate: skip every other frame when idle (no particles, no effects, sim settled)
|
|
const state = simulationRef.current.stateRef.current;
|
|
const isIdle = state.particles.length === 0 && state.effects.length === 0;
|
|
if (isIdle) {
|
|
idleFrameSkip.current++;
|
|
if (idleFrameSkip.current % 2 !== 0) {
|
|
rafRef.current = requestAnimationFrame(animate);
|
|
return; // skip draw, halve fps when idle
|
|
}
|
|
} else {
|
|
idleFrameSkip.current = 0;
|
|
}
|
|
|
|
// 4. Draw canvas imperatively (NO React re-render)
|
|
canvasHandle.current?.draw({
|
|
nodes: state.nodes,
|
|
edges: state.edges,
|
|
particles: state.particles,
|
|
effects: state.effects,
|
|
time: state.time,
|
|
camera: cameraRef.current.transformRef.current,
|
|
selectedNodeId: selectedNodeIdRef.current,
|
|
hoveredNodeId: interaction.hoveredNodeId.current,
|
|
});
|
|
|
|
rafRef.current = requestAnimationFrame(animate);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- all data read from .current refs
|
|
}, []);
|
|
|
|
// Start/stop RAF
|
|
useEffect(() => {
|
|
if (!filters.paused) {
|
|
runningRef.current = true;
|
|
lastTimeRef.current = 0;
|
|
rafRef.current = requestAnimationFrame(animate);
|
|
} else {
|
|
runningRef.current = false;
|
|
cancelAnimationFrame(rafRef.current);
|
|
}
|
|
return () => {
|
|
runningRef.current = false;
|
|
cancelAnimationFrame(rafRef.current);
|
|
};
|
|
}, [filters.paused, animate]);
|
|
|
|
// ─── Auto-fit: center graph immediately when data arrives ──────────────
|
|
const hasAutoFit = useRef(false);
|
|
useEffect(() => {
|
|
if (data.nodes.length > 0 && !hasAutoFit.current) {
|
|
hasAutoFit.current = true;
|
|
// Immediate fit (simulation already settled from 120 pre-ticks)
|
|
const el = containerRef.current;
|
|
if (el) {
|
|
camera.zoomToFit(simulation.stateRef.current.nodes, el.clientWidth, el.clientHeight);
|
|
}
|
|
// Second fit after mount stabilizes (ResizeObserver may fire late)
|
|
const timer = setTimeout(() => {
|
|
if (el) {
|
|
camera.zoomToFit(simulation.stateRef.current.nodes, el.clientWidth, el.clientHeight);
|
|
}
|
|
}, 300);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [data.nodes.length, camera, simulation.stateRef]);
|
|
|
|
// ─── Mouse handlers (Figma-style: drag empty space = pan, drag node = move) ─
|
|
const isPanningRef = useRef(false);
|
|
|
|
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
|
if (e.button !== 0) return; // only left click
|
|
|
|
const canvas = canvasHandle.current?.getCanvas();
|
|
if (!canvas) return;
|
|
const rect = canvas.getBoundingClientRect();
|
|
const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top);
|
|
|
|
// Check if we hit a node
|
|
interaction.handleMouseDown(world.x, world.y, simulation.stateRef.current.nodes);
|
|
|
|
if (interaction.dragNodeId.current) {
|
|
// Hit a node → will drag it
|
|
isPanningRef.current = false;
|
|
} else {
|
|
// Hit empty space → pan
|
|
isPanningRef.current = true;
|
|
camera.handlePanStart(e.clientX, e.clientY);
|
|
}
|
|
}, [camera, interaction, simulation.stateRef]);
|
|
|
|
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
|
// Dragging with left button held
|
|
if (e.buttons & 1) {
|
|
if (isPanningRef.current) {
|
|
camera.handlePanMove(e.clientX, e.clientY);
|
|
return;
|
|
}
|
|
const canvas = canvasHandle.current?.getCanvas();
|
|
if (!canvas) return;
|
|
const rect = canvas.getBoundingClientRect();
|
|
const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top);
|
|
interaction.handleMouseMove(world.x, world.y, simulation.stateRef.current.nodes);
|
|
return;
|
|
}
|
|
|
|
// No button held — hover detection + cursor update
|
|
const canvas = canvasHandle.current?.getCanvas();
|
|
if (!canvas) return;
|
|
const rect = canvas.getBoundingClientRect();
|
|
const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top);
|
|
interaction.hoveredNodeId.current = findNodeAt(world.x, world.y, simulation.stateRef.current.nodes);
|
|
canvas.style.cursor = interaction.hoveredNodeId.current ? 'pointer' : 'grab';
|
|
}, [camera, interaction, simulation.stateRef]);
|
|
|
|
const handleMouseUp = useCallback(() => {
|
|
if (isPanningRef.current) {
|
|
camera.handlePanEnd();
|
|
isPanningRef.current = false;
|
|
setSelectedNodeId(null); // hide popover after pan
|
|
return;
|
|
}
|
|
|
|
const clickedId = interaction.handleMouseUp();
|
|
if (clickedId) {
|
|
setSelectedNodeId(clickedId);
|
|
const node = simulation.stateRef.current.nodes.find((n) => n.id === clickedId);
|
|
if (node) events?.onNodeClick?.(node.domainRef);
|
|
} else {
|
|
setSelectedNodeId(null); // click on empty space — hide popover
|
|
if (!interaction.isDragging.current) {
|
|
events?.onBackgroundClick?.();
|
|
}
|
|
}
|
|
}, [interaction, simulation.stateRef, events, camera]);
|
|
|
|
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, simulation.stateRef.current.nodes);
|
|
if (nodeId) {
|
|
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, interaction, simulation.stateRef, events]);
|
|
|
|
// ─── 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) {
|
|
setSelectedNodeId(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);
|
|
}
|
|
if (e.key === ' ') {
|
|
e.preventDefault();
|
|
setFilters((f) => ({ ...f, paused: !f.paused }));
|
|
}
|
|
};
|
|
window.addEventListener('keydown', handler);
|
|
return () => window.removeEventListener('keydown', handler);
|
|
}, [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;
|
|
|
|
// ─── Render ─────────────────────────────────────────────────────────────
|
|
return (
|
|
<div ref={containerRef} className={`relative w-full h-full ${className ?? ''}`}>
|
|
<GraphCanvas
|
|
ref={canvasHandle}
|
|
showHexGrid={config?.showHexGrid ?? true}
|
|
showStarField={config?.showStarField ?? true}
|
|
bloomIntensity={config?.bloomIntensity ?? 0.6}
|
|
onWheel={camera.handleWheel}
|
|
onMouseDown={handleMouseDown}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseUp={handleMouseUp}
|
|
onDoubleClick={handleDoubleClick}
|
|
/>
|
|
|
|
<GraphControls
|
|
filters={filters}
|
|
onFiltersChange={setFilters}
|
|
onZoomIn={camera.zoomIn}
|
|
onZoomOut={camera.zoomOut}
|
|
onZoomToFit={() => {
|
|
const el = containerRef.current;
|
|
if (el) camera.zoomToFit(simulation.stateRef.current.nodes, el.clientWidth, el.clientHeight);
|
|
}}
|
|
onRequestClose={onRequestClose}
|
|
onRequestPinAsTab={onRequestPinAsTab}
|
|
teamName={data.teamName}
|
|
teamColor={data.teamColor}
|
|
isAlive={data.isAlive}
|
|
/>
|
|
|
|
<GraphOverlay
|
|
selectedNode={selectedNode}
|
|
worldToScreen={camera.worldToScreen}
|
|
events={events}
|
|
onDeselect={() => setSelectedNodeId(null)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|