supermemory/packages/ui/memory-graph/graph-webgl-canvas.tsx
MaheshtheDev 10ada4a1e2 fix(web): sentry issues across the web app (#570)
Fixes all following sentry issues
- CONSUMER-APP-FF
- CONSUMER-APP-1T
- CONSUMER-APP-86
- CONSUMER-APP-7H
- CONSUMER-APP-4F
- CONSUMER-APP-7X
2025-11-09 07:32:52 +00:00

794 lines
23 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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";