aboutsummaryrefslogtreecommitdiff
path: root/packages/ui/memory-graph/graph-webgl-canvas.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'packages/ui/memory-graph/graph-webgl-canvas.tsx')
-rw-r--r--packages/ui/memory-graph/graph-webgl-canvas.tsx794
1 files changed, 794 insertions, 0 deletions
diff --git a/packages/ui/memory-graph/graph-webgl-canvas.tsx b/packages/ui/memory-graph/graph-webgl-canvas.tsx
new file mode 100644
index 00000000..af13eefc
--- /dev/null
+++ b/packages/ui/memory-graph/graph-webgl-canvas.tsx
@@ -0,0 +1,794 @@
+"use client";
+
+import { Application, extend } from "@pixi/react";
+import { Container as PixiContainer, Graphics as PixiGraphics } from "pixi.js";
+import { memo, useCallback, useEffect, useMemo, useRef } from "react";
+import { colors } from "./constants";
+import type { GraphCanvasProps, MemoryEntry } from "./types";
+
+// Register Pixi Graphics and Container so they can be used as JSX elements
+extend({ Graphics: PixiGraphics, Container: PixiContainer });
+
+export const GraphWebGLCanvas = memo<GraphCanvasProps>(
+ ({
+ nodes,
+ edges,
+ panX,
+ panY,
+ zoom,
+ width,
+ height,
+ onNodeHover,
+ onNodeClick,
+ onNodeDragStart,
+ onNodeDragMove,
+ onNodeDragEnd,
+ onPanStart,
+ onPanMove,
+ onPanEnd,
+ onWheel,
+ onDoubleClick,
+ onTouchStart,
+ onTouchMove,
+ onTouchEnd,
+ draggingNodeId,
+ }) => {
+ const containerRef = useRef<HTMLDivElement>(null);
+ const isPanningRef = useRef(false);
+ const currentHoveredRef = useRef<string | null>(null);
+ const pointerDownPosRef = useRef<{ x: number; y: number } | null>(null);
+ const pointerMovedRef = useRef(false);
+ // World container that is transformed instead of redrawing every pan/zoom
+ const worldContainerRef = useRef<PixiContainer | null>(null);
+
+ // Throttled wheel handling -------------------------------------------
+ const pendingWheelDeltaRef = useRef<{ dx: number; dy: number }>({
+ dx: 0,
+ dy: 0,
+ });
+ const wheelRafRef = useRef<number | null>(null);
+ // Removed bitmap caching due to black-screen issues – throttle already boosts zoom performance
+
+ // Persistent graphics refs
+ const gridG = useRef<PixiGraphics | null>(null);
+ const edgesG = useRef<PixiGraphics | null>(null);
+ const docsG = useRef<PixiGraphics | null>(null);
+ const memsG = useRef<PixiGraphics | null>(null);
+
+ // ---------- Zoom bucket (reduces redraw frequency) ----------
+ const zoomBucket = useMemo(() => Math.round(zoom * 4) / 4, [zoom]);
+
+ // Redraw layers only when their data changes ----------------------
+ useEffect(() => {
+ if (gridG.current) drawGrid(gridG.current);
+ }, [panX, panY, zoom, width, height]);
+
+ useEffect(() => {
+ if (edgesG.current) drawEdges(edgesG.current);
+ }, [edgesG.current, edges, nodes, zoomBucket]);
+
+ useEffect(() => {
+ if (docsG.current) drawDocuments(docsG.current);
+ }, [docsG.current, nodes, zoomBucket]);
+
+ useEffect(() => {
+ if (memsG.current) drawMemories(memsG.current);
+ }, [memsG.current, nodes, zoomBucket]);
+
+ // Apply pan & zoom via world transform instead of geometry rebuilds
+ useEffect(() => {
+ if (worldContainerRef.current) {
+ worldContainerRef.current.position.set(panX, panY);
+ worldContainerRef.current.scale.set(zoom);
+ }
+ }, [panX, panY, zoom]);
+
+ // No bitmap caching – nothing to clean up
+
+ /* ---------- Helpers ---------- */
+ const getNodeAtPosition = useCallback(
+ (clientX: number, clientY: number): string | null => {
+ const rect = containerRef.current?.getBoundingClientRect();
+ if (!rect) return null;
+
+ const localX = clientX - rect.left;
+ const localY = clientY - rect.top;
+
+ const worldX = (localX - panX) / zoom;
+ const worldY = (localY - panY) / zoom;
+
+ for (const node of nodes) {
+ if (node.type === "document") {
+ const halfW = (node.size * 1.4) / 2;
+ const halfH = (node.size * 0.9) / 2;
+ if (
+ worldX >= node.x - halfW &&
+ worldX <= node.x + halfW &&
+ worldY >= node.y - halfH &&
+ worldY <= node.y + halfH
+ ) {
+ return node.id;
+ }
+ } else if (node.type === "memory") {
+ const r = node.size / 2;
+ const dx = worldX - node.x;
+ const dy = worldY - node.y;
+ if (dx * dx + dy * dy <= r * r) {
+ return node.id;
+ }
+ }
+ }
+ return null;
+ },
+ [nodes, panX, panY, zoom],
+ );
+
+ /* ---------- Grid drawing ---------- */
+ const drawGrid = useCallback(
+ (g: PixiGraphics) => {
+ g.clear();
+
+ const gridColor = 0x94a3b8; // rgb(148,163,184)
+ const gridAlpha = 0.03;
+ const gridSpacing = 100 * zoom;
+
+ // panning offsets
+ const offsetX = panX % gridSpacing;
+ const offsetY = panY % gridSpacing;
+
+ g.lineStyle(1, gridColor, gridAlpha);
+
+ // vertical lines
+ for (let x = offsetX; x < width; x += gridSpacing) {
+ g.moveTo(x, 0);
+ g.lineTo(x, height);
+ }
+
+ // horizontal lines
+ for (let y = offsetY; y < height; y += gridSpacing) {
+ g.moveTo(0, y);
+ g.lineTo(width, y);
+ }
+
+ // Stroke to render grid lines
+ g.stroke();
+ },
+ [panX, panY, zoom, width, height],
+ );
+
+ /* ---------- Color parsing ---------- */
+ const toHexAlpha = (input: string): { hex: number; alpha: number } => {
+ if (!input) return { hex: 0xffffff, alpha: 1 };
+ const str = input.trim().toLowerCase();
+ // rgba() or rgb()
+ const rgbaMatch = str
+ .replace(/\s+/g, "")
+ .match(/rgba?\((\d+),(\d+),(\d+)(?:,(\d*\.?\d+))?\)/i);
+ if (rgbaMatch) {
+ const r = Number.parseInt(rgbaMatch[1] || "0");
+ const g = Number.parseInt(rgbaMatch[2] || "0");
+ const b = Number.parseInt(rgbaMatch[3] || "0");
+ const a =
+ rgbaMatch[4] !== undefined ? Number.parseFloat(rgbaMatch[4]) : 1;
+ return { hex: (r << 16) + (g << 8) + b, alpha: a };
+ }
+ // #rrggbb or #rrggbbaa
+ if (str.startsWith("#")) {
+ const hexBody = str.slice(1);
+ if (hexBody.length === 6) {
+ return { hex: Number.parseInt(hexBody, 16), alpha: 1 };
+ }
+ if (hexBody.length === 8) {
+ const rgb = Number.parseInt(hexBody.slice(0, 6), 16);
+ const aByte = Number.parseInt(hexBody.slice(6, 8), 16);
+ return { hex: rgb, alpha: aByte / 255 };
+ }
+ }
+ // 0xRRGGBB
+ if (str.startsWith("0x")) {
+ return { hex: Number.parseInt(str, 16), alpha: 1 };
+ }
+ return { hex: 0xffffff, alpha: 1 };
+ };
+
+ const drawDocuments = useCallback(
+ (g: PixiGraphics) => {
+ g.clear();
+
+ nodes.forEach((node) => {
+ if (node.type !== "document") return;
+
+ // World-space coordinates – container transform handles pan/zoom
+ const screenX = node.x;
+ const screenY = node.y;
+ const nodeSize = node.size;
+
+ const docWidth = nodeSize * 1.4;
+ const docHeight = nodeSize * 0.9;
+
+ // Choose colors similar to canvas version
+ const fill = node.isDragging
+ ? colors.document.accent
+ : node.isHovered
+ ? colors.document.secondary
+ : colors.document.primary;
+
+ const strokeCol = node.isDragging
+ ? colors.document.glow
+ : node.isHovered
+ ? colors.document.accent
+ : colors.document.border;
+
+ const { hex: fillHex, alpha: fillAlpha } = toHexAlpha(fill);
+ const { hex: strokeHex, alpha: strokeAlpha } = toHexAlpha(strokeCol);
+
+ // Stroke first then fill for proper shape borders
+ const docStrokeWidth =
+ (node.isDragging ? 3 : node.isHovered ? 2 : 1) / zoom;
+ g.lineStyle(docStrokeWidth, strokeHex, strokeAlpha);
+ g.beginFill(fillHex, fillAlpha);
+
+ const radius = zoom < 0.3 ? 6 : 12;
+ g.drawRoundedRect(
+ screenX - docWidth / 2,
+ screenY - docHeight / 2,
+ docWidth,
+ docHeight,
+ radius,
+ );
+ g.endFill();
+
+ // Inner highlight for glass effect (match GraphCanvas)
+ if (zoom >= 0.3 && (node.isHovered || node.isDragging)) {
+ const { hex: hlHex } = toHexAlpha("#ffffff");
+ // Inner highlight stroke width constant
+ const innerStroke = 1 / zoom;
+ g.lineStyle(innerStroke, hlHex, 0.1);
+ g.drawRoundedRect(
+ screenX - docWidth / 2 + 1,
+ screenY - docHeight / 2 + 1,
+ docWidth - 2,
+ docHeight - 2,
+ radius - 1,
+ );
+ g.stroke();
+ }
+ });
+ },
+ [nodes, zoom],
+ );
+
+ /* ---------- Memories layer ---------- */
+ const drawMemories = useCallback(
+ (g: PixiGraphics) => {
+ g.clear();
+
+ nodes.forEach((node) => {
+ if (node.type !== "memory") return;
+
+ const mem = node.data as MemoryEntry;
+ const screenX = node.x;
+ const screenY = node.y;
+ const nodeSize = node.size;
+
+ const radius = nodeSize / 2;
+
+ // status checks
+ const isForgotten =
+ mem?.isForgotten ||
+ (mem?.forgetAfter &&
+ new Date(mem.forgetAfter).getTime() < Date.now());
+ const isLatest = mem?.isLatest;
+ const expiringSoon =
+ mem?.forgetAfter &&
+ !isForgotten &&
+ new Date(mem.forgetAfter).getTime() - Date.now() <
+ 1000 * 60 * 60 * 24 * 7;
+ const isNew =
+ !isForgotten &&
+ new Date(mem?.createdAt).getTime() >
+ Date.now() - 1000 * 60 * 60 * 24;
+
+ // colours
+ let fillColor = colors.memory.primary;
+ let borderColor = colors.memory.border;
+ let glowColor = colors.memory.glow;
+
+ if (isForgotten) {
+ fillColor = colors.status.forgotten;
+ borderColor = "rgba(220,38,38,0.3)";
+ glowColor = "rgba(220,38,38,0.2)";
+ } else if (expiringSoon) {
+ borderColor = colors.status.expiring;
+ glowColor = colors.accent.amber;
+ } else if (isNew) {
+ borderColor = colors.status.new;
+ glowColor = colors.accent.emerald;
+ }
+
+ if (node.isDragging) {
+ fillColor = colors.memory.accent;
+ borderColor = glowColor;
+ } else if (node.isHovered) {
+ fillColor = colors.memory.secondary;
+ }
+
+ const { hex: fillHex, alpha: fillAlpha } = toHexAlpha(fillColor);
+ const { hex: borderHex, alpha: borderAlpha } =
+ toHexAlpha(borderColor);
+
+ // Match canvas behavior: multiply by isLatest global alpha
+ const globalAlpha = isLatest ? 1 : 0.4;
+ const finalFillAlpha = globalAlpha * fillAlpha;
+ const finalStrokeAlpha = globalAlpha * borderAlpha;
+ // Stroke first then fill for visible border
+ const memStrokeW =
+ (node.isDragging ? 3 : node.isHovered ? 2 : 1.5) / zoom;
+ g.lineStyle(memStrokeW, borderHex, finalStrokeAlpha);
+ g.beginFill(fillHex, finalFillAlpha);
+
+ if (zoom < 0.3) {
+ // simplified circle when zoomed out
+ g.drawCircle(screenX, screenY, radius);
+ } else {
+ // hexagon
+ const sides = 6;
+ const points: number[] = [];
+ for (let i = 0; i < sides; i++) {
+ const angle = (i * 2 * Math.PI) / sides - Math.PI / 2;
+ points.push(screenX + radius * Math.cos(angle));
+ points.push(screenY + radius * Math.sin(angle));
+ }
+ g.drawPolygon(points);
+ }
+
+ g.endFill();
+
+ // Status overlays (forgotten / new) – match GraphCanvas visuals
+ if (isForgotten) {
+ const { hex: crossHex, alpha: crossAlpha } = toHexAlpha(
+ "rgba(220,38,38,0.4)",
+ );
+ // Cross/ dot overlay stroke widths constant
+ const overlayStroke = 2 / zoom;
+ g.lineStyle(overlayStroke, crossHex, globalAlpha * crossAlpha);
+ const rCross = nodeSize * 0.25;
+ g.moveTo(screenX - rCross, screenY - rCross);
+ g.lineTo(screenX + rCross, screenY + rCross);
+ g.moveTo(screenX + rCross, screenY - rCross);
+ g.lineTo(screenX - rCross, screenY + rCross);
+ g.stroke();
+ } else if (isNew) {
+ const { hex: dotHex, alpha: dotAlpha } = toHexAlpha(
+ colors.status.new,
+ );
+ // Dot scales with node (GraphCanvas behaviour)
+ const dotRadius = Math.max(2, nodeSize * 0.15);
+ g.beginFill(dotHex, globalAlpha * dotAlpha);
+ g.drawCircle(
+ screenX + nodeSize * 0.25,
+ screenY - nodeSize * 0.25,
+ dotRadius,
+ );
+ g.endFill();
+ }
+ });
+ },
+ [nodes, zoom],
+ );
+
+ /* ---------- Edges layer ---------- */
+ // Helper: draw dashed quadratic curve to approximate canvas setLineDash
+ const drawDashedQuadratic = useCallback(
+ (
+ g: PixiGraphics,
+ sx: number,
+ sy: number,
+ cx: number,
+ cy: number,
+ tx: number,
+ ty: number,
+ dash = 10,
+ gap = 5,
+ ) => {
+ // Sample the curve and accumulate lines per dash to avoid overdraw
+ const curveLength = Math.sqrt((sx - tx) ** 2 + (sy - ty) ** 2);
+ const totalSamples = Math.max(
+ 20,
+ Math.min(120, Math.floor(curveLength / 10)),
+ );
+ let prevX = sx;
+ let prevY = sy;
+ let distanceSinceToggle = 0;
+ let drawSegment = true;
+ let hasActiveDash = false;
+ let dashStartX = sx;
+ let dashStartY = sy;
+
+ for (let i = 1; i <= totalSamples; i++) {
+ const t = i / totalSamples;
+ const mt = 1 - t;
+ const x = mt * mt * sx + 2 * mt * t * cx + t * t * tx;
+ const y = mt * mt * sy + 2 * mt * t * cy + t * t * ty;
+
+ const dx = x - prevX;
+ const dy = y - prevY;
+ const segLen = Math.sqrt(dx * dx + dy * dy);
+ distanceSinceToggle += segLen;
+
+ if (drawSegment) {
+ if (!hasActiveDash) {
+ dashStartX = prevX;
+ dashStartY = prevY;
+ hasActiveDash = true;
+ }
+ }
+
+ const threshold = drawSegment ? dash : gap;
+ if (distanceSinceToggle >= threshold) {
+ // end of current phase
+ if (drawSegment && hasActiveDash) {
+ g.moveTo(dashStartX, dashStartY);
+ g.lineTo(prevX, prevY);
+ g.stroke();
+ hasActiveDash = false;
+ }
+ distanceSinceToggle = 0;
+ drawSegment = !drawSegment;
+ // If we transition into draw mode, start a new dash at current segment start
+ if (drawSegment) {
+ dashStartX = prevX;
+ dashStartY = prevY;
+ hasActiveDash = true;
+ }
+ }
+
+ prevX = x;
+ prevY = y;
+ }
+
+ // Flush any active dash at the end
+ if (drawSegment && hasActiveDash) {
+ g.moveTo(dashStartX, dashStartY);
+ g.lineTo(prevX, prevY);
+ g.stroke();
+ }
+ },
+ [],
+ );
+ const drawEdges = useCallback(
+ (g: PixiGraphics) => {
+ g.clear();
+
+ // Match GraphCanvas LOD behaviour
+ const useSimplified = zoom < 0.3;
+
+ // quick node lookup
+ const nodeMap = new Map(nodes.map((n) => [n.id, n]));
+
+ edges.forEach((edge) => {
+ // Skip very weak doc-memory edges when zoomed out – behaviour copied from GraphCanvas
+ if (
+ useSimplified &&
+ edge.edgeType === "doc-memory" &&
+ (edge.visualProps?.opacity ?? 1) < 0.3
+ ) {
+ return;
+ }
+ const source = nodeMap.get(edge.source);
+ const target = nodeMap.get(edge.target);
+ if (!source || !target) return;
+
+ const sx = source.x;
+ const sy = source.y;
+ const tx = target.x;
+ const ty = target.y;
+
+ // No viewport culling here because container transform handles visibility
+
+ let lineWidth = Math.max(1, edge.visualProps?.thickness ?? 1);
+ // Use opacity exactly as provided to match GraphCanvas behaviour
+ let opacity = edge.visualProps.opacity;
+ let col = edge.color || colors.connection.weak;
+
+ if (edge.edgeType === "doc-memory") {
+ lineWidth = 1;
+ opacity = 0.9;
+ col = colors.connection.memory;
+
+ if (useSimplified && opacity < 0.3) return;
+ } else if (edge.edgeType === "doc-doc") {
+ opacity = Math.max(0, edge.similarity * 0.5);
+ lineWidth = Math.max(1, edge.similarity * 2);
+ col = colors.connection.medium;
+ if (edge.similarity > 0.85) col = colors.connection.strong;
+ } else if (edge.edgeType === "version") {
+ col = edge.color || colors.relations.updates;
+ opacity = 0.8;
+ lineWidth = 2;
+ }
+
+ const { hex: strokeHex, alpha: colorAlpha } = toHexAlpha(col);
+ const finalEdgeAlpha = Math.max(0, Math.min(1, opacity * colorAlpha));
+
+ // Always use round line caps (same as Canvas 2D)
+ const screenLineWidth = lineWidth / zoom;
+ g.lineStyle(screenLineWidth, strokeHex, finalEdgeAlpha);
+
+ if (edge.edgeType === "version") {
+ // double line effect to match canvas (outer thicker, faint + inner thin)
+ g.lineStyle(3 / zoom, strokeHex, finalEdgeAlpha * 0.3);
+ g.moveTo(sx, sy);
+ g.lineTo(tx, ty);
+ g.stroke();
+
+ g.lineStyle(1 / zoom, strokeHex, finalEdgeAlpha);
+ g.moveTo(sx, sy);
+ g.lineTo(tx, ty);
+ g.stroke();
+
+ // arrow head
+ const angle = Math.atan2(ty - sy, tx - sx);
+ const arrowLen = Math.max(6 / zoom, 8);
+ const nodeRadius = target.size / 2;
+ const ax = tx - Math.cos(angle) * (nodeRadius + 2);
+ const ay = ty - Math.sin(angle) * (nodeRadius + 2);
+
+ g.moveTo(ax, ay);
+ g.lineTo(
+ ax - arrowLen * Math.cos(angle - Math.PI / 6),
+ ay - arrowLen * Math.sin(angle - Math.PI / 6),
+ );
+ g.moveTo(ax, ay);
+ g.lineTo(
+ ax - arrowLen * Math.cos(angle + Math.PI / 6),
+ ay - arrowLen * Math.sin(angle + Math.PI / 6),
+ );
+ g.stroke();
+ } else {
+ // straight line when zoomed out; dashed curved when zoomed in for doc-doc
+ if (useSimplified) {
+ g.moveTo(sx, sy);
+ g.lineTo(tx, ty);
+ g.stroke();
+ } else {
+ const midX = (sx + tx) / 2;
+ const midY = (sy + ty) / 2;
+ const dx = tx - sx;
+ const dy = ty - sy;
+ const dist = Math.sqrt(dx * dx + dy * dy);
+ const ctrlOffset =
+ edge.edgeType === "doc-memory" ? 15 : Math.min(30, dist * 0.2);
+
+ const cx = midX + ctrlOffset * (dy / dist);
+ const cy = midY - ctrlOffset * (dx / dist);
+
+ if (edge.edgeType === "doc-doc") {
+ if (useSimplified) {
+ // Straight line when zoomed out (no dash)
+ g.moveTo(sx, sy);
+ g.quadraticCurveTo(cx, cy, tx, ty);
+ g.stroke();
+ } else {
+ // Dash lengths scale with zoom to keep screen size constant
+ const dash = 10 / zoom;
+ const gap = 5 / zoom;
+ drawDashedQuadratic(g, sx, sy, cx, cy, tx, ty, dash, gap);
+ }
+ } else {
+ g.moveTo(sx, sy);
+ g.quadraticCurveTo(cx, cy, tx, ty);
+ g.stroke();
+ }
+ }
+ }
+ });
+ },
+ [edges, nodes, zoom, width, drawDashedQuadratic],
+ );
+
+ /* ---------- pointer handlers (unchanged) ---------- */
+ // Pointer move (pan or drag)
+ const handlePointerMove = useCallback(
+ (e: React.PointerEvent<HTMLDivElement>) => {
+ const mouseEvent = {
+ clientX: e.clientX,
+ clientY: e.clientY,
+ preventDefault: () => {},
+ stopPropagation: () => {},
+ } as React.MouseEvent;
+
+ if (draggingNodeId) {
+ // Node dragging handled elsewhere (future steps)
+ onNodeDragMove(mouseEvent);
+ } else if (isPanningRef.current) {
+ onPanMove(mouseEvent);
+ }
+
+ // Track movement for distinguishing click vs drag/pan
+ if (pointerDownPosRef.current) {
+ const dx = e.clientX - pointerDownPosRef.current.x;
+ const dy = e.clientY - pointerDownPosRef.current.y;
+ if (Math.sqrt(dx * dx + dy * dy) > 3) pointerMovedRef.current = true;
+ }
+
+ // Hover detection
+ const nodeId = getNodeAtPosition(e.clientX, e.clientY);
+ if (nodeId !== currentHoveredRef.current) {
+ currentHoveredRef.current = nodeId;
+ onNodeHover(nodeId);
+ }
+ },
+ [
+ draggingNodeId,
+ onNodeDragMove,
+ onPanMove,
+ onNodeHover,
+ getNodeAtPosition,
+ ],
+ );
+
+ const handlePointerDown = useCallback(
+ (e: React.PointerEvent<HTMLDivElement>) => {
+ const mouseEvent = {
+ clientX: e.clientX,
+ clientY: e.clientY,
+ preventDefault: () => {},
+ stopPropagation: () => {},
+ } as React.MouseEvent;
+
+ const nodeId = getNodeAtPosition(e.clientX, e.clientY);
+ if (nodeId) {
+ onNodeDragStart(nodeId, mouseEvent);
+ // drag handled externally
+ } else {
+ onPanStart(mouseEvent);
+ isPanningRef.current = true;
+ }
+ pointerDownPosRef.current = { x: e.clientX, y: e.clientY };
+ pointerMovedRef.current = false;
+ },
+ [onPanStart, onNodeDragStart, getNodeAtPosition],
+ );
+
+ const handlePointerUp = useCallback(
+ (e: React.PointerEvent<HTMLDivElement>) => {
+ const wasPanning = isPanningRef.current;
+ if (draggingNodeId) onNodeDragEnd();
+ else if (wasPanning) onPanEnd();
+
+ // Consider it a click if not panning and movement was minimal
+ if (!wasPanning && !pointerMovedRef.current) {
+ const nodeId = getNodeAtPosition(e.clientX, e.clientY);
+ if (nodeId) onNodeClick(nodeId);
+ }
+
+ isPanningRef.current = false;
+ pointerDownPosRef.current = null;
+ pointerMovedRef.current = false;
+ },
+ [draggingNodeId, onNodeDragEnd, onPanEnd, getNodeAtPosition, onNodeClick],
+ );
+
+ // Click handler – opens detail panel
+ const handleClick = useCallback(
+ (e: React.MouseEvent<HTMLDivElement>) => {
+ if (isPanningRef.current) return;
+ const nodeId = getNodeAtPosition(e.clientX, e.clientY);
+ if (nodeId) onNodeClick(nodeId);
+ },
+ [getNodeAtPosition, onNodeClick],
+ );
+
+ // Click handled in pointer up to avoid duplicate events
+
+ const handleWheel = useCallback(
+ (e: React.WheelEvent<HTMLDivElement>) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ // Accumulate deltas
+ pendingWheelDeltaRef.current.dx += e.deltaX;
+ pendingWheelDeltaRef.current.dy += e.deltaY;
+
+ // Schedule a single update per frame
+ if (wheelRafRef.current === null) {
+ wheelRafRef.current = requestAnimationFrame(() => {
+ const { dx, dy } = pendingWheelDeltaRef.current;
+ pendingWheelDeltaRef.current = { dx: 0, dy: 0 };
+
+ // @ts-expect-error
+ onWheel({
+ deltaY: dy,
+ deltaX: dx,
+ clientX: e.clientX,
+ clientY: e.clientY,
+ currentTarget: containerRef.current,
+ nativeEvent: e.nativeEvent,
+ preventDefault: () => {},
+ stopPropagation: () => {},
+ } as React.WheelEvent);
+
+ wheelRafRef.current = null;
+
+ // nothing else – caching removed
+ });
+ }
+ },
+ [onWheel],
+ );
+
+ // Cleanup any pending RAF on unmount
+ useEffect(() => {
+ return () => {
+ if (wheelRafRef.current !== null) {
+ cancelAnimationFrame(wheelRafRef.current);
+ }
+ };
+ }, []);
+
+ return (
+ <div
+ className="absolute inset-0"
+ onDoubleClick={(ev) =>
+ onDoubleClick?.(ev as unknown as React.MouseEvent)
+ }
+ onKeyDown={(ev) => {
+ if (ev.key === "Enter")
+ handleClick(ev as unknown as React.MouseEvent<HTMLDivElement>);
+ }}
+ onPointerDown={handlePointerDown}
+ onPointerLeave={() => {
+ if (draggingNodeId) onNodeDragEnd();
+ if (isPanningRef.current) onPanEnd();
+ isPanningRef.current = false;
+ pointerDownPosRef.current = null;
+ pointerMovedRef.current = false;
+ }}
+ onPointerMove={handlePointerMove}
+ onPointerUp={handlePointerUp}
+ onTouchStart={onTouchStart}
+ onTouchMove={onTouchMove}
+ onTouchEnd={onTouchEnd}
+ onWheel={handleWheel}
+ ref={containerRef}
+ role="application"
+ style={{
+ cursor: draggingNodeId ? "grabbing" : "move",
+ touchAction: "none",
+ userSelect: "none",
+ WebkitUserSelect: "none",
+ }}
+ >
+ <Application
+ preference="webgl"
+ antialias
+ autoDensity
+ backgroundColor={0x0f1419}
+ height={height}
+ resolution={
+ typeof window !== "undefined" ? window.devicePixelRatio : 1
+ }
+ width={width}
+ >
+ {/* Grid background (not affected by world transform) */}
+ <pixiGraphics ref={gridG} draw={() => {}} />
+
+ {/* World container that pans/zooms as a single transform */}
+ <pixiContainer ref={worldContainerRef}>
+ {/* Edges */}
+ <pixiGraphics ref={edgesG} draw={() => {}} />
+
+ {/* Documents */}
+ <pixiGraphics ref={docsG} draw={() => {}} />
+
+ {/* Memories */}
+ <pixiGraphics ref={memsG} draw={() => {}} />
+ </pixiContainer>
+ </Application>
+ </div>
+ );
+ },
+);
+
+GraphWebGLCanvas.displayName = "GraphWebGLCanvas";