mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
use latest graph and remove old graph (#604)
This commit is contained in:
parent
1ff6b7f951
commit
7a2f2cb99c
25 changed files with 46 additions and 4564 deletions
|
|
@ -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/memory-graph/constants"
|
||||
import { colors } from "@repo/ui/colors"
|
||||
import { getPastelBackgroundColor } from "../memories-utils"
|
||||
|
||||
interface GoogleDocsCardProps {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import {
|
|||
AlertDialogTrigger,
|
||||
} from "@repo/ui/components/alert-dialog"
|
||||
|
||||
import { colors } from "@repo/ui/memory-graph/constants"
|
||||
import { colors } from "@repo/ui/colors"
|
||||
import { Brain, ExternalLink, Trash2 } from "lucide-react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { useState } from "react"
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ import {
|
|||
AlertDialogTrigger,
|
||||
} from "@repo/ui/components/alert-dialog"
|
||||
import { Brain, Trash2 } from "lucide-react"
|
||||
import { colors } from "@repo/ui/memory-graph/constants"
|
||||
import { colors } from "@repo/ui/colors"
|
||||
import { getPastelBackgroundColor } from "../memories-utils"
|
||||
|
||||
type MyTweetProps = {
|
||||
|
|
|
|||
|
|
@ -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/memory-graph/constants"
|
||||
import { colors } from "@repo/ui/colors"
|
||||
|
||||
interface WebsiteCardProps {
|
||||
title: string
|
||||
|
|
|
|||
|
|
@ -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 "@repo/ui/memory-graph"
|
||||
import { MemoryGraph } from "@supermemory/memory-graph"
|
||||
import { Dialog, DialogContent } from "@repo/ui/components/dialog"
|
||||
import { ConnectAIModal } from "@/components/connect-ai-modal"
|
||||
import { AddMemoryView } from "@/components/views/add-memory"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
|
||||
import { colors } from "@repo/ui/memory-graph/constants"
|
||||
import { colors } from "@repo/ui/colors"
|
||||
import { Sparkles } from "lucide-react"
|
||||
import { Masonry, useInfiniteLoader } from "masonic"
|
||||
import { memo, useCallback, useMemo, useState } from "react"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { DocumentWithMemories } from "@ui/memory-graph/types"
|
||||
import type { DocumentWithMemories } from "@supermemory/memory-graph"
|
||||
|
||||
export const formatDate = (date: string | Date) => {
|
||||
const dateObj = new Date(date)
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import {
|
|||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@repo/ui/components/alert-dialog"
|
||||
import { colors } from "@repo/ui/memory-graph/constants"
|
||||
import { colors } from "@repo/ui/colors"
|
||||
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
|
||||
import { useVirtualizer } from "@tanstack/react-virtual"
|
||||
import { Brain, ExternalLink, Sparkles, Trash2 } from "lucide-react"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { MCPIcon } from "@/components/menu"
|
||||
import { colors } from "@repo/ui/memory-graph/constants"
|
||||
import { colors } from "@repo/ui/colors"
|
||||
import {
|
||||
GoogleDocs,
|
||||
MicrosoftWord,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
"@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",
|
||||
|
|
|
|||
|
|
@ -47,54 +47,4 @@ 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",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
"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";
|
||||
|
|
@ -1,762 +0,0 @@
|
|||
"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";
|
||||
|
|
@ -1,794 +0,0 @@
|
|||
"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";
|
||||
|
|
@ -1,304 +0,0 @@
|
|||
"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]);
|
||||
}
|
||||
|
|
@ -1,564 +0,0 @@
|
|||
"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,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
// 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";
|
||||
|
|
@ -1,311 +0,0 @@
|
|||
"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";
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
"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";
|
||||
|
|
@ -1,458 +0,0 @@
|
|||
"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>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
"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"
|
||||
|
|
@ -1,268 +0,0 @@
|
|||
"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";
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
"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";
|
||||
|
|
@ -1,122 +0,0 @@
|
|||
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";
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue