mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-26 06:04:08 +00:00
fix(memory-graph): release cancelled drags and keep minimaps current
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
parent
2dbf02018f
commit
5f3e8e1902
10 changed files with 464 additions and 43 deletions
|
|
@ -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([]))
|
||||
|
|
|
|||
193
packages/memory-graph/src/__tests__/minimap-interaction.test.tsx
Normal file
193
packages/memory-graph/src/__tests__/minimap-interaction.test.tsx
Normal file
|
|
@ -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<number, FrameRequestCallback>()
|
||||
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(
|
||||
<Minimap
|
||||
nodes={nodes}
|
||||
colors={DEFAULT_COLORS}
|
||||
viewportRef={{ current: viewport }}
|
||||
canvasWidth={800}
|
||||
canvasHeight={600}
|
||||
simulation={simulation}
|
||||
/>,
|
||||
)
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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", [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<GraphNode, GraphEdge> | 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -411,14 +411,18 @@ export function computeEdges(documents: GraphApiDocument[]): GraphEdge[] {
|
|||
for (const mem of doc.memories) allNodeIds.add(mem.id)
|
||||
}
|
||||
|
||||
const documentEdgeIds = new Set<string>()
|
||||
const documentTargets = new Map<string, Set<string>>()
|
||||
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<string>()
|
||||
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<targetId, relationType>) as primary source,
|
||||
// falls back to parentMemoryId for legacy data.
|
||||
const relationEdgeIds = new Set<string>()
|
||||
const relationSources = new Map<string, Set<string>>()
|
||||
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<string>()
|
||||
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),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue