diff --git a/apps/web/app/(navigation)/graph/page.tsx b/apps/web/app/(navigation)/graph/page.tsx index a1159c53..a6f20b4e 100644 --- a/apps/web/app/(navigation)/graph/page.tsx +++ b/apps/web/app/(navigation)/graph/page.tsx @@ -14,10 +14,10 @@ export default function GraphPage() { if (!user) { return ( -
+
- -

Loading...

+ +

Loading...

) @@ -29,15 +29,15 @@ export default function GraphPage() { : undefined return ( -
+
-

No memories found

+

No memories found

diff --git a/apps/web/components/graph/constants.ts b/apps/web/components/graph/constants.ts index 15dbe9b8..8383b6ff 100644 --- a/apps/web/components/graph/constants.ts +++ b/apps/web/components/graph/constants.ts @@ -1,38 +1,50 @@ export const colors = { background: { - primary: "#0f1419", - secondary: "#1a1f29", - accent: "#252a35", + primary: "#000B1B", + secondary: "#01173C", + accent: "#0A2351", }, + // All nodes are now hexagons with cyan color + node: { + primary: "rgba(0, 180, 216, 0.25)", + secondary: "rgba(0, 180, 216, 0.35)", + accent: "rgba(0, 180, 216, 0.45)", + border: "rgba(0, 180, 216, 0.7)", + glow: "rgba(0, 180, 216, 0.5)", + }, + // Legacy document colors (for compatibility) document: { - primary: "rgba(255, 255, 255, 0.21)", - secondary: "rgba(255, 255, 255, 0.31)", - accent: "rgba(255, 255, 255, 0.31)", - border: "rgba(255, 255, 255, 0.6)", - glow: "rgba(147, 197, 253, 0.4)", + primary: "rgba(0, 180, 216, 0.25)", + secondary: "rgba(0, 180, 216, 0.35)", + accent: "rgba(0, 180, 216, 0.45)", + border: "rgba(0, 180, 216, 0.7)", + glow: "rgba(0, 180, 216, 0.5)", }, + // Legacy memory colors (for compatibility) memory: { - primary: "rgba(147, 196, 253, 0.21)", - secondary: "rgba(147, 196, 253, 0.31)", - accent: "rgba(147, 197, 253, 0.31)", - border: "rgba(147, 196, 253, 0.6)", - glow: "rgba(147, 197, 253, 0.5)", + primary: "rgba(0, 180, 216, 0.25)", + secondary: "rgba(0, 180, 216, 0.35)", + accent: "rgba(0, 180, 216, 0.45)", + border: "rgba(0, 180, 216, 0.7)", + glow: "rgba(0, 180, 216, 0.5)", }, connection: { - weak: "rgba(35, 189, 255, 0.3)", - memory: "rgba(148, 163, 184, 0.35)", - medium: "rgba(35, 189, 255, 0.6)", - strong: "rgba(35, 189, 255, 0.9)", + weak: "rgba(0, 180, 216, 0.3)", + memory: "rgba(0, 180, 216, 0.4)", + medium: "rgba(0, 180, 216, 0.6)", + strong: "rgba(0, 180, 216, 0.9)", + // Pink/magenta for special relationships + relation: "rgba(236, 72, 153, 0.6)", }, text: { primary: "#ffffff", secondary: "#e2e8f0", - muted: "#94a3b8", + muted: "#525D6E", }, accent: { - primary: "rgba(59, 130, 246, 0.7)", - secondary: "rgba(99, 102, 241, 0.6)", - glow: "rgba(147, 197, 253, 0.6)", + primary: "rgba(0, 180, 216, 0.7)", + secondary: "rgba(34, 97, 202, 0.6)", + glow: "rgba(0, 180, 216, 0.6)", amber: "rgba(251, 165, 36, 0.8)", emerald: "rgba(16, 185, 129, 0.4)", }, @@ -42,9 +54,9 @@ export const colors = { new: "rgba(16, 185, 129, 0.4)", }, relations: { - updates: "rgba(147, 77, 253, 0.5)", + updates: "rgba(236, 72, 153, 0.5)", extends: "rgba(16, 185, 129, 0.5)", - derives: "rgba(147, 197, 253, 0.5)", + derives: "rgba(0, 180, 216, 0.5)", }, } diff --git a/apps/web/components/graph/graph-canvas.tsx b/apps/web/components/graph/graph-canvas.tsx index f78a9b25..634cdfad 100644 --- a/apps/web/components/graph/graph-canvas.tsx +++ b/apps/web/components/graph/graph-canvas.tsx @@ -138,28 +138,14 @@ export const GraphCanvas = memo( const screenY = node.y * zoom + panY const nodeSize = node.size * zoom - if (node.type === "document") { - const docWidth = nodeSize * 1.4 - const docHeight = nodeSize * 0.9 - const halfW = docWidth / 2 - const halfH = docHeight / 2 + // All nodes are hexagons now, use circular hit detection + const radius = node.type === "document" ? nodeSize * 0.6 : nodeSize / 2 + const dx = x - screenX + const dy = y - screenY + const distance = Math.sqrt(dx * dx + dy * dy) - if ( - x >= screenX - halfW && - x <= screenX + halfW && - y >= screenY - halfH && - y <= screenY + halfH - ) { - return node.id - } - } else { - const dx = x - screenX - const dy = y - screenY - const distance = Math.sqrt(dx * dx + dy * dy) - - if (distance <= nodeSize / 2) { - return node.id - } + if (distance <= radius) { + return node.id } } } @@ -227,23 +213,20 @@ export const GraphCanvas = memo( ctx.clearRect(0, 0, width, height) - ctx.strokeStyle = "rgba(148, 163, 184, 0.03)" - ctx.lineWidth = 1 - const gridSpacing = 100 * zoom - const offsetX = panX % gridSpacing - const offsetY = panY % gridSpacing + // Draw dotted background pattern + const dotSpacing = 30 * Math.max(zoom, 0.5) // Maintain reasonable spacing + const offsetX = panX % dotSpacing + const offsetY = panY % dotSpacing + const dotRadius = 1 - for (let x = offsetX; x < width; x += gridSpacing) { - ctx.beginPath() - ctx.moveTo(x, 0) - ctx.lineTo(x, height) - ctx.stroke() - } - for (let y = offsetY; y < height; y += gridSpacing) { - ctx.beginPath() - ctx.moveTo(0, y) - ctx.lineTo(width, y) - ctx.stroke() + ctx.fillStyle = "rgba(0, 180, 216, 0.15)" + + for (let x = offsetX; x < width; x += dotSpacing) { + for (let y = offsetY; y < height; y += dotSpacing) { + ctx.beginPath() + ctx.arc(x, y, dotRadius, 0, 2 * Math.PI) + ctx.fill() + } } ctx.lineCap = "round" @@ -420,115 +403,71 @@ export const GraphCanvas = memo( return highlightSet.has(doc.id) })() - if (node.type === "document") { - const docWidth = nodeSize * 1.4 - const docHeight = nodeSize * 0.9 + // All nodes are now rendered as hexagons + const isDocument = node.type === "document" + const isMemory = node.type === "memory" - ctx.fillStyle = isHovered - ? colors.document.secondary - : colors.document.primary - ctx.globalAlpha = nodeOpacity + // Get node data for status checks + let isNew = false + if (isMemory) { + const mem = node.data as ViewportMemoryEntry + isNew = new Date(mem.createdAt).getTime() > Date.now() - 1000 * 60 * 60 * 24 + } - ctx.strokeStyle = isHovered - ? colors.document.accent - : colors.document.border - ctx.lineWidth = isHovered ? 2 : 1 + // Use consistent node colors for all nodes + let fillColor = colors.node.primary + let borderColor = colors.node.border - const radius = useSimplifiedRendering ? 6 : 12 + if (isHovered) { + fillColor = colors.node.secondary + } + + if (isNew) { + borderColor = colors.status.new + } + + // Document nodes are slightly larger + const radius = isDocument ? nodeSize * 0.6 : nodeSize / 2 + + ctx.fillStyle = fillColor + ctx.globalAlpha = shouldDim ? nodeOpacity : 1 + ctx.strokeStyle = borderColor + ctx.lineWidth = isHovered ? 2 : 1.5 + + if (useSimplifiedRendering) { + // Simple circle for low zoom ctx.beginPath() - ctx.roundRect( - screenX - docWidth / 2, - screenY - docHeight / 2, - docWidth, - docHeight, - radius, - ) + ctx.arc(screenX, screenY, radius, 0, 2 * Math.PI) + ctx.fill() + ctx.stroke() + } else { + // Hexagon for normal zoom + const sides = 6 + ctx.beginPath() + for (let i = 0; i < sides; i++) { + const angle = (i * 2 * Math.PI) / sides - Math.PI / 2 + const x = screenX + radius * Math.cos(angle) + const y = screenY + radius * Math.sin(angle) + if (i === 0) { + ctx.moveTo(x, y) + } else { + ctx.lineTo(x, y) + } + } + ctx.closePath() ctx.fill() ctx.stroke() - if (!useSimplifiedRendering && isHovered) { - ctx.strokeStyle = "rgba(255, 255, 255, 0.1)" - ctx.lineWidth = 1 - ctx.beginPath() - ctx.roundRect( - screenX - docWidth / 2 + 1, - screenY - docHeight / 2 + 1, - docWidth - 2, - docHeight - 2, - radius - 1, - ) - ctx.stroke() - } - - if (isHighlightedDocument) { - ctx.save() - ctx.globalAlpha = 0.9 - ctx.strokeStyle = colors.accent.primary - ctx.lineWidth = 3 - ctx.setLineDash([6, 4]) - const avgDimension = (docWidth + docHeight) / 2 - const ringPadding = avgDimension * 0.1 - ctx.beginPath() - ctx.roundRect( - screenX - docWidth / 2 - ringPadding, - screenY - docHeight / 2 - ringPadding, - docWidth + ringPadding * 2, - docHeight + ringPadding * 2, - radius + 6, - ) - ctx.stroke() - ctx.setLineDash([]) - ctx.restore() - } - - if (!useSimplifiedRendering) { - const doc = node.data as ViewportDocument - const iconSize = docHeight * 0.4 - - drawDocumentIcon( - ctx, - screenX, - screenY, - iconSize, - doc.type || "text", - "rgba(255, 255, 255, 0.8)", - ) - } - } else { - const mem = node.data as ViewportMemoryEntry - const isNew = - new Date(mem.createdAt).getTime() > Date.now() - 1000 * 60 * 60 * 24 - - let fillColor = colors.memory.primary - let borderColor = colors.memory.border - + // Inner highlight on hover if (isHovered) { - fillColor = colors.memory.secondary - } - - if (isNew) { - borderColor = colors.status.new - } - - const radius = nodeSize / 2 - - ctx.fillStyle = fillColor - ctx.globalAlpha = shouldDim ? nodeOpacity : 1 - ctx.strokeStyle = borderColor - ctx.lineWidth = isHovered ? 2 : 1.5 - - if (useSimplifiedRendering) { - ctx.beginPath() - ctx.arc(screenX, screenY, radius, 0, 2 * Math.PI) - ctx.fill() - ctx.stroke() - } else { - const sides = 6 + ctx.strokeStyle = "rgba(0, 180, 216, 0.3)" + ctx.lineWidth = 1 + const innerRadius = radius - 2 ctx.beginPath() for (let i = 0; i < sides; i++) { const angle = (i * 2 * Math.PI) / sides - Math.PI / 2 - const x = screenX + radius * Math.cos(angle) - const y = screenY + radius * Math.sin(angle) + const x = screenX + innerRadius * Math.cos(angle) + const y = screenY + innerRadius * Math.sin(angle) if (i === 0) { ctx.moveTo(x, y) } else { @@ -536,80 +475,73 @@ export const GraphCanvas = memo( } } ctx.closePath() - ctx.fill() ctx.stroke() - - if (isHovered) { - ctx.strokeStyle = "rgba(147, 197, 253, 0.3)" - ctx.lineWidth = 1 - const innerRadius = radius - 2 - ctx.beginPath() - for (let i = 0; i < sides; i++) { - const angle = (i * 2 * Math.PI) / sides - Math.PI / 2 - const x = screenX + innerRadius * Math.cos(angle) - const y = screenY + innerRadius * Math.sin(angle) - if (i === 0) { - ctx.moveTo(x, y) - } else { - ctx.lineTo(x, y) - } - } - ctx.closePath() - ctx.stroke() - } - } - - if (isNew) { - ctx.fillStyle = colors.status.new - ctx.beginPath() - ctx.arc( - screenX + nodeSize * 0.25, - screenY - nodeSize * 0.25, - Math.max(2, nodeSize * 0.15), - 0, - 2 * Math.PI, - ) - ctx.fill() } } + // Highlighted document indicator + if (isHighlightedDocument) { + ctx.save() + ctx.globalAlpha = 0.9 + ctx.strokeStyle = colors.accent.primary + ctx.lineWidth = 3 + ctx.setLineDash([6, 4]) + const highlightRadius = radius * 1.2 + const sides = 6 + ctx.beginPath() + for (let i = 0; i < sides; i++) { + const angle = (i * 2 * Math.PI) / sides - Math.PI / 2 + const x = screenX + highlightRadius * Math.cos(angle) + const y = screenY + highlightRadius * Math.sin(angle) + if (i === 0) { + ctx.moveTo(x, y) + } else { + ctx.lineTo(x, y) + } + } + ctx.closePath() + ctx.stroke() + ctx.setLineDash([]) + ctx.restore() + } + + // New item indicator dot + if (isNew) { + ctx.fillStyle = colors.status.new + ctx.beginPath() + ctx.arc( + screenX + nodeSize * 0.25, + screenY - nodeSize * 0.25, + Math.max(2, nodeSize * 0.15), + 0, + 2 * Math.PI, + ) + ctx.fill() + } + + // Unified glow effect for all nodes (hexagon) if (!useSimplifiedRendering && isHovered) { - const glowColor = - node.type === "document" ? colors.document.glow : colors.memory.glow + const glowColor = colors.node.glow ctx.strokeStyle = glowColor ctx.lineWidth = 1 ctx.setLineDash([3, 3]) ctx.globalAlpha = 0.6 + const glowRadius = (node.type === "document" ? nodeSize * 0.6 : nodeSize / 2) * 1.3 + const sides = 6 ctx.beginPath() - if (node.type === "document") { - const docWidth = nodeSize * 1.4 - const docHeight = nodeSize * 0.9 - const avgDimension = (docWidth + docHeight) / 2 - const glowPadding = avgDimension * 0.1 - ctx.roundRect( - screenX - docWidth / 2 - glowPadding, - screenY - docHeight / 2 - glowPadding, - docWidth + glowPadding * 2, - docHeight + glowPadding * 2, - 15, - ) - } else { - 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 - const x = screenX + glowRadius * Math.cos(angle) - const y = screenY + glowRadius * Math.sin(angle) - if (i === 0) { - ctx.moveTo(x, y) - } else { - ctx.lineTo(x, y) - } + for (let i = 0; i < sides; i++) { + const angle = (i * 2 * Math.PI) / sides - Math.PI / 2 + const x = screenX + glowRadius * Math.cos(angle) + const y = screenY + glowRadius * Math.sin(angle) + if (i === 0) { + ctx.moveTo(x, y) + } else { + ctx.lineTo(x, y) } - ctx.closePath() } + ctx.closePath() ctx.stroke() ctx.setLineDash([]) } diff --git a/apps/web/components/graph/graph.tsx b/apps/web/components/graph/graph.tsx index ed5542e2..a9e7e73e 100644 --- a/apps/web/components/graph/graph.tsx +++ b/apps/web/components/graph/graph.tsx @@ -436,6 +436,7 @@ export function Graph({ containerTags, children }: GraphProps) { isTimelineActive={isTimelineStreaming} timelineProgress={timelineProgress} nodes={nodes} + zoom={zoom} /> )}
diff --git a/apps/web/components/graph/legend.tsx b/apps/web/components/graph/legend.tsx index ca04f1e5..9410f33b 100644 --- a/apps/web/components/graph/legend.tsx +++ b/apps/web/components/graph/legend.tsx @@ -1,8 +1,7 @@ "use client" -import { Brain, ChevronDown, ChevronUp, FileText } from "lucide-react" +import { ChevronUp, ChevronDown, Settings } from "lucide-react" import { memo, useEffect, useState } from "react" -import { colors } from "./constants" import type { LegendProps } from "./types" const setCookie = (name: string, value: string, days = 365) => { @@ -25,22 +24,51 @@ const getCookie = (name: string): string | null => { return null } +// Toggle switch component +function Toggle({ enabled, onChange }: { enabled: boolean; onChange: (v: boolean) => void }) { + return ( + + ) +} + export const Legend = memo(function Legend({ id, nodes = [], edges = [], isLoading = false, }: LegendProps) { - const [isExpanded, setIsExpanded] = useState(true) + const [isExpanded, setIsExpanded] = useState(false) const [isInitialized, setIsInitialized] = useState(false) + const [connectionsExpanded, setConnectionsExpanded] = useState(true) + + // Toggle states for filtering + const [showDocMemory, setShowDocMemory] = useState(true) + const [showDocSimilarity, setShowDocSimilarity] = useState(true) + const [showUpdates, setShowUpdates] = useState(false) + const [showExtends, setShowExtends] = useState(true) + const [showInferences, setShowInferences] = useState(false) + const [showStrong, setShowStrong] = useState(true) + const [showWeak, setShowWeak] = useState(true) useEffect(() => { if (!isInitialized) { const savedState = getCookie("legendCollapsed") - if (savedState === "true") { - setIsExpanded(false) - } else if (savedState === "false") { + if (savedState === "false") { setIsExpanded(true) + } else { + setIsExpanded(false) } setIsInitialized(true) } @@ -54,124 +82,202 @@ export const Legend = memo(function Legend({ const memoryCount = nodes.filter((n) => n.type === "memory").length const documentCount = nodes.filter((n) => n.type === "document").length + const connectionCount = edges.length return ( -
-
+ <> + {/* Settings button - Bottom right */} + + + {/* Legend - Positioned at bottom left */} +
+ {/* Legend expanded content - appears above the button */} + {isExpanded && ( +
+ {/* Header */} + + +
+ {/* STATISTICS */} +
+
+ Statistics +
+
+ {/* Memories */} +
+
+
+ Memories + +
+ {memoryCount} +
+ + {/* Documents */} +
+
+
+ Documents + +
+ {documentCount} +
+ + {/* Connections */} +
+ + + {/* Expanded connections */} + {connectionsExpanded && ( +
+
+
+
+ Doc > Memory +
+
+
+
+
+ Doc similarity +
+ +
+
+ )} +
+
+
+ + {/* RELATIONS */} +
+
+ Relations +
+
+
+
+
+ Updates +
+ +
+
+
+
+ Extends +
+ +
+
+
+
+ Inferences +
+ +
+
+
+ + {/* SIMILARITY */} +
+
+ Similarity +
+
+
+
+
+ Strong +
+ +
+
+
+
+ Weak +
+ +
+
+
+
+
+ )} + + {/* Legend toggle button (collapsed state) */} {!isExpanded && ( )} - - {isExpanded && ( - <> -
- Legend - -
- -
- {!isLoading && ( -
-
Statistics
-
-
- - - {memoryCount} memories - -
-
- - - {documentCount} documents - -
-
-
- - {edges.length} connections - -
-
-
- )} - -
-
Nodes
-
-
-
- Document -
-
-
- Memory -
-
-
- -
-
Connections
-
-
-
- Doc → Memory -
-
-
- Doc similarity -
-
-
- -
-
Similarity
-
-
-
- Weak -
-
-
- Strong -
-
-
-
- - )}
-
+ ) }) diff --git a/apps/web/components/graph/loading-indicator.tsx b/apps/web/components/graph/loading-indicator.tsx index 3af7167b..06df2cd4 100644 --- a/apps/web/components/graph/loading-indicator.tsx +++ b/apps/web/components/graph/loading-indicator.tsx @@ -9,10 +9,10 @@ export const LoadingIndicator = memo( if (!isLoading && !isLoadingMore) return null return ( -
+
- + {isLoading ? "Loading memory graph..." diff --git a/apps/web/components/graph/navigation-controls.tsx b/apps/web/components/graph/navigation-controls.tsx index e7457e1f..ce4f862e 100644 --- a/apps/web/components/graph/navigation-controls.tsx +++ b/apps/web/components/graph/navigation-controls.tsx @@ -1,6 +1,7 @@ "use client" import { memo } from "react" +import { Play, Minus, Plus } from "lucide-react" import type { NavigationControlsProps } from "./types" export const NavigationControls = memo( @@ -14,67 +15,95 @@ export const NavigationControls = memo( timelineProgress, nodes, className = "", + zoom = 1, }) => { + const zoomPercent = Math.round(zoom * 100) + return (
+ {/* Timeline controls */} {onTimeline && ( - + + ⌘ P + + {isTimelineActive && timelineProgress && ( + + {timelineProgress.streamed} + )} - + Today +
)} + + {/* Navigation controls */} {nodes.length > 0 && ( <> + {/* Fit button */} + + {/* Center button */} -
- + + {/* Zoom controls */} +
+ + {zoomPercent}% + +
diff --git a/apps/web/components/graph/node-popover.tsx b/apps/web/components/graph/node-popover.tsx index 48205320..956da541 100644 --- a/apps/web/components/graph/node-popover.tsx +++ b/apps/web/components/graph/node-popover.tsx @@ -31,6 +31,30 @@ export const NodePopover = memo(function NodePopover({ } : undefined + // Get content based on node type + const getContent = () => { + if (node.type === "document") { + const doc = node.data as ViewportDocument + return doc.summary || doc.title || "No content available" + } + const mem = node.data as ViewportMemoryEntry + return mem.content || mem.summary || "No content available" + } + + // Check if this is the latest/new item + const isLatest = () => { + if (node.type === "memory") { + const mem = node.data as ViewportMemoryEntry + return new Date(mem.createdAt).getTime() > Date.now() - 1000 * 60 * 60 * 24 + } + return false + } + + // Get version info + const getVersion = () => { + return "v1" + } + return ( <>
(function NodePopover({
e.stopPropagation()} - className="fixed z-30 w-80 bg-slate-900/95 backdrop-blur-xl border border-white/20 rounded-xl shadow-2xl overflow-hidden" + className="fixed z-30 w-[400px] bg-[#0A1628]/95 backdrop-blur-xl border border-[#1E3A5F] rounded-2xl shadow-2xl overflow-hidden" style={{ left: `${x}px`, top: `${y}px`, }} > - {node.type === "document" ? ( -
-
-
- - - - - - - -

Document

-
-
+ + {/* Bottom bar with version and latest badge */} +
+ + {getVersion()} + + + {isLatest() && ( +
+ - × - + + + + + Latest
- -
-
-
Title
-

- {(node.data as ViewportDocument).title || "Untitled Document"} -

-
- - {(node.data as ViewportDocument).summary && ( -
-
Summary
-

- {(node.data as ViewportDocument).summary} -

-
- )} - -
-
Type
-

- {(node.data as ViewportDocument).type || "Document"} -

-
- -
-
Memory Count
-

- {(node.data as ViewportDocument).memoryEntries?.length || 0}{" "} - memories -

-
- - {(node.data as ViewportDocument).url && ( - - )} - -
-
- - - - - - - - {new Date( - (node.data as ViewportDocument).createdAt, - ).toLocaleDateString()} - -
- - {node.id} - -
-
-
- ) : ( -
-
-
- - - - -

Memory

-
- -
- -
-
-
Memory
-

- {(node.data as ViewportMemoryEntry).content || "No content"} -

-
- -
-
Space
-

- {(node.data as ViewportMemoryEntry).spaceId || "Default"} -

-
- -
-
- - - - - - - - {new Date( - (node.data as ViewportMemoryEntry).createdAt, - ).toLocaleDateString()} - -
- - {node.id} - -
-
-
- )} + )} +
) diff --git a/apps/web/components/graph/types.ts b/apps/web/components/graph/types.ts index 0461b84d..fa732b94 100644 --- a/apps/web/components/graph/types.ts +++ b/apps/web/components/graph/types.ts @@ -85,6 +85,7 @@ export interface NavigationControlsProps { timelineProgress?: { streamed: number; total: number | null } nodes: GraphNode[] className?: string + zoom?: number } export interface NodePopoverProps {