mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-07 08:26:15 +00:00
Merge pull request #1 from vrupak/experiment/graph-ui-improvements
Experiment/graph UI improvements
This commit is contained in:
commit
47a1bc764e
16 changed files with 1948 additions and 905 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -40,6 +40,6 @@ yarn-error.log*
|
|||
# Misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
.claude
|
||||
.venv
|
||||
__pycache__
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
83
packages/memory-graph/CHANGELOG.md
Normal file
83
packages/memory-graph/CHANGELOG.md
Normal 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
|
||||
|
||||
---
|
||||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
463
packages/memory-graph/src/components/node-popover.tsx
Normal file
463
packages/memory-graph/src/components/node-popover.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
180
packages/memory-graph/src/hooks/use-force-simulation.ts
Normal file
180
packages/memory-graph/src/hooks/use-force-simulation.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
237
packages/memory-graph/src/utils/document-icons.ts
Normal file
237
packages/memory-graph/src/utils/document-icons.ts
Normal 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()
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue