-
-
Memory
+
-
-
-
-
-
-
-
diff --git a/apps/mcp/src/ui/mcp-app.css b/apps/mcp/src/ui/mcp-app.css
index 593df120..65ea3de0 100644
--- a/apps/mcp/src/ui/mcp-app.css
+++ b/apps/mcp/src/ui/mcp-app.css
@@ -1,8 +1,17 @@
#graph {
width: 100%;
height: 600px;
+ border-radius: 12px;
+ overflow: hidden;
+ background-image: radial-gradient(
+ circle,
+ var(--text-muted) 0.5px,
+ transparent 0.5px
+ );
+ background-size: 16px 16px;
}
+/* Loading */
#loading {
display: flex;
position: absolute;
@@ -31,6 +40,7 @@
}
}
+/* Stats badge */
#stats {
position: fixed;
top: 12px;
@@ -38,20 +48,26 @@
font-size: 12px;
color: var(--text-muted);
z-index: 10;
- padding: 4px 10px;
+ padding: 6px 12px;
background: var(--bg-secondary);
border: 1px solid var(--border);
- border-radius: 6px;
+ border-radius: 10px;
+ box-shadow:
+ 0 4px 6px -1px rgba(0, 0, 0, 0.1),
+ 0 2px 4px -2px rgba(0, 0, 0, 0.1);
}
+/* Popup */
#popup {
display: none;
position: fixed;
background: var(--bg-secondary);
border: 1px solid var(--border);
- border-radius: 10px;
+ border-radius: 12px;
padding: 14px;
- box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
+ box-shadow:
+ 0 10px 15px -3px rgba(0, 0, 0, 0.1),
+ 0 4px 6px -4px rgba(0, 0, 0, 0.1);
z-index: 100;
min-width: 220px;
max-width: 360px;
@@ -65,26 +81,23 @@
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
- padding: 2px 6px;
- border-radius: 4px;
- margin-bottom: 6px;
+ padding: 2px 8px;
+ border-radius: 6px;
+ margin-bottom: 8px;
}
#popup-type.document {
background: rgba(59, 115, 184, 0.15);
color: #3b73b8;
}
-
#popup-type.memory {
background: rgba(59, 115, 184, 0.15);
color: #3b73b8;
}
-
#popup-type.forgotten {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
}
-
#popup-type.latest {
background: rgba(16, 185, 129, 0.15);
color: #10b981;
@@ -92,7 +105,7 @@
#popup-title {
font-weight: 600;
- font-size: 14px;
+ font-size: 13px;
margin-bottom: 6px;
color: var(--text);
word-wrap: break-word;
@@ -113,77 +126,173 @@
opacity: 0.7;
}
+/* Controls - vertical stack, bottom left, above legend */
#controls {
position: fixed;
- bottom: 16px;
- right: 16px;
+ bottom: 72px;
+ left: 16px;
display: flex;
- flex-direction: row;
- gap: 6px;
- z-index: 10;
+ flex-direction: column;
+ gap: 4px;
+ z-index: 15;
}
-#controls button {
- width: 36px;
- height: 36px;
+#controls > button {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
background: var(--bg-secondary);
border: 1px solid var(--border);
- border-radius: 8px;
- color: var(--text);
- font-size: 16px;
+ border-radius: 9999px;
+ color: var(--text-muted);
+ font-size: 12px;
+ cursor: pointer;
+ padding: 8px 12px;
+ white-space: nowrap;
+ box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
+ transition: opacity 0.15s;
+ min-width: 0;
+}
+
+#controls > button:hover {
+ opacity: 0.85;
+}
+
+#controls > button kbd {
+ font-family: inherit;
+ font-size: 10px;
+ font-weight: 500;
+ background: var(--border);
+ padding: 2px 6px;
+ border-radius: 4px;
+ color: var(--text-muted);
+}
+
+#zoom-row {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border);
+ border-radius: 9999px;
+ padding: 6px 10px;
+ box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
+}
+
+#zoom-row button {
+ width: 20px;
+ height: 20px;
+ padding: 0;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ background: var(--bg-secondary);
+ color: var(--text-muted);
+ font-size: 13px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
- transition:
- background 0.15s,
- border-color 0.15s;
+ box-shadow: none;
+ transition: opacity 0.15s;
}
-#controls button:hover {
- border-color: var(--accent);
+#zoom-row button:hover {
+ opacity: 0.85;
}
-#controls button:active {
- background: var(--border);
+#zoom-display {
+ font-size: 12px;
+ color: var(--text-muted);
+ min-width: 36px;
+ text-align: center;
+ user-select: none;
+ padding: 0 4px;
}
+/* Legend - bottom left, single expandable card, 214px wide */
#legend {
position: fixed;
bottom: 16px;
left: 16px;
+ width: 214px;
background: var(--bg-secondary);
border: 1px solid var(--border);
- border-radius: 10px;
- padding: 10px 14px;
- z-index: 10;
- font-size: 11px;
+ border-radius: 12px;
+ z-index: 20;
+ font-size: 12px;
color: var(--text-muted);
+ box-shadow:
+ 0 4px 6px -1px rgba(0, 0, 0, 0.1),
+ 0 2px 4px -2px rgba(0, 0, 0, 0.1);
+ overflow: hidden;
+}
+
+#legend-toggle {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 12px;
+ cursor: pointer;
+ user-select: none;
+ font-weight: 600;
+ font-size: 12px;
+ color: var(--text);
+}
+
+.legend-chevron {
+ transition: transform 0.2s;
+ transform: rotate(90deg);
+}
+
+#legend.collapsed .legend-chevron {
+ transform: rotate(0deg);
+}
+
+#legend-body {
+ padding: 0 12px 12px;
+ overflow: hidden;
+ max-height: 400px;
+ transition:
+ max-height 0.25s ease,
+ padding 0.25s ease,
+ opacity 0.2s;
+ opacity: 1;
+}
+
+#legend.collapsed #legend-body {
+ max-height: 0;
+ padding: 0 12px;
+ opacity: 0;
+}
+
+.legend-group {
+ padding: 4px 0;
+}
+
+.legend-divider {
+ height: 1px;
+ background: var(--border);
+ margin: 2px 0;
}
.legend-row {
display: flex;
align-items: center;
gap: 8px;
- margin-bottom: 4px;
+ padding: 3px 0;
+ font-size: 12px;
}
-.legend-row:last-child {
- margin-bottom: 0;
-}
-
-.legend-doc {
- width: 10px;
- height: 10px;
- border-radius: 2px;
- background: var(--doc-fill);
- border: 1.5px solid var(--doc-stroke);
- flex-shrink: 0;
+.legend-count {
+ margin-left: auto;
+ font-size: 11px;
+ opacity: 0.7;
}
.legend-line {
- width: 16px;
+ width: 18px;
height: 0;
border-top: 1.5px solid;
flex-shrink: 0;
diff --git a/apps/mcp/src/ui/mcp-app.ts b/apps/mcp/src/ui/mcp-app.ts
index 6939edcc..4c0c7d34 100644
--- a/apps/mcp/src/ui/mcp-app.ts
+++ b/apps/mcp/src/ui/mcp-app.ts
@@ -45,10 +45,10 @@ interface GraphApiDocument {
id: string
title: string | null
summary: string | null
- documentType: string
+ type: string
createdAt: string
updatedAt: string
- memories: GraphApiMemory[]
+ memoryEntries: GraphApiMemory[]
}
interface ToolResultData {
@@ -100,18 +100,25 @@ const MEMORY_BORDER = {
}
const EDGE_COLORS = {
- dark: {
- derives: "#38BDF8",
- updates: "#A78BFA",
- extends: "#2DD4BF",
- },
- light: {
- derives: "#7DD3FC",
- updates: "#A78BFA",
- extends: "#5EEAD4",
- },
+ dark: { derives: "#FBBF24", updates: "#A78BFA", extends: "#38BDF8" },
+ light: { derives: "#FBBF24", updates: "#A78BFA", extends: "#38BDF8" },
}
+const EDGE_OPACITY: Record
= {
+ derives: 0.4,
+ updates: 0.7,
+ extends: 0.55,
+}
+const EDGE_WIDTH: Record = {
+ derives: 1.2,
+ updates: 2,
+ extends: 1.5,
+}
+
+// Node sizes
+const MEM_RADIUS = 12
+const DOC_SIZE = 28
+
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000
const ONE_DAY_MS = 24 * 60 * 60 * 1000
const CLUSTER_SPREAD = 120
@@ -121,20 +128,32 @@ const CLUSTER_SPREAD = 120
// =============================================================================
let isDark = true
let selectedNode: GraphNode | null = null
+let hoveredNode: GraphNode | null = null
// =============================================================================
-// DOM References
+// DOM References (elements are guaranteed to exist in mcp-app.html)
// =============================================================================
+// biome-ignore lint/style/noNonNullAssertion: DOM element guaranteed to exist in HTML
const container = document.getElementById("graph")!
+// biome-ignore lint/style/noNonNullAssertion: DOM element guaranteed to exist in HTML
const popup = document.getElementById("popup")!
+// biome-ignore lint/style/noNonNullAssertion: DOM element guaranteed to exist in HTML
const popupType = document.getElementById("popup-type")!
+// biome-ignore lint/style/noNonNullAssertion: DOM element guaranteed to exist in HTML
const popupTitle = document.getElementById("popup-title")!
+// biome-ignore lint/style/noNonNullAssertion: DOM element guaranteed to exist in HTML
const popupContent = document.getElementById("popup-content")!
+// biome-ignore lint/style/noNonNullAssertion: DOM element guaranteed to exist in HTML
const popupMeta = document.getElementById("popup-meta")!
+// biome-ignore lint/style/noNonNullAssertion: DOM element guaranteed to exist in HTML
const loadingEl = document.getElementById("loading")!
+// biome-ignore lint/style/noNonNullAssertion: DOM element guaranteed to exist in HTML
const statsEl = document.getElementById("stats")!
+// biome-ignore lint/style/noNonNullAssertion: DOM element guaranteed to exist in HTML
const zoomInBtn = document.getElementById("zoom-in")!
+// biome-ignore lint/style/noNonNullAssertion: DOM element guaranteed to exist in HTML
const zoomOutBtn = document.getElementById("zoom-out")!
+// biome-ignore lint/style/noNonNullAssertion: DOM element guaranteed to exist in HTML
const fitBtn = document.getElementById("fit-btn")!
// =============================================================================
@@ -180,7 +199,7 @@ function transformData(data: ToolResultData): {
const nodeIds = new Set()
for (const doc of data.documents) {
nodeIds.add(doc.id)
- for (const mem of doc.memories) nodeIds.add(mem.id)
+ for (const mem of doc.memoryEntries) nodeIds.add(mem.id)
}
for (const doc of data.documents) {
@@ -190,16 +209,17 @@ function transformData(data: ToolResultData): {
nodeType: "document",
title: doc.title || "Untitled",
summary: doc.summary,
- docType: doc.documentType,
+ docType: doc.type,
createdAt: doc.createdAt,
- memoryCount: doc.memories.length,
+ memoryCount: doc.memoryEntries.length,
x: pos.x,
y: pos.y,
} as DocumentNode)
- const memCount = doc.memories.length
+ const memCount = doc.memoryEntries.length
for (let i = 0; i < memCount; i++) {
- const mem = doc.memories[i]!
+ // biome-ignore lint/style/noNonNullAssertion: index is always valid within loop bounds
+ const mem = doc.memoryEntries[i]!
const angle = (i / memCount) * 2 * Math.PI
nodes.push({
@@ -255,27 +275,145 @@ function transformData(data: ToolResultData): {
// =============================================================================
// Drawing
// =============================================================================
-function drawHexagon(
+function hexPath(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
radius: number,
- strokeColor: string,
) {
ctx.beginPath()
for (let i = 0; i < 6; i++) {
const angle = (Math.PI / 3) * i - Math.PI / 6
- const px = x + radius * Math.cos(angle)
- const py = y + radius * Math.sin(angle)
- if (i === 0) ctx.moveTo(px, py)
- else ctx.lineTo(px, py)
+ ctx.lineTo(x + radius * Math.cos(angle), y + radius * Math.sin(angle))
}
ctx.closePath()
- ctx.fillStyle = isDark ? "#0D2034" : "#E8F0FE"
+}
+
+function lightenColor(hex: string, amount: number): string {
+ const h = hex.replace("#", "")
+ if (h.length !== 6) return hex
+ const r = Math.min(
+ 255,
+ Number.parseInt(h.substring(0, 2), 16) + Math.round(255 * amount),
+ )
+ const g = Math.min(
+ 255,
+ Number.parseInt(h.substring(2, 4), 16) + Math.round(255 * amount),
+ )
+ const b = Math.min(
+ 255,
+ Number.parseInt(h.substring(4, 6), 16) + Math.round(255 * amount),
+ )
+ return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`
+}
+
+function drawMemoryNode(
+ ctx: CanvasRenderingContext2D,
+ x: number,
+ y: number,
+ radius: number,
+ screenSize: number,
+ borderColor: string,
+ isHovered: boolean,
+ isSelected: boolean,
+ isForgotten: boolean,
+ isLatest: boolean,
+) {
+ const accent = isDark ? "#3B73B8" : "#2563eb"
+ const memFill = isDark ? "#0D2034" : "#E8F0FE"
+
+ // Dot mode at very small screen sizes
+ if (screenSize < 8) {
+ const r = Math.max(2, screenSize * 0.45)
+ // Glow halo
+ ctx.save()
+ ctx.globalAlpha = 0.25
+ ctx.beginPath()
+ ctx.arc(x, y, r * 2.5, 0, Math.PI * 2)
+ ctx.fillStyle = borderColor
+ ctx.fill()
+ ctx.restore()
+ // Dot
+ ctx.beginPath()
+ ctx.arc(x, y, r, 0, Math.PI * 2)
+ ctx.fillStyle = memFill
+ ctx.fill()
+ ctx.strokeStyle = borderColor
+ ctx.lineWidth = 1.5
+ ctx.stroke()
+ return
+ }
+
+ // Superseded (non-latest) memory: dimmed with dashed border
+ if (!isLatest && !isSelected && !isHovered) {
+ ctx.save()
+ ctx.globalAlpha = 0.5
+ hexPath(ctx, x, y, radius)
+ ctx.fillStyle = memFill
+ ctx.fill()
+ ctx.strokeStyle = borderColor
+ ctx.lineWidth = 1
+ ctx.setLineDash([3, 3])
+ ctx.stroke()
+ ctx.setLineDash([])
+ // Strikethrough
+ const sr = radius * 0.55
+ ctx.beginPath()
+ ctx.moveTo(x - sr, y - sr)
+ ctx.lineTo(x + sr, y + sr)
+ ctx.strokeStyle = isDark ? "#94a3b8" : "#64748b"
+ ctx.lineWidth = 1.5
+ ctx.stroke()
+ ctx.restore()
+ return
+ }
+
+ // Shadow for hover/selected
+ if (isSelected || isHovered) {
+ ctx.save()
+ ctx.shadowColor = isSelected ? accent : isDark ? "#3B73B8" : "#2563eb"
+ ctx.shadowBlur = isSelected ? 18 : 12
+ hexPath(ctx, x, y, radius)
+ ctx.fillStyle = memFill
+ ctx.fill()
+ ctx.restore()
+
+ // Dashed glow ring
+ ctx.save()
+ const scale = isSelected ? 1.15 : 1.1
+ hexPath(ctx, x, y, radius * scale)
+ ctx.strokeStyle = accent
+ ctx.lineWidth = isSelected ? 2 : 1.5
+ ctx.globalAlpha = isSelected ? 0.8 : 0.5
+ ctx.setLineDash(isSelected ? [3, 3] : [4, 4])
+ ctx.stroke()
+ ctx.setLineDash([])
+ ctx.restore()
+ }
+
+ // Main hexagon
+ hexPath(ctx, x, y, radius)
+ ctx.fillStyle = isHovered ? (isDark ? "#112840" : "#dbeafe") : memFill
ctx.fill()
- ctx.strokeStyle = strokeColor
- ctx.lineWidth = 1.5
+ ctx.strokeStyle = isSelected ? accent : borderColor
+ ctx.lineWidth = isSelected ? 2.5 : isHovered ? 2 : 1.5
ctx.stroke()
+
+ // Forgotten X icon
+ if (isForgotten && radius > 7) {
+ const iconR = radius * 0.3
+ ctx.save()
+ ctx.lineCap = "round"
+ ctx.beginPath()
+ ctx.moveTo(x - iconR, y - iconR)
+ ctx.lineTo(x + iconR, y + iconR)
+ ctx.moveTo(x + iconR, y - iconR)
+ ctx.lineTo(x - iconR, y + iconR)
+ ctx.strokeStyle = MEMORY_BORDER.forgotten
+ ctx.lineWidth = Math.max(1.5, radius / 10)
+ ctx.stroke()
+ ctx.restore()
+ }
}
function drawDocumentNode(
@@ -283,66 +421,157 @@ function drawDocumentNode(
x: number,
y: number,
size: number,
+ screenSize: number,
+ isHovered: boolean,
+ isSelected: boolean,
) {
+ const accent = isDark ? "#3B73B8" : "#2563eb"
+ const docFill = isDark ? "#1B1F24" : "#F1F5F9"
+ const docStroke = isDark ? "#2A2F36" : "#CBD5E1"
const half = size / 2
- // Outer rounded rect
+ const cornerR = Math.round(8 * (size / 50))
+
+ // Dot mode at very small screen sizes
+ if (screenSize < 8) {
+ const s = Math.max(3, screenSize)
+ ctx.fillStyle = docFill
+ ctx.fillRect(x - s / 2, y - s / 2, s, s)
+ return
+ }
+
+ // Shadow for hover/selected
+ if (isSelected || isHovered) {
+ ctx.save()
+ ctx.shadowColor = accent
+ ctx.shadowBlur = isSelected ? 16 : 10
+ ctx.beginPath()
+ ctx.roundRect(x - half, y - half, size, size, cornerR)
+ ctx.fillStyle = docFill
+ ctx.fill()
+ ctx.restore()
+
+ // Dashed glow ring
+ ctx.save()
+ const scale = isSelected ? 1.15 : 1.1
+ const gh = (size * scale) / 2
+ ctx.beginPath()
+ ctx.roundRect(
+ x - gh,
+ y - gh,
+ size * scale,
+ size * scale,
+ Math.round(8 * ((size * scale) / 50)),
+ )
+ ctx.strokeStyle = accent
+ ctx.lineWidth = isSelected ? 2 : 1.5
+ ctx.globalAlpha = isSelected ? 0.8 : 0.5
+ ctx.setLineDash(isSelected ? [3, 3] : [4, 4])
+ ctx.stroke()
+ ctx.setLineDash([])
+ ctx.restore()
+ }
+
+ // Outer rect with gradient
ctx.beginPath()
- ctx.roundRect(x - half, y - half, size, size, 3)
- ctx.fillStyle = isDark ? "#1B1F24" : "#F1F5F9"
+ ctx.roundRect(x - half, y - half, size, size, cornerR)
+ const gradient = ctx.createLinearGradient(
+ x - half,
+ y - half,
+ x + half,
+ y + half,
+ )
+ gradient.addColorStop(0, docFill)
+ gradient.addColorStop(1, lightenColor(docFill, 0.08))
+ ctx.fillStyle = gradient
ctx.fill()
- ctx.strokeStyle = isDark ? "#2A2F36" : "#CBD5E1"
- ctx.lineWidth = 1.5
+ ctx.strokeStyle = isSelected || isHovered ? accent : docStroke
+ ctx.lineWidth = isSelected ? 2.5 : isHovered ? 1.5 : 1
ctx.stroke()
- // Inner icon area
- const iconSize = size * 0.5
- const iconHalf = iconSize / 2
+ // Inner area
+ const innerSize = size * 0.72
+ const innerHalf = innerSize / 2
+ const innerCornerR = Math.round(6 * (size / 50))
ctx.beginPath()
- ctx.roundRect(x - iconHalf, y - iconHalf, iconSize, iconSize, 2)
+ ctx.roundRect(
+ x - innerHalf,
+ y - innerHalf,
+ innerSize,
+ innerSize,
+ innerCornerR,
+ )
ctx.fillStyle = isDark ? "#13161A" : "#E2E8F0"
ctx.fill()
+
+ // Document icon (page with fold)
+ const iconS = size * 0.35
+ const iconColor = isDark ? "#3B73B8" : "#2563eb"
+ const w = iconS * 0.7
+ const h = iconS * 0.85
+ const fold = iconS * 0.2
+ const ix = x - w / 2
+ const iy = y - h / 2
+ ctx.save()
+ ctx.strokeStyle = iconColor
+ ctx.lineWidth = Math.max(1, iconS / 12)
+ ctx.lineCap = "round"
+ ctx.lineJoin = "round"
+ ctx.beginPath()
+ ctx.moveTo(ix, iy)
+ ctx.lineTo(ix + w - fold, iy)
+ ctx.lineTo(ix + w, iy + fold)
+ ctx.lineTo(ix + w, iy + h)
+ ctx.lineTo(ix, iy + h)
+ ctx.closePath()
+ ctx.stroke()
+ ctx.beginPath()
+ ctx.moveTo(ix + w - fold, iy)
+ ctx.lineTo(ix + w - fold, iy + fold)
+ ctx.lineTo(ix + w, iy + fold)
+ ctx.stroke()
+ ctx.restore()
}
// =============================================================================
// Force Graph Setup
// =============================================================================
-function getLinkColor(link: GraphLink): string {
- const palette = isDark ? EDGE_COLORS.dark : EDGE_COLORS.light
- return palette[link.edgeType] || palette["derives"]
-}
-
const graph = new ForceGraph(container)
.nodeId("id")
.nodeCanvasObject(
(node: GraphNode, ctx: CanvasRenderingContext2D, globalScale: number) => {
+ // biome-ignore lint/style/noNonNullAssertion: force-graph guarantees x/y during render
const x = node.x!
+ // biome-ignore lint/style/noNonNullAssertion: force-graph guarantees x/y during render
const y = node.y!
+ const isHovered = hoveredNode?.id === node.id
+ const isSelected = selectedNode?.id === node.id
+
+ // Dim non-connected nodes when something is selected
+ if (selectedNode && !isSelected && !isHovered) {
+ ctx.globalAlpha = 0.3
+ }
if (node.nodeType === "memory") {
const mem = node as MemoryNode
- drawHexagon(ctx, x, y, 10, mem.borderColor)
-
- if (globalScale > 2) {
- const label = mem.memory.slice(0, 30)
- ctx.font = `${Math.max(4, 10 / globalScale)}px system-ui, sans-serif`
- ctx.fillStyle = isDark ? "#94a3b8" : "#64748b"
- ctx.textAlign = "center"
- ctx.textBaseline = "top"
- ctx.fillText(label, x, y + 12)
- }
+ const screenSize = MEM_RADIUS * 2 * globalScale
+ drawMemoryNode(
+ ctx,
+ x,
+ y,
+ MEM_RADIUS,
+ screenSize,
+ mem.borderColor,
+ isHovered,
+ isSelected,
+ mem.isForgotten,
+ mem.isLatest,
+ )
} else {
- const doc = node as DocumentNode
- drawDocumentNode(ctx, x, y, 22)
-
- if (globalScale > 1.2) {
- const label = (doc.title || "").slice(0, 25)
- ctx.font = `600 ${Math.max(4, 11 / globalScale)}px system-ui, sans-serif`
- ctx.fillStyle = isDark ? "#e2e8f0" : "#1e293b"
- ctx.textAlign = "center"
- ctx.textBaseline = "top"
- ctx.fillText(label, x, y + 14)
- }
+ const screenSize = DOC_SIZE * globalScale
+ drawDocumentNode(ctx, x, y, DOC_SIZE, screenSize, isHovered, isSelected)
}
+
+ ctx.globalAlpha = 1
},
)
.nodeCanvasObjectMode(() => "replace")
@@ -351,29 +580,92 @@ const graph = new ForceGraph(container)
ctx.fillStyle = color
ctx.beginPath()
ctx.arc(
+ // biome-ignore lint/style/noNonNullAssertion: force-graph guarantees x/y during render
node.x!,
+ // biome-ignore lint/style/noNonNullAssertion: force-graph guarantees x/y during render
node.y!,
- node.nodeType === "document" ? 12 : 11,
+ node.nodeType === "document" ? DOC_SIZE / 2 + 1 : MEM_RADIUS + 1,
0,
Math.PI * 2,
)
ctx.fill()
},
)
- .linkWidth((link: GraphLink) => {
- if (link.edgeType === "updates") return 2
- if (link.edgeType === "extends") return 0.5
- return 1
- })
- .linkColor(getLinkColor)
- .linkLineDash((link: GraphLink) => {
- if (link.edgeType === "extends") return [4, 2]
- return null as unknown as number[]
- })
- .linkDirectionalArrowLength((link: GraphLink) =>
- link.edgeType === "updates" ? 4 : 0,
+ .linkCanvasObject(
+ (link: GraphLink, ctx: CanvasRenderingContext2D, globalScale: number) => {
+ const source = link.source as GraphNode
+ const target = link.target as GraphNode
+ if (!source.x || !source.y || !target.x || !target.y) return
+
+ const { edgeType } = link
+ const palette = isDark ? EDGE_COLORS.dark : EDGE_COLORS.light
+ const color = palette[edgeType] || palette.derives
+ const width = EDGE_WIDTH[edgeType] || 1.2
+ const opacity = EDGE_OPACITY[edgeType] || 0.4
+ const isDimmed = !!selectedNode
+
+ // Culling: extends edges at very low zoom
+ if (edgeType === "extends" && globalScale < 0.08) return
+
+ const dimFactor = isDimmed ? 0.3 : 1
+ const isExtends = edgeType === "extends"
+
+ // Glow pass (behind main edge)
+ if (!isDimmed) {
+ ctx.save()
+ ctx.globalAlpha = edgeType === "updates" ? opacity * 0.4 : opacity * 0.3
+ ctx.strokeStyle = color
+ ctx.lineWidth = edgeType === "updates" ? width + 2 : width + 1.5
+ if (isExtends) ctx.setLineDash([6, 4])
+ ctx.beginPath()
+ ctx.moveTo(source.x, source.y)
+ ctx.lineTo(target.x, target.y)
+ ctx.stroke()
+ if (isExtends) ctx.setLineDash([])
+ ctx.restore()
+ }
+
+ // Main edge
+ ctx.save()
+ ctx.globalAlpha = opacity * dimFactor
+ ctx.strokeStyle = color
+ ctx.lineWidth = width
+ if (isExtends) ctx.setLineDash([6, 4])
+ ctx.beginPath()
+ ctx.moveTo(source.x, source.y)
+ ctx.lineTo(target.x, target.y)
+ ctx.stroke()
+ if (isExtends) ctx.setLineDash([])
+ ctx.restore()
+
+ // Arrowhead for updates edges
+ if (edgeType === "updates") {
+ const arrowSize = Math.max(6, 8 * globalScale)
+ const angle = Math.atan2(target.y - source.y, target.x - source.x)
+ ctx.save()
+ ctx.globalAlpha = opacity * 0.6 * dimFactor
+ ctx.fillStyle = color
+ ctx.beginPath()
+ ctx.moveTo(target.x, target.y)
+ ctx.lineTo(
+ target.x - arrowSize * Math.cos(angle - Math.PI / 6),
+ target.y - arrowSize * Math.sin(angle - Math.PI / 6),
+ )
+ ctx.lineTo(
+ target.x - arrowSize * Math.cos(angle + Math.PI / 6),
+ target.y - arrowSize * Math.sin(angle + Math.PI / 6),
+ )
+ ctx.closePath()
+ ctx.fill()
+ ctx.restore()
+ }
+ },
)
- .linkDirectionalArrowRelPos(1)
+ .linkCanvasObjectMode(() => "replace")
+ .onNodeHover((node: GraphNode | null) => {
+ hoveredNode = node
+ container.style.cursor = node ? "pointer" : "default"
+ })
.onNodeClick(handleNodeClick)
.onBackgroundClick(() => hidePopup())
.d3Force(
@@ -454,10 +746,33 @@ function showPopup(node: GraphNode, x: number, y: number) {
popup.style.display = "block"
+ // Smart quadrant positioning (right > left > below > above)
const rect = popup.getBoundingClientRect()
- const gap = 15
- const left = x < window.innerWidth / 2 ? x + gap : x - rect.width - gap
- const top = y < window.innerHeight / 2 ? y + gap : y - rect.height - gap
+ const gap = 24
+ const vw = window.innerWidth
+ const vh = window.innerHeight
+ let left: number
+ let top: number
+
+ // Try right
+ if (x + gap + rect.width < vw - 8) {
+ left = x + gap
+ } else if (x - gap - rect.width > 8) {
+ // Try left
+ left = x - gap - rect.width
+ } else {
+ // Fallback center
+ left = Math.max(8, (vw - rect.width) / 2)
+ }
+
+ if (y - rect.height / 2 > 8 && y + rect.height / 2 < vh - 8) {
+ top = y - rect.height / 2
+ } else if (y + gap + rect.height < vh - 8) {
+ top = y + gap
+ } else {
+ top = y - gap - rect.height
+ }
+
popup.style.left = `${Math.max(8, left)}px`
popup.style.top = `${Math.max(8, top)}px`
}
@@ -470,7 +785,12 @@ function hidePopup() {
// =============================================================================
// Controls
// =============================================================================
-const ZOOM_FACTOR = 1.5
+const ZOOM_FACTOR = 1.3
+// biome-ignore lint/style/noNonNullAssertion: DOM element guaranteed to exist in HTML
+const centerBtn = document.getElementById("center-btn")!
+// biome-ignore lint/style/noNonNullAssertion: DOM element guaranteed to exist in HTML
+const zoomDisplay = document.getElementById("zoom-display")!
+
zoomInBtn.addEventListener("click", () =>
graph.zoom(graph.zoom() * ZOOM_FACTOR, 200),
)
@@ -478,11 +798,49 @@ zoomOutBtn.addEventListener("click", () =>
graph.zoom(graph.zoom() / ZOOM_FACTOR, 200),
)
fitBtn.addEventListener("click", () => graph.zoomToFit(400, 40))
+centerBtn.addEventListener("click", () => graph.centerAt(0, 0, 400))
+
+// Update zoom display
+graph.onZoom(({ k }) => {
+ zoomDisplay.textContent = `${Math.round(k * 100)}%`
+})
document.addEventListener("keydown", (e) => {
- if (e.key === "Escape") hidePopup()
+ const tag = (e.target as HTMLElement).tagName
+ if (tag === "INPUT" || tag === "TEXTAREA") return
+
+ switch (e.key) {
+ case "Escape":
+ hidePopup()
+ break
+ case "z":
+ case "Z":
+ graph.zoomToFit(400, 40)
+ break
+ case "c":
+ case "C":
+ graph.centerAt(0, 0, 400)
+ break
+ case "+":
+ case "=":
+ graph.zoom(graph.zoom() * ZOOM_FACTOR, 200)
+ break
+ case "-":
+ case "_":
+ graph.zoom(graph.zoom() / ZOOM_FACTOR, 200)
+ break
+ }
})
+// Legend toggle
+// biome-ignore lint/style/noNonNullAssertion: DOM element guaranteed to exist in HTML
+const legendEl = document.getElementById("legend")!
+// biome-ignore lint/style/noNonNullAssertion: DOM element guaranteed to exist in HTML
+const legendToggle = document.getElementById("legend-toggle")!
+legendToggle.addEventListener("click", () =>
+ legendEl.classList.toggle("collapsed"),
+)
+
// =============================================================================
// Theme
// =============================================================================
@@ -529,6 +887,12 @@ app.ontoolresult = (result: CallToolResult) => {
statsEl.textContent = `${docCount} docs \u00b7 ${memCount} memories \u00b7 ${links.length} connections`
+ // Update legend counts
+ const docCountEl = document.getElementById("legend-doc-count")
+ const memCountEl = document.getElementById("legend-mem-count")
+ if (docCountEl) docCountEl.textContent = String(docCount)
+ if (memCountEl) memCountEl.textContent = String(memCount)
+
graph.graphData({ nodes, links })
// Fit to view after layout stabilizes
diff --git a/bun.lock b/bun.lock
index 050d8960..39d9842e 100644
--- a/bun.lock
+++ b/bun.lock
@@ -2951,7 +2951,7 @@
"for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
- "force-graph": ["force-graph@1.51.1", "", { "dependencies": { "@tweenjs/tween.js": "18 - 25", "accessor-fn": "1", "bezier-js": "3 - 6", "canvas-color-tracker": "^1.3", "d3-array": "1 - 3", "d3-drag": "2 - 3", "d3-force-3d": "2 - 3", "d3-scale": "1 - 4", "d3-scale-chromatic": "1 - 3", "d3-selection": "2 - 3", "d3-zoom": "2 - 3", "float-tooltip": "^1.7", "index-array-by": "1", "kapsule": "^1.16", "lodash-es": "4" } }, "sha512-uEEX8iRzgq1IKRISOw6RrB2RLMhcI25xznQYrCTVvxZHZZ+A2jH6qIolYuwavVxAMi64pFp2yZm4KFVdD993cg=="],
+ "force-graph": ["force-graph@1.51.2", "", { "dependencies": { "@tweenjs/tween.js": "18 - 25", "accessor-fn": "1", "bezier-js": "3 - 6", "canvas-color-tracker": "^1.3", "d3-array": "1 - 3", "d3-drag": "2 - 3", "d3-force-3d": "2 - 3", "d3-scale": "1 - 4", "d3-scale-chromatic": "1 - 3", "d3-selection": "2 - 3", "d3-zoom": "2 - 3", "float-tooltip": "^1.7", "index-array-by": "1", "kapsule": "^1.16", "lodash-es": "4" } }, "sha512-zZNdMqx8qIQGurgnbgYIUsdXxSfvhfRSIdncsKGv/twUOZpwCsk9hPHmdjdcme1+epATgb41G0rkIGHJ0Wydng=="],
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
diff --git a/packages/memory-graph/src/constants.ts b/packages/memory-graph/src/constants.ts
index 3361f6f2..f407a1d2 100644
--- a/packages/memory-graph/src/constants.ts
+++ b/packages/memory-graph/src/constants.ts
@@ -48,7 +48,7 @@ export const DEFAULT_COLORS: GraphThemeColors = {
textPrimary: "#ffffff",
textSecondary: "#e2e8f0",
textMuted: "#94a3b8",
- edgeDerives: "#7094B8",
+ edgeDerives: "#FBBF24",
edgeUpdates: "#A78BFA",
edgeExtends: "#38BDF8",
memBorderForgotten: "#EF4444",