"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( ({ nodes, edges, panX, panY, zoom, width, height, onNodeHover, onNodeClick, onNodeDragStart, onNodeDragMove, onNodeDragEnd, onPanStart, onPanMove, onPanEnd, onWheel, onDoubleClick, onTouchStart, onTouchMove, onTouchEnd, draggingNodeId, }) => { const containerRef = useRef(null); const isPanningRef = useRef(false); const currentHoveredRef = useRef(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(null); // Throttled wheel handling ------------------------------------------- const pendingWheelDeltaRef = useRef<{ dx: number; dy: number }>({ dx: 0, dy: 0, }); const wheelRafRef = useRef(null); // Removed bitmap caching due to black-screen issues – throttle already boosts zoom performance // Persistent graphics refs const gridG = useRef(null); const edgesG = useRef(null); const docsG = useRef(null); const memsG = useRef(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) => { 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) => { 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) => { 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) => { 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) => { 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 (
onDoubleClick?.(ev as unknown as React.MouseEvent) } onKeyDown={(ev) => { if (ev.key === "Enter") handleClick(ev as unknown as React.MouseEvent); }} 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", }} > {/* Grid background (not affected by world transform) */} {}} /> {/* World container that pans/zooms as a single transform */} {/* Edges */} {}} /> {/* Documents */} {}} /> {/* Memories */} {}} />
); }, ); GraphWebGLCanvas.displayName = "GraphWebGLCanvas";