fix(mcp): fix broken memory graph and improve UI design (#837)

## Summary
- Fixed memory graph stuck on "Loading graph data..." caused by field name mismatch after package rewrite (`memoryEntries` vs `memories`, `type` vs `documentType`)
- Improved MCP app UI to match console-v2 design: golden derives edges (#FBBF24), edge glow pass, node hover effects, dot mode at low zoom, vertical controls with keyboard shortcuts, expandable legend
- Synced local `memory-graph` edgeDerives constant with published npm version

## Test plan
- [ ] Run `cd apps/mcp && bun run dev`, connect via Claude Desktop
- [ ] Verify graph renders with golden derives edges and blue dashed extends edges
- [ ] Verify node hover/selection glow effects
- [ ] Verify Fit (Z), Center (C), zoom (+/-) keyboard shortcuts
- [ ] Verify legend expands/collapses
This commit is contained in:
Prasanna721
2026-04-08 21:21:10 +00:00
parent bc7f6fcf74
commit 543c45a626
5 changed files with 661 additions and 162 deletions
+59 -33
View File
@@ -24,43 +24,69 @@
</div>
<div id="controls">
<button id="fit-btn" title="Fit to view">&#x229E;</button>
<button id="zoom-in" title="Zoom in">+</button>
<button id="zoom-out" title="Zoom out">&minus;</button>
<button type="button" id="fit-btn" title="Fit to view">
<span>Fit</span>
<kbd>Z</kbd>
</button>
<button type="button" id="center-btn" title="Center graph">
<span>Center</span>
<kbd>C</kbd>
</button>
<div id="zoom-row">
<span id="zoom-display">100%</span>
<button type="button" id="zoom-out" title="Zoom out">&minus;</button>
<button type="button" id="zoom-in" title="Zoom in">+</button>
</div>
</div>
<div id="legend">
<div class="legend-row">
<svg width="12" height="12" viewBox="0 0 12 12"><polygon points="6,1.5 10.4,3.75 10.4,8.25 6,10.5 1.6,8.25 1.6,3.75" fill="#0D2034" stroke="#3B73B8" stroke-width="1.2"/></svg>
<span>Memory</span>
<div id="legend-toggle">
<span>Legend</span>
<svg class="legend-chevron" width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><path d="M3 2l4 3-4 3" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
</div>
<div class="legend-row">
<svg width="12" height="12" viewBox="0 0 12 12"><polygon points="6,1.5 10.4,3.75 10.4,8.25 6,10.5 1.6,8.25 1.6,3.75" fill="#0D2034" stroke="#10B981" stroke-width="1.2"/></svg>
<span>Recent (&lt;1d)</span>
</div>
<div class="legend-row">
<svg width="12" height="12" viewBox="0 0 12 12"><polygon points="6,1.5 10.4,3.75 10.4,8.25 6,10.5 1.6,8.25 1.6,3.75" fill="#0D2034" stroke="#F59E0B" stroke-width="1.2"/></svg>
<span>Expiring</span>
</div>
<div class="legend-row">
<svg width="12" height="12" viewBox="0 0 12 12"><polygon points="6,1.5 10.4,3.75 10.4,8.25 6,10.5 1.6,8.25 1.6,3.75" fill="#0D2034" stroke="#EF4444" stroke-width="1.2"/></svg>
<span>Forgotten</span>
</div>
<div class="legend-row">
<div class="legend-doc"></div>
<span>Document</span>
</div>
<div class="legend-row">
<div class="legend-line" style="border-color: #4A5568;"></div>
<span>Doc &rarr; Memory</span>
</div>
<div class="legend-row">
<div class="legend-line" style="border-color: #8B5CF6; border-width: 2px;"></div>
<span>Version</span>
</div>
<div class="legend-row">
<div class="legend-line dashed" style="border-color: #00D4B8;"></div>
<span>Similarity</span>
<div id="legend-body">
<div class="legend-group">
<div class="legend-row">
<svg width="12" height="12" viewBox="0 0 14 14" aria-hidden="true"><rect x="1" y="1" width="12" height="12" rx="3" fill="var(--doc-fill)" stroke="var(--doc-stroke)" stroke-width="1.2"/></svg>
<span>Documents</span>
<span class="legend-count" id="legend-doc-count"></span>
</div>
<div class="legend-row">
<svg width="12" height="12" viewBox="0 0 14 14" aria-hidden="true"><polygon points="7,1.5 12,4 12,9.5 7,12 2,9.5 2,4" fill="var(--hex-fill)" stroke="#3B73B8" stroke-width="1.2"/></svg>
<span>Memories</span>
<span class="legend-count" id="legend-mem-count"></span>
</div>
</div>
<div class="legend-divider"></div>
<div class="legend-group">
<div class="legend-row">
<svg width="12" height="12" viewBox="0 0 14 14" aria-hidden="true"><polygon points="7,1.5 12,4 12,9.5 7,12 2,9.5 2,4" fill="var(--hex-fill)" stroke="#10B981" stroke-width="1.5"/></svg>
<span>Recent</span>
</div>
<div class="legend-row">
<svg width="12" height="12" viewBox="0 0 14 14" aria-hidden="true"><polygon points="7,1.5 12,4 12,9.5 7,12 2,9.5 2,4" fill="var(--hex-fill)" stroke="#F59E0B" stroke-width="1.5"/></svg>
<span>Expiring</span>
</div>
<div class="legend-row">
<svg width="12" height="12" viewBox="0 0 14 14" aria-hidden="true"><polygon points="7,1.5 12,4 12,9.5 7,12 2,9.5 2,4" fill="var(--hex-fill)" stroke="#EF4444" stroke-width="1.5"/></svg>
<span>Forgotten</span>
</div>
</div>
<div class="legend-divider"></div>
<div class="legend-group">
<div class="legend-row">
<div class="legend-line" style="border-color: #FBBF24;"></div>
<span>Derives</span>
</div>
<div class="legend-row">
<div class="legend-line" style="border-color: #A78BFA; border-width: 2px;"></div>
<span>Updates</span>
</div>
<div class="legend-row">
<div class="legend-line dashed" style="border-color: #38BDF8;"></div>
<span>Extends</span>
</div>
</div>
</div>
</div>
+155 -46
View File
@@ -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;
+445 -81
View File
@@ -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<string, number> = {
derives: 0.4,
updates: 0.7,
extends: 0.55,
}
const EDGE_WIDTH: Record<string, number> = {
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<string>()
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<GraphNode, GraphLink>(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<GraphNode, GraphLink>(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
+1 -1
View File
@@ -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=="],
+1 -1
View File
@@ -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",