diff options
Diffstat (limited to 'packages/ui/memory-graph/graph-webgl-canvas.tsx')
| -rw-r--r-- | packages/ui/memory-graph/graph-webgl-canvas.tsx | 794 |
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"; |