fix ui issues and package issue (#610)

This commit is contained in:
Mahesh Sanikommu 2025-12-06 17:23:42 -08:00 committed by GitHub
parent f9d304ec3e
commit 41d24a4a1c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 5345 additions and 1071 deletions

View file

@ -16,7 +16,7 @@ import {
} from "@repo/ui/components/alert-dialog"
import { ExternalLink, FileText, Brain, Trash2 } from "lucide-react"
import { cn } from "@lib/utils"
import { colors } from "@repo/ui/colors"
import { colors } from "@repo/ui/memory-graph/constants"
import { getPastelBackgroundColor } from "../memories-utils"
interface GoogleDocsCardProps {

View file

@ -12,7 +12,7 @@ import {
AlertDialogTrigger,
} from "@repo/ui/components/alert-dialog"
import { colors } from "@repo/ui/colors"
import { colors } from "@repo/ui/memory-graph/constants"
import { Brain, ExternalLink, Trash2 } from "lucide-react"
import { cn } from "@lib/utils"
import { useState } from "react"

View file

@ -26,7 +26,7 @@ import {
AlertDialogTrigger,
} from "@repo/ui/components/alert-dialog"
import { Brain, Trash2 } from "lucide-react"
import { colors } from "@repo/ui/colors"
import { colors } from "@repo/ui/memory-graph/constants"
import { getPastelBackgroundColor } from "../memories-utils"
type MyTweetProps = {

View file

@ -16,7 +16,7 @@ import { ExternalLink, Trash2 } from "lucide-react"
import { useState } from "react"
import { cn } from "@lib/utils"
import { getPastelBackgroundColor } from "../memories-utils"
import { colors } from "@repo/ui/colors"
import { colors } from "@repo/ui/memory-graph/constants"
interface WebsiteCardProps {
title: string

View file

@ -6,7 +6,7 @@ import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import { useInfiniteQuery } from "@tanstack/react-query"
import { useCallback, useEffect, useMemo, useState } from "react"
import type { z } from "zod"
import { MemoryGraph } from "@supermemory/memory-graph"
import { MemoryGraph } from "@repo/ui/memory-graph"
import { Dialog, DialogContent } from "@repo/ui/components/dialog"
import { ConnectAIModal } from "@/components/connect-ai-modal"
import { AddMemoryView } from "@/components/views/add-memory"

View file

@ -2,7 +2,7 @@
import { useIsMobile } from "@hooks/use-mobile"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import { colors } from "@repo/ui/colors"
import { colors } from "@repo/ui/memory-graph/constants"
import { Sparkles } from "lucide-react"
import { Masonry, useInfiniteLoader } from "masonic"
import { memo, useCallback, useMemo, useState } from "react"

View file

@ -1,4 +1,4 @@
import type { DocumentWithMemories } from "@supermemory/memory-graph"
import type { DocumentWithMemories } from "@ui/memory-graph/types"
export const formatDate = (date: string | Date) => {
const dateObj = new Date(date)

View file

@ -15,7 +15,7 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from "@repo/ui/components/alert-dialog"
import { colors } from "@repo/ui/colors"
import { colors } from "@repo/ui/memory-graph/constants"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import { useVirtualizer } from "@tanstack/react-virtual"
import { Brain, ExternalLink, Sparkles, Trash2 } from "lucide-react"

View file

@ -1,5 +1,5 @@
import { MCPIcon } from "@/components/menu"
import { colors } from "@repo/ui/colors"
import { colors } from "@repo/ui/memory-graph/constants"
import {
GoogleDocs,
MicrosoftWord,

View file

@ -18,7 +18,6 @@
"@ai-sdk/google": "^2.0.0-beta.13",
"@ai-sdk/react": "2.0.0-beta.24",
"@better-fetch/fetch": "^1.1.18",
"@supermemory/memory-graph": "workspace:*",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
@ -73,6 +72,7 @@
"react": "19.2.0",
"react-dom": "19.2.0",
"react-dropzone": "^14.3.8",
"react-markdown": "^10.1.0",
"react-tweet": "^3.2.2",
"recharts": "2",
"slate": "^0.118.0",

2444
bun.lock

File diff suppressed because it is too large Load diff

View file

@ -47,4 +47,54 @@ export const colors = {
extends: "rgba(16, 185, 129, 0.5)", // green
derives: "rgba(147, 197, 253, 0.5)", // blue
},
}
};
export const LAYOUT_CONSTANTS = {
centerX: 400,
centerY: 300,
clusterRadius: 300, // Memory "bubble" size around a doc - smaller bubble
spaceSpacing: 1600, // How far apart the *spaces* (groups of docs) sit - push spaces way out
documentSpacing: 1000, // How far the first doc in a space sits from its space-centre - push docs way out
minDocDist: 900, // Minimum distance two documents in the **same space** are allowed to be - sets repulsion radius
memoryClusterRadius: 300,
};
// Graph view settings
export const GRAPH_SETTINGS = {
console: {
initialZoom: 0.8, // Higher zoom for console - better overview
initialPanX: 0,
initialPanY: 0,
},
consumer: {
initialZoom: 0.5, // Changed from 0.1 to 0.5 for better initial visibility
initialPanX: 400, // Pan towards center to compensate for larger layout
initialPanY: 300, // Pan towards center to compensate for larger layout
},
};
// Responsive positioning for different app variants
export const POSITIONING = {
console: {
legend: {
desktop: "bottom-4 right-4",
mobile: "bottom-4 right-4",
},
loadingIndicator: "top-20 right-4",
spacesSelector: "top-4 left-4",
viewToggle: "", // Not used in console
nodeDetail: "top-4 right-4",
},
consumer: {
legend: {
desktop: "top-18 right-4",
mobile: "bottom-[180px] left-4",
},
loadingIndicator: "top-20 right-4",
spacesSelector: "", // Hidden in consumer
viewToggle: "top-4 right-4", // Consumer has view toggle
nodeDetail: "top-4 right-4",
},
};

View file

@ -0,0 +1,67 @@
"use client";
import { cn } from "@repo/lib/utils";
import { Button } from "@repo/ui/components/button";
import { GlassMenuEffect } from "@repo/ui/other/glass-effect";
import { Move, ZoomIn, ZoomOut } from "lucide-react";
import { memo } from "react";
import type { ControlsProps } from "./types";
export const Controls = memo<ControlsProps>(
({ onZoomIn, onZoomOut, onResetView, variant = "console" }) => {
// Use explicit classes - controls positioning not defined in constants
// Using a reasonable default position
const getPositioningClasses = () => {
if (variant === "console") {
return "bottom-4 left-4";
}
if (variant === "consumer") {
return "bottom-20 right-4";
}
return "";
};
return (
<div
className={cn(
"absolute z-10 rounded-xl overflow-hidden",
getPositioningClasses(),
)}
>
{/* Glass effect background */}
<GlassMenuEffect rounded="rounded-xl" />
<div className="relative z-10 px-4 py-3">
<div className="flex items-center gap-2">
<Button
className="h-8 w-8 p-0 text-slate-200 hover:bg-slate-700/40 hover:text-slate-100 transition-colors"
onClick={onZoomIn}
size="sm"
variant="ghost"
>
<ZoomIn className="w-4 h-4" />
</Button>
<Button
className="h-8 w-8 p-0 text-slate-200 hover:bg-slate-700/40 hover:text-slate-100 transition-colors"
onClick={onZoomOut}
size="sm"
variant="ghost"
>
<ZoomOut className="w-4 h-4" />
</Button>
<Button
className="h-8 w-8 p-0 text-slate-200 hover:bg-slate-700/40 hover:text-slate-100 transition-colors"
onClick={onResetView}
size="sm"
variant="ghost"
>
<Move className="w-4 h-4" />
</Button>
</div>
</div>
</div>
);
},
);
Controls.displayName = "Controls";

View file

@ -0,0 +1,762 @@
"use client";
import {
memo,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
} from "react";
import { colors } from "./constants";
import type {
DocumentWithMemories,
GraphCanvasProps,
GraphNode,
MemoryEntry,
} from "./types";
export const GraphCanvas = memo<GraphCanvasProps>(
({
nodes,
edges,
panX,
panY,
zoom,
width,
height,
onNodeHover,
onNodeClick,
onNodeDragStart,
onNodeDragMove,
onNodeDragEnd,
onPanStart,
onPanMove,
onPanEnd,
onWheel,
onDoubleClick,
onTouchStart,
onTouchMove,
onTouchEnd,
draggingNodeId,
highlightDocumentIds,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const animationRef = useRef<number>(0);
const startTimeRef = useRef<number>(Date.now());
const mousePos = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
const currentHoveredNode = useRef<string | null>(null);
// Initialize start time once
useEffect(() => {
startTimeRef.current = Date.now();
}, []);
// Efficient hit detection
const getNodeAtPosition = useCallback(
(x: number, y: number): string | null => {
// Check from top-most to bottom-most: memory nodes are drawn after documents
for (let i = nodes.length - 1; i >= 0; i--) {
const node = nodes[i]!;
const screenX = node.x * zoom + panX;
const screenY = node.y * zoom + panY;
const nodeSize = node.size * zoom;
const dx = x - screenX;
const dy = y - screenY;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance <= nodeSize / 2) {
return node.id;
}
}
return null;
},
[nodes, panX, panY, zoom],
);
// Handle mouse events
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
mousePos.current = { x, y };
const nodeId = getNodeAtPosition(x, y);
if (nodeId !== currentHoveredNode.current) {
currentHoveredNode.current = nodeId;
onNodeHover(nodeId);
}
// Handle node dragging
if (draggingNodeId) {
onNodeDragMove(e);
}
},
[getNodeAtPosition, onNodeHover, draggingNodeId, onNodeDragMove],
);
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const nodeId = getNodeAtPosition(x, y);
if (nodeId) {
// When starting a node drag, prevent initiating pan
e.stopPropagation();
onNodeDragStart(nodeId, e);
return;
}
onPanStart(e);
},
[getNodeAtPosition, onNodeDragStart, onPanStart],
);
const handleClick = useCallback(
(e: React.MouseEvent) => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const nodeId = getNodeAtPosition(x, y);
if (nodeId) {
onNodeClick(nodeId);
}
},
[getNodeAtPosition, onNodeClick],
);
// Professional rendering function with LOD
const render = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const currentTime = Date.now();
const _elapsed = currentTime - startTimeRef.current;
// Level-of-detail optimization based on zoom
const useSimplifiedRendering = zoom < 0.3;
// Clear canvas
ctx.clearRect(0, 0, width, height);
// Set high quality rendering
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
// Draw minimal background grid
ctx.strokeStyle = "rgba(148, 163, 184, 0.03)"; // Very subtle grid
ctx.lineWidth = 1;
const gridSpacing = 100 * zoom;
const offsetX = panX % gridSpacing;
const offsetY = panY % gridSpacing;
// Simple, clean grid lines
for (let x = offsetX; x < width; x += gridSpacing) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
for (let y = offsetY; y < height; y += gridSpacing) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
// Create node lookup map
const nodeMap = new Map(nodes.map((node) => [node.id, node]));
// Draw enhanced edges with sophisticated styling
ctx.lineCap = "round";
edges.forEach((edge) => {
const sourceNode = nodeMap.get(edge.source);
const targetNode = nodeMap.get(edge.target);
if (sourceNode && targetNode) {
const sourceX = sourceNode.x * zoom + panX;
const sourceY = sourceNode.y * zoom + panY;
const targetX = targetNode.x * zoom + panX;
const targetY = targetNode.y * zoom + panY;
// Enhanced viewport culling with edge type considerations
if (
sourceX < -100 ||
sourceX > width + 100 ||
targetX < -100 ||
targetX > width + 100
) {
return;
}
// Skip very weak connections when zoomed out for performance
if (useSimplifiedRendering) {
if (
edge.edgeType === "doc-memory" &&
edge.visualProps.opacity < 0.3
) {
return; // Skip very weak doc-memory edges when zoomed out
}
}
// Enhanced connection styling based on edge type
let connectionColor = colors.connection.weak;
let dashPattern: number[] = [];
let opacity = edge.visualProps.opacity;
let lineWidth = Math.max(1, edge.visualProps.thickness * zoom);
if (edge.edgeType === "doc-memory") {
// Doc-memory: Solid thin lines, subtle
dashPattern = [];
connectionColor = colors.connection.memory;
opacity = 0.9;
lineWidth = 1;
} else if (edge.edgeType === "doc-doc") {
// Doc-doc: Thick dashed lines with strong similarity emphasis
dashPattern = useSimplifiedRendering ? [] : [10, 5]; // Solid lines when zoomed out
opacity = Math.max(0, edge.similarity * 0.5);
lineWidth = Math.max(1, edge.similarity * 2); // Thicker for stronger similarity
if (edge.similarity > 0.85)
connectionColor = colors.connection.strong;
else if (edge.similarity > 0.725)
connectionColor = colors.connection.medium;
} else if (edge.edgeType === "version") {
// Version chains: Double line effect with relation-specific colors
dashPattern = [];
connectionColor = edge.color || colors.relations.updates;
opacity = 0.8;
lineWidth = 2;
}
ctx.strokeStyle = connectionColor;
ctx.lineWidth = lineWidth;
ctx.globalAlpha = opacity;
ctx.setLineDash(dashPattern);
if (edge.edgeType === "version") {
// Special double-line rendering for version chains
// First line (outer)
ctx.lineWidth = 3;
ctx.globalAlpha = opacity * 0.3;
ctx.beginPath();
ctx.moveTo(sourceX, sourceY);
ctx.lineTo(targetX, targetY);
ctx.stroke();
// Second line (inner)
ctx.lineWidth = 1;
ctx.globalAlpha = opacity;
ctx.beginPath();
ctx.moveTo(sourceX, sourceY);
ctx.lineTo(targetX, targetY);
ctx.stroke();
} else {
// Simplified lines when zoomed out, curved when zoomed in
if (useSimplifiedRendering) {
// Straight lines for performance
ctx.beginPath();
ctx.moveTo(sourceX, sourceY);
ctx.lineTo(targetX, targetY);
ctx.stroke();
} else {
// Regular curved line for doc-memory and doc-doc
const midX = (sourceX + targetX) / 2;
const midY = (sourceY + targetY) / 2;
const dx = targetX - sourceX;
const dy = targetY - sourceY;
const distance = Math.sqrt(dx * dx + dy * dy);
const controlOffset =
edge.edgeType === "doc-memory"
? 15
: Math.min(30, distance * 0.2);
ctx.beginPath();
ctx.moveTo(sourceX, sourceY);
ctx.quadraticCurveTo(
midX + controlOffset * (dy / distance),
midY - controlOffset * (dx / distance),
targetX,
targetY,
);
ctx.stroke();
}
}
// Subtle arrow head for version edges
if (edge.edgeType === "version") {
const angle = Math.atan2(targetY - sourceY, targetX - sourceX);
const arrowLength = Math.max(6, 8 * zoom); // Shorter, more subtle
const arrowWidth = Math.max(8, 12 * zoom);
// Calculate arrow position offset from node edge
const nodeRadius = (targetNode.size * zoom) / 2;
const offsetDistance = nodeRadius + 2;
const arrowX = targetX - Math.cos(angle) * offsetDistance;
const arrowY = targetY - Math.sin(angle) * offsetDistance;
ctx.save();
ctx.translate(arrowX, arrowY);
ctx.rotate(angle);
ctx.setLineDash([]);
// Simple outlined arrow (not filled)
ctx.strokeStyle = connectionColor;
ctx.lineWidth = Math.max(1, 1.5 * zoom);
ctx.globalAlpha = opacity;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(-arrowLength, arrowWidth / 2);
ctx.moveTo(0, 0);
ctx.lineTo(-arrowLength, -arrowWidth / 2);
ctx.stroke();
ctx.restore();
}
}
});
ctx.globalAlpha = 1;
ctx.setLineDash([]);
// Prepare highlight set from provided document IDs (customId or internal)
const highlightSet = new Set<string>(highlightDocumentIds ?? []);
// Draw nodes with enhanced styling and LOD optimization
nodes.forEach((node) => {
const screenX = node.x * zoom + panX;
const screenY = node.y * zoom + panY;
const nodeSize = node.size * zoom;
// Enhanced viewport culling
const margin = nodeSize + 50;
if (
screenX < -margin ||
screenX > width + margin ||
screenY < -margin ||
screenY > height + margin
) {
return;
}
const isHovered = currentHoveredNode.current === node.id;
const isDragging = node.isDragging;
const isHighlightedDocument = (() => {
if (node.type !== "document" || highlightSet.size === 0) return false;
const doc = node.data as DocumentWithMemories;
if (doc.customId && highlightSet.has(doc.customId)) return true;
return highlightSet.has(doc.id);
})();
if (node.type === "document") {
// Enhanced glassmorphism document styling
const docWidth = nodeSize * 1.4;
const docHeight = nodeSize * 0.9;
// Multi-layer glass effect
ctx.fillStyle = isDragging
? colors.document.accent
: isHovered
? colors.document.secondary
: colors.document.primary;
ctx.globalAlpha = 1;
// Enhanced border with subtle glow
ctx.strokeStyle = isDragging
? colors.document.glow
: isHovered
? colors.document.accent
: colors.document.border;
ctx.lineWidth = isDragging ? 3 : isHovered ? 2 : 1;
// Rounded rectangle with enhanced styling
const radius = useSimplifiedRendering ? 6 : 12;
ctx.beginPath();
ctx.roundRect(
screenX - docWidth / 2,
screenY - docHeight / 2,
docWidth,
docHeight,
radius,
);
ctx.fill();
ctx.stroke();
// Subtle inner highlight for glass effect (skip when zoomed out)
if (!useSimplifiedRendering && (isHovered || isDragging)) {
ctx.strokeStyle = "rgba(255, 255, 255, 0.1)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.roundRect(
screenX - docWidth / 2 + 1,
screenY - docHeight / 2 + 1,
docWidth - 2,
docHeight - 2,
radius - 1,
);
ctx.stroke();
}
// Highlight ring for search hits
if (isHighlightedDocument) {
ctx.save();
ctx.globalAlpha = 0.9;
ctx.strokeStyle = colors.accent.primary;
ctx.lineWidth = 3;
ctx.setLineDash([6, 4]);
const ringPadding = 10;
ctx.beginPath();
ctx.roundRect(
screenX - docWidth / 2 - ringPadding,
screenY - docHeight / 2 - ringPadding,
docWidth + ringPadding * 2,
docHeight + ringPadding * 2,
radius + 6,
);
ctx.stroke();
ctx.setLineDash([]);
ctx.restore();
}
} else {
// Enhanced memory styling with status indicators
const mem = node.data as MemoryEntry;
const isForgotten =
mem.isForgotten ||
(mem.forgetAfter &&
new Date(mem.forgetAfter).getTime() < Date.now());
const isLatest = mem.isLatest;
// Check if memory is expiring soon (within 7 days)
const expiringSoon =
mem.forgetAfter &&
!isForgotten &&
new Date(mem.forgetAfter).getTime() - Date.now() <
1000 * 60 * 60 * 24 * 7;
// Check if memory is new (created within last 24 hours)
const isNew =
!isForgotten &&
new Date(mem.createdAt).getTime() >
Date.now() - 1000 * 60 * 60 * 24;
// Determine colors based on status
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 (isDragging) {
fillColor = colors.memory.accent;
borderColor = glowColor;
} else if (isHovered) {
fillColor = colors.memory.secondary;
}
const radius = nodeSize / 2;
ctx.fillStyle = fillColor;
ctx.globalAlpha = isLatest ? 1 : 0.4;
ctx.strokeStyle = borderColor;
ctx.lineWidth = isDragging ? 3 : isHovered ? 2 : 1.5;
if (useSimplifiedRendering) {
// Simple circles when zoomed out for performance
ctx.beginPath();
ctx.arc(screenX, screenY, radius, 0, 2 * Math.PI);
ctx.fill();
ctx.stroke();
} else {
// HEXAGONAL memory nodes when zoomed in
const sides = 6;
ctx.beginPath();
for (let i = 0; i < sides; i++) {
const angle = (i * 2 * Math.PI) / sides - Math.PI / 2; // Start from top
const x = screenX + radius * Math.cos(angle);
const y = screenY + radius * Math.sin(angle);
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
}
ctx.closePath();
ctx.fill();
ctx.stroke();
// Inner highlight for glass effect
if (isHovered || isDragging) {
ctx.strokeStyle = "rgba(147, 197, 253, 0.3)";
ctx.lineWidth = 1;
const innerRadius = radius - 2;
ctx.beginPath();
for (let i = 0; i < sides; i++) {
const angle = (i * 2 * Math.PI) / sides - Math.PI / 2;
const x = screenX + innerRadius * Math.cos(angle);
const y = screenY + innerRadius * Math.sin(angle);
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
}
ctx.closePath();
ctx.stroke();
}
}
// Status indicators overlay (always preserve these as required)
if (isForgotten) {
// Cross for forgotten memories
ctx.strokeStyle = "rgba(220,38,38,0.4)";
ctx.lineWidth = 2;
const r = nodeSize * 0.25;
ctx.beginPath();
ctx.moveTo(screenX - r, screenY - r);
ctx.lineTo(screenX + r, screenY + r);
ctx.moveTo(screenX + r, screenY - r);
ctx.lineTo(screenX - r, screenY + r);
ctx.stroke();
} else if (isNew) {
// Small dot for new memories
ctx.fillStyle = colors.status.new;
ctx.beginPath();
ctx.arc(
screenX + nodeSize * 0.25,
screenY - nodeSize * 0.25,
Math.max(2, nodeSize * 0.15), // Scale with node size, minimum 2px
0,
2 * Math.PI,
);
ctx.fill();
}
}
// Enhanced hover glow effect (skip when zoomed out for performance)
if (!useSimplifiedRendering && (isHovered || isDragging)) {
const glowColor =
node.type === "document"
? colors.document.glow
: colors.memory.glow;
ctx.strokeStyle = glowColor;
ctx.lineWidth = 1;
ctx.setLineDash([3, 3]);
ctx.globalAlpha = 0.6;
ctx.beginPath();
const glowSize = nodeSize * 0.7;
if (node.type === "document") {
ctx.roundRect(
screenX - glowSize,
screenY - glowSize / 1.4,
glowSize * 2,
glowSize * 1.4,
15,
);
} else {
// Hexagonal glow for memory nodes
const glowRadius = glowSize;
const sides = 6;
for (let i = 0; i < sides; i++) {
const angle = (i * 2 * Math.PI) / sides - Math.PI / 2;
const x = screenX + glowRadius * Math.cos(angle);
const y = screenY + glowRadius * Math.sin(angle);
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
}
ctx.closePath();
}
ctx.stroke();
ctx.setLineDash([]);
}
});
ctx.globalAlpha = 1;
}, [nodes, edges, panX, panY, zoom, width, height, highlightDocumentIds]);
// Change-based rendering instead of continuous animation
const lastRenderParams = useRef<string>("");
// Create a render key that changes when visual state changes
const renderKey = useMemo(() => {
const nodePositions = nodes
.map(
(n) =>
`${n.id}:${n.x}:${n.y}:${n.isDragging ? "1" : "0"}:${currentHoveredNode.current === n.id ? "1" : "0"}`,
)
.join("|");
const highlightKey = (highlightDocumentIds ?? []).join("|");
return `${nodePositions}-${edges.length}-${panX}-${panY}-${zoom}-${width}-${height}-${highlightKey}`;
}, [
nodes,
edges.length,
panX,
panY,
zoom,
width,
height,
highlightDocumentIds,
]);
// Only render when something actually changed
useEffect(() => {
if (renderKey !== lastRenderParams.current) {
lastRenderParams.current = renderKey;
render();
}
}, [renderKey, render]);
// Cleanup any existing animation frames
useEffect(() => {
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
};
}, []);
// Add native wheel event listener to prevent browser zoom
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const handleNativeWheel = (e: WheelEvent) => {
e.preventDefault();
e.stopPropagation();
// Call the onWheel handler with a synthetic-like event
onWheel({
deltaY: e.deltaY,
deltaX: e.deltaX,
clientX: e.clientX,
clientY: e.clientY,
currentTarget: canvas,
nativeEvent: e,
preventDefault: () => {},
stopPropagation: () => {},
} as React.WheelEvent);
};
// Add listener with passive: false to ensure preventDefault works
canvas.addEventListener("wheel", handleNativeWheel, { passive: false });
// Also prevent gesture events for touch devices
const handleGesture = (e: Event) => {
e.preventDefault();
};
canvas.addEventListener("gesturestart", handleGesture, {
passive: false,
});
canvas.addEventListener("gesturechange", handleGesture, {
passive: false,
});
canvas.addEventListener("gestureend", handleGesture, { passive: false });
return () => {
canvas.removeEventListener("wheel", handleNativeWheel);
canvas.removeEventListener("gesturestart", handleGesture);
canvas.removeEventListener("gesturechange", handleGesture);
canvas.removeEventListener("gestureend", handleGesture);
};
}, [onWheel]);
// High-DPI handling --------------------------------------------------
const dpr =
typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1;
useLayoutEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
// upscale backing store
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
canvas.width = width * dpr;
canvas.height = height * dpr;
const ctx = canvas.getContext("2d");
ctx?.scale(dpr, dpr);
}, [width, height, dpr]);
// -----------------------------------------------------------------------
return (
<canvas
className="absolute inset-0"
height={height}
onClick={handleClick}
onDoubleClick={onDoubleClick}
onMouseDown={handleMouseDown}
onMouseLeave={() => {
if (draggingNodeId) {
onNodeDragEnd();
} else {
onPanEnd();
}
}}
onMouseMove={(e) => {
handleMouseMove(e);
if (!draggingNodeId) {
onPanMove(e);
}
}}
onMouseUp={() => {
if (draggingNodeId) {
onNodeDragEnd();
} else {
onPanEnd();
}
}}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
ref={canvasRef}
style={{
cursor: draggingNodeId
? "grabbing"
: currentHoveredNode.current
? "grab"
: "move",
touchAction: "none",
userSelect: "none",
WebkitUserSelect: "none",
}}
width={width}
/>
);
},
);
GraphCanvas.displayName = "GraphCanvas";

View file

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

View file

@ -0,0 +1,304 @@
"use client";
import {
calculateSemanticSimilarity,
getConnectionVisualProps,
getMagicalConnectionColor,
} from "@repo/lib/similarity";
import { useMemo } from "react";
import { colors, LAYOUT_CONSTANTS } from "../constants";
import type {
DocumentsResponse,
DocumentWithMemories,
GraphEdge,
GraphNode,
MemoryEntry,
MemoryRelation,
} from "../types";
export function useGraphData(
data: DocumentsResponse | null,
selectedSpace: string,
nodePositions: Map<string, { x: number; y: number }>,
draggingNodeId: string | null,
) {
return useMemo(() => {
if (!data?.documents) return { nodes: [], edges: [] };
const allNodes: GraphNode[] = [];
const allEdges: GraphEdge[] = [];
// Filter documents that have memories in selected space
const filteredDocuments = data.documents
.map((doc) => ({
...doc,
memoryEntries:
selectedSpace === "all"
? doc.memoryEntries
: doc.memoryEntries.filter(
(memory) =>
(memory.spaceContainerTag ?? memory.spaceId ?? "default") ===
selectedSpace,
),
}))
.filter((doc) => doc.memoryEntries.length > 0);
// Group documents by space for better clustering
const documentsBySpace = new Map<string, typeof filteredDocuments>();
filteredDocuments.forEach((doc) => {
const docSpace =
doc.memoryEntries[0]?.spaceContainerTag ??
doc.memoryEntries[0]?.spaceId ??
"default";
if (!documentsBySpace.has(docSpace)) {
documentsBySpace.set(docSpace, []);
}
const spaceDocsArr = documentsBySpace.get(docSpace);
if (spaceDocsArr) {
spaceDocsArr.push(doc);
}
});
// Enhanced Layout with Space Separation
const { centerX, centerY, clusterRadius, spaceSpacing, documentSpacing } =
LAYOUT_CONSTANTS;
/* 1. Build DOCUMENT nodes with space-aware clustering */
const documentNodes: GraphNode[] = [];
let spaceIndex = 0;
documentsBySpace.forEach((spaceDocs) => {
const spaceAngle = (spaceIndex / documentsBySpace.size) * Math.PI * 2;
const spaceOffsetX = Math.cos(spaceAngle) * spaceSpacing;
const spaceOffsetY = Math.sin(spaceAngle) * spaceSpacing;
const spaceCenterX = centerX + spaceOffsetX;
const spaceCenterY = centerY + spaceOffsetY;
spaceDocs.forEach((doc, docIndex) => {
// Create proper circular layout with concentric rings
const docsPerRing = 6; // Start with 6 docs in inner ring
let currentRing = 0;
let docsInCurrentRing = docsPerRing;
let totalDocsInPreviousRings = 0;
// Find which ring this document belongs to
while (totalDocsInPreviousRings + docsInCurrentRing <= docIndex) {
totalDocsInPreviousRings += docsInCurrentRing;
currentRing++;
docsInCurrentRing = docsPerRing + currentRing * 4; // Each ring has more docs
}
// Position within the ring
const positionInRing = docIndex - totalDocsInPreviousRings;
const angleInRing = (positionInRing / docsInCurrentRing) * Math.PI * 2;
// Radius increases significantly with each ring
const baseRadius = documentSpacing * 0.8;
const radius =
currentRing === 0
? baseRadius
: baseRadius + currentRing * documentSpacing * 1.2;
const defaultX = spaceCenterX + Math.cos(angleInRing) * radius;
const defaultY = spaceCenterY + Math.sin(angleInRing) * radius;
const customPos = nodePositions.get(doc.id);
documentNodes.push({
id: doc.id,
type: "document",
x: customPos?.x ?? defaultX,
y: customPos?.y ?? defaultY,
data: doc,
size: 58,
color: colors.document.primary,
isHovered: false,
isDragging: draggingNodeId === doc.id,
} satisfies GraphNode);
});
spaceIndex++;
});
/* 2. Gentle document collision avoidance with dampening */
const minDocDist = LAYOUT_CONSTANTS.minDocDist;
// Reduced iterations and gentler repulsion for smoother movement
for (let iter = 0; iter < 2; iter++) {
documentNodes.forEach((nodeA) => {
documentNodes.forEach((nodeB) => {
if (nodeA.id >= nodeB.id) return;
// Only repel documents in the same space
const spaceA =
(nodeA.data as DocumentWithMemories).memoryEntries[0]
?.spaceContainerTag ??
(nodeA.data as DocumentWithMemories).memoryEntries[0]?.spaceId ??
"default";
const spaceB =
(nodeB.data as DocumentWithMemories).memoryEntries[0]
?.spaceContainerTag ??
(nodeB.data as DocumentWithMemories).memoryEntries[0]?.spaceId ??
"default";
if (spaceA !== spaceB) return;
const dx = nodeB.x - nodeA.x;
const dy = nodeB.y - nodeA.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
if (dist < minDocDist) {
// Much gentler push with dampening
const push = (minDocDist - dist) / 8;
const dampening = Math.max(0.1, Math.min(1, dist / minDocDist));
const smoothPush = push * dampening * 0.5;
const nx = dx / dist;
const ny = dy / dist;
nodeA.x -= nx * smoothPush;
nodeA.y -= ny * smoothPush;
nodeB.x += nx * smoothPush;
nodeB.y += ny * smoothPush;
}
});
});
}
allNodes.push(...documentNodes);
/* 3. Add memories around documents WITH doc-memory connections */
documentNodes.forEach((docNode) => {
const memoryNodeMap = new Map<string, GraphNode>();
const doc = docNode.data as DocumentWithMemories;
doc.memoryEntries.forEach((memory, memIndex) => {
const memoryId = `${memory.id}`;
const customMemPos = nodePositions.get(memoryId);
const clusterAngle =
(memIndex / doc.memoryEntries.length) * Math.PI * 2;
const variation = Math.sin(memIndex * 2.5) * 0.3 + 0.7;
const distance = clusterRadius * variation;
const seed =
memIndex * 12345 + Number.parseInt(docNode.id.slice(0, 6), 36);
const offsetX = Math.sin(seed) * 0.5 * 40;
const offsetY = Math.cos(seed) * 0.5 * 40;
const defaultMemX =
docNode.x + Math.cos(clusterAngle) * distance + offsetX;
const defaultMemY =
docNode.y + Math.sin(clusterAngle) * distance + offsetY;
if (!memoryNodeMap.has(memoryId)) {
const memoryNode: GraphNode = {
id: memoryId,
type: "memory",
x: customMemPos?.x ?? defaultMemX,
y: customMemPos?.y ?? defaultMemY,
data: memory,
size: Math.max(
32,
Math.min(48, (memory.memory?.length || 50) * 0.5),
),
color: colors.memory.primary,
isHovered: false,
isDragging: draggingNodeId === memoryId,
};
memoryNodeMap.set(memoryId, memoryNode);
allNodes.push(memoryNode);
}
// Create doc-memory edge with similarity
allEdges.push({
id: `edge-${docNode.id}-${memory.id}`,
source: docNode.id,
target: memoryId,
similarity: 1,
visualProps: getConnectionVisualProps(1),
color: colors.connection.memory,
edgeType: "doc-memory",
});
});
});
// Build mapping of memoryId -> nodeId for version chains
const memNodeIdMap = new Map<string, string>();
allNodes.forEach((n) => {
if (n.type === "memory") {
memNodeIdMap.set((n.data as MemoryEntry).id, n.id);
}
});
// Add version-chain edges (old -> new)
data.documents.forEach((doc) => {
doc.memoryEntries.forEach((mem: MemoryEntry) => {
// Support both new object structure and legacy array/single parent fields
let parentRelations: Record<string, MemoryRelation> = {};
if (
mem.memoryRelations &&
typeof mem.memoryRelations === "object" &&
Object.keys(mem.memoryRelations).length > 0
) {
parentRelations = mem.memoryRelations;
} else if (mem.parentMemoryId) {
parentRelations = {
[mem.parentMemoryId]: "updates" as MemoryRelation,
};
}
Object.entries(parentRelations).forEach(([pid, relationType]) => {
const fromId = memNodeIdMap.get(pid);
const toId = memNodeIdMap.get(mem.id);
if (fromId && toId) {
allEdges.push({
id: `version-${fromId}-${toId}`,
source: fromId,
target: toId,
similarity: 1,
visualProps: {
opacity: 0.8,
thickness: 1,
glow: 0,
pulseDuration: 3000,
},
// choose color based on relation type
color: colors.relations[relationType] ?? colors.relations.updates,
edgeType: "version",
relationType: relationType as MemoryRelation,
});
}
});
});
});
// Document-to-document similarity edges
for (let i = 0; i < filteredDocuments.length; i++) {
const docI = filteredDocuments[i];
if (!docI) continue;
for (let j = i + 1; j < filteredDocuments.length; j++) {
const docJ = filteredDocuments[j];
if (!docJ) continue;
const sim = calculateSemanticSimilarity(
docI.summaryEmbedding ? Array.from(docI.summaryEmbedding) : null,
docJ.summaryEmbedding ? Array.from(docJ.summaryEmbedding) : null,
);
if (sim > 0.725) {
allEdges.push({
id: `doc-doc-${docI.id}-${docJ.id}`,
source: docI.id,
target: docJ.id,
similarity: sim,
visualProps: getConnectionVisualProps(sim),
color: getMagicalConnectionColor(sim, 200),
edgeType: "doc-doc",
});
}
}
}
return { nodes: allNodes, edges: allEdges };
}, [data, selectedSpace, nodePositions, draggingNodeId]);
}

View file

@ -0,0 +1,564 @@
"use client";
import { useCallback, useRef, useState } from "react";
import { GRAPH_SETTINGS } from "../constants";
import type { GraphNode } from "../types";
export function useGraphInteractions(
variant: "console" | "consumer" = "console",
) {
const settings = GRAPH_SETTINGS[variant];
const [panX, setPanX] = useState(settings.initialPanX);
const [panY, setPanY] = useState(settings.initialPanY);
const [zoom, setZoom] = useState(settings.initialZoom);
const [isPanning, setIsPanning] = useState(false);
const [panStart, setPanStart] = useState({ x: 0, y: 0 });
const [hoveredNode, setHoveredNode] = useState<string | null>(null);
const [selectedNode, setSelectedNode] = useState<string | null>(null);
const [draggingNodeId, setDraggingNodeId] = useState<string | null>(null);
const [dragStart, setDragStart] = useState({
x: 0,
y: 0,
nodeX: 0,
nodeY: 0,
});
const [nodePositions, setNodePositions] = useState<
Map<string, { x: number; y: number }>
>(new Map());
// Touch gesture state
const [touchState, setTouchState] = useState<{
touches: { id: number; x: number; y: number }[];
lastDistance: number;
lastCenter: { x: number; y: number };
isGesturing: boolean;
}>({
touches: [],
lastDistance: 0,
lastCenter: { x: 0, y: 0 },
isGesturing: false,
});
// Animation state for smooth transitions
const animationRef = useRef<number | null>(null);
const [isAnimating, setIsAnimating] = useState(false);
// Smooth animation helper
const animateToViewState = useCallback(
(
targetPanX: number,
targetPanY: number,
targetZoom: number,
duration = 300,
) => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
const startPanX = panX;
const startPanY = panY;
const startZoom = zoom;
const startTime = Date.now();
setIsAnimating(true);
const animate = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
// Ease out cubic function for smooth transitions
const easeOut = 1 - (1 - progress) ** 3;
const currentPanX = startPanX + (targetPanX - startPanX) * easeOut;
const currentPanY = startPanY + (targetPanY - startPanY) * easeOut;
const currentZoom = startZoom + (targetZoom - startZoom) * easeOut;
setPanX(currentPanX);
setPanY(currentPanY);
setZoom(currentZoom);
if (progress < 1) {
animationRef.current = requestAnimationFrame(animate);
} else {
setIsAnimating(false);
animationRef.current = null;
}
};
animate();
},
[panX, panY, zoom],
);
// Node drag handlers
const handleNodeDragStart = useCallback(
(nodeId: string, e: React.MouseEvent, nodes?: GraphNode[]) => {
const node = nodes?.find((n) => n.id === nodeId);
if (!node) return;
setDraggingNodeId(nodeId);
setDragStart({
x: e.clientX,
y: e.clientY,
nodeX: node.x,
nodeY: node.y,
});
},
[],
);
const handleNodeDragMove = useCallback(
(e: React.MouseEvent) => {
if (!draggingNodeId) return;
const deltaX = (e.clientX - dragStart.x) / zoom;
const deltaY = (e.clientY - dragStart.y) / zoom;
const newX = dragStart.nodeX + deltaX;
const newY = dragStart.nodeY + deltaY;
setNodePositions((prev) =>
new Map(prev).set(draggingNodeId, { x: newX, y: newY }),
);
},
[draggingNodeId, dragStart, zoom],
);
const handleNodeDragEnd = useCallback(() => {
setDraggingNodeId(null);
}, []);
// Pan handlers
const handlePanStart = useCallback(
(e: React.MouseEvent) => {
setIsPanning(true);
setPanStart({ x: e.clientX - panX, y: e.clientY - panY });
},
[panX, panY],
);
const handlePanMove = useCallback(
(e: React.MouseEvent) => {
if (!isPanning || draggingNodeId) return;
const newPanX = e.clientX - panStart.x;
const newPanY = e.clientY - panStart.y;
setPanX(newPanX);
setPanY(newPanY);
},
[isPanning, panStart, draggingNodeId],
);
const handlePanEnd = useCallback(() => {
setIsPanning(false);
}, []);
// Zoom handlers
const handleWheel = useCallback(
(e: React.WheelEvent) => {
// Always prevent default to stop browser navigation
e.preventDefault();
e.stopPropagation();
// Handle horizontal scrolling (trackpad swipe) by converting to pan
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
// Horizontal scroll - pan the graph instead of zooming
const panDelta = e.deltaX * 0.5;
setPanX((prev) => prev - panDelta);
return;
}
// Vertical scroll - zoom behavior
const delta = e.deltaY > 0 ? 0.97 : 1.03;
const newZoom = Math.max(0.05, Math.min(3, zoom * delta));
// Get mouse position relative to the viewport
let mouseX = e.clientX;
let mouseY = e.clientY;
// Try to get the container bounds to make coordinates relative to the graph container
const target = e.currentTarget;
if (target && "getBoundingClientRect" in target) {
const rect = target.getBoundingClientRect();
mouseX = e.clientX - rect.left;
mouseY = e.clientY - rect.top;
}
// Calculate the world position of the mouse cursor
const worldX = (mouseX - panX) / zoom;
const worldY = (mouseY - panY) / zoom;
// Calculate new pan to keep the mouse position stationary
const newPanX = mouseX - worldX * newZoom;
const newPanY = mouseY - worldY * newZoom;
setZoom(newZoom);
setPanX(newPanX);
setPanY(newPanY);
},
[zoom, panX, panY],
);
const zoomIn = useCallback(
(centerX?: number, centerY?: number, animate = true) => {
const zoomFactor = 1.2;
const newZoom = Math.min(3, zoom * zoomFactor); // Increased max zoom to 3x
if (centerX !== undefined && centerY !== undefined) {
// Mouse-centered zoom for programmatic zoom in
const worldX = (centerX - panX) / zoom;
const worldY = (centerY - panY) / zoom;
const newPanX = centerX - worldX * newZoom;
const newPanY = centerY - worldY * newZoom;
if (animate && !isAnimating) {
animateToViewState(newPanX, newPanY, newZoom, 200);
} else {
setZoom(newZoom);
setPanX(newPanX);
setPanY(newPanY);
}
} else {
if (animate && !isAnimating) {
animateToViewState(panX, panY, newZoom, 200);
} else {
setZoom(newZoom);
}
}
},
[zoom, panX, panY, isAnimating, animateToViewState],
);
const zoomOut = useCallback(
(centerX?: number, centerY?: number, animate = true) => {
const zoomFactor = 0.8;
const newZoom = Math.max(0.05, zoom * zoomFactor); // Decreased min zoom to 0.05x
if (centerX !== undefined && centerY !== undefined) {
// Mouse-centered zoom for programmatic zoom out
const worldX = (centerX - panX) / zoom;
const worldY = (centerY - panY) / zoom;
const newPanX = centerX - worldX * newZoom;
const newPanY = centerY - worldY * newZoom;
if (animate && !isAnimating) {
animateToViewState(newPanX, newPanY, newZoom, 200);
} else {
setZoom(newZoom);
setPanX(newPanX);
setPanY(newPanY);
}
} else {
if (animate && !isAnimating) {
animateToViewState(panX, panY, newZoom, 200);
} else {
setZoom(newZoom);
}
}
},
[zoom, panX, panY, isAnimating, animateToViewState],
);
const resetView = useCallback(() => {
setPanX(settings.initialPanX);
setPanY(settings.initialPanY);
setZoom(settings.initialZoom);
setNodePositions(new Map());
}, [settings]);
// Auto-fit graph to viewport
const autoFitToViewport = useCallback(
(
nodes: GraphNode[],
viewportWidth: number,
viewportHeight: number,
options?: { occludedRightPx?: number; animate?: boolean },
) => {
if (nodes.length === 0) return;
// Find the bounds of all nodes
let minX = Number.POSITIVE_INFINITY;
let maxX = Number.NEGATIVE_INFINITY;
let minY = Number.POSITIVE_INFINITY;
let maxY = Number.NEGATIVE_INFINITY;
nodes.forEach((node) => {
minX = Math.min(minX, node.x - node.size / 2);
maxX = Math.max(maxX, node.x + node.size / 2);
minY = Math.min(minY, node.y - node.size / 2);
maxY = Math.max(maxY, node.y + node.size / 2);
});
// Calculate the center of the content
const contentCenterX = (minX + maxX) / 2;
const contentCenterY = (minY + maxY) / 2;
// Calculate the size of the content
const contentWidth = maxX - minX;
const contentHeight = maxY - minY;
// Add padding (20% on each side)
const paddingFactor = 1.4;
const paddedWidth = contentWidth * paddingFactor;
const paddedHeight = contentHeight * paddingFactor;
// Account for occluded area on the right (e.g., chat panel)
const occludedRightPx = Math.max(0, options?.occludedRightPx ?? 0);
const availableWidth = Math.max(1, viewportWidth - occludedRightPx);
// Calculate the zoom needed to fit the content within available width
const zoomX = availableWidth / paddedWidth;
const zoomY = viewportHeight / paddedHeight;
const newZoom = Math.min(Math.max(0.05, Math.min(zoomX, zoomY)), 3);
// Calculate pan to center the content within available area
const availableCenterX = availableWidth / 2;
const newPanX = availableCenterX - contentCenterX * newZoom;
const newPanY = viewportHeight / 2 - contentCenterY * newZoom;
// Apply the new view (optional animation)
if (options?.animate) {
const steps = 8;
const durationMs = 160; // snappy
const intervalMs = Math.max(1, Math.floor(durationMs / steps));
const startZoom = zoom;
const startPanX = panX;
const startPanY = panY;
let i = 0;
const ease = (t: number) => 1 - (1 - t) ** 2; // ease-out quad
const timer = setInterval(() => {
i++;
const t = ease(i / steps);
setZoom(startZoom + (newZoom - startZoom) * t);
setPanX(startPanX + (newPanX - startPanX) * t);
setPanY(startPanY + (newPanY - startPanY) * t);
if (i >= steps) clearInterval(timer);
}, intervalMs);
} else {
setZoom(newZoom);
setPanX(newPanX);
setPanY(newPanY);
}
},
[zoom, panX, panY],
);
// Touch gesture handlers for mobile pinch-to-zoom
const handleTouchStart = useCallback((e: React.TouchEvent) => {
const touches = Array.from(e.touches).map((touch) => ({
id: touch.identifier,
x: touch.clientX,
y: touch.clientY,
}));
if (touches.length >= 2) {
// Start gesture with two or more fingers
const touch1 = touches[0]!;
const touch2 = touches[1]!;
const distance = Math.sqrt(
(touch2.x - touch1.x) ** 2 + (touch2.y - touch1.y) ** 2,
);
const center = {
x: (touch1.x + touch2.x) / 2,
y: (touch1.y + touch2.y) / 2,
};
setTouchState({
touches,
lastDistance: distance,
lastCenter: center,
isGesturing: true,
});
} else {
setTouchState((prev) => ({ ...prev, touches, isGesturing: false }));
}
}, []);
const handleTouchMove = useCallback(
(e: React.TouchEvent) => {
e.preventDefault();
const touches = Array.from(e.touches).map((touch) => ({
id: touch.identifier,
x: touch.clientX,
y: touch.clientY,
}));
if (touches.length >= 2 && touchState.isGesturing) {
const touch1 = touches[0]!;
const touch2 = touches[1]!;
const distance = Math.sqrt(
(touch2.x - touch1.x) ** 2 + (touch2.y - touch1.y) ** 2,
);
const center = {
x: (touch1.x + touch2.x) / 2,
y: (touch1.y + touch2.y) / 2,
};
// Calculate zoom change based on pinch distance change
const distanceChange = distance / touchState.lastDistance;
const newZoom = Math.max(0.05, Math.min(3, zoom * distanceChange));
// Get canvas bounds for center calculation
const canvas = e.currentTarget as HTMLElement;
const rect = canvas.getBoundingClientRect();
const centerX = center.x - rect.left;
const centerY = center.y - rect.top;
// Calculate the world position of the pinch center
const worldX = (centerX - panX) / zoom;
const worldY = (centerY - panY) / zoom;
// Calculate new pan to keep the pinch center stationary
const newPanX = centerX - worldX * newZoom;
const newPanY = centerY - worldY * newZoom;
// Calculate pan change based on center movement
const centerDx = center.x - touchState.lastCenter.x;
const centerDy = center.y - touchState.lastCenter.y;
setZoom(newZoom);
setPanX(newPanX + centerDx);
setPanY(newPanY + centerDy);
setTouchState({
touches,
lastDistance: distance,
lastCenter: center,
isGesturing: true,
});
} else if (touches.length === 1 && !touchState.isGesturing && isPanning) {
// Single finger pan (only if not in gesture mode)
const touch = touches[0]!;
const newPanX = touch.x - panStart.x;
const newPanY = touch.y - panStart.y;
setPanX(newPanX);
setPanY(newPanY);
}
},
[touchState, zoom, panX, panY, isPanning, panStart],
);
const handleTouchEnd = useCallback((e: React.TouchEvent) => {
const touches = Array.from(e.touches).map((touch) => ({
id: touch.identifier,
x: touch.clientX,
y: touch.clientY,
}));
if (touches.length < 2) {
setTouchState((prev) => ({ ...prev, touches, isGesturing: false }));
} else {
setTouchState((prev) => ({ ...prev, touches }));
}
if (touches.length === 0) {
setIsPanning(false);
}
}, []);
// Center viewport on a specific world position (with animation)
const centerViewportOn = useCallback(
(
worldX: number,
worldY: number,
viewportWidth: number,
viewportHeight: number,
animate = true,
) => {
const newPanX = viewportWidth / 2 - worldX * zoom;
const newPanY = viewportHeight / 2 - worldY * zoom;
if (animate && !isAnimating) {
animateToViewState(newPanX, newPanY, zoom, 400);
} else {
setPanX(newPanX);
setPanY(newPanY);
}
},
[zoom, isAnimating, animateToViewState],
);
// Node interaction handlers
const handleNodeHover = useCallback((nodeId: string | null) => {
setHoveredNode(nodeId);
}, []);
const handleNodeClick = useCallback(
(nodeId: string) => {
setSelectedNode(selectedNode === nodeId ? null : nodeId);
},
[selectedNode],
);
const handleDoubleClick = useCallback(
(e: React.MouseEvent) => {
// Calculate new zoom (zoom in by 1.5x)
const zoomFactor = 1.5;
const newZoom = Math.min(3, zoom * zoomFactor);
// Get mouse position relative to the container
let mouseX = e.clientX;
let mouseY = e.clientY;
// Try to get the container bounds to make coordinates relative to the graph container
const target = e.currentTarget;
if (target && "getBoundingClientRect" in target) {
const rect = target.getBoundingClientRect();
mouseX = e.clientX - rect.left;
mouseY = e.clientY - rect.top;
}
// Calculate the world position of the clicked point
const worldX = (mouseX - panX) / zoom;
const worldY = (mouseY - panY) / zoom;
// Calculate new pan to keep the clicked point in the same screen position
const newPanX = mouseX - worldX * newZoom;
const newPanY = mouseY - worldY * newZoom;
setZoom(newZoom);
setPanX(newPanX);
setPanY(newPanY);
},
[zoom, panX, panY],
);
return {
// State
panX,
panY,
zoom,
hoveredNode,
selectedNode,
draggingNodeId,
nodePositions,
// Handlers
handlePanStart,
handlePanMove,
handlePanEnd,
handleWheel,
handleNodeHover,
handleNodeClick,
handleNodeDragStart,
handleNodeDragMove,
handleNodeDragEnd,
handleDoubleClick,
// Touch handlers
handleTouchStart,
handleTouchMove,
handleTouchEnd,
// Controls
zoomIn,
zoomOut,
resetView,
autoFitToViewport,
centerViewportOn,
setSelectedNode,
};
}

View file

@ -0,0 +1,19 @@
// Components
// Types and constants
export {
colors,
GRAPH_SETTINGS,
LAYOUT_CONSTANTS,
POSITIONING,
} from "./constants";
export { GraphWebGLCanvas as GraphCanvas } from "./graph-webgl-canvas";
// Hooks
export { useGraphData } from "./hooks/use-graph-data";
export { useGraphInteractions } from "./hooks/use-graph-interactions";
export { Legend } from "./legend";
export { LoadingIndicator } from "./loading-indicator";
export { MemoryGraph } from "./memory-graph";
export { NodeDetailPanel } from "./node-detail-panel";
export { SpacesDropdown } from "./spaces-dropdown";
export * from "./types";

View file

@ -0,0 +1,311 @@
"use client";
import { useIsMobile } from "@hooks/use-mobile";
import { cn } from "@repo/lib/utils";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@repo/ui/components/collapsible";
import { GlassMenuEffect } from "@repo/ui/other/glass-effect";
import { Brain, ChevronDown, ChevronUp, FileText } from "lucide-react";
import { memo, useEffect, useState } from "react";
import { colors } from "./constants";
import type { GraphEdge, GraphNode, LegendProps } from "./types";
// Cookie utility functions for legend state
const setCookie = (name: string, value: string, days = 365) => {
if (typeof document === "undefined") return;
const expires = new Date();
expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000);
document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/`;
};
const getCookie = (name: string): string | null => {
if (typeof document === "undefined") return null;
const nameEQ = `${name}=`;
const ca = document.cookie.split(";");
for (let i = 0; i < ca.length; i++) {
let c = ca[i];
if (!c) continue;
while (c.charAt(0) === " ") c = c.substring(1, c.length);
if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length);
}
return null;
};
interface ExtendedLegendProps extends LegendProps {
id?: string;
nodes?: GraphNode[];
edges?: GraphEdge[];
isLoading?: boolean;
}
export const Legend = memo(function Legend({
variant = "console",
id,
nodes = [],
edges = [],
isLoading = false,
}: ExtendedLegendProps) {
const isMobile = useIsMobile();
const [isExpanded, setIsExpanded] = useState(true);
const [isInitialized, setIsInitialized] = useState(false);
// Load saved preference on client side
useEffect(() => {
if (!isInitialized) {
const savedState = getCookie("legendCollapsed");
if (savedState === "true") {
setIsExpanded(false);
} else if (savedState === "false") {
setIsExpanded(true);
} else {
// Default: collapsed on mobile, expanded on desktop
setIsExpanded(!isMobile);
}
setIsInitialized(true);
}
}, [isInitialized, isMobile]);
// Save to cookie when state changes
const handleToggleExpanded = (expanded: boolean) => {
setIsExpanded(expanded);
setCookie("legendCollapsed", expanded ? "false" : "true");
};
// Use explicit classes that Tailwind can detect
const getPositioningClasses = () => {
if (variant === "console") {
// Both desktop and mobile use same positioning for console
return "bottom-4 right-4";
}
if (variant === "consumer") {
return isMobile ? "bottom-48 left-4" : "top-18 right-4";
}
return "";
};
const getMobileSize = () => {
if (!isMobile) return "";
return isExpanded ? "max-w-xs" : "w-16 h-12";
};
const hexagonClipPath =
"polygon(50% 0%, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)";
// Calculate stats
const memoryCount = nodes.filter((n) => n.type === "memory").length;
const documentCount = nodes.filter((n) => n.type === "document").length;
return (
<div
className={cn(
"absolute z-10 rounded-xl overflow-hidden w-fit h-fit",
getPositioningClasses(),
getMobileSize(),
isMobile && "hidden md:block",
)}
id={id}
>
<Collapsible onOpenChange={handleToggleExpanded} open={isExpanded}>
{/* Glass effect background */}
<GlassMenuEffect rounded="rounded-xl" />
<div className="relative z-10">
{/* Mobile and Desktop collapsed state */}
{!isExpanded && (
<CollapsibleTrigger className="w-full h-full p-2 flex items-center justify-center hover:bg-white/5 transition-colors">
<div className="flex flex-col items-center gap-1">
<div className="text-xs text-slate-300 font-medium">?</div>
<ChevronUp className="w-3 h-3 text-slate-400" />
</div>
</CollapsibleTrigger>
)}
{/* Expanded state */}
{isExpanded && (
<>
{/* Header with toggle */}
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-600/50">
<div className="text-sm font-medium text-slate-100">Legend</div>
<CollapsibleTrigger className="p-1 hover:bg-white/10 rounded">
<ChevronDown className="w-4 h-4 text-slate-400" />
</CollapsibleTrigger>
</div>
<CollapsibleContent>
<div className="text-xs text-slate-200 px-4 py-3 space-y-3">
{/* Stats Section */}
{!isLoading && (
<div className="space-y-2">
<div className="text-xs font-medium text-slate-300 uppercase tracking-wide">
Statistics
</div>
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<Brain className="w-3 h-3 text-blue-400" />
<span className="text-xs">
{memoryCount} memories
</span>
</div>
<div className="flex items-center gap-2">
<FileText className="w-3 h-3 text-slate-300" />
<span className="text-xs">
{documentCount} documents
</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-gradient-to-r from-slate-400 to-blue-400 rounded-full" />
<span className="text-xs">
{edges.length} connections
</span>
</div>
</div>
</div>
)}
{/* Node Types */}
<div className="space-y-2">
<div className="text-xs font-medium text-slate-300 uppercase tracking-wide">
Nodes
</div>
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<div className="w-4 h-3 bg-white/8 border border-white/25 rounded-sm flex-shrink-0" />
<span className="text-xs">Document</span>
</div>
<div className="flex items-center gap-2">
<div
className="w-3 h-3 bg-blue-400/10 border border-blue-400/35 flex-shrink-0"
style={{
clipPath: hexagonClipPath,
}}
/>
<span className="text-xs">Memory (latest)</span>
</div>
<div className="flex items-center gap-2">
<div
className="w-3 h-3 bg-blue-400/10 border border-blue-400/35 opacity-40 flex-shrink-0"
style={{
clipPath: hexagonClipPath,
}}
/>
<span className="text-xs">Memory (older)</span>
</div>
</div>
</div>
{/* Status Indicators */}
<div className="space-y-2">
<div className="text-xs font-medium text-slate-300 uppercase tracking-wide">
Status
</div>
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<div
className="w-3 h-3 bg-red-500/30 border border-red-500/80 relative flex-shrink-0"
style={{
clipPath: hexagonClipPath,
}}
>
<div className="absolute inset-0 flex items-center justify-center text-red-400 text-xs leading-none">
</div>
</div>
<span className="text-xs">Forgotten</span>
</div>
<div className="flex items-center gap-2">
<div
className="w-3 h-3 bg-blue-400/10 border-2 border-amber-500 flex-shrink-0"
style={{
clipPath: hexagonClipPath,
}}
/>
<span className="text-xs">Expiring soon</span>
</div>
<div className="flex items-center gap-2">
<div
className="w-3 h-3 bg-blue-400/10 border-2 border-emerald-500 relative flex-shrink-0"
style={{
clipPath: hexagonClipPath,
}}
>
<div className="absolute -top-1 -right-1 w-2 h-2 bg-emerald-500 rounded-full" />
</div>
<span className="text-xs">New memory</span>
</div>
</div>
</div>
{/* Connection Types */}
<div className="space-y-2">
<div className="text-xs font-medium text-slate-300 uppercase tracking-wide">
Connections
</div>
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<div className="w-4 h-0 border-t border-slate-400 flex-shrink-0" />
<span className="text-xs">Doc Memory</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-0 border-t-2 border-dashed border-slate-400 flex-shrink-0" />
<span className="text-xs">Doc similarity</span>
</div>
</div>
</div>
{/* Relation Types */}
<div className="space-y-2">
<div className="text-xs font-medium text-slate-300 uppercase tracking-wide">
Relations
</div>
<div className="space-y-1.5">
{[
["updates", colors.relations.updates],
["extends", colors.relations.extends],
["derives", colors.relations.derives],
].map(([label, color]) => (
<div className="flex items-center gap-2" key={label}>
<div
className="w-4 h-0 border-t-2 flex-shrink-0"
style={{ borderColor: color }}
/>
<span
className="text-xs capitalize"
style={{ color: color }}
>
{label}
</span>
</div>
))}
</div>
</div>
{/* Similarity Strength */}
<div className="space-y-2">
<div className="text-xs font-medium text-slate-300 uppercase tracking-wide">
Similarity
</div>
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-slate-400/20 flex-shrink-0" />
<span className="text-xs">Weak</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-slate-400/60 flex-shrink-0" />
<span className="text-xs">Strong</span>
</div>
</div>
</div>
</div>
</CollapsibleContent>
</>
)}
</div>
</Collapsible>
</div>
);
});
Legend.displayName = "Legend";

View file

@ -0,0 +1,44 @@
"use client";
import { cn } from "@repo/lib/utils";
import { GlassMenuEffect } from "@repo/ui/other/glass-effect";
import { Sparkles } from "lucide-react";
import { memo } from "react";
import type { LoadingIndicatorProps } from "./types";
export const LoadingIndicator = memo<LoadingIndicatorProps>(
({ isLoading, isLoadingMore, totalLoaded, variant = "console" }) => {
// Use explicit classes that Tailwind can detect
const getPositioningClasses = () => {
// Both variants use the same positioning for loadingIndicator
return "top-20 left-4";
};
if (!isLoading && !isLoadingMore) return null;
return (
<div
className={cn(
"absolute z-10 rounded-xl overflow-hidden",
getPositioningClasses(),
)}
>
{/* Glass effect background */}
<GlassMenuEffect rounded="rounded-xl" />
<div className="relative z-10 text-slate-200 px-4 py-3">
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 animate-spin text-blue-400" />
<span className="text-sm">
{isLoading
? "Loading memory graph..."
: `Loading more documents... (${totalLoaded})`}
</span>
</div>
</div>
</div>
);
},
);
LoadingIndicator.displayName = "LoadingIndicator";

View file

@ -0,0 +1,458 @@
"use client";
import { GlassMenuEffect } from "@repo/ui/other/glass-effect";
import { AnimatePresence } from "motion/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { colors } from "./constants";
import { GraphWebGLCanvas as GraphCanvas } from "./graph-webgl-canvas";
import { useGraphData } from "./hooks/use-graph-data";
import { useGraphInteractions } from "./hooks/use-graph-interactions";
import { Legend } from "./legend";
import { LoadingIndicator } from "./loading-indicator";
import { NavigationControls } from "./navigation-controls";
import { NodeDetailPanel } from "./node-detail-panel";
import { SpacesDropdown } from "./spaces-dropdown";
import type { MemoryGraphProps } from "./types";
export const MemoryGraph = ({
children,
documents,
isLoading,
isLoadingMore,
error,
totalLoaded,
hasMore,
loadMoreDocuments,
showSpacesSelector,
variant = "console",
legendId,
highlightDocumentIds = [],
highlightsVisible = true,
occludedRightPx = 0,
autoLoadOnViewport = true,
}: MemoryGraphProps) => {
// Derive showSpacesSelector from variant if not explicitly provided
// console variant shows spaces selector, consumer variant hides it
const finalShowSpacesSelector = showSpacesSelector ?? (variant === "console");
const [selectedSpace, setSelectedSpace] = useState<string>("all");
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
const containerRef = useRef<HTMLDivElement>(null);
// Create data object with dummy pagination to satisfy type requirements
const data = useMemo(() => {
return documents && documents.length > 0
? {
documents,
pagination: {
currentPage: 1,
limit: documents.length,
totalItems: documents.length,
totalPages: 1,
},
}
: null;
}, [documents]);
// Graph interactions with variant-specific settings
const {
panX,
panY,
zoom,
/** hoveredNode currently unused within this component */
hoveredNode: _hoveredNode,
selectedNode,
draggingNodeId,
nodePositions,
handlePanStart,
handlePanMove,
handlePanEnd,
handleWheel,
handleNodeHover,
handleNodeClick,
handleNodeDragStart,
handleNodeDragMove,
handleNodeDragEnd,
handleDoubleClick,
handleTouchStart,
handleTouchMove,
handleTouchEnd,
setSelectedNode,
autoFitToViewport,
centerViewportOn,
zoomIn,
zoomOut,
} = useGraphInteractions(variant);
// Graph data
const { nodes, edges } = useGraphData(
data,
selectedSpace,
nodePositions,
draggingNodeId,
);
// Auto-fit once per unique highlight set to show the full graph for context
const lastFittedHighlightKeyRef = useRef<string>("");
useEffect(() => {
const highlightKey = highlightsVisible
? highlightDocumentIds.join("|")
: "";
if (
highlightKey &&
highlightKey !== lastFittedHighlightKeyRef.current &&
containerSize.width > 0 &&
containerSize.height > 0 &&
nodes.length > 0
) {
autoFitToViewport(nodes, containerSize.width, containerSize.height, {
occludedRightPx,
animate: true,
});
lastFittedHighlightKeyRef.current = highlightKey;
}
}, [
highlightsVisible,
highlightDocumentIds,
containerSize.width,
containerSize.height,
nodes.length,
occludedRightPx,
autoFitToViewport,
]);
// Auto-fit graph when component mounts or nodes change significantly
const hasAutoFittedRef = useRef(false);
useEffect(() => {
// Only auto-fit once when we have nodes and container size
if (
!hasAutoFittedRef.current &&
nodes.length > 0 &&
containerSize.width > 0 &&
containerSize.height > 0
) {
// Auto-fit to show all content for both variants
// Add a small delay to ensure the canvas is fully initialized
const timer = setTimeout(() => {
autoFitToViewport(nodes, containerSize.width, containerSize.height);
hasAutoFittedRef.current = true;
}, 100);
return () => clearTimeout(timer);
}
}, [
nodes,
containerSize.width,
containerSize.height,
autoFitToViewport,
]);
// Reset auto-fit flag when nodes array becomes empty (switching views)
useEffect(() => {
if (nodes.length === 0) {
hasAutoFittedRef.current = false;
}
}, [nodes.length]);
// Extract unique spaces from memories and calculate counts
const { availableSpaces, spaceMemoryCounts } = useMemo(() => {
if (!data?.documents) return { availableSpaces: [], spaceMemoryCounts: {} };
const spaceSet = new Set<string>();
const counts: Record<string, number> = {};
data.documents.forEach((doc) => {
doc.memoryEntries.forEach((memory) => {
const spaceId = memory.spaceContainerTag || memory.spaceId || "default";
spaceSet.add(spaceId);
counts[spaceId] = (counts[spaceId] || 0) + 1;
});
});
return {
availableSpaces: Array.from(spaceSet).sort(),
spaceMemoryCounts: counts,
};
}, [data]);
// Handle container resize
useEffect(() => {
const updateSize = () => {
if (containerRef.current) {
const newWidth = containerRef.current.clientWidth;
const newHeight = containerRef.current.clientHeight;
// Only update if size actually changed and is valid
setContainerSize((prev) => {
if (prev.width !== newWidth || prev.height !== newHeight) {
return { width: newWidth, height: newHeight };
}
return prev;
});
}
};
// Use a slight delay to ensure DOM is fully rendered
const timer = setTimeout(updateSize, 0);
updateSize(); // Also call immediately
window.addEventListener("resize", updateSize);
// Use ResizeObserver for more accurate container size detection
const resizeObserver = new ResizeObserver(updateSize);
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
return () => {
clearTimeout(timer);
window.removeEventListener("resize", updateSize);
resizeObserver.disconnect();
};
}, []);
// Enhanced node drag start that includes nodes data
const handleNodeDragStartWithNodes = useCallback(
(nodeId: string, e: React.MouseEvent) => {
handleNodeDragStart(nodeId, e, nodes);
},
[handleNodeDragStart, nodes],
);
// Navigation callbacks
const handleCenter = useCallback(() => {
if (nodes.length > 0) {
// Calculate center of all nodes
let sumX = 0
let sumY = 0
let count = 0
nodes.forEach((node) => {
sumX += node.x
sumY += node.y
count++
})
if (count > 0) {
const centerX = sumX / count
const centerY = sumY / count
centerViewportOn(centerX, centerY, containerSize.width, containerSize.height)
}
}
}, [nodes, centerViewportOn, containerSize.width, containerSize.height])
const handleAutoFit = useCallback(() => {
if (nodes.length > 0 && containerSize.width > 0 && containerSize.height > 0) {
autoFitToViewport(nodes, containerSize.width, containerSize.height, {
occludedRightPx,
animate: true,
})
}
}, [nodes, containerSize.width, containerSize.height, occludedRightPx, autoFitToViewport])
// Get selected node data
const selectedNodeData = useMemo(() => {
if (!selectedNode) return null;
return nodes.find((n) => n.id === selectedNode) || null;
}, [selectedNode, nodes]);
// Viewport-based loading: load more when most documents are visible (optional)
const checkAndLoadMore = useCallback(() => {
if (
isLoadingMore ||
!hasMore ||
!data?.documents ||
data.documents.length === 0
)
return;
// Calculate viewport bounds
const viewportBounds = {
left: -panX / zoom - 200,
right: (-panX + containerSize.width) / zoom + 200,
top: -panY / zoom - 200,
bottom: (-panY + containerSize.height) / zoom + 200,
};
// Count visible documents
const visibleDocuments = data.documents.filter((doc) => {
const docNodes = nodes.filter(
(node) => node.type === "document" && node.data.id === doc.id,
);
return docNodes.some(
(node) =>
node.x >= viewportBounds.left &&
node.x <= viewportBounds.right &&
node.y >= viewportBounds.top &&
node.y <= viewportBounds.bottom,
);
});
// If 80% or more of documents are visible, load more
const visibilityRatio = visibleDocuments.length / data.documents.length;
if (visibilityRatio >= 0.8) {
loadMoreDocuments();
}
}, [
isLoadingMore,
hasMore,
data,
panX,
panY,
zoom,
containerSize.width,
containerSize.height,
nodes,
loadMoreDocuments,
]);
// Throttled version to avoid excessive checks
const lastLoadCheckRef = useRef(0);
const throttledCheckAndLoadMore = useCallback(() => {
const now = Date.now();
if (now - lastLoadCheckRef.current > 1000) {
// Check at most once per second
lastLoadCheckRef.current = now;
checkAndLoadMore();
}
}, [checkAndLoadMore]);
// Monitor viewport changes to trigger loading
useEffect(() => {
if (!autoLoadOnViewport) return;
throttledCheckAndLoadMore();
}, [throttledCheckAndLoadMore, autoLoadOnViewport]);
// Initial load trigger when graph is first rendered
useEffect(() => {
if (!autoLoadOnViewport) return;
if (data?.documents && data.documents.length > 0 && hasMore) {
// Start loading more documents after initial render
setTimeout(() => {
throttledCheckAndLoadMore();
}, 500); // Small delay to allow initial layout
}
}, [data, hasMore, throttledCheckAndLoadMore, autoLoadOnViewport]);
if (error) {
return (
<div
className="h-full flex items-center justify-center"
style={{ backgroundColor: colors.background.primary }}
>
<div className="rounded-xl overflow-hidden">
{/* Glass effect background */}
<GlassMenuEffect rounded="rounded-xl" />
<div className="relative z-10 text-slate-200 px-6 py-4">
Error loading documents: {error.message}
</div>
</div>
</div>
);
}
return (
<div
className="h-full rounded-xl overflow-hidden"
style={{ backgroundColor: colors.background.primary }}
>
{/* Spaces selector - only shown for console */}
{finalShowSpacesSelector && availableSpaces.length > 0 && (
<div className="absolute top-4 left-4 z-10">
<SpacesDropdown
availableSpaces={availableSpaces}
onSpaceChange={setSelectedSpace}
selectedSpace={selectedSpace}
spaceMemoryCounts={spaceMemoryCounts}
/>
</div>
)}
{/* Loading indicator */}
<LoadingIndicator
isLoading={isLoading}
isLoadingMore={isLoadingMore}
totalLoaded={totalLoaded}
variant={variant}
/>
{/* Legend */}
<Legend
edges={edges}
id={legendId}
isLoading={isLoading}
nodes={nodes}
variant={variant}
/>
{/* Node detail panel */}
<AnimatePresence>
{selectedNodeData && (
<NodeDetailPanel
node={selectedNodeData}
onClose={() => setSelectedNode(null)}
variant={variant}
/>
)}
</AnimatePresence>
{/* Show welcome screen when no memories exist */}
{!isLoading &&
(!data || nodes.filter((n) => n.type === "document").length === 0) && (
<>{children}</>
)}
{/* Graph container */}
<div
className="w-full h-full relative overflow-hidden"
ref={containerRef}
style={{
touchAction: "none",
userSelect: "none",
WebkitUserSelect: "none",
}}
>
{(containerSize.width > 0 && containerSize.height > 0) && (
<GraphCanvas
draggingNodeId={draggingNodeId}
edges={edges}
height={containerSize.height}
nodes={nodes}
highlightDocumentIds={highlightsVisible ? highlightDocumentIds : []}
onDoubleClick={handleDoubleClick}
onNodeClick={handleNodeClick}
onNodeDragEnd={handleNodeDragEnd}
onNodeDragMove={handleNodeDragMove}
onNodeDragStart={handleNodeDragStartWithNodes}
onNodeHover={handleNodeHover}
onPanEnd={handlePanEnd}
onPanMove={handlePanMove}
onPanStart={handlePanStart}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onWheel={handleWheel}
panX={panX}
panY={panY}
width={containerSize.width}
zoom={zoom}
/>
)}
{/* Navigation controls */}
{containerSize.width > 0 && (
<NavigationControls
onCenter={handleCenter}
onZoomIn={() => zoomIn(containerSize.width / 2, containerSize.height / 2)}
onZoomOut={() => zoomOut(containerSize.width / 2, containerSize.height / 2)}
onAutoFit={handleAutoFit}
nodes={nodes}
className="absolute bottom-4 left-4"
/>
)}
</div>
</div>
);
};

View file

@ -0,0 +1,67 @@
"use client"
import { memo } from "react"
import type { GraphNode } from "./types"
interface NavigationControlsProps {
onCenter: () => void
onZoomIn: () => void
onZoomOut: () => void
onAutoFit: () => void
nodes: GraphNode[]
className?: string
}
export const NavigationControls = memo<NavigationControlsProps>(({
onCenter,
onZoomIn,
onZoomOut,
onAutoFit,
nodes,
className = "",
}) => {
if (nodes.length === 0) {
return null
}
return (
<div className={`flex flex-col gap-1 ${className}`}>
<button
type="button"
onClick={onAutoFit}
className="bg-black/20 backdrop-blur-sm hover:bg-black/30 border border-white/10 hover:border-white/20 rounded-lg p-2 text-white/70 hover:text-white transition-colors text-xs font-medium min-w-16"
title="Auto-fit graph to viewport"
>
Fit
</button>
<button
type="button"
onClick={onCenter}
className="bg-black/20 backdrop-blur-sm hover:bg-black/30 border border-white/10 hover:border-white/20 rounded-lg p-2 text-white/70 hover:text-white transition-colors text-xs font-medium min-w-16"
title="Center view on graph"
>
Center
</button>
<div className="flex flex-col">
<button
type="button"
onClick={onZoomIn}
className="bg-black/20 backdrop-blur-sm hover:bg-black/30 border border-white/10 hover:border-white/20 rounded-t-lg p-2 text-white/70 hover:text-white transition-colors text-xs font-medium min-w-16 border-b-0"
title="Zoom in"
>
+
</button>
<button
type="button"
onClick={onZoomOut}
className="bg-black/20 backdrop-blur-sm hover:bg-black/30 border border-white/10 hover:border-white/20 rounded-b-lg p-2 text-white/70 hover:text-white transition-colors text-xs font-medium min-w-16"
title="Zoom out"
>
</button>
</div>
</div>
)
})
NavigationControls.displayName = "NavigationControls"

View file

@ -0,0 +1,268 @@
"use client";
import { cn } from "@repo/lib/utils";
import { Badge } from "@repo/ui/components/badge";
import { Button } from "@repo/ui/components/button";
import { GlassMenuEffect } from "@repo/ui/other/glass-effect";
import { Brain, Calendar, ExternalLink, FileText, Hash, X } from "lucide-react";
import { motion } from "motion/react";
import { memo } from "react";
import {
GoogleDocs,
GoogleDrive,
GoogleSheets,
GoogleSlides,
MicrosoftExcel,
MicrosoftOneNote,
MicrosoftPowerpoint,
MicrosoftWord,
NotionDoc,
OneDrive,
PDF,
} from "../assets/icons";
import { HeadingH3Bold } from "../text/heading/heading-h3-bold";
import type {
DocumentWithMemories,
MemoryEntry,
NodeDetailPanelProps,
} from "./types";
const formatDocumentType = (type: string) => {
// Special case for PDF
if (type.toLowerCase() === "pdf") return "PDF";
// Replace underscores with spaces and capitalize each word
return type
.split("_")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(" ");
};
const getDocumentIcon = (type: string) => {
const iconProps = { className: "w-5 h-5 text-slate-300" };
switch (type) {
case "google_doc":
return <GoogleDocs {...iconProps} />;
case "google_sheet":
return <GoogleSheets {...iconProps} />;
case "google_slide":
return <GoogleSlides {...iconProps} />;
case "google_drive":
return <GoogleDrive {...iconProps} />;
case "notion":
case "notion_doc":
return <NotionDoc {...iconProps} />;
case "word":
case "microsoft_word":
return <MicrosoftWord {...iconProps} />;
case "excel":
case "microsoft_excel":
return <MicrosoftExcel {...iconProps} />;
case "powerpoint":
case "microsoft_powerpoint":
return <MicrosoftPowerpoint {...iconProps} />;
case "onenote":
case "microsoft_onenote":
return <MicrosoftOneNote {...iconProps} />;
case "onedrive":
return <OneDrive {...iconProps} />;
case "pdf":
return <PDF {...iconProps} />;
default:
return <FileText {...iconProps} />;
}
};
export const NodeDetailPanel = memo<NodeDetailPanelProps>(
({ node, onClose, variant = "console" }) => {
// Use explicit classes that Tailwind can detect
const getPositioningClasses = () => {
// Both variants use the same positioning for nodeDetail
return "top-4 right-4";
};
if (!node) return null;
const isDocument = node.type === "document";
const data = node.data;
return (
<motion.div
animate={{ opacity: 1 }}
className={cn(
"absolute w-80 rounded-xl overflow-hidden z-20 max-h-[80vh]",
getPositioningClasses(),
)}
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
transition={{
duration: 0.2,
ease: "easeInOut",
}}
>
{/* Glass effect background */}
<GlassMenuEffect rounded="rounded-xl" />
<motion.div
animate={{ opacity: 1 }}
className="relative z-10 p-4 overflow-y-auto max-h-[80vh]"
initial={{ opacity: 0 }}
transition={{ delay: 0.05, duration: 0.15 }}
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
{isDocument ? (
getDocumentIcon((data as DocumentWithMemories).type)
) : (
<Brain className="w-5 h-5 text-blue-400" />
)}
<HeadingH3Bold className="text-slate-100">
{isDocument ? "Document" : "Memory"}
</HeadingH3Bold>
</div>
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
<Button
className="h-8 w-8 p-0 text-slate-300 hover:text-slate-100"
onClick={onClose}
size="sm"
variant="ghost"
>
<X className="w-4 h-4" />
</Button>
</motion.div>
</div>
<div className="space-y-3">
{isDocument ? (
<>
<div>
<span className="text-xs text-slate-400 uppercase tracking-wide">
Title
</span>
<p className="text-sm text-slate-200 mt-1">
{(data as DocumentWithMemories).title ||
"Untitled Document"}
</p>
</div>
{(data as DocumentWithMemories).summary && (
<div>
<span className="text-xs text-slate-400 uppercase tracking-wide">
Summary
</span>
<p className="text-sm text-slate-300 mt-1 line-clamp-3">
{(data as DocumentWithMemories).summary}
</p>
</div>
)}
<div>
<span className="text-xs text-slate-400 uppercase tracking-wide">
Type
</span>
<p className="text-sm text-slate-200 mt-1">
{formatDocumentType((data as DocumentWithMemories).type)}
</p>
</div>
<div>
<span className="text-xs text-slate-400 uppercase tracking-wide">
Memory Count
</span>
<p className="text-sm text-slate-200 mt-1">
{(data as DocumentWithMemories).memoryEntries.length}{" "}
memories
</p>
</div>
{((data as DocumentWithMemories).url ||
(data as DocumentWithMemories).customId) && (
<div>
<span className="text-xs text-slate-400 uppercase tracking-wide">
URL
</span>
<a
className="text-sm text-indigo-400 hover:text-indigo-300 mt-1 flex items-center gap-1"
href={(() => {
const doc = data as DocumentWithMemories;
if (doc.type === "google_doc" && doc.customId) {
return `https://docs.google.com/document/d/${doc.customId}`;
}
if (doc.type === "google_sheet" && doc.customId) {
return `https://docs.google.com/spreadsheets/d/${doc.customId}`;
}
if (doc.type === "google_slide" && doc.customId) {
return `https://docs.google.com/presentation/d/${doc.customId}`;
}
return doc.url ?? undefined;
})()}
rel="noopener noreferrer"
target="_blank"
>
<ExternalLink className="w-3 h-3" />
View Document
</a>
</div>
)}
</>
) : (
<>
<div>
<span className="text-xs text-slate-400 uppercase tracking-wide">
Memory
</span>
<p className="text-sm text-slate-200 mt-1">
{(data as MemoryEntry).memory}
</p>
{(data as MemoryEntry).isForgotten && (
<Badge className="mt-2" variant="destructive">
Forgotten
</Badge>
)}
{(data as MemoryEntry).forgetAfter && (
<p className="text-xs text-slate-400 mt-1">
Expires:{" "}
{(data as MemoryEntry).forgetAfter
? new Date(
(data as MemoryEntry).forgetAfter!,
).toLocaleDateString()
: ""}{" "}
{"forgetReason" in data &&
data.forgetReason &&
`- ${data.forgetReason}`}
</p>
)}
</div>
<div>
<span className="text-xs text-slate-400 uppercase tracking-wide">
Space
</span>
<p className="text-sm text-slate-200 mt-1">
{(data as MemoryEntry).spaceId || "Default"}
</p>
</div>
</>
)}
<div className="pt-2 border-t border-slate-700/50">
<div className="flex items-center gap-4 text-xs text-slate-400">
<span className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
{new Date(data.createdAt).toLocaleDateString()}
</span>
<span className="flex items-center gap-1">
<Hash className="w-3 h-3" />
{node.id}
</span>
</div>
</div>
</div>
</motion.div>
</motion.div>
);
},
);
NodeDetailPanel.displayName = "NodeDetailPanel";

View file

@ -0,0 +1,120 @@
"use client";
import { cn } from "@repo/lib/utils";
import { Badge } from "@repo/ui/components/badge";
import { ChevronDown, Eye } from "lucide-react";
import { memo, useEffect, useRef, useState } from "react";
import type { SpacesDropdownProps } from "./types";
export const SpacesDropdown = memo<SpacesDropdownProps>(
({ selectedSpace, availableSpaces, spaceMemoryCounts, onSpaceChange }) => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () =>
document.removeEventListener("mousedown", handleClickOutside);
}, []);
const totalMemories = Object.values(spaceMemoryCounts).reduce(
(sum, count) => sum + count,
0,
);
return (
<div className="relative" ref={dropdownRef}>
<button
className={cn(
"flex items-center gap-3 px-4 py-3 rounded-xl border-2 border-solid border-transparent",
"[background-image:linear-gradient(#1a1f29,_#1a1f29),_linear-gradient(150.262deg,_#A4E8F5_0%,_#267FFA_26%,_#464646_49%,_#747474_70%,_#A4E8F5_100%)]",
"[background-origin:border-box] [background-clip:padding-box,_border-box]",
"shadow-[inset_0px_2px_1px_rgba(84,84,84,0.15)] backdrop-blur-md",
"transition-all duration-200 hover:shadow-[inset_0px_2px_1px_rgba(84,84,84,0.25)]",
"cursor-pointer min-w-60",
)}
onClick={() => setIsOpen(!isOpen)}
type="button"
>
<Eye className="w-4 h-4 text-slate-300" />
<div className="flex-1 text-left">
<span className="text-sm text-slate-200 font-medium">
{selectedSpace === "all"
? "All Spaces"
: selectedSpace || "Select space"}
</span>
<div className="text-xs text-slate-400">
{selectedSpace === "all"
? `${totalMemories} total memories`
: `${spaceMemoryCounts[selectedSpace] || 0} memories`}
</div>
</div>
<ChevronDown
className={cn(
"w-4 h-4 text-slate-300 transition-transform duration-200",
isOpen && "rotate-180",
)}
/>
</button>
{isOpen && (
<div className="absolute top-full left-0 right-0 mt-2 bg-slate-900/95 backdrop-blur-md border border-slate-700/40 rounded-xl shadow-xl z-20 overflow-hidden">
<div className="p-1">
<button
className={cn(
"w-full flex items-center justify-between px-3 py-2 rounded-lg text-left transition-colors",
selectedSpace === "all"
? "bg-blue-500/20 text-blue-300"
: "text-slate-200 hover:bg-slate-700/50",
)}
onClick={() => {
onSpaceChange("all");
setIsOpen(false);
}}
type="button"
>
<span className="text-sm">All Spaces</span>
<Badge className="bg-slate-700/50 text-slate-300 text-xs">
{totalMemories}
</Badge>
</button>
{availableSpaces.map((space) => (
<button
className={cn(
"w-full flex items-center justify-between px-3 py-2 rounded-lg text-left transition-colors",
selectedSpace === space
? "bg-blue-500/20 text-blue-300"
: "text-slate-200 hover:bg-slate-700/50",
)}
key={space}
onClick={() => {
onSpaceChange(space);
setIsOpen(false);
}}
type="button"
>
<span className="text-sm truncate flex-1">{space}</span>
<Badge className="bg-slate-700/50 text-slate-300 text-xs ml-2">
{spaceMemoryCounts[space] || 0}
</Badge>
</button>
))}
</div>
</div>
)}
</div>
);
},
);
SpacesDropdown.displayName = "SpacesDropdown";

View file

@ -0,0 +1,122 @@
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api";
import type { z } from "zod";
export type DocumentsResponse = z.infer<
typeof DocumentsWithMemoriesResponseSchema
>;
export type DocumentWithMemories = DocumentsResponse["documents"][0];
export type MemoryEntry = DocumentWithMemories["memoryEntries"][0];
export interface GraphNode {
id: string;
type: "document" | "memory";
x: number;
y: number;
data: DocumentWithMemories | MemoryEntry;
size: number;
color: string;
isHovered: boolean;
isDragging: boolean;
}
export type MemoryRelation = "updates" | "extends" | "derives";
export interface GraphEdge {
id: string;
source: string;
target: string;
similarity: number;
visualProps: {
opacity: number;
thickness: number;
glow: number;
pulseDuration: number;
};
color: string;
edgeType: "doc-memory" | "doc-doc" | "version";
relationType?: MemoryRelation;
}
export interface SpacesDropdownProps {
selectedSpace: string;
availableSpaces: string[];
spaceMemoryCounts: Record<string, number>;
onSpaceChange: (space: string) => void;
}
export interface NodeDetailPanelProps {
node: GraphNode | null;
onClose: () => void;
variant?: "console" | "consumer";
}
export interface GraphCanvasProps {
nodes: GraphNode[];
edges: GraphEdge[];
panX: number;
panY: number;
zoom: number;
width: number;
height: number;
onNodeHover: (nodeId: string | null) => void;
onNodeClick: (nodeId: string) => void;
onNodeDragStart: (nodeId: string, e: React.MouseEvent) => void;
onNodeDragMove: (e: React.MouseEvent) => void;
onNodeDragEnd: () => void;
onPanStart: (e: React.MouseEvent) => void;
onPanMove: (e: React.MouseEvent) => void;
onPanEnd: () => void;
onWheel: (e: React.WheelEvent) => void;
onDoubleClick: (e: React.MouseEvent) => void;
onTouchStart?: (e: React.TouchEvent) => void;
onTouchMove?: (e: React.TouchEvent) => void;
onTouchEnd?: (e: React.TouchEvent) => void;
draggingNodeId: string | null;
// Optional list of document IDs (customId or internal id) to highlight
highlightDocumentIds?: string[];
}
export interface MemoryGraphProps {
children?: React.ReactNode;
documents: DocumentWithMemories[];
isLoading: boolean;
isLoadingMore: boolean;
error: Error | null;
totalLoaded: number;
hasMore: boolean;
loadMoreDocuments: () => Promise<void>;
// App-specific props
showSpacesSelector?: boolean; // true for console, false for consumer
variant?: "console" | "consumer"; // for different positioning and styling
legendId?: string; // Optional ID for the legend component
// Optional document highlight list (document custom IDs)
highlightDocumentIds?: string[];
// Whether highlights are currently visible (e.g., chat open)
highlightsVisible?: boolean;
// Pixels occluded on the right side of the viewport (e.g., chat panel)
occludedRightPx?: number;
// Whether to auto-load more documents based on viewport visibility
autoLoadOnViewport?: boolean;
}
export interface LegendProps {
variant?: "console" | "consumer";
nodes?: GraphNode[];
edges?: GraphEdge[];
isLoading?: boolean;
hoveredNode?: string | null;
}
export interface LoadingIndicatorProps {
isLoading: boolean;
isLoadingMore: boolean;
totalLoaded: number;
variant?: "console" | "consumer";
}
export interface ControlsProps {
onZoomIn: () => void;
onZoomOut: () => void;
onResetView: () => void;
variant?: "console" | "consumer";
}