Merge pull request #1 from vrupak/experiment/graph-ui-improvements

Experiment/graph UI improvements
This commit is contained in:
Vidya Rupak 2025-12-22 18:07:44 -07:00 committed by GitHub
commit 47a1bc764e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1948 additions and 905 deletions

2
.gitignore vendored
View file

@ -40,6 +40,6 @@ yarn-error.log*
# Misc
.DS_Store
*.pem
.claude
.venv
__pycache__

View file

@ -29,6 +29,10 @@ export default function Home() {
// State for controlled space selection
const [selectedSpace, setSelectedSpace] = useState<string>("all")
// State for slideshow
const [isSlideshowActive, setIsSlideshowActive] = useState(false)
const [currentSlideshowNode, setCurrentSlideshowNode] = useState<string | null>(null)
const PAGE_SIZE = 500
const fetchDocuments = useCallback(
@ -109,6 +113,23 @@ export default function Home() {
setSelectedSpace("all")
}
// Toggle slideshow
const handleToggleSlideshow = () => {
setIsSlideshowActive((prev) => !prev)
}
// Handle slideshow node change
const handleSlideshowNodeChange = useCallback((nodeId: string | null) => {
// Track which node is being shown in slideshow
setCurrentSlideshowNode(nodeId)
console.log("Slideshow showing node:", nodeId)
}, [])
// Handle slideshow stop (when user clicks outside)
const handleSlideshowStop = useCallback(() => {
setIsSlideshowActive(false)
}, [])
return (
<div className="flex flex-col h-screen bg-zinc-950">
{/* Header */}
@ -158,12 +179,49 @@ export default function Home() {
</span>
</div>
</div>
<button
onClick={handleReset}
className="rounded-lg border border-zinc-700 px-3 py-1 text-xs font-medium text-zinc-300 transition-colors hover:bg-zinc-800"
>
Reset Filters
</button>
<div className="flex items-center gap-3">
<button
onClick={handleToggleSlideshow}
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex items-center gap-1.5 ${
isSlideshowActive
? "bg-blue-600 text-white hover:bg-blue-700"
: "border border-zinc-700 text-zinc-300 hover:bg-zinc-800"
}`}
>
{isSlideshowActive ? (
<>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="currentColor"
>
<rect x="6" y="6" width="12" height="12" />
</svg>
Slideshow
</>
) : (
<>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M8 5v14l11-7z" />
</svg>
Slideshow
</>
)}
</button>
<div className="h-6 w-px bg-zinc-700" />
<button
onClick={handleReset}
className="rounded-lg border border-zinc-700 px-3 py-1.5 text-xs font-medium text-zinc-300 transition-colors hover:bg-zinc-800"
>
Reset Filters
</button>
</div>
</div>
</div>
)}
@ -225,6 +283,10 @@ export default function Home() {
// Controlled space selection
selectedSpace={selectedSpace}
onSpaceChange={handleSpaceChange}
// Slideshow control
isSlideshowActive={isSlideshowActive}
onSlideshowNodeChange={handleSlideshowNodeChange}
onSlideshowStop={handleSlideshowStop}
>
<div className="flex h-full items-center justify-center">
<p className="text-zinc-400">

772
bun.lock

File diff suppressed because it is too large Load diff

View file

@ -31,7 +31,7 @@
"@scalar/hono-api-reference": "^0.9.11",
"@vanilla-extract/recipes": "^0.5.7",
"ai": "^5.0.59",
"alchemy": "^0.55.2",
"alchemy": "^0.81.4",
"atmn": "^0.0.16",
"better-auth": "^1.3.3",
"boxen": "^8.0.1",
@ -61,7 +61,7 @@
"drizzle-kit": "^0.31.4",
"turbo": "^2.5.4",
"typescript": "5.8.3",
"wrangler": "4.22.0"
"wrangler": "^4.42.2"
},
"workerd": {
"import": "./esm/index.mjs",

View file

@ -0,0 +1,83 @@
# Memory Graph Changelog
## Development Setup
To test changes, run these commands in separate terminals:
**Terminal 1** - Build memory-graph in watch mode:
```bash
cd packages/memory-graph && bun run dev
```
**Terminal 2** - Run the playground:
```bash
cd apps/memory-graph-playground && bun run dev
```
Then open http://localhost:3000 in your browser.
---
### Features
#### Slideshow Mode
Auto-cycling through nodes with smooth animations and physics simulation
- Random node selection every 3.5s (avoids consecutive duplicates)
- Smooth pan-to-node animation with automatic popover
- Brief physics pulse (1s) on each selection
- Background dimming animation
- Single-click to stop
#### Node Popover with Background Dimming
Floating popover with smart positioning and focus dimming effect
- Smooth 1.5s cubic ease-out dimming animation
- Non-selected nodes: 20% opacity, unconnected edges: 10% opacity
- Smart edge detection with 20px gap from node
- Auto-flips to avoid viewport edges
- Close via backdrop click, X button, or Escape key
- Shows: title, summary, type, memory count, URL, date, ID
#### Document Type Icons
Canvas-rendered icons centered on document cards
- Supported: TXT, PDF, MD, DOC/DOCX, RTF, CSV, JSON
- Scales with card size (40% of height)
- Only renders when zoomed in
#### Physics-Driven Layout
Simplified initial positioning, letting physics create natural layouts
- Simple grid with random offsets (no concentric rings)
- 50 quick pre-ticks + smooth animation
- Eliminates teleportation on node interaction
- Faster, non-blocking initial render
#### Updated Color Scheme
Refined palette for better contrast and readability
### Bug Fixes
#### Edge Viewport Culling
Fixed edges disappearing during zoom/pan
- Now checks both X and Y axis bounds
- Only culls when BOTH endpoints off-screen in same direction
- 100px margin on all sides
#### Memory Nodes Follow Parents
Memory nodes now move with parent documents when dragged
- Store relative offset instead of absolute position
- Automatically repositions based on parent location
### Performance
#### k-NN Similarity Algorithm
Reduced from O(n²) to O(n·k)
- 3x faster: ~50ms → ~17ms for 100 docs
- 4,950 → 1,500 comparisons for 100 docs
- Separated into own memo (doesn't recalculate on UI interactions)
#### Memory Leak Fix
NodeCache now cleans up deleted nodes properly
#### Race Condition Fix
Atomic node/edge updates eliminate NaN positions
---

View file

@ -62,9 +62,11 @@
"@emotion/is-prop-valid": "^1.4.0",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-slot": "^1.2.4",
"@supermemory/memory-graph": "^0.1.7",
"@vanilla-extract/css": "^1.17.4",
"@vanilla-extract/recipes": "^0.5.7",
"@vanilla-extract/sprinkles": "^1.6.5",
"d3-force": "^3.0.0",
"lucide-react": "^0.552.0",
"motion": "^12.23.24"
},

View file

@ -7,14 +7,16 @@ import {
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react"
import { colors } from "@/constants"
import { colors, ANIMATION } from "@/constants"
import type {
DocumentWithMemories,
GraphCanvasProps,
GraphNode,
MemoryEntry,
} from "@/types"
import { drawDocumentIcon } from "@/utils/document-icons"
import { canvasWrapper } from "./canvas-common.css"
export const GraphCanvas = memo<GraphCanvasProps>(
@ -41,18 +43,58 @@ export const GraphCanvas = memo<GraphCanvasProps>(
onTouchEnd,
draggingNodeId,
highlightDocumentIds,
isSimulationActive = false,
selectedNodeId = null,
}) => {
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)
const dimProgress = useRef<number>(selectedNodeId ? 1 : 0)
const dimAnimationRef = useRef<number>(0)
const [, forceRender] = useState(0)
// Initialize start time once
useEffect(() => {
startTimeRef.current = Date.now()
}, [])
// Smooth dimming animation
useEffect(() => {
const targetDim = selectedNodeId ? 1 : 0
const duration = ANIMATION.dimDuration // Match physics settling time
const startDim = dimProgress.current
const startTime = Date.now()
const animate = () => {
const elapsed = Date.now() - startTime
const progress = Math.min(elapsed / duration, 1)
// Ease-out cubic easing for smooth deceleration
const eased = 1 - Math.pow(1 - progress, 3)
dimProgress.current = startDim + (targetDim - startDim) * eased
// Force re-render to update canvas during animation
forceRender(prev => prev + 1)
if (progress < 1) {
dimAnimationRef.current = requestAnimationFrame(animate)
}
}
if (dimAnimationRef.current) {
cancelAnimationFrame(dimAnimationRef.current)
}
animate()
return () => {
if (dimAnimationRef.current) {
cancelAnimationFrame(dimAnimationRef.current)
}
}
}, [selectedNodeId])
// Efficient hit detection
const getNodeAtPosition = useCallback(
(x: number, y: number): string | null => {
@ -63,12 +105,30 @@ export const GraphCanvas = memo<GraphCanvasProps>(
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 (node.type === "document") {
// Rectangular hit detection for documents (matches visual size)
const docWidth = nodeSize * 1.4
const docHeight = nodeSize * 0.9
const halfW = docWidth / 2
const halfH = docHeight / 2
if (distance <= nodeSize / 2) {
return node.id
if (
x >= screenX - halfW &&
x <= screenX + halfW &&
y >= screenY - halfH &&
y <= screenY + halfH
) {
return node.id
}
} else {
// Circular hit detection for memory nodes
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
@ -188,8 +248,15 @@ export const GraphCanvas = memo<GraphCanvasProps>(
// Draw enhanced edges with sophisticated styling
ctx.lineCap = "round"
edges.forEach((edge) => {
const sourceNode = nodeMap.get(edge.source)
const targetNode = nodeMap.get(edge.target)
// Handle both string IDs and node references (d3-force mutates these)
const sourceNode =
typeof edge.source === "string"
? nodeMap.get(edge.source)
: edge.source
const targetNode =
typeof edge.target === "string"
? nodeMap.get(edge.target)
: edge.target
if (sourceNode && targetNode) {
const sourceX = sourceNode.x * zoom + panX
@ -197,12 +264,14 @@ export const GraphCanvas = memo<GraphCanvasProps>(
const targetX = targetNode.x * zoom + panX
const targetY = targetNode.y * zoom + panY
// Enhanced viewport culling with edge type considerations
// Enhanced viewport culling with proper X and Y axis bounds checking
// Only cull edges when BOTH endpoints are off-screen in the same direction
const edgeMargin = 100
if (
sourceX < -100 ||
sourceX > width + 100 ||
targetX < -100 ||
targetX > width + 100
(sourceX < -edgeMargin && targetX < -edgeMargin) ||
(sourceX > width + edgeMargin && targetX > width + edgeMargin) ||
(sourceY < -edgeMargin && targetY < -edgeMargin) ||
(sourceY > height + edgeMargin && targetY > height + edgeMargin)
) {
return
}
@ -217,22 +286,29 @@ export const GraphCanvas = memo<GraphCanvasProps>(
}
}
// Check if edge should be dimmed (not connected to selected node)
const edgeShouldDim = selectedNodeId !== null &&
sourceNode.id !== selectedNodeId &&
targetNode.id !== selectedNodeId
// Smooth edge opacity: interpolate between full and 0.1 (dimmed)
const edgeDimOpacity = 1 - (dimProgress.current * 0.9)
// Enhanced connection styling based on edge type
let connectionColor = colors.connection.weak
let dashPattern: number[] = []
let opacity = edge.visualProps.opacity
let opacity = edgeShouldDim ? edgeDimOpacity : 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
opacity = edgeShouldDim ? edgeDimOpacity : 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)
opacity = edgeShouldDim ? edgeDimOpacity : Math.max(0, edge.similarity * 0.5)
lineWidth = Math.max(1, edge.similarity * 2) // Thicker for stronger similarity
if (edge.similarity > 0.85)
@ -243,7 +319,7 @@ export const GraphCanvas = memo<GraphCanvasProps>(
// Version chains: Double line effect with relation-specific colors
dashPattern = []
connectionColor = edge.color || colors.relations.updates
opacity = 0.8
opacity = edgeShouldDim ? edgeDimOpacity : 0.8
lineWidth = 2
}
@ -360,6 +436,10 @@ export const GraphCanvas = memo<GraphCanvasProps>(
const isHovered = currentHoveredNode.current === node.id
const isDragging = node.isDragging
const isSelected = selectedNodeId === node.id
const shouldDim = selectedNodeId !== null && !isSelected
// Smooth opacity: interpolate between 1 (full) and 0.2 (dimmed) based on animation progress
const nodeOpacity = shouldDim ? 1 - (dimProgress.current * 0.8) : 1
const isHighlightedDocument = (() => {
if (node.type !== "document" || highlightSet.size === 0) return false
const doc = node.data as DocumentWithMemories
@ -378,7 +458,7 @@ export const GraphCanvas = memo<GraphCanvasProps>(
: isHovered
? colors.document.secondary
: colors.document.primary
ctx.globalAlpha = 1
ctx.globalAlpha = nodeOpacity
// Enhanced border with subtle glow
ctx.strokeStyle = isDragging
@ -423,7 +503,9 @@ export const GraphCanvas = memo<GraphCanvasProps>(
ctx.strokeStyle = colors.accent.primary
ctx.lineWidth = 3
ctx.setLineDash([6, 4])
const ringPadding = 10
// Add equal padding on all sides (15% of average dimension)
const avgDimension = (docWidth + docHeight) / 2
const ringPadding = avgDimension * 0.1
ctx.beginPath()
ctx.roundRect(
screenX - docWidth / 2 - ringPadding,
@ -436,6 +518,21 @@ export const GraphCanvas = memo<GraphCanvasProps>(
ctx.setLineDash([])
ctx.restore()
}
// Draw document type icon (centered)
if (!useSimplifiedRendering) {
const doc = node.data as DocumentWithMemories
const iconSize = docHeight * 0.4 // Icon size relative to card height
drawDocumentIcon(
ctx,
screenX,
screenY,
iconSize,
doc.type || "text",
"rgba(255, 255, 255, 0.8)",
)
}
} else {
// Enhanced memory styling with status indicators
const mem = node.data as MemoryEntry
@ -484,7 +581,7 @@ export const GraphCanvas = memo<GraphCanvasProps>(
const radius = nodeSize / 2
ctx.fillStyle = fillColor
ctx.globalAlpha = isLatest ? 1 : 0.4
ctx.globalAlpha = shouldDim ? nodeOpacity : (isLatest ? 1 : 0.4)
ctx.strokeStyle = borderColor
ctx.lineWidth = isDragging ? 3 : isHovered ? 2 : 1.5
@ -571,18 +668,23 @@ export const GraphCanvas = memo<GraphCanvasProps>(
ctx.globalAlpha = 0.6
ctx.beginPath()
const glowSize = nodeSize * 0.7
if (node.type === "document") {
// Use actual document dimensions for glow
const docWidth = nodeSize * 1.4
const docHeight = nodeSize * 0.9
// Make glow 10% larger than document
const avgDimension = (docWidth + docHeight) / 2
const glowPadding = avgDimension * 0.1
ctx.roundRect(
screenX - glowSize,
screenY - glowSize / 1.4,
glowSize * 2,
glowSize * 1.4,
screenX - docWidth / 2 - glowPadding,
screenY - docHeight / 2 - glowPadding,
docWidth + glowPadding * 2,
docHeight + glowPadding * 2,
15,
)
} else {
// Hexagonal glow for memory nodes
const glowRadius = glowSize
const glowRadius = nodeSize * 0.7
const sides = 6
for (let i = 0; i < sides; i++) {
const angle = (i * 2 * Math.PI) / sides - Math.PI / 2
@ -604,7 +706,7 @@ export const GraphCanvas = memo<GraphCanvasProps>(
ctx.globalAlpha = 1
}, [nodes, edges, panX, panY, zoom, width, height, highlightDocumentIds])
// Change-based rendering instead of continuous animation
// Hybrid rendering: continuous when simulation active, change-based when idle
const lastRenderParams = useRef<string>("")
// Create a render key that changes when visual state changes
@ -628,13 +730,28 @@ export const GraphCanvas = memo<GraphCanvasProps>(
highlightDocumentIds,
])
// Only render when something actually changed
// Render based on simulation state
useEffect(() => {
if (isSimulationActive) {
// Continuous rendering during physics simulation
const renderLoop = () => {
render()
animationRef.current = requestAnimationFrame(renderLoop)
}
renderLoop()
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current)
}
}
}
// Change-based rendering when simulation is idle
if (renderKey !== lastRenderParams.current) {
lastRenderParams.current = renderKey
render()
}
}, [renderKey, render])
}, [isSimulationActive, renderKey, render])
// Cleanup any existing animation frames
useEffect(() => {

View file

@ -213,48 +213,66 @@ export const legendText = style({
/**
* Shape styles
*/
export const hexagon = style({
clipPath: "polygon(50% 0%, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)",
})
export const documentNode = style({
width: "1rem",
height: "0.75rem",
background: "rgba(255, 255, 255, 0.08)",
border: "1px solid rgba(255, 255, 255, 0.25)",
background: "rgba(255, 255, 255, 0.21)",
border: "1px solid rgba(255, 255, 255, 0.6)",
borderRadius: themeContract.radii.sm,
flexShrink: 0,
})
export const memoryNode = style([
hexagon,
{
width: "0.75rem",
height: "0.75rem",
background: "rgba(147, 197, 253, 0.1)",
border: "1px solid rgba(147, 197, 253, 0.35)",
flexShrink: 0,
},
])
// Hexagon shapes using SVG background (matching graph's flat-top hexagon)
// Points calculated: angle = (i * 2π / 6) - π/2, center (6,6), radius 4.5
const hexagonPoints = "6,1.5 10.4,3.75 10.4,8.25 6,10.5 1.6,8.25 1.6,3.75"
export const memoryNodeOlder = style([
memoryNode,
{
opacity: 0.4,
},
])
export const memoryNode = style({
width: "1rem",
height: "1rem",
flexShrink: 0,
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 12 12' xmlns='http://www.w3.org/2000/svg'%3E%3Cpolygon points='${hexagonPoints}' fill='rgba(147,197,253,0.21)' stroke='rgba(147,196,253,0.6)' stroke-width='1'/%3E%3C/svg%3E")`,
backgroundSize: "contain",
backgroundRepeat: "no-repeat",
})
export const forgottenNode = style([
hexagon,
{
width: "0.75rem",
height: "0.75rem",
background: "rgba(239, 68, 68, 0.3)",
border: "1px solid rgba(239, 68, 68, 0.8)",
position: "relative",
flexShrink: 0,
},
])
export const memoryNodeOlder = style({
opacity: 0.4,
width: "1rem",
height: "1rem",
flexShrink: 0,
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 12 12' xmlns='http://www.w3.org/2000/svg'%3E%3Cpolygon points='${hexagonPoints}' fill='rgba(147,197,253,0.21)' stroke='rgba(147,196,253,0.6)' stroke-width='1'/%3E%3C/svg%3E")`,
backgroundSize: "contain",
backgroundRepeat: "no-repeat",
})
export const forgottenNode = style({
width: "1rem",
height: "1rem",
flexShrink: 0,
position: "relative",
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 12 12' xmlns='http://www.w3.org/2000/svg'%3E%3Cpolygon points='${hexagonPoints}' fill='rgba(239,68,68,0.3)' stroke='rgba(239,68,68,0.8)' stroke-width='1'/%3E%3C/svg%3E")`,
backgroundSize: "contain",
backgroundRepeat: "no-repeat",
})
export const expiringNode = style({
width: "1rem",
height: "1rem",
flexShrink: 0,
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 12 12' xmlns='http://www.w3.org/2000/svg'%3E%3Cpolygon points='${hexagonPoints}' fill='rgba(147,197,253,0.1)' stroke='rgb(245,158,11)' stroke-width='1.5'/%3E%3C/svg%3E")`,
backgroundSize: "contain",
backgroundRepeat: "no-repeat",
})
export const newNode = style({
width: "1rem",
height: "1rem",
flexShrink: 0,
position: "relative",
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 12 12' xmlns='http://www.w3.org/2000/svg'%3E%3Cpolygon points='${hexagonPoints}' fill='rgba(147,197,253,0.1)' stroke='rgb(16,185,129)' stroke-width='1.5'/%3E%3C/svg%3E")`,
backgroundSize: "contain",
backgroundRepeat: "no-repeat",
})
export const forgottenIcon = style({
position: "absolute",
@ -265,31 +283,9 @@ export const forgottenIcon = style({
color: "rgb(248, 113, 113)",
fontSize: themeContract.typography.fontSize.xs,
lineHeight: "1",
pointerEvents: "none",
})
export const expiringNode = style([
hexagon,
{
width: "0.75rem",
height: "0.75rem",
background: "rgba(147, 197, 253, 0.1)",
border: "2px solid rgb(245, 158, 11)",
flexShrink: 0,
},
])
export const newNode = style([
hexagon,
{
width: "0.75rem",
height: "0.75rem",
background: "rgba(147, 197, 253, 0.1)",
border: "2px solid rgb(16, 185, 129)",
position: "relative",
flexShrink: 0,
},
])
export const newBadge = style({
position: "absolute",
top: "-0.25rem",
@ -303,14 +299,14 @@ export const newBadge = style({
export const connectionLine = style({
width: "1rem",
height: 0,
borderTop: "1px solid rgb(148, 163, 184)",
borderTop: "1px solid rgb(148, 163, 184, 0.5)",
flexShrink: 0,
})
export const similarityLine = style({
width: "1rem",
height: 0,
borderTop: "2px dashed rgb(148, 163, 184)",
borderTop: "2px dashed rgba(79, 255, 226, 0.5)",
flexShrink: 0,
})
@ -325,7 +321,7 @@ export const weakSimilarity = style({
width: "0.75rem",
height: "0.75rem",
borderRadius: themeContract.radii.full,
background: "rgba(148, 163, 184, 0.2)",
background: "rgba(79, 255, 226, 0.3)",
flexShrink: 0,
})
@ -333,7 +329,7 @@ export const strongSimilarity = style({
width: "0.75rem",
height: "0.75rem",
borderRadius: themeContract.radii.full,
background: "rgba(148, 163, 184, 0.6)",
background: "rgba(79, 255, 226, 0.7)",
flexShrink: 0,
})

View file

@ -6,11 +6,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { GraphCanvas } from "./graph-canvas"
import { useGraphData } from "@/hooks/use-graph-data"
import { useGraphInteractions } from "@/hooks/use-graph-interactions"
import { useForceSimulation } from "@/hooks/use-force-simulation"
import { injectStyles } from "@/lib/inject-styles"
import { Legend } from "./legend"
import { LoadingIndicator } from "./loading-indicator"
import { NavigationControls } from "./navigation-controls"
import { NodeDetailPanel } from "./node-detail-panel"
import { NodePopover } from "./node-popover"
import { SpacesDropdown } from "./spaces-dropdown"
import * as styles from "./memory-graph.css"
import { defaultTheme } from "@/styles/theme.css"
@ -38,6 +40,10 @@ export const MemoryGraph = ({
onSpaceChange: externalOnSpaceChange,
memoryLimit,
isExperimental,
// Slideshow control
isSlideshowActive = false,
onSlideshowNodeChange,
onSlideshowStop,
}: MemoryGraphProps) => {
// Inject styles on first render (client-side only)
useEffect(() => {
@ -128,6 +134,30 @@ export const MemoryGraph = ({
memoryLimit,
)
// State to trigger re-renders when simulation ticks
const [, setSimulationTick] = useState(0)
// Track drag state for physics integration
const dragStateRef = useRef<{
nodeId: string | null
startX: number
startY: number
nodeStartX: number
nodeStartY: number
}>({ nodeId: null, startX: 0, startY: 0, nodeStartX: 0, nodeStartY: 0 })
// Force simulation - only runs during interactions (drag)
const forceSimulation = useForceSimulation(
nodes,
edges,
() => {
// On each tick, trigger a re-render
// D3 directly mutates node.x and node.y
setSimulationTick((prev) => prev + 1)
},
true, // enabled
)
// Auto-fit once per unique highlight set to show the full graph for context
const lastFittedHighlightKeyRef = useRef<string>("")
useEffect(() => {
@ -240,12 +270,91 @@ export const MemoryGraph = ({
}
}, [])
// Enhanced node drag start that includes nodes data
// Physics-enabled node drag start
const handleNodeDragStartWithNodes = useCallback(
(nodeId: string, e: React.MouseEvent) => {
// Find the node being dragged
const node = nodes.find((n) => n.id === nodeId)
if (node) {
// Store drag start state
dragStateRef.current = {
nodeId,
startX: e.clientX,
startY: e.clientY,
nodeStartX: node.x,
nodeStartY: node.y,
}
// Pin the node at its current position (d3-force pattern)
node.fx = node.x
node.fy = node.y
// Reheat simulation immediately (like d3 reference code)
forceSimulation.reheat()
}
// Set dragging state (still need this for visual feedback)
handleNodeDragStart(nodeId, e, nodes)
},
[handleNodeDragStart, nodes],
[handleNodeDragStart, nodes, forceSimulation],
)
// Physics-enabled node drag move
const handleNodeDragMoveWithNodes = useCallback(
(e: React.MouseEvent) => {
if (draggingNodeId && dragStateRef.current.nodeId === draggingNodeId) {
// Update the fixed position during drag (this is what d3 uses)
const node = nodes.find((n) => n.id === draggingNodeId)
if (node) {
// Calculate new position based on drag delta
const deltaX = (e.clientX - dragStateRef.current.startX) / zoom
const deltaY = (e.clientY - dragStateRef.current.startY) / zoom
// Update subject position (matches d3 reference code pattern)
// Only update fx/fy, let simulation handle x/y
node.fx = dragStateRef.current.nodeStartX + deltaX
node.fy = dragStateRef.current.nodeStartY + deltaY
}
}
},
[nodes, draggingNodeId, zoom],
)
// Physics-enabled node drag end
const handleNodeDragEndWithPhysics = useCallback(() => {
if (draggingNodeId) {
// Unpin the node (allow physics to take over) - matches d3 reference code
const node = nodes.find((n) => n.id === draggingNodeId)
if (node) {
node.fx = null
node.fy = null
}
// Cool down the simulation (restore target alpha to 0)
forceSimulation.coolDown()
// Reset drag state
dragStateRef.current = {
nodeId: null,
startX: 0,
startY: 0,
nodeStartX: 0,
nodeStartY: 0,
}
}
// Call original handler to clear dragging state
handleNodeDragEnd()
}, [draggingNodeId, nodes, forceSimulation, handleNodeDragEnd])
// Physics-aware node click - let simulation continue naturally
const handleNodeClickWithPhysics = useCallback(
(nodeId: string) => {
// Just call original handler to update selected node state
// Don't stop the simulation - let it cool down naturally
handleNodeClick(nodeId)
},
[handleNodeClick],
)
// Navigation callbacks
@ -300,6 +409,54 @@ export const MemoryGraph = ({
return nodes.find((n) => n.id === selectedNode) || null
}, [selectedNode, nodes])
// Calculate popover position (memoized for performance)
const popoverPosition = useMemo(() => {
if (!selectedNodeData) return null
// Calculate screen position of the node
const screenX = selectedNodeData.x * zoom + panX
const screenY = selectedNodeData.y * zoom + panY
// Popover dimensions (estimated)
const popoverWidth = 320
const popoverHeight = 400
const padding = 16
// Calculate node dimensions to position popover with proper gap
const nodeSize = selectedNodeData.size * zoom
const nodeWidth = selectedNodeData.type === "document" ? nodeSize * 1.4 : nodeSize
const nodeHeight = selectedNodeData.type === "document" ? nodeSize * 0.9 : nodeSize
const gap = 20 // Gap between node and popover
// Smart positioning: flip to other side if would go off-screen
let popoverX = screenX + nodeWidth / 2 + gap
let popoverY = screenY - popoverHeight / 2
// Check right edge
if (popoverX + popoverWidth > containerSize.width - padding) {
// Flip to left side of node
popoverX = screenX - nodeWidth / 2 - gap - popoverWidth
}
// Check left edge
if (popoverX < padding) {
popoverX = padding
}
// Check bottom edge
if (popoverY + popoverHeight > containerSize.height - padding) {
// Move up
popoverY = containerSize.height - popoverHeight - padding
}
// Check top edge
if (popoverY < padding) {
popoverY = padding
}
return { x: popoverX, y: popoverY }
}, [selectedNodeData, zoom, panX, panY, containerSize.width, containerSize.height])
// Viewport-based loading: load more when most documents are visible (optional)
const checkAndLoadMore = useCallback(() => {
if (
@ -378,6 +535,125 @@ export const MemoryGraph = ({
}
}, [data, hasMore, throttledCheckAndLoadMore, autoLoadOnViewport])
// Slideshow logic - simulate actual node clicks with physics
const slideshowIntervalRef = useRef<NodeJS.Timeout | null>(null)
const physicsTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const lastSelectedIndexRef = useRef<number>(-1)
const isSlideshowActiveRef = useRef(isSlideshowActive)
// Update slideshow active ref
useEffect(() => {
isSlideshowActiveRef.current = isSlideshowActive
}, [isSlideshowActive])
// Use refs to store current values without triggering re-renders
const nodesRef = useRef(nodes)
const handleNodeClickRef = useRef(handleNodeClick)
const centerViewportOnRef = useRef(centerViewportOn)
const containerSizeRef = useRef(containerSize)
const onSlideshowNodeChangeRef = useRef(onSlideshowNodeChange)
const forceSimulationRef = useRef(forceSimulation)
// Update refs when values change
useEffect(() => {
nodesRef.current = nodes
handleNodeClickRef.current = handleNodeClick
centerViewportOnRef.current = centerViewportOn
containerSizeRef.current = containerSize
onSlideshowNodeChangeRef.current = onSlideshowNodeChange
forceSimulationRef.current = forceSimulation
}, [nodes, handleNodeClick, centerViewportOn, containerSize, onSlideshowNodeChange, forceSimulation])
useEffect(() => {
// Clear any existing interval and timeout when isSlideshowActive changes
if (slideshowIntervalRef.current) {
clearInterval(slideshowIntervalRef.current)
slideshowIntervalRef.current = null
}
if (physicsTimeoutRef.current) {
clearTimeout(physicsTimeoutRef.current)
physicsTimeoutRef.current = null
}
if (!isSlideshowActive) {
// Close the popover when stopping slideshow
setSelectedNode(null)
// Explicitly cool down physics simulation in case timeout hasn't fired yet
forceSimulation.coolDown()
return
}
// Select a random node (avoid selecting the same one twice in a row)
const selectRandomNode = () => {
// Double-check slideshow is still active
if (!isSlideshowActiveRef.current) return
const currentNodes = nodesRef.current
if (currentNodes.length === 0) return
let randomIndex: number
// If we have more than one node, avoid selecting the same one
if (currentNodes.length > 1) {
do {
randomIndex = Math.floor(Math.random() * currentNodes.length)
} while (randomIndex === lastSelectedIndexRef.current)
} else {
randomIndex = 0
}
lastSelectedIndexRef.current = randomIndex
const randomNode = currentNodes[randomIndex]
if (randomNode) {
// Smoothly pan to the node first
centerViewportOnRef.current(
randomNode.x,
randomNode.y,
containerSizeRef.current.width,
containerSizeRef.current.height,
)
// Simulate the actual node click (triggers dimming and popover)
handleNodeClickRef.current(randomNode.id)
// Trigger physics animation briefly
forceSimulationRef.current.reheat()
// Cool down physics after 1 second (cleanup old timeout first)
if (physicsTimeoutRef.current) {
clearTimeout(physicsTimeoutRef.current)
}
physicsTimeoutRef.current = setTimeout(() => {
// Only cool down if slideshow is still active or if this is cleanup
forceSimulationRef.current.coolDown()
physicsTimeoutRef.current = null
}, 1000)
// Notify parent component
onSlideshowNodeChangeRef.current?.(randomNode.id)
}
}
// Start immediately
selectRandomNode()
// Set interval for subsequent selections (3.5 seconds)
slideshowIntervalRef.current = setInterval(() => {
selectRandomNode()
}, 3500)
return () => {
if (slideshowIntervalRef.current) {
clearInterval(slideshowIntervalRef.current)
slideshowIntervalRef.current = null
}
if (physicsTimeoutRef.current) {
clearTimeout(physicsTimeoutRef.current)
physicsTimeoutRef.current = null
}
}
}, [isSlideshowActive]) // Only depend on isSlideshowActive
if (error) {
return (
<div className={styles.errorContainer}>
@ -426,16 +702,17 @@ export const MemoryGraph = ({
variant={variant}
/>
{/* Node detail panel */}
<AnimatePresence>
{selectedNodeData && (
<NodeDetailPanel
node={selectedNodeData}
onClose={() => setSelectedNode(null)}
variant={variant}
/>
)}
</AnimatePresence>
{/* Node popover - positioned near clicked node */}
{selectedNodeData && popoverPosition && (
<NodePopover
node={selectedNodeData}
x={popoverPosition.x}
y={popoverPosition.y}
onClose={() => setSelectedNode(null)}
containerBounds={containerRef.current?.getBoundingClientRect()}
onBackdropClick={isSlideshowActive ? onSlideshowStop : undefined}
/>
)}
{/* Show welcome screen when no memories exist */}
{!isLoading &&
@ -452,10 +729,11 @@ export const MemoryGraph = ({
height={containerSize.height}
nodes={nodes}
highlightDocumentIds={highlightsVisible ? highlightDocumentIds : []}
isSimulationActive={forceSimulation.isActive()}
onDoubleClick={handleDoubleClick}
onNodeClick={handleNodeClick}
onNodeDragEnd={handleNodeDragEnd}
onNodeDragMove={handleNodeDragMove}
onNodeClick={handleNodeClickWithPhysics}
onNodeDragEnd={handleNodeDragEndWithPhysics}
onNodeDragMove={handleNodeDragMoveWithNodes}
onNodeDragStart={handleNodeDragStartWithNodes}
onNodeHover={handleNodeHover}
onPanEnd={handlePanEnd}
@ -469,6 +747,7 @@ export const MemoryGraph = ({
panY={panY}
width={containerSize.width}
zoom={zoom}
selectedNodeId={selectedNode}
/>
)}

View file

@ -0,0 +1,463 @@
"use client"
import { memo, useEffect } from "react"
import type { GraphNode } from "@/types"
export interface NodePopoverProps {
node: GraphNode
x: number // Screen X position
y: number // Screen Y position
onClose: () => void
containerBounds?: DOMRect // Optional container bounds to limit backdrop
onBackdropClick?: () => void // Optional callback when backdrop is clicked
}
export const NodePopover = memo<NodePopoverProps>(function NodePopover({
node,
x,
y,
onClose,
containerBounds,
onBackdropClick,
}) {
// Handle Escape key to close popover
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [onClose])
// Calculate backdrop bounds - use container bounds if provided, otherwise full viewport
const backdropStyle = containerBounds
? {
position: "fixed" as const,
left: `${containerBounds.left}px`,
top: `${containerBounds.top}px`,
width: `${containerBounds.width}px`,
height: `${containerBounds.height}px`,
zIndex: 999,
pointerEvents: "auto" as const,
backgroundColor: "transparent",
}
: {
position: "fixed" as const,
inset: 0,
zIndex: 999,
pointerEvents: "auto" as const,
backgroundColor: "transparent",
}
const handleBackdropClick = () => {
onBackdropClick?.()
onClose()
}
return (
<>
{/* Invisible backdrop to catch clicks outside */}
<div onClick={handleBackdropClick} style={backdropStyle} />
{/* Popover content */}
<div
onClick={(e) => e.stopPropagation()} // Prevent closing when clicking inside
style={{
position: "fixed",
left: `${x}px`,
top: `${y}px`,
background: "rgba(255, 255, 255, 0.05)",
backdropFilter: "blur(12px)",
WebkitBackdropFilter: "blur(12px)",
border: "1px solid rgba(255, 255, 255, 0.25)",
borderRadius: "12px",
padding: "16px",
width: "320px",
zIndex: 1000,
pointerEvents: "auto",
boxShadow: "0 20px 25px -5px rgb(0 0 0 / 0.3), 0 8px 10px -6px rgb(0 0 0 / 0.3)",
}}
>
{node.type === "document" ? (
// Document popover
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
{/* Header */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "4px" }}>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ color: "rgba(148, 163, 184, 1)" }}>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
<polyline points="10 9 9 9 8 9"></polyline>
</svg>
<h3 style={{
fontSize: "16px",
fontWeight: "700",
color: "white",
margin: 0,
}}>
Document
</h3>
</div>
<button
type="button"
onClick={onClose}
style={{
padding: "4px",
background: "transparent",
border: "none",
color: "rgba(148, 163, 184, 1)",
cursor: "pointer",
fontSize: "16px",
lineHeight: "1",
transition: "color 0.2s",
}}
onMouseEnter={(e) => e.currentTarget.style.color = "white"}
onMouseLeave={(e) => e.currentTarget.style.color = "rgba(148, 163, 184, 1)"}
>
×
</button>
</div>
{/* Sections */}
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
{/* Title */}
<div>
<div style={{
fontSize: "11px",
color: "rgba(148, 163, 184, 0.8)",
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: "4px",
}}>
Title
</div>
<p style={{
fontSize: "14px",
color: "rgba(203, 213, 225, 1)",
margin: 0,
lineHeight: "1.4",
}}>
{(node.data as any).title || "Untitled Document"}
</p>
</div>
{/* Summary - truncated to 2 lines */}
{(node.data as any).summary && (
<div>
<div style={{
fontSize: "11px",
color: "rgba(148, 163, 184, 0.8)",
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: "4px",
}}>
Summary
</div>
<p style={{
fontSize: "14px",
color: "rgba(203, 213, 225, 1)",
margin: 0,
lineHeight: "1.4",
overflow: "hidden",
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
}}>
{(node.data as any).summary}
</p>
</div>
)}
{/* Type */}
<div>
<div style={{
fontSize: "11px",
color: "rgba(148, 163, 184, 0.8)",
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: "4px",
}}>
Type
</div>
<p style={{
fontSize: "14px",
color: "rgba(203, 213, 225, 1)",
margin: 0,
}}>
{(node.data as any).type || "Document"}
</p>
</div>
{/* Memory Count */}
<div>
<div style={{
fontSize: "11px",
color: "rgba(148, 163, 184, 0.8)",
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: "4px",
}}>
Memory Count
</div>
<p style={{
fontSize: "14px",
color: "rgba(203, 213, 225, 1)",
margin: 0,
}}>
{(node.data as any).memoryEntries?.length || 0} memories
</p>
</div>
{/* URL */}
{((node.data as any).url || (node.data as any).customId) && (
<div>
<div style={{
fontSize: "11px",
color: "rgba(148, 163, 184, 0.8)",
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: "4px",
}}>
URL
</div>
<a
href={(() => {
const doc = node.data as any
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
})()}
target="_blank"
rel="noopener noreferrer"
style={{
fontSize: "14px",
color: "rgb(129, 140, 248)",
textDecoration: "none",
display: "flex",
alignItems: "center",
gap: "4px",
transition: "color 0.2s",
}}
onMouseEnter={(e) => e.currentTarget.style.color = "rgb(165, 180, 252)"}
onMouseLeave={(e) => e.currentTarget.style.color = "rgb(129, 140, 248)"}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
<polyline points="15 3 21 3 21 9"></polyline>
<line x1="10" y1="14" x2="21" y2="3"></line>
</svg>
View Document
</a>
</div>
)}
{/* Footer with metadata */}
<div style={{
paddingTop: "12px",
borderTop: "1px solid rgba(71, 85, 105, 0.5)",
display: "flex",
alignItems: "center",
gap: "16px",
fontSize: "12px",
color: "rgba(148, 163, 184, 1)",
}}>
<div style={{
display: "flex",
alignItems: "center",
gap: "4px",
}}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
<span>{new Date((node.data as any).createdAt).toLocaleDateString()}</span>
</div>
<div style={{
display: "flex",
alignItems: "center",
gap: "4px",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
flex: 1,
}}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="4" y1="9" x2="20" y2="9"></line>
<line x1="4" y1="15" x2="20" y2="15"></line>
<line x1="10" y1="3" x2="8" y2="21"></line>
<line x1="16" y1="3" x2="14" y2="21"></line>
</svg>
<span style={{ overflow: "hidden", textOverflow: "ellipsis" }}>{node.id}</span>
</div>
</div>
</div>
</div>
) : (
// Memory popover
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
{/* Header */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "4px" }}>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ color: "rgb(96, 165, 250)" }}>
<path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2Z"></path>
<path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2Z"></path>
</svg>
<h3 style={{
fontSize: "16px",
fontWeight: "700",
color: "white",
margin: 0,
}}>
Memory
</h3>
</div>
<button
type="button"
onClick={onClose}
style={{
padding: "4px",
background: "transparent",
border: "none",
color: "rgba(148, 163, 184, 1)",
cursor: "pointer",
fontSize: "16px",
lineHeight: "1",
transition: "color 0.2s",
}}
onMouseEnter={(e) => e.currentTarget.style.color = "white"}
onMouseLeave={(e) => e.currentTarget.style.color = "rgba(148, 163, 184, 1)"}
>
×
</button>
</div>
{/* Sections */}
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
{/* Memory content */}
<div>
<div style={{
fontSize: "11px",
color: "rgba(148, 163, 184, 0.8)",
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: "4px",
}}>
Memory
</div>
<p style={{
fontSize: "14px",
color: "rgba(203, 213, 225, 1)",
margin: 0,
lineHeight: "1.4",
}}>
{(node.data as any).memory || (node.data as any).content || "No content"}
</p>
{(node.data as any).isForgotten && (
<div style={{
marginTop: "8px",
padding: "4px 8px",
background: "rgba(220, 38, 38, 0.15)",
borderRadius: "4px",
fontSize: "12px",
color: "rgba(248, 113, 113, 1)",
display: "inline-block"
}}>
Forgotten
</div>
)}
{/* Expires (inline with memory if exists) */}
{(node.data as any).forgetAfter && (
<p style={{
fontSize: "12px",
color: "rgba(148, 163, 184, 1)",
margin: "8px 0 0 0",
lineHeight: "1.4",
}}>
Expires: {new Date((node.data as any).forgetAfter).toLocaleDateString()}
{(node.data as any).forgetReason && ` - ${(node.data as any).forgetReason}`}
</p>
)}
</div>
{/* Space */}
<div>
<div style={{
fontSize: "11px",
color: "rgba(148, 163, 184, 0.8)",
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: "4px",
}}>
Space
</div>
<p style={{
fontSize: "14px",
color: "rgba(203, 213, 225, 1)",
margin: 0,
}}>
{(node.data as any).spaceId || "Default"}
</p>
</div>
{/* Footer with metadata */}
<div style={{
paddingTop: "12px",
borderTop: "1px solid rgba(71, 85, 105, 0.5)",
display: "flex",
alignItems: "center",
gap: "16px",
fontSize: "12px",
color: "rgba(148, 163, 184, 1)",
}}>
<div style={{
display: "flex",
alignItems: "center",
gap: "4px",
}}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
<span>{new Date((node.data as any).createdAt).toLocaleDateString()}</span>
</div>
<div style={{
display: "flex",
alignItems: "center",
gap: "4px",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
flex: 1,
}}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="4" y1="9" x2="20" y2="9"></line>
<line x1="4" y1="15" x2="20" y2="15"></line>
<line x1="10" y1="3" x2="8" y2="21"></line>
<line x1="16" y1="3" x2="14" y2="21"></line>
</svg>
<span style={{ overflow: "hidden", textOverflow: "ellipsis" }}>{node.id}</span>
</div>
</div>
</div>
</div>
)}
</div>
</>
)
})

View file

@ -6,24 +6,24 @@ export const colors = {
accent: "#252a35", // Card backgrounds
},
document: {
primary: "rgba(255, 255, 255, 0.06)", // Subtle glass white
secondary: "rgba(255, 255, 255, 0.12)", // More visible
accent: "rgba(255, 255, 255, 0.18)", // Hover state
border: "rgba(255, 255, 255, 0.25)", // Sharp borders
primary: "rgba(255, 255, 255, 0.21)", // Subtle glass white
secondary: "rgba(255, 255, 255, 0.31)", // More visible
accent: "rgba(255, 255, 255, 0.31)", // Hover state
border: "rgba(255, 255, 255, 0.6)", // Sharp borders
glow: "rgba(147, 197, 253, 0.4)", // Blue glow for interaction
},
memory: {
primary: "rgba(147, 197, 253, 0.08)", // Subtle glass blue
secondary: "rgba(147, 197, 253, 0.16)", // More visible
accent: "rgba(147, 197, 253, 0.24)", // Hover state
border: "rgba(147, 197, 253, 0.35)", // Sharp borders
primary: "rgba(147, 196, 253, 0.21)", // Subtle glass blue
secondary: "rgba(147, 196, 253, 0.31)", // More visible
accent: "rgba(147, 197, 253, 0.31)", // Hover state
border: "rgba(147, 196, 253, 0.6)", // Sharp borders
glow: "rgba(147, 197, 253, 0.5)", // Blue glow for interaction
},
connection: {
weak: "rgba(148, 163, 184, 0)", // Very subtle
memory: "rgba(148, 163, 184, 0.3)", // Very subtle
medium: "rgba(148, 163, 184, 0.125)", // Medium visibility
strong: "rgba(148, 163, 184, 0.4)", // Strong connection
weak: "rgba(79, 255, 226, 0.3)", // subtle
memory: "rgba(148, 163, 184, 0.35)", // Very subtle
medium: "rgba(79, 255, 226, 0.6)", // Medium visibility
strong: "rgba(79, 255, 226, 0.9)", // Strong connection
},
text: {
primary: "#ffffff", // Pure white
@ -59,6 +59,38 @@ export const LAYOUT_CONSTANTS = {
memoryClusterRadius: 300,
}
// Similarity calculation configuration
export const SIMILARITY_CONFIG = {
threshold: 0.725, // Minimum similarity (72.5%) to create edge
maxComparisonsPerDoc: 15, // k-NN: each doc compares with 15 neighbors (balanced performance)
}
// D3-Force simulation configuration
export const FORCE_CONFIG = {
// Link force (spring between connected nodes)simil
linkStrength: {
docMemory: 0.8, // Strong for doc-memory connections
version: 1.0, // Strongest for version chains
docDocBase: 0.3, // Base for doc-doc similarity
},
linkDistance: 300, // Desired spring length
// Charge force (repulsion between nodes)
chargeStrength: -1000, // Negative = repulsion, higher magnitude = stronger push
// Collision force (prevents node overlap)
collisionRadius: {
document: 80, // Collision radius for document nodes
memory: 40, // Collision radius for memory nodes
},
// Simulation behavior
alphaDecay: 0.03, // How fast simulation cools down (higher = faster cooldown)
alphaMin: 0.001, // Threshold to stop simulation (when alpha drops below this)
velocityDecay: 0.6, // Friction/damping (0 = no friction, 1 = instant stop) - increased for less movement
alphaTarget: 0.3, // Target alpha when reheating (on drag start)
}
// Graph view settings
export const GRAPH_SETTINGS = {
console: {
@ -73,6 +105,12 @@ export const GRAPH_SETTINGS = {
},
}
// Animation settings
export const ANIMATION = {
// Dim effect duration - shortened for better UX
dimDuration: 1500, // milliseconds
}
// Responsive positioning for different app variants
export const POSITIONING = {
console: {

View file

@ -0,0 +1,180 @@
"use client"
import { useEffect, useRef, useCallback } from "react"
import * as d3 from "d3-force"
import { FORCE_CONFIG } from "@/constants"
import type { GraphNode, GraphEdge } from "@/types"
export interface ForceSimulationControls {
/** The d3 simulation instance */
simulation: d3.Simulation<GraphNode, GraphEdge> | null
/** Reheat the simulation (call on drag start) */
reheat: () => void
/** Cool down the simulation (call on drag end) */
coolDown: () => void
/** Check if simulation is currently active */
isActive: () => boolean
/** Stop the simulation completely */
stop: () => void
/** Get current alpha value */
getAlpha: () => number
}
/**
* Custom hook to manage d3-force simulation lifecycle
* Simulation only runs during interactions (drag) for performance
*/
export function useForceSimulation(
nodes: GraphNode[],
edges: GraphEdge[],
onTick: (nodes: GraphNode[]) => void,
enabled = true,
): ForceSimulationControls {
const simulationRef = useRef<d3.Simulation<GraphNode, GraphEdge> | null>(null)
// Initialize simulation ONCE
useEffect(() => {
if (!enabled || nodes.length === 0) {
return
}
// Only create simulation once
if (!simulationRef.current) {
const simulation = d3
.forceSimulation<GraphNode>(nodes)
.alphaDecay(FORCE_CONFIG.alphaDecay)
.alphaMin(FORCE_CONFIG.alphaMin)
.velocityDecay(FORCE_CONFIG.velocityDecay)
.on("tick", () => {
// Trigger re-render by calling onTick
// D3 has already mutated node.x and node.y
onTick([...nodes])
})
// Configure forces
// 1. Link force - spring connections between nodes
simulation.force(
"link",
d3
.forceLink<GraphNode, GraphEdge>(edges)
.id((d) => d.id)
.distance(FORCE_CONFIG.linkDistance)
.strength((link) => {
// Different strength based on edge type
if (link.edgeType === "doc-memory") {
return FORCE_CONFIG.linkStrength.docMemory
}
if (link.edgeType === "version") {
return FORCE_CONFIG.linkStrength.version
}
// doc-doc: variable strength based on similarity
return link.similarity * FORCE_CONFIG.linkStrength.docDocBase
}),
)
// 2. Charge force - repulsion between nodes
simulation.force(
"charge",
d3.forceManyBody<GraphNode>().strength(FORCE_CONFIG.chargeStrength),
)
// 3. Collision force - prevent node overlap
simulation.force(
"collide",
d3
.forceCollide<GraphNode>()
.radius((d) =>
d.type === "document"
? FORCE_CONFIG.collisionRadius.document
: FORCE_CONFIG.collisionRadius.memory,
)
.strength(0.7),
)
// 4. forceX and forceY - weak centering forces (like reference code)
simulation.force("x", d3.forceX().strength(0.05))
simulation.force("y", d3.forceY().strength(0.05))
// Store reference
simulationRef.current = simulation
// Quick pre-settle to avoid initial chaos, then animate the rest
// This gives best of both worlds: fast initial render + smooth settling
simulation.alpha(1)
for (let i = 0; i < 50; ++i) simulation.tick() // Just 50 ticks = ~5-10ms
simulation.alphaTarget(0).restart() // Continue animating to full stability
}
// Cleanup on unmount
return () => {
if (simulationRef.current) {
simulationRef.current.stop()
simulationRef.current = null
}
}
// Only run on mount/unmount, not when nodes/edges/onTick change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled])
// Update simulation nodes and edges together to prevent race conditions
useEffect(() => {
if (!simulationRef.current) return
// Update nodes
if (nodes.length > 0) {
simulationRef.current.nodes(nodes)
}
// Update edges
if (edges.length > 0) {
const linkForce = simulationRef.current.force<
d3.ForceLink<GraphNode, GraphEdge>
>("link")
if (linkForce) {
linkForce.links(edges)
}
}
}, [nodes, edges])
// Reheat simulation (called on drag start)
const reheat = useCallback(() => {
if (simulationRef.current) {
simulationRef.current.alphaTarget(FORCE_CONFIG.alphaTarget).restart()
}
}, [])
// Cool down simulation (called on drag end)
const coolDown = useCallback(() => {
if (simulationRef.current) {
simulationRef.current.alphaTarget(0)
}
}, [])
// Check if simulation is active
const isActive = useCallback(() => {
if (!simulationRef.current) return false
return simulationRef.current.alpha() > FORCE_CONFIG.alphaMin
}, [])
// Stop simulation completely
const stop = useCallback(() => {
if (simulationRef.current) {
simulationRef.current.stop()
}
}, [])
// Get current alpha
const getAlpha = useCallback(() => {
if (!simulationRef.current) return 0
return simulationRef.current.alpha()
}, [])
return {
simulation: simulationRef.current,
reheat,
coolDown,
isActive,
stop,
getAlpha,
}
}

View file

@ -5,8 +5,8 @@ import {
getConnectionVisualProps,
getMagicalConnectionColor,
} from "@/lib/similarity"
import { useMemo } from "react"
import { colors, LAYOUT_CONSTANTS } from "@/constants"
import { useMemo, useRef, useEffect } from "react"
import { colors, LAYOUT_CONSTANTS, SIMILARITY_CONFIG } from "@/constants"
import type {
DocumentsResponse,
DocumentWithMemories,
@ -19,19 +19,39 @@ import type {
export function useGraphData(
data: DocumentsResponse | null,
selectedSpace: string,
nodePositions: Map<string, { x: number; y: number }>,
nodePositions: Map<string, { x: number; y: number; parentDocId?: string; offsetX?: number; offsetY?: number }>,
draggingNodeId: string | null,
memoryLimit?: number,
) {
return useMemo(() => {
if (!data?.documents) return { nodes: [], edges: [] }
// Cache nodes to preserve d3-force mutations (x, y, vx, vy, fx, fy)
const nodeCache = useRef<Map<string, GraphNode>>(new Map())
const allNodes: GraphNode[] = []
const allEdges: GraphEdge[] = []
// Cleanup nodeCache to prevent memory leak
useEffect(() => {
if (!data?.documents) return
// Filter documents that have memories in selected space
// AND limit memories per document when memoryLimit is provided
const filteredDocuments = data.documents
// Build set of current node IDs
const currentNodeIds = new Set<string>()
data.documents.forEach((doc) => {
currentNodeIds.add(doc.id)
doc.memoryEntries.forEach((mem) => {
currentNodeIds.add(`${mem.id}`)
})
})
// Remove stale nodes from cache
for (const [id] of nodeCache.current.entries()) {
if (!currentNodeIds.has(id)) {
nodeCache.current.delete(id)
}
}
}, [data, selectedSpace])
// Memo 1: Filter documents by selected space
const filteredDocuments = useMemo(() => {
if (!data?.documents) return []
return data.documents
.map((doc) => {
let memories =
selectedSpace === "all"
@ -53,6 +73,59 @@ export function useGraphData(
}
})
.filter((doc) => doc.memoryEntries.length > 0)
}, [data, selectedSpace, memoryLimit])
// Memo 2: Calculate similarity edges using k-NN approach
const similarityEdges = useMemo(() => {
const edges: GraphEdge[] = []
// k-NN: Each document compares with k neighbors (configurable)
const { maxComparisonsPerDoc, threshold } = SIMILARITY_CONFIG
for (let i = 0; i < filteredDocuments.length; i++) {
const docI = filteredDocuments[i]
if (!docI) continue
// Only compare with next k documents (k-nearest neighbors approach)
const endIdx = Math.min(
i + maxComparisonsPerDoc + 1,
filteredDocuments.length,
)
for (let j = i + 1; j < endIdx; 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 > threshold) {
edges.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 edges
}, [filteredDocuments])
// Memo 3: Build full graph data (nodes + edges)
return useMemo(() => {
if (!data?.documents || filteredDocuments.length === 0) {
return { nodes: [], edges: [] }
}
const allNodes: GraphNode[] = []
const allEdges: GraphEdge[] = []
// Group documents by space for better clustering
const documentsBySpace = new Map<string, typeof filteredDocuments>()
@ -71,7 +144,7 @@ export function useGraphData(
})
// Enhanced Layout with Space Separation
const { centerX, centerY, clusterRadius, spaceSpacing, documentSpacing } =
const { centerX, centerY, clusterRadius } =
LAYOUT_CONSTANTS
/* 1. Build DOCUMENT nodes with space-aware clustering */
@ -79,101 +152,52 @@ export function useGraphData(
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
// Simple grid-like layout that physics will naturally organize
// Start documents near the center with some random offset
const gridSize = Math.ceil(Math.sqrt(spaceDocs.length))
const row = Math.floor(docIndex / gridSize)
const col = docIndex % gridSize
// 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
// Loose grid spacing - physics will organize it better
const spacing = 200
const defaultX = centerX + (col - gridSize / 2) * spacing + (Math.random() - 0.5) * 50
const defaultY = centerY + (row - gridSize / 2) * spacing + (Math.random() - 0.5) * 50
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)
// Check if node exists in cache (preserves d3-force mutations)
let node = nodeCache.current.get(doc.id)
if (node) {
// Update existing node's data, preserve physics properties (x, y, vx, vy, fx, fy)
node.data = doc
node.isDragging = draggingNodeId === doc.id
// Don't reset x/y - they're managed by d3-force
} else {
// Create new node with initial position
node = {
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
nodeCache.current.set(doc.id, node)
}
documentNodes.push(node)
})
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
}
})
})
}
/* 2. Manual collision avoidance removed - now handled by d3-force simulation */
// The initial circular layout provides good starting positions
// D3-force will handle collision avoidance and spacing dynamically
allNodes.push(...documentNodes)
@ -186,34 +210,58 @@ export function useGraphData(
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
// Simple circular positioning around parent doc
// Physics will naturally cluster them better
const angle = (memIndex / doc.memoryEntries.length) * Math.PI * 2
const distance = clusterRadius * 1 // Closer to parent, let physics separate
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(angle) * distance
const defaultMemY = docNode.y + Math.sin(angle) * distance
const defaultMemX =
docNode.x + Math.cos(clusterAngle) * distance + offsetX
const defaultMemY =
docNode.y + Math.sin(clusterAngle) * distance + offsetY
// Calculate final position
let finalMemX = defaultMemX
let finalMemY = defaultMemY
if (customMemPos) {
// If memory was manually positioned and has stored offset relative to parent
if (customMemPos.parentDocId === docNode.id &&
customMemPos.offsetX !== undefined &&
customMemPos.offsetY !== undefined) {
// Apply the stored offset to the current document position
finalMemX = docNode.x + customMemPos.offsetX
finalMemY = docNode.y + customMemPos.offsetY
} else {
// Fallback: use absolute position (for backward compatibility or if parent changed)
finalMemX = customMemPos.x
finalMemY = customMemPos.y
}
}
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,
// Check if memory node exists in cache (preserves d3-force mutations)
let memoryNode = nodeCache.current.get(memoryId)
if (memoryNode) {
// Update existing node's data, preserve physics properties
memoryNode.data = memory
memoryNode.isDragging = draggingNodeId === memoryId
// Don't reset x/y - they're managed by d3-force
} else {
// Create new node with initial position
memoryNode = {
id: memoryId,
type: "memory",
x: finalMemX,
y: finalMemY,
data: memory,
size: Math.max(
32,
Math.min(48, (memory.memory?.length || 50) * 0.5),
),
color: colors.memory.primary,
isHovered: false,
isDragging: draggingNodeId === memoryId,
}
nodeCache.current.set(memoryId, memoryNode)
}
memoryNodeMap.set(memoryId, memoryNode)
allNodes.push(memoryNode)
@ -289,33 +337,9 @@ export function useGraphData(
})
})
// 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",
})
}
}
}
// Append similarity edges (calculated in separate memo)
allEdges.push(...similarityEdges)
return { nodes: allNodes, edges: allEdges }
}, [data, selectedSpace, nodePositions, draggingNodeId, memoryLimit])
}, [data, filteredDocuments, nodePositions, draggingNodeId, similarityEdges])
}

View file

@ -24,7 +24,7 @@ export function useGraphInteractions(
nodeY: 0,
})
const [nodePositions, setNodePositions] = useState<
Map<string, { x: number; y: number }>
Map<string, { x: number; y: number; parentDocId?: string; offsetX?: number; offsetY?: number }>
>(new Map())
// Touch gesture state
@ -109,7 +109,7 @@ export function useGraphInteractions(
)
const handleNodeDragMove = useCallback(
(e: React.MouseEvent) => {
(e: React.MouseEvent, nodes?: GraphNode[]) => {
if (!draggingNodeId) return
const deltaX = (e.clientX - dragStart.x) / zoom
@ -118,6 +118,36 @@ export function useGraphInteractions(
const newX = dragStart.nodeX + deltaX
const newY = dragStart.nodeY + deltaY
// Find the node being dragged to determine if it's a memory
const draggedNode = nodes?.find((n) => n.id === draggingNodeId)
if (draggedNode?.type === "memory") {
// For memory nodes, find the parent document and store relative offset
const memoryData = draggedNode.data as any // MemoryEntry type
const parentDoc = nodes?.find(
(n) => n.type === "document" &&
(n.data as any).memoryEntries?.some((m: any) => m.id === memoryData.id)
)
if (parentDoc) {
// Store the offset from the parent document
const offsetX = newX - parentDoc.x
const offsetY = newY - parentDoc.y
setNodePositions((prev) =>
new Map(prev).set(draggingNodeId, {
x: newX,
y: newY,
parentDocId: parentDoc.id,
offsetX,
offsetY
}),
)
return
}
}
// For document nodes or if parent not found, just store absolute position
setNodePositions((prev) =>
new Map(prev).set(draggingNodeId, { x: newX, y: newY }),
)

View file

@ -17,14 +17,20 @@ export interface GraphNode {
color: string
isHovered: boolean
isDragging: boolean
// D3-force simulation properties
vx?: number // velocity x
vy?: number // velocity y
fx?: number | null // fixed x position (for pinning during drag)
fy?: number | null // fixed y position (for pinning during drag)
}
export type MemoryRelation = "updates" | "extends" | "derives"
export interface GraphEdge {
id: string
source: string
target: string
// D3-force mutates source/target from string IDs to node references during simulation
source: string | GraphNode
target: string | GraphNode
similarity: number
visualProps: {
opacity: number
@ -74,6 +80,10 @@ export interface GraphCanvasProps {
draggingNodeId: string | null
// Optional list of document IDs (customId or internal id) to highlight
highlightDocumentIds?: string[]
// Physics simulation state
isSimulationActive?: boolean
// Selected node ID - dims all other nodes and edges
selectedNodeId?: string | null
}
export interface MemoryGraphProps {
@ -123,6 +133,14 @@ export interface MemoryGraphProps {
// Feature flags
/** Enable experimental features */
isExperimental?: boolean
// Slideshow control
/** Whether slideshow mode is currently active */
isSlideshowActive?: boolean
/** Callback when slideshow selects a new node (provides node ID) */
onSlideshowNodeChange?: (nodeId: string | null) => void
/** Callback when user clicks outside during slideshow (to stop it) */
onSlideshowStop?: () => void
}
export interface LegendProps {

View file

@ -0,0 +1,237 @@
/**
* Canvas-based document type icon rendering utilities
* Simplified to match supported file types: PDF, TXT, MD, DOCX, DOC, RTF, CSV, JSON
*/
export type DocumentIconType =
| "text"
| "pdf"
| "md"
| "markdown"
| "docx"
| "doc"
| "rtf"
| "csv"
| "json"
/**
* Draws a document type icon on canvas
* @param ctx - Canvas 2D rendering context
* @param x - X position (center of icon)
* @param y - Y position (center of icon)
* @param size - Icon size (width/height)
* @param type - Document type
* @param color - Icon color (default: white)
*/
export function drawDocumentIcon(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
size: number,
type: string,
color = "rgba(255, 255, 255, 0.9)",
): void {
ctx.save()
ctx.fillStyle = color
ctx.strokeStyle = color
ctx.lineWidth = Math.max(1, size / 12)
ctx.lineCap = "round"
ctx.lineJoin = "round"
switch (type) {
case "pdf":
drawPdfIcon(ctx, x, y, size)
break
case "md":
case "markdown":
drawMarkdownIcon(ctx, x, y, size)
break
case "doc":
case "docx":
drawWordIcon(ctx, x, y, size)
break
case "rtf":
drawRtfIcon(ctx, x, y, size)
break
case "csv":
drawCsvIcon(ctx, x, y, size)
break
case "json":
drawJsonIcon(ctx, x, y, size)
break
case "txt":
case "text":
default:
drawTextIcon(ctx, x, y, size)
break
}
ctx.restore()
}
// Individual icon drawing functions
function drawTextIcon(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
size: number,
): void {
// Simple document outline with lines
const w = size * 0.7
const h = size * 0.85
const cornerFold = size * 0.2
ctx.beginPath()
ctx.moveTo(x - w / 2, y - h / 2)
ctx.lineTo(x + w / 2 - cornerFold, y - h / 2)
ctx.lineTo(x + w / 2, y - h / 2 + cornerFold)
ctx.lineTo(x + w / 2, y + h / 2)
ctx.lineTo(x - w / 2, y + h / 2)
ctx.closePath()
ctx.stroke()
// Text lines
const lineSpacing = size * 0.15
const lineWidth = size * 0.4
ctx.beginPath()
ctx.moveTo(x - lineWidth / 2, y - lineSpacing)
ctx.lineTo(x + lineWidth / 2, y - lineSpacing)
ctx.moveTo(x - lineWidth / 2, y)
ctx.lineTo(x + lineWidth / 2, y)
ctx.moveTo(x - lineWidth / 2, y + lineSpacing)
ctx.lineTo(x + lineWidth / 2, y + lineSpacing)
ctx.stroke()
}
function drawPdfIcon(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
size: number,
): void {
// Document with "PDF" text
const w = size * 0.7
const h = size * 0.85
ctx.beginPath()
ctx.rect(x - w / 2, y - h / 2, w, h)
ctx.stroke()
// "PDF" letters (simplified)
ctx.font = `bold ${size * 0.35}px sans-serif`
ctx.textAlign = "center"
ctx.textBaseline = "middle"
ctx.fillText("PDF", x, y)
}
function drawMarkdownIcon(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
size: number,
): void {
// Document with "MD" text
const w = size * 0.7
const h = size * 0.85
ctx.beginPath()
ctx.rect(x - w / 2, y - h / 2, w, h)
ctx.stroke()
// "MD" letters
ctx.font = `bold ${size * 0.3}px sans-serif`
ctx.textAlign = "center"
ctx.textBaseline = "middle"
ctx.fillText("MD", x, y)
}
function drawWordIcon(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
size: number,
): void {
// Document with "DOC" text
const w = size * 0.7
const h = size * 0.85
ctx.beginPath()
ctx.rect(x - w / 2, y - h / 2, w, h)
ctx.stroke()
// "DOC" letters
ctx.font = `bold ${size * 0.28}px sans-serif`
ctx.textAlign = "center"
ctx.textBaseline = "middle"
ctx.fillText("DOC", x, y)
}
function drawRtfIcon(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
size: number,
): void {
// Document with "RTF" text
const w = size * 0.7
const h = size * 0.85
ctx.beginPath()
ctx.rect(x - w / 2, y - h / 2, w, h)
ctx.stroke()
// "RTF" letters
ctx.font = `bold ${size * 0.3}px sans-serif`
ctx.textAlign = "center"
ctx.textBaseline = "middle"
ctx.fillText("RTF", x, y)
}
function drawCsvIcon(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
size: number,
): void {
// Grid table for CSV
const w = size * 0.7
const h = size * 0.85
ctx.strokeRect(x - w / 2, y - h / 2, w, h)
// Grid lines (2x2)
ctx.beginPath()
// Vertical line
ctx.moveTo(x, y - h / 2)
ctx.lineTo(x, y + h / 2)
// Horizontal line
ctx.moveTo(x - w / 2, y)
ctx.lineTo(x + w / 2, y)
ctx.stroke()
}
function drawJsonIcon(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
size: number,
): void {
// Curly braces for JSON
const w = size * 0.6
const h = size * 0.8
// Left brace
ctx.beginPath()
ctx.moveTo(x - w / 4, y - h / 2)
ctx.quadraticCurveTo(x - w / 2, y - h / 3, x - w / 2, y)
ctx.quadraticCurveTo(x - w / 2, y + h / 3, x - w / 4, y + h / 2)
ctx.stroke()
// Right brace
ctx.beginPath()
ctx.moveTo(x + w / 4, y - h / 2)
ctx.quadraticCurveTo(x + w / 2, y - h / 3, x + w / 2, y)
ctx.quadraticCurveTo(x + w / 2, y + h / 3, x + w / 4, y + h / 2)
ctx.stroke()
}