feat: rename edge types to derives/updates/extends with distinct colors

- Rename edge types: doc-memory -> derives, version -> updates, same-space -> extends
- Assign distinct colors: derives (#3B82F6 blue), updates (#8B5CF6 purple), extends (#14B8A6 teal)
- Update legend labels to show Derives, Updates, Extends
- Update theme color properties: edgeDerives, edgeUpdates, edgeExtends
- Update MCP app edge types and colors to match
- Remove redundant drawDocDocLines proximity rendering (extends edges now serve this purpose)
- Update all tests and comments for new edge type names
This commit is contained in:
Vorflux AI 2026-03-28 07:17:12 +00:00
parent 06e51f2126
commit b43c311092
10 changed files with 82 additions and 178 deletions

View file

@ -84,7 +84,7 @@ type GraphNode = MemoryNode | DocumentNode
interface GraphLink extends LinkObject {
source: string | GraphNode
target: string | GraphNode
edgeType: "doc-memory" | "version" | "same-space"
edgeType: "derives" | "updates" | "extends"
}
// =============================================================================
@ -99,14 +99,14 @@ const MEMORY_BORDER = {
const EDGE_COLORS = {
dark: {
"doc-memory": "#4A5568",
version: "#8B5CF6",
"same-space": "#00D4B8",
derives: "#3B82F6",
updates: "#8B5CF6",
extends: "#14B8A6",
},
light: {
"doc-memory": "#A0AEC0",
version: "#8B5CF6",
"same-space": "#0D9488",
derives: "#60A5FA",
updates: "#8B5CF6",
extends: "#2DD4BF",
},
}
@ -174,7 +174,7 @@ function transformData(data: ToolResultData): {
const nodeIds = new Set<string>()
const SPREAD = 50
// Group documents by spaceId for same-space edges
// Group documents by spaceId for extends edges
const spaceGroups = new Map<string, string[]>()
for (const doc of data.documents) {
@ -214,19 +214,19 @@ function transformData(data: ToolResultData): {
} as MemoryNode)
nodeIds.add(mem.id)
// Doc-memory link
links.push({ source: doc.id, target: mem.id, edgeType: "doc-memory" })
// Derives link
links.push({ source: doc.id, target: mem.id, edgeType: "derives" })
// Version chain link
// Updates link
if (mem.parentMemoryId && nodeIds.has(mem.parentMemoryId)) {
links.push({
source: mem.parentMemoryId,
target: mem.id,
edgeType: "version",
edgeType: "updates",
})
}
// Track space groups for same-space edges
// Track space groups for extends edges
if (mem.spaceId) {
const group = spaceGroups.get(mem.spaceId)
if (group) group.push(doc.id)
@ -235,7 +235,7 @@ function transformData(data: ToolResultData): {
}
}
// Same-space edges between documents sharing a space
// Extends edges between documents sharing a space
const addedEdges = new Set<string>()
for (const docIds of spaceGroups.values()) {
const unique = [...new Set(docIds)]
@ -247,7 +247,7 @@ function transformData(data: ToolResultData): {
links.push({
source: unique[i]!,
target: unique[j]!,
edgeType: "same-space",
edgeType: "extends",
})
}
}
@ -313,7 +313,7 @@ function drawDocumentNode(
// =============================================================================
function getLinkColor(link: GraphLink): string {
const palette = isDark ? EDGE_COLORS.dark : EDGE_COLORS.light
return palette[link.edgeType] || palette["doc-memory"]
return palette[link.edgeType] || palette["derives"]
}
const graph = new ForceGraph<GraphNode, GraphLink>(container)
@ -366,17 +366,17 @@ const graph = new ForceGraph<GraphNode, GraphLink>(container)
},
)
.linkWidth((link: GraphLink) => {
if (link.edgeType === "version") return 2
if (link.edgeType === "same-space") return 0.5
if (link.edgeType === "updates") return 2
if (link.edgeType === "extends") return 0.5
return 1
})
.linkColor(getLinkColor)
.linkLineDash((link: GraphLink) => {
if (link.edgeType === "same-space") return [4, 2]
if (link.edgeType === "extends") return [4, 2]
return null as unknown as number[]
})
.linkDirectionalArrowLength((link: GraphLink) =>
link.edgeType === "version" ? 4 : 0,
link.edgeType === "updates" ? 4 : 0,
)
.linkDirectionalArrowRelPos(1)
.onNodeClick(handleNodeClick)
@ -390,11 +390,11 @@ const graph = new ForceGraph<GraphNode, GraphLink>(container)
.d3Force(
"link",
forceLink()
.distance((l: GraphLink) => (l.edgeType === "doc-memory" ? 40 : 80))
.distance((l: GraphLink) => (l.edgeType === "derives" ? 40 : 80))
.strength((l: GraphLink) => {
if (l.edgeType === "doc-memory") return 0.8
if (l.edgeType === "version") return 1.0
return 0.15 // same-space
if (l.edgeType === "derives") return 0.8
if (l.edgeType === "updates") return 1.0
return 0.15 // extends
}),
)
.d3Force("collide", forceCollide(18))

View file

@ -59,20 +59,20 @@ describe("getMemoryBorderColor", () => {
})
describe("getEdgeVisualProps", () => {
it("returns correct props for doc-memory edges", () => {
const props = getEdgeVisualProps("doc-memory")
it("returns correct props for derives edges", () => {
const props = getEdgeVisualProps("derives")
expect(props.opacity).toBeCloseTo(0.3)
expect(props.thickness).toBeCloseTo(1.5)
})
it("returns correct props for version edges", () => {
const props = getEdgeVisualProps("version")
it("returns correct props for updates edges", () => {
const props = getEdgeVisualProps("updates")
expect(props.opacity).toBeCloseTo(0.6)
expect(props.thickness).toBeCloseTo(2)
})
it("returns correct props for same-space edges", () => {
const props = getEdgeVisualProps("same-space")
it("returns correct props for extends edges", () => {
const props = getEdgeVisualProps("extends")
expect(props.opacity).toBeCloseTo(0.15)
expect(props.thickness).toBeCloseTo(1)
})

View file

@ -30,7 +30,7 @@ function makeEdge(source: string, target: string): GraphEdge {
source,
target,
visualProps: { opacity: 0.5, thickness: 1.5 },
edgeType: "doc-memory",
edgeType: "derives",
}
}

View file

@ -34,111 +34,21 @@ export function renderFrame(
colors: GraphThemeColors,
): void {
ctx.clearRect(0, 0, width, height)
drawDocDocLines(ctx, nodes, viewport, width, height, colors)
drawEdges(ctx, edges, viewport, width, height, state, nodeMap, colors)
drawNodes(ctx, nodes, viewport, width, height, state, colors)
}
function drawDocDocLines(
ctx: CanvasRenderingContext2D,
nodes: GraphNode[],
viewport: ViewportState,
width: number,
height: number,
colors: GraphThemeColors,
): void {
// Collect visible docs with screen positions
const docs: { x: number; y: number }[] = []
for (const n of nodes) {
if (n.type !== "document") continue
const s = viewport.worldToScreen(n.x, n.y)
if (s.x > -100 && s.x < width + 100 && s.y > -100 && s.y < height + 100) {
docs.push(s)
}
}
if (docs.length < 2) return
// Build spatial grid for O(n*k) nearest-neighbor instead of O(n^2)
const CELL = 200
const grid = new Map<string, number[]>()
for (let i = 0; i < docs.length; i++) {
const d = docs[i]!
const key = `${Math.floor(d.x / CELL)},${Math.floor(d.y / CELL)}`
let cell = grid.get(key)
if (!cell) {
cell = []
grid.set(key, cell)
}
cell.push(i)
}
// For each doc, find 2 nearest from neighboring cells only
ctx.strokeStyle = colors.edgeSameSpace
ctx.lineWidth = 1
ctx.globalAlpha = 0.3
ctx.setLineDash([4, 6])
ctx.beginPath()
for (let i = 0; i < docs.length; i++) {
const d = docs[i]!
const cx = Math.floor(d.x / CELL)
const cy = Math.floor(d.y / CELL)
let best1 = -1
let best2 = -1
let dist1 = Number.POSITIVE_INFINITY
let dist2 = Number.POSITIVE_INFINITY
for (let dx = -1; dx <= 1; dx++) {
for (let dy = -1; dy <= 1; dy++) {
const cell = grid.get(`${cx + dx},${cy + dy}`)
if (!cell) continue
for (const j of cell) {
if (j === i) continue
const other = docs[j]!
const ox = other.x - d.x
const oy = other.y - d.y
const dist = ox * ox + oy * oy
if (dist < dist1) {
best2 = best1
dist2 = dist1
best1 = j
dist1 = dist
} else if (dist < dist2) {
best2 = j
dist2 = dist
}
}
}
}
if (best1 >= 0 && i < best1) {
const b1 = docs[best1]!
ctx.moveTo(d.x, d.y)
ctx.lineTo(b1.x, b1.y)
}
if (best2 >= 0 && i < best2) {
const b2 = docs[best2]!
ctx.moveTo(d.x, d.y)
ctx.lineTo(b2.x, b2.y)
}
}
ctx.stroke()
ctx.setLineDash([])
ctx.globalAlpha = 1
}
function edgeStyle(
edge: GraphEdge,
colors: GraphThemeColors,
): { color: string; width: number } {
if (edge.edgeType === "doc-memory")
return { color: colors.edgeDocMemory, width: 1.5 }
if (edge.edgeType === "version")
return { color: colors.edgeVersion, width: 2 }
if (edge.edgeType === "same-space")
return { color: colors.edgeSameSpace, width: 1 }
return { color: colors.edgeSameSpace, width: 1 }
if (edge.edgeType === "derives")
return { color: colors.edgeDerives, width: 1.5 }
if (edge.edgeType === "updates")
return { color: colors.edgeUpdates, width: 2 }
if (edge.edgeType === "extends")
return { color: colors.edgeExtends, width: 1 }
return { color: colors.edgeExtends, width: 1 }
}
function batchKey(style: { color: string; width: number }): string {
@ -152,7 +62,7 @@ interface PreparedEdge {
endY: number
connected: boolean
style: { color: string; width: number }
isVersion: boolean
isUpdates: boolean
arrowSize: number
}
@ -172,8 +82,8 @@ function drawEdges(
const prepared: PreparedEdge[] = []
for (const edge of edges) {
// Zoom-based edge culling for same-space edges (low-priority visual)
if (edge.edgeType === "same-space") {
// Zoom-based edge culling for extends edges (low-priority visual)
if (edge.edgeType === "extends") {
if (viewport.zoom < 0.3) continue
}
@ -183,7 +93,7 @@ function drawEdges(
typeof edge.target === "string" ? nodeMap.get(edge.target) : edge.target
if (!src || !tgt) continue
if (edge.edgeType === "doc-memory") {
if (edge.edgeType === "derives") {
const mem = src.type === "memory" ? src : tgt
if (mem.size * viewport.zoom < 3) continue
}
@ -226,9 +136,9 @@ function drawEdges(
endY: t.y - uy * tr,
connected,
style: edgeStyle(edge, colors),
isVersion: edge.edgeType === "version",
isUpdates: edge.edgeType === "updates",
arrowSize:
edge.edgeType === "version" ? Math.max(6, 8 * viewport.zoom) : 0,
edge.edgeType === "updates" ? Math.max(6, 8 * viewport.zoom) : 0,
})
}
@ -251,10 +161,10 @@ function drawEdges(
if (!first) continue
const isDimmed = key.endsWith("|d")
// Draw glow pass behind version edges for visual emphasis
// Version edges share a distinct style key, so a batch is all-version or none
const isVersionBatch = first.isVersion
if (isVersionBatch && !isDimmed) {
// Draw glow pass behind updates edges for visual emphasis
// Updates edges share a distinct style key, so a batch is all-updates or none
const isUpdatesBatch = first.isUpdates
if (isUpdatesBatch && !isDimmed) {
ctx.save()
ctx.globalAlpha = 0.3
ctx.strokeStyle = first.style.color
@ -279,7 +189,7 @@ function drawEdges(
}
ctx.stroke()
if (isVersionBatch) {
if (isUpdatesBatch) {
ctx.fillStyle = first.style.color
for (const e of batch) {
drawArrowHead(ctx, e.startX, e.startY, e.endX, e.endY, e.arrowSize)

View file

@ -19,11 +19,11 @@ export class ForceSimulation {
d3
.forceLink<GraphNode, GraphEdge>(edges)
.id((d) => d.id)
.distance((link) => (link.edgeType === "doc-memory" ? 150 : 300))
.distance((link) => (link.edgeType === "derives" ? 150 : 300))
.strength((link) => {
if (link.edgeType === "doc-memory") return 0.8
if (link.edgeType === "version") return 1.0
if (link.edgeType === "same-space") return 0.1
if (link.edgeType === "derives") return 0.8
if (link.edgeType === "updates") return 1.0
if (link.edgeType === "extends") return 0.1
return 0.3
}),
)

View file

@ -383,20 +383,20 @@ export const Legend = memo(function Legend({
>
<div style={rowStyle}>
<div style={rowLeftStyle}>
<LineIcon color={colors.edgeDocMemory} />
<span style={edgeLabelStyle}>Doc &gt; Memory</span>
<LineIcon color={colors.edgeDerives} />
<span style={edgeLabelStyle}>Derives</span>
</div>
</div>
<div style={rowStyle}>
<div style={rowLeftStyle}>
<LineIcon color={colors.edgeVersion} />
<span style={edgeLabelStyle}>Version chain</span>
<LineIcon color={colors.edgeUpdates} />
<span style={edgeLabelStyle}>Updates</span>
</div>
</div>
<div style={rowStyle}>
<div style={rowLeftStyle}>
<LineIcon color={colors.edgeSameSpace} dashed />
<span style={edgeLabelStyle}>Same space</span>
<LineIcon color={colors.edgeExtends} dashed />
<span style={edgeLabelStyle}>Extends</span>
</div>
</div>
</div>

View file

@ -45,9 +45,9 @@ export const DEFAULT_COLORS: GraphThemeColors = {
textPrimary: "#ffffff",
textSecondary: "#e2e8f0",
textMuted: "#94a3b8",
edgeDocMemory: "#4A5568",
edgeVersion: "#8B5CF6",
edgeSameSpace: "#4A6A8A",
edgeDerives: "#3B82F6",
edgeUpdates: "#8B5CF6",
edgeExtends: "#14B8A6",
memBorderForgotten: "#EF4444",
memBorderExpiring: "#F59E0B",
memBorderRecent: "#10B981",

View file

@ -29,11 +29,11 @@ export function getMemoryBorderColor(
export function getEdgeVisualProps(edgeType: string) {
switch (edgeType) {
case "doc-memory":
case "derives":
return { opacity: 0.3, thickness: 1.5 }
case "version":
case "updates":
return { opacity: 0.6, thickness: 2 }
case "same-space":
case "extends":
return { opacity: 0.15, thickness: 1 }
default:
return { opacity: 0.3, thickness: 1 }
@ -168,20 +168,20 @@ export function useGraphData(
for (const mem of doc.memories) allNodeIds.add(mem.id)
}
// Doc-memory edges
// Derives edges (doc -> memory)
for (const doc of documents) {
for (const mem of doc.memories) {
result.push({
id: `dm-${doc.id}-${mem.id}`,
source: doc.id,
target: mem.id,
visualProps: getEdgeVisualProps("doc-memory"),
edgeType: "doc-memory",
visualProps: getEdgeVisualProps("derives"),
edgeType: "derives",
})
}
}
// Version chain edges
// Updates edges (version chain)
for (const doc of documents) {
for (const mem of doc.memories) {
if (mem.parentMemoryId && allNodeIds.has(mem.parentMemoryId)) {
@ -189,14 +189,14 @@ export function useGraphData(
id: `ver-${mem.parentMemoryId}-${mem.id}`,
source: mem.parentMemoryId,
target: mem.id,
visualProps: getEdgeVisualProps("version"),
edgeType: "version",
visualProps: getEdgeVisualProps("updates"),
edgeType: "updates",
})
}
}
}
// Same-space edges: connect documents that share a spaceId
// Extends edges: connect documents that share a spaceId
const spaceGroups = new Map<string, string[]>()
for (const doc of documents) {
for (const mem of doc.memories) {
@ -221,8 +221,8 @@ export function useGraphData(
id: `ss-${key}`,
source: a,
target: b,
visualProps: getEdgeVisualProps("same-space"),
edgeType: "same-space",
visualProps: getEdgeVisualProps("extends"),
edgeType: "extends",
})
}
}

View file

@ -32,15 +32,9 @@ function resolveColors(): GraphThemeColors {
DEFAULT_COLORS.textSecondary,
),
textMuted: readCssVar("--graph-text-muted", DEFAULT_COLORS.textMuted),
edgeDocMemory: readCssVar(
"--graph-edge-doc-mem",
DEFAULT_COLORS.edgeDocMemory,
),
edgeVersion: readCssVar("--graph-edge-version", DEFAULT_COLORS.edgeVersion),
edgeSameSpace: readCssVar(
"--graph-edge-same-space",
DEFAULT_COLORS.edgeSameSpace,
),
edgeDerives: readCssVar("--graph-edge-derives", DEFAULT_COLORS.edgeDerives),
edgeUpdates: readCssVar("--graph-edge-updates", DEFAULT_COLORS.edgeUpdates),
edgeExtends: readCssVar("--graph-edge-extends", DEFAULT_COLORS.edgeExtends),
memBorderForgotten: readCssVar(
"--graph-mem-border-forgotten",
DEFAULT_COLORS.memBorderForgotten,

View file

@ -29,7 +29,7 @@ export interface GraphApiDocument {
export interface GraphApiEdge {
source: string
target: string
edgeType: "doc-memory" | "version" | "same-space"
edgeType: "derives" | "updates" | "extends"
}
// Typed node data
@ -86,7 +86,7 @@ export interface GraphEdge {
opacity: number
thickness: number
}
edgeType: "doc-memory" | "version" | "same-space"
edgeType: "derives" | "updates" | "extends"
}
export interface GraphThemeColors {
@ -101,9 +101,9 @@ export interface GraphThemeColors {
textPrimary: string
textSecondary: string
textMuted: string
edgeDocMemory: string
edgeVersion: string
edgeSameSpace: string
edgeDerives: string
edgeUpdates: string
edgeExtends: string
memBorderForgotten: string
memBorderExpiring: string
memBorderRecent: string