diff --git a/packages/memory-graph/src/__tests__/input-handler-touch.test.ts b/packages/memory-graph/src/__tests__/input-handler-touch.test.ts index 653255f3..65a48506 100644 --- a/packages/memory-graph/src/__tests__/input-handler-touch.test.ts +++ b/packages/memory-graph/src/__tests__/input-handler-touch.test.ts @@ -1,19 +1,10 @@ -import { beforeEach, describe, expect, it } from "vitest" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { SpatialIndex } from "../canvas/hit-test" import { InputHandler } from "../canvas/input-handler" +import { ForceSimulation } from "../canvas/simulation" import { ViewportState } from "../canvas/viewport" import type { GraphNode } from "../types" -/** - * InputHandler tap-to-select on touch devices. - * - * onTouchStart calls preventDefault() (required to stop scroll/zoom of the - * page), which also suppresses the browser's synthesized click event — so - * node selection must be detected from the raw touch lifecycle. These tests - * drive the handler through a stub canvas that records the listeners it - * registers. - */ - type Listener = (e: Event) => void function makeStubCanvas() { @@ -81,6 +72,8 @@ describe("InputHandler touch tap-to-select", () => { let dragStarts: Array<{ id: string; node: GraphNode }> let dragEnds: number let testNode: GraphNode + let handler: InputHandler + let simulation: ForceSimulation | null const fire = (name: string, e: TouchEvent) => { const fn = listeners.get(name) @@ -94,28 +87,106 @@ describe("InputHandler touch tap-to-select", () => { clicks = [] dragStarts = [] dragEnds = 0 + simulation = null - // zoom 1 / pan 0 so screen coordinates equal world coordinates viewport = new ViewportState(0, 0, 1) const index = new SpatialIndex() testNode = makeNode("doc-1", 100, 100) index.rebuild([testNode]) - new InputHandler(stub.canvas, viewport, index, { + handler = new InputHandler(stub.canvas, viewport, index, { onNodeHover: () => {}, onNodeClick: (id) => { clicks.push(id) }, onNodeDragStart: (id, node) => { dragStarts.push({ id, node }) + simulation?.reheat() }, onNodeDragEnd: () => { dragEnds++ + simulation?.coolDown() }, onRequestRender: () => {}, }) }) + afterEach(() => { + handler.destroy() + simulation?.destroy() + vi.unstubAllGlobals() + }) + + it("cancels a pending tap without selecting and permits a subsequent tap", () => { + fire("touchstart", touchEvent([touch(100, 100)])) + fire("touchcancel", touchEvent([])) + fire("touchend", touchEvent([])) + expect(clicks).toEqual([]) + expect(dragEnds).toBe(0) + fire("touchstart", touchEvent([touch(100, 100)])) + fire("touchend", touchEvent([])) + expect(clicks).toEqual(["doc-1"]) + }) + + it.each([ + false, + true, + ])("releases cancelled touch drags with reduced motion %s", (reduced) => { + vi.stubGlobal("matchMedia", () => ({ matches: reduced })) + simulation = new ForceSimulation() + simulation.init([testNode], []) + simulation.stop() + testNode.x = 100 + testNode.y = 100 + fire("touchstart", touchEvent([touch(100, 100)])) + fire("touchmove", touchEvent([touch(160, 120)])) + expect(testNode.fx).toBe(160) + expect(simulation.isActive()).toBe(!reduced) + fire("touchcancel", touchEvent([])) + expect(testNode.fx).toBeNull() + expect(testNode.fy).toBeNull() + expect(handler.getDraggingNode()).toBeNull() + expect(simulation.isActive()).toBe(false) + expect(dragEnds).toBe(1) + fire("touchcancel", touchEvent([])) + fire("touchend", touchEvent([])) + expect(dragEnds).toBe(1) + expect(clicks).toEqual([]) + }) + + it("clears cancelled pan and pinch state without selecting", () => { + fire("touchstart", touchEvent([touch(400, 400)])) + fire("touchmove", touchEvent([touch(460, 400)])) + fire("touchcancel", touchEvent([])) + fire("touchmove", touchEvent([touch(500, 400)])) + expect(viewport.panX).toBe(60) + fire("touchstart", touchEvent([touch(100, 100), touch(200, 200)])) + fire("touchcancel", touchEvent([])) + fire("touchend", touchEvent([])) + expect(clicks).toEqual([]) + }) + + it("releases a touch drag on pinch and removes cancellation listeners on destroy", () => { + fire("touchstart", touchEvent([touch(100, 100)])) + fire("touchmove", touchEvent([touch(160, 120)])) + fire("touchstart", touchEvent([touch(160, 120), touch(240, 200)])) + expect(testNode.fx).toBeNull() + expect(dragEnds).toBe(1) + handler.destroy() + expect(listeners.has("touchcancel")).toBe(false) + expect(listeners.size).toBe(0) + expect(clicks).toEqual([]) + }) + + it("increments the position revision only when a drag moves the node", () => { + fire("touchstart", touchEvent([touch(100, 100)])) + fire("touchmove", touchEvent([touch(160, 120)])) + expect(viewport.nodePositionVersion).toBe(1) + fire("touchmove", touchEvent([touch(160, 120)])) + fire("touchend", touchEvent([])) + expect(viewport.nodePositionVersion).toBe(1) + }) + it("selects a node on a single tap", () => { fire("touchstart", touchEvent([touch(100, 100)])) fire("touchend", touchEvent([])) diff --git a/packages/memory-graph/src/__tests__/minimap-interaction.test.tsx b/packages/memory-graph/src/__tests__/minimap-interaction.test.tsx new file mode 100644 index 00000000..59136546 --- /dev/null +++ b/packages/memory-graph/src/__tests__/minimap-interaction.test.tsx @@ -0,0 +1,193 @@ +import { act, cleanup, render } from "@testing-library/react" +import { afterEach, describe, expect, it, vi } from "vitest" +import { InputHandler } from "../canvas/input-handler" +import { SpatialIndex } from "../canvas/hit-test" +import { ForceSimulation } from "../canvas/simulation" +import { ViewportState } from "../canvas/viewport" +import { Minimap } from "../components/minimap" +import { DEFAULT_COLORS } from "../constants" +import type { GraphNode } from "../types" + +const handlers: InputHandler[] = [] +const simulations: ForceSimulation[] = [] + +afterEach(() => { + cleanup() + for (const handler of handlers.splice(0)) handler.destroy() + for (const simulation of simulations.splice(0)) simulation.destroy() + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +function makeNode(id: string, x: number, y: number): GraphNode { + return { + id, + type: "document", + x, + y, + size: 40, + borderColor: "#fff", + isHovered: false, + isDragging: false, + data: { + id, + title: id, + summary: null, + type: "text", + createdAt: "2026-01-01", + updatedAt: "2026-01-01", + memories: [], + }, + } +} + +describe("Minimap stopped-layout dragging", () => { + it.each([ + { reduced: false, touch: false }, + { reduced: false, touch: true }, + { reduced: true, touch: false }, + { reduced: true, touch: true }, + ])("refreshes actual dragging with reduced=$reduced and touch=$touch without idle scans", ({ + reduced, + touch, + }) => { + const frames = new Map() + let nextFrame = 0 + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + frames.set(++nextFrame, callback) + return nextFrame + }) + vi.stubGlobal("cancelAnimationFrame", (id: number) => frames.delete(id)) + vi.stubGlobal("matchMedia", () => ({ matches: reduced })) + const frame = () => { + const callbacks = [...frames.values()] + frames.clear() + act(() => { + for (const callback of callbacks) callback(0) + }) + } + const clearRect = vi.fn() + const arc = vi.fn() + const ctx = new Proxy( + { clearRect, arc }, + { + get: (target, key) => Reflect.get(target, key) ?? (() => {}), + set: () => true, + }, + ) as unknown as CanvasRenderingContext2D + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(ctx) + vi.spyOn( + HTMLCanvasElement.prototype, + "getBoundingClientRect", + ).mockReturnValue({ + x: 0, + y: 0, + left: 0, + top: 0, + width: 800, + height: 600, + right: 800, + bottom: 600, + toJSON: () => ({}), + }) + const node = makeNode("drag", 100, 100) + const nodes = [makeNode("left", 0, 0), node, makeNode("right", 400, 300)] + const viewport = new ViewportState(0, 0, 1) + let simulation: ForceSimulation | undefined + if (reduced) { + simulation = new ForceSimulation() + simulations.push(simulation) + simulation.init(nodes, []) + expect(simulation.isActive()).toBe(false) + } + let x = node.x + const readX = vi.fn(() => x) + Object.defineProperty(node, "x", { + get: readX, + set: (value: number) => { + x = value + }, + }) + const result = render( + , + ) + frame() + expect(clearRect).toHaveBeenCalledTimes(1) + const initialArcs = [...arc.mock.calls] + readX.mockClear() + for (let i = 0; i < 5; i++) frame() + expect(clearRect).toHaveBeenCalledTimes(1) + expect(readX).not.toHaveBeenCalled() + const canvas = document.createElement("canvas") + document.body.appendChild(canvas) + const index = new SpatialIndex() + index.rebuild(nodes, true) + const onClick = vi.fn() + const onEnd = vi.fn(() => simulation?.coolDown()) + const handler = new InputHandler(canvas, viewport, index, { + onNodeHover: () => {}, + onNodeClick: onClick, + onNodeDragStart: () => simulation?.reheat(), + onNodeDragEnd: onEnd, + onRequestRender: () => {}, + }) + handlers.push(handler) + const startX = node.x + const startY = node.y + if (touch) { + canvas.dispatchEvent( + Object.assign(new Event("touchstart"), { + touches: [{ clientX: startX, clientY: startY }], + }), + ) + canvas.dispatchEvent( + Object.assign(new Event("touchmove"), { + touches: [{ clientX: startX + 60, clientY: startY + 30 }], + }), + ) + } else { + canvas.dispatchEvent( + new MouseEvent("mousedown", { + clientX: startX, + clientY: startY, + button: 0, + }), + ) + window.dispatchEvent( + new MouseEvent("mousemove", { + clientX: startX + 60, + clientY: startY + 30, + }), + ) + } + expect(node.x).toBe(startX + 60) + expect(viewport.nodePositionVersion).toBe(1) + frame() + expect(clearRect).toHaveBeenCalledTimes(2) + expect(arc.mock.calls.slice(3)).not.toEqual(initialArcs) + if (touch) + canvas.dispatchEvent( + Object.assign(new Event("touchend"), { touches: [] }), + ) + else window.dispatchEvent(new MouseEvent("mouseup")) + expect(onEnd).toHaveBeenCalledOnce() + expect(onClick).not.toHaveBeenCalled() + readX.mockClear() + for (let i = 0; i < 5; i++) frame() + expect(clearRect).toHaveBeenCalledTimes(2) + expect(readX).not.toHaveBeenCalled() + viewport.pan(1, 0) + frame() + expect(clearRect).toHaveBeenCalledTimes(3) + result.unmount() + expect(frames.size).toBe(0) + canvas.remove() + }) +}) diff --git a/packages/memory-graph/src/__tests__/reduced-motion.test.ts b/packages/memory-graph/src/__tests__/reduced-motion.test.ts index a39dcd69..0b4eddaa 100644 --- a/packages/memory-graph/src/__tests__/reduced-motion.test.ts +++ b/packages/memory-graph/src/__tests__/reduced-motion.test.ts @@ -71,6 +71,54 @@ describe("prefersReducedMotion", () => { }) describe("ForceSimulation reduced-motion", () => { + it.each([ + false, + true, + ])("stops live motion and cleans up the %s legacy-query listener", (legacy) => { + const listeners = new Set<(event: MediaQueryListEvent) => void>() + const query = { + matches: false, + addEventListener: legacy + ? undefined + : (_type: string, listener: (event: MediaQueryListEvent) => void) => + listeners.add(listener), + removeEventListener: legacy + ? undefined + : (_type: string, listener: (event: MediaQueryListEvent) => void) => + listeners.delete(listener), + addListener: (listener: (event: MediaQueryListEvent) => void) => + listeners.add(listener), + removeListener: (listener: (event: MediaQueryListEvent) => void) => + listeners.delete(listener), + } + vi.stubGlobal("matchMedia", () => query) + const sim = new ForceSimulation() + sim.init(nodes, edges) + sim.reheat() + expect(sim.isActive()).toBe(true) + expect(listeners.size).toBe(1) + query.matches = true + for (const listener of listeners) + listener({ matches: true } as MediaQueryListEvent) + expect(sim.isActive()).toBe(false) + sim.reheat() + expect(sim.isActive()).toBe(false) + sim.init(nodes, edges) + expect(listeners.size).toBe(1) + sim.destroy() + expect(listeners.size).toBe(0) + }) + + it("preserves allowed-motion layout when matchMedia throws", () => { + vi.stubGlobal("matchMedia", () => { + throw new Error("unavailable") + }) + const sim = new ForceSimulation() + sim.init(nodes, edges) + expect(sim.isActive()).toBe(true) + sim.destroy() + }) + it("leaves the layout static after init and ignores reheat", () => { stubReducedMotion(true) const sim = new ForceSimulation() @@ -93,6 +141,21 @@ describe("ForceSimulation reduced-motion", () => { }) describe("ViewportState reduced-motion", () => { + it("discards an existing fling when the live preference changes", () => { + const query = { matches: false } + vi.stubGlobal("matchMedia", () => query) + const vp = new ViewportState(0, 0, 1) + vp.releaseWithVelocity(50, 25) + vp.tick() + expect(vp.panX).toBe(50) + query.matches = true + vp.tick() + expect(vp.panX).toBe(50) + expect(vp.panY).toBe(25) + query.matches = false + expect(vp.tick()).toBe(false) + }) + it("drops fling momentum under reduced motion", () => { stubReducedMotion(true) const vp = new ViewportState(0, 0, 1) diff --git a/packages/memory-graph/src/__tests__/shared-memory-identity.test.tsx b/packages/memory-graph/src/__tests__/shared-memory-identity.test.tsx index 4ba5c728..e4c190c5 100644 --- a/packages/memory-graph/src/__tests__/shared-memory-identity.test.tsx +++ b/packages/memory-graph/src/__tests__/shared-memory-identity.test.tsx @@ -98,6 +98,41 @@ function getDocumentNode( } describe("shared memory identity", () => { + it("retains distinct hyphenated document endpoint pairs and deduplicates exact repeats", () => { + const documents = [ + makeDocument("a-b", [makeMemory({ id: "c" }), makeMemory({ id: "c" })]), + makeDocument("a", [makeMemory({ id: "b-c" })]), + ] + expect( + computeEdges(documents).map((edge) => [edge.source, edge.target]), + ).toEqual([ + ["a-b", "c"], + ["a", "b-c"], + ]) + }) + + it("retains distinct hyphenated relation endpoint pairs and the first repeated type", () => { + const documents = [ + makeDocument("doc", [ + makeMemory({ id: "a-b" }), + makeMemory({ id: "a" }), + makeMemory({ id: "c", memoryRelations: { "a-b": "updates" } }), + makeMemory({ id: "b-c", memoryRelations: { a: "extends" } }), + ]), + makeDocument("other", [ + makeMemory({ id: "c", memoryRelations: { "a-b": "extends" } }), + ]), + ] + expect( + computeEdges(documents) + .filter((edge) => edge.edgeType !== "document") + .map((edge) => [edge.source, edge.target, edge.edgeType]), + ).toEqual([ + ["a-b", "c", "updates"], + ["a", "b-c", "extends"], + ]) + }) + it("materializes the first source occurrence once while retaining every document edge", () => { const documents = [ makeDocument("doc-a", [ diff --git a/packages/memory-graph/src/canvas/input-handler.ts b/packages/memory-graph/src/canvas/input-handler.ts index 9530be93..05dca878 100644 --- a/packages/memory-graph/src/canvas/input-handler.ts +++ b/packages/memory-graph/src/canvas/input-handler.ts @@ -58,6 +58,7 @@ export class InputHandler { private boundTouchStart: (e: TouchEvent) => void private boundTouchMove: (e: TouchEvent) => void private boundTouchEnd: (e: TouchEvent) => void + private boundTouchCancel: () => void private boundGesture: (e: Event) => void constructor( @@ -84,6 +85,7 @@ export class InputHandler { this.boundTouchStart = this.onTouchStart.bind(this) this.boundTouchMove = this.onTouchMove.bind(this) this.boundTouchEnd = this.onTouchEnd.bind(this) + this.boundTouchCancel = this.onTouchCancel.bind(this) this.boundGesture = (e: Event) => e.preventDefault() canvas.addEventListener("mousedown", this.boundMouseDown) @@ -98,6 +100,7 @@ export class InputHandler { passive: false, }) canvas.addEventListener("touchend", this.boundTouchEnd) + canvas.addEventListener("touchcancel", this.boundTouchCancel) canvas.addEventListener("gesturestart", this.boundGesture, { passive: false, }) @@ -118,6 +121,7 @@ export class InputHandler { c.removeEventListener("touchstart", this.boundTouchStart) c.removeEventListener("touchmove", this.boundTouchMove) c.removeEventListener("touchend", this.boundTouchEnd) + c.removeEventListener("touchcancel", this.boundTouchCancel) c.removeEventListener("gesturestart", this.boundGesture) c.removeEventListener("gesturechange", this.boundGesture) c.removeEventListener("gestureend", this.boundGesture) @@ -188,9 +192,7 @@ export class InputHandler { } if (this.draggingNode) { - const world = this.viewport.screenToWorld(x, y) - this.draggingNode.x = this.draggingNode.fx = world.x - this.grabOffset.x - this.draggingNode.y = this.draggingNode.fy = world.y - this.grabOffset.y + this.moveDraggingNode(x, y) this.didDrag = true this.callbacks.onRequestRender() return @@ -382,9 +384,7 @@ export class InputHandler { this.callbacks.onNodeDragStart(this.draggingNode.id, this.draggingNode) } if (this.draggingNode) { - const world = this.viewport.screenToWorld(x, y) - this.draggingNode.x = this.draggingNode.fx = world.x - this.grabOffset.x - this.draggingNode.y = this.draggingNode.fy = world.y - this.grabOffset.y + this.moveDraggingNode(x, y) this.callbacks.onRequestRender() } else if (this.isPanning) { this.viewport.pan(x - this.lastMouseX, y - this.lastMouseY) @@ -395,6 +395,26 @@ export class InputHandler { } } + private moveDraggingNode(x: number, y: number): void { + const node = this.draggingNode + if (!node) return + const world = this.viewport.screenToWorld(x, y) + const nextX = world.x - this.grabOffset.x + const nextY = world.y - this.grabOffset.y + if (node.x !== nextX || node.y !== nextY) { + this.viewport.nodePositionVersion++ + } + node.x = node.fx = nextX + node.y = node.fy = nextY + } + + private onTouchCancel(): void { + this.tapCandidate = false + this.isTouchGesture = false + this.isPanning = false + this.endTouchDrag() + } + private endTouchDrag(): void { this.pressedNode = null if (!this.draggingNode) return diff --git a/packages/memory-graph/src/canvas/reduced-motion.ts b/packages/memory-graph/src/canvas/reduced-motion.ts index 89b75535..5f6ab3b9 100644 --- a/packages/memory-graph/src/canvas/reduced-motion.ts +++ b/packages/memory-graph/src/canvas/reduced-motion.ts @@ -1,9 +1,13 @@ -// SSR-safe; false when matchMedia is unavailable. -export function prefersReducedMotion(): boolean { - if (typeof globalThis.matchMedia !== "function") return false +// SSR-safe; null when matchMedia is unavailable. +export function getReducedMotionQuery(): MediaQueryList | null { + if (typeof globalThis.matchMedia !== "function") return null try { - return globalThis.matchMedia("(prefers-reduced-motion: reduce)").matches + return globalThis.matchMedia("(prefers-reduced-motion: reduce)") } catch { - return false + return null } } + +export function prefersReducedMotion(): boolean { + return getReducedMotionQuery()?.matches ?? false +} diff --git a/packages/memory-graph/src/canvas/simulation.ts b/packages/memory-graph/src/canvas/simulation.ts index 67483f10..92eedcf8 100644 --- a/packages/memory-graph/src/canvas/simulation.ts +++ b/packages/memory-graph/src/canvas/simulation.ts @@ -1,18 +1,31 @@ import * as d3 from "d3-force" import type { DocumentNodeData, GraphEdge, GraphNode } from "../types" import { FORCE_CONFIG } from "../constants" -import { prefersReducedMotion } from "./reduced-motion" +import { getReducedMotionQuery, prefersReducedMotion } from "./reduced-motion" export const DENSE_GRAPH_STATIC_THRESHOLD = 6000 export class ForceSimulation { private sim: d3.Simulation | null = null private holdingHeat = false + private reducedMotionQuery: MediaQueryList | null = null + private readonly onReducedMotionChange = (event: MediaQueryListEvent) => { + if (event.matches) this.stop() + } init(nodes: GraphNode[], edges: GraphEdge[]): void { this.destroy() try { + this.reducedMotionQuery = getReducedMotionQuery() + if (this.reducedMotionQuery?.addEventListener) { + this.reducedMotionQuery.addEventListener( + "change", + this.onReducedMotionChange, + ) + } else { + this.reducedMotionQuery?.addListener?.(this.onReducedMotionChange) + } const structuralEdges = edges.filter((e) => e.edgeType !== "extends") this.sim = d3 @@ -148,6 +161,15 @@ export class ForceSimulation { destroy(): void { this.holdingHeat = false + if (this.reducedMotionQuery?.removeEventListener) { + this.reducedMotionQuery.removeEventListener( + "change", + this.onReducedMotionChange, + ) + } else { + this.reducedMotionQuery?.removeListener?.(this.onReducedMotionChange) + } + this.reducedMotionQuery = null if (this.sim) { this.sim.stop() this.sim = null diff --git a/packages/memory-graph/src/canvas/viewport.ts b/packages/memory-graph/src/canvas/viewport.ts index d58068de..b6473a76 100644 --- a/packages/memory-graph/src/canvas/viewport.ts +++ b/packages/memory-graph/src/canvas/viewport.ts @@ -1,7 +1,10 @@ +import { getReducedMotionQuery } from "./reduced-motion" + export class ViewportState { panX: number panY: number zoom: number + nodePositionVersion = 0 private velocityX = 0 private velocityY = 0 @@ -21,10 +24,7 @@ export class ViewportState { private static readonly MAX_ZOOM = 5.0 private minZoom = ViewportState.DEFAULT_MIN_ZOOM - private readonly reducedMotionQuery: MediaQueryList | null = - typeof globalThis.matchMedia === "function" - ? globalThis.matchMedia("(prefers-reduced-motion: reduce)") - : null + private readonly reducedMotionQuery = getReducedMotionQuery() private get reducedMotion(): boolean { return this.reducedMotionQuery?.matches ?? false @@ -145,6 +145,10 @@ export class ViewportState { tick(): boolean { const reduced = this.reducedMotion let moving = false + if (reduced) { + this.velocityX = 0 + this.velocityY = 0 + } if (Math.abs(this.velocityX) > 0.5 || Math.abs(this.velocityY) > 0.5) { this.panX += this.velocityX diff --git a/packages/memory-graph/src/components/minimap.tsx b/packages/memory-graph/src/components/minimap.tsx index 35f1f2ca..f3044957 100644 --- a/packages/memory-graph/src/components/minimap.tsx +++ b/packages/memory-graph/src/components/minimap.tsx @@ -100,6 +100,7 @@ export function Minimap({ let lastPanX = Number.NaN let lastPanY = Number.NaN let lastZoom = Number.NaN + let lastNodePositionVersion = -1 let wasSimActive = true const loop = () => { const vp = viewportRef.current @@ -107,12 +108,15 @@ export function Minimap({ const vpChanged = !!vp && (vp.panX !== lastPanX || vp.panY !== lastPanY || vp.zoom !== lastZoom) - if (vpChanged || simActive || wasSimActive) { + const nodesMoved = + !!vp && vp.nodePositionVersion !== lastNodePositionVersion + if (vpChanged || nodesMoved || simActive || wasSimActive) { draw() if (vp) { lastPanX = vp.panX lastPanY = vp.panY lastZoom = vp.zoom + lastNodePositionVersion = vp.nodePositionVersion } } wasSimActive = simActive diff --git a/packages/memory-graph/src/hooks/use-graph-data.ts b/packages/memory-graph/src/hooks/use-graph-data.ts index 82ea6b5b..57b4ae1f 100644 --- a/packages/memory-graph/src/hooks/use-graph-data.ts +++ b/packages/memory-graph/src/hooks/use-graph-data.ts @@ -411,14 +411,18 @@ export function computeEdges(documents: GraphApiDocument[]): GraphEdge[] { for (const mem of doc.memories) allNodeIds.add(mem.id) } - const documentEdgeIds = new Set() + const documentTargets = new Map>() for (const doc of documents) { + let targets = documentTargets.get(doc.id) for (const mem of doc.memories) { - const edgeId = `dm-${doc.id}-${mem.id}` - if (documentEdgeIds.has(edgeId)) continue - documentEdgeIds.add(edgeId) + if (targets?.has(mem.id)) continue + if (!targets) { + targets = new Set() + documentTargets.set(doc.id, targets) + } + targets.add(mem.id) result.push({ - id: edgeId, + id: `dm-${doc.id}-${mem.id}`, source: doc.id, target: mem.id, visualProps: getEdgeVisualProps("document"), @@ -427,19 +431,20 @@ export function computeEdges(documents: GraphApiDocument[]): GraphEdge[] { } } - // 2. Memory-to-memory relation edges from backend data. - // Uses memoryRelations (Record) as primary source, - // falls back to parentMemoryId for legacy data. - const relationEdgeIds = new Set() + const relationSources = new Map>() for (const doc of documents) { for (const mem of doc.memories) { const relations = getMemoryRelationTargets(mem) + let sources = relationSources.get(mem.id) for (const [targetId, relationType] of Object.entries(relations)) { if (!allNodeIds.has(targetId)) continue - const edgeId = `rel-${targetId}-${mem.id}` - if (relationEdgeIds.has(edgeId)) continue - relationEdgeIds.add(edgeId) + if (sources?.has(targetId)) continue + if (!sources) { + sources = new Set() + relationSources.set(mem.id, sources) + } + sources.add(targetId) const edgeType = relationType === "updates" || relationType === "extends" || @@ -447,7 +452,7 @@ export function computeEdges(documents: GraphApiDocument[]): GraphEdge[] { ? relationType : "updates" result.push({ - id: edgeId, + id: `rel-${targetId}-${mem.id}`, source: targetId, target: mem.id, visualProps: getEdgeVisualProps(edgeType),