diff --git a/apps/mcp/mcp-app.html b/apps/mcp/mcp-app.html index 73bad9f1..1e3fbf73 100644 --- a/apps/mcp/mcp-app.html +++ b/apps/mcp/mcp-app.html @@ -24,43 +24,69 @@
- - - + + +
+ 100% + + +
-
- - Memory +
+ Legend +
-
- - Recent (<1d) -
-
- - Expiring -
-
- - Forgotten -
-
-
- Document -
-
-
- Doc → Memory -
-
-
- Version -
-
-
- Similarity +
+
+
+ + Documents + +
+
+ + Memories + +
+
+
+
+
+ + Recent +
+
+ + Expiring +
+
+ + Forgotten +
+
+
+
+
+
+ Derives +
+
+
+ Updates +
+
+
+ Extends +
+
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",