Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 138 additions & 0 deletions packages/memory-graph/src/__tests__/reduced-motion.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { afterEach, describe, expect, it, vi } from "vitest"
import { prefersReducedMotion } from "../canvas/reduced-motion"
import { ForceSimulation } from "../canvas/simulation"
import { ViewportState } from "../canvas/viewport"
import type { GraphEdge, GraphNode } from "../types"

function stubReducedMotion(matches: boolean) {
vi.stubGlobal("matchMedia", (query: string) => ({
matches: query.includes("reduce") ? matches : false,
media: query,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
onchange: null,
dispatchEvent: () => false,
}))
}

afterEach(() => vi.unstubAllGlobals())

function makeNode(id: string, x: number, y: number): GraphNode {
return {
id,
type: "document",
x,
y,
size: 50,
borderColor: "#fff",
isHovered: false,
isDragging: false,
data: {
id,
title: id,
summary: null,
type: "text",
createdAt: "2024-01-01",
updatedAt: "2024-01-01",
memories: [],
},
}
}

const nodes: GraphNode[] = [
makeNode("a", 0, 0),
makeNode("b", 100, 0),
makeNode("c", 0, 100),
]
const edges: GraphEdge[] = [
{
id: "a-b",
source: "a",
target: "b",
edgeType: "derives",
visualProps: { opacity: 1, thickness: 1 },
},
]

describe("prefersReducedMotion", () => {
it("returns false when matchMedia is unavailable", () => {
vi.stubGlobal("matchMedia", undefined)
expect(prefersReducedMotion()).toBe(false)
})

it("reflects the matchMedia result", () => {
stubReducedMotion(true)
expect(prefersReducedMotion()).toBe(true)
stubReducedMotion(false)
expect(prefersReducedMotion()).toBe(false)
})
})

describe("ForceSimulation reduced-motion", () => {
it("leaves the layout static after init and ignores reheat", () => {
stubReducedMotion(true)
const sim = new ForceSimulation()
sim.init(nodes, edges)
expect(sim.isActive()).toBe(false)
sim.reheat()
expect(sim.isActive()).toBe(false)
sim.destroy()
})

it("keeps the simulation running after init when motion is allowed", () => {
stubReducedMotion(false)
const sim = new ForceSimulation()
sim.init(nodes, edges)
expect(sim.isActive()).toBe(true)
sim.destroy()
})
})

describe("ViewportState reduced-motion", () => {
it("drops fling momentum under reduced motion", () => {
stubReducedMotion(true)
const vp = new ViewportState(0, 0, 1)
vp.releaseWithVelocity(50, 50)
vp.tick()
expect(vp.panX).toBe(0)
expect(vp.panY).toBe(0)
})

it("keeps fling momentum when motion is allowed", () => {
stubReducedMotion(false)
const vp = new ViewportState(0, 0, 1)
vp.releaseWithVelocity(50, 50)
vp.tick()
expect(vp.panX).toBeGreaterThan(0)
})

it("snaps zoom to target in a single tick under reduced motion", () => {
stubReducedMotion(true)
const vp = new ViewportState(0, 0, 1)
vp.zoomTo(3, 100, 100)
vp.tick()
expect(vp.zoom).toBe(3)
})

it("eases zoom across ticks when motion is allowed", () => {
stubReducedMotion(false)
const vp = new ViewportState(0, 0, 1)
vp.zoomTo(3, 100, 100)
vp.tick()
expect(vp.zoom).toBeGreaterThan(1)
expect(vp.zoom).toBeLessThan(3)
})

it("snaps a pan target instantly under reduced motion", () => {
stubReducedMotion(true)
const vp = new ViewportState(0, 0, 1)
vp.centerOn(500, 500, 800, 600)
const moved = vp.tick()
expect(moved).toBe(true)
// target = width/2 - worldX*zoom = 400 - 500 = -100, etc.
expect(vp.panX).toBe(-100)
expect(vp.panY).toBe(-200)
})
})
18 changes: 18 additions & 0 deletions packages/memory-graph/src/canvas/reduced-motion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* Detects the user's `prefers-reduced-motion` setting.
*
* The graph is otherwise in constant motion (force simulation settling,
* momentum panning, spring zoom), which can be uncomfortable for people with
* vestibular / motion sensitivities. Callers use this to render a calm, static
* layout instead while keeping every interaction available.
*
* SSR-safe and defensive: returns false when `matchMedia` is unavailable.
*/
export function prefersReducedMotion(): boolean {
if (typeof globalThis.matchMedia !== "function") return false
try {
return globalThis.matchMedia("(prefers-reduced-motion: reduce)").matches
} catch {
return false
}
}
12 changes: 11 additions & 1 deletion packages/memory-graph/src/canvas/simulation.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as d3 from "d3-force"
import type { DocumentNodeData, GraphEdge, GraphNode } from "../types"
import { FORCE_CONFIG } from "../constants"
import { prefersReducedMotion } from "./reduced-motion"

export const DENSE_GRAPH_STATIC_THRESHOLD = 6000

Expand Down Expand Up @@ -69,7 +70,13 @@ export class ForceSimulation {
: FORCE_CONFIG.preSettleTicks
for (let i = 0; i < preSettleTicks; i++) this.sim.tick()

if (nodes.length > DENSE_GRAPH_STATIC_THRESHOLD) {
// A dense graph is pre-settled and left static for performance; under
// reduced-motion we do the same for comfort, so the layout appears
// already-settled instead of visibly animating into place.
if (
nodes.length > DENSE_GRAPH_STATIC_THRESHOLD ||
prefersReducedMotion()
) {
this.stop()
} else {
this.sim.alphaTarget(0).restart()
Expand All @@ -89,6 +96,9 @@ export class ForceSimulation {
}

reheat(): void {
// Dragging still repositions the dragged node directly; skip the
// perpetual re-settle so neighbours don't jiggle under reduced-motion.
if (prefersReducedMotion()) return
this.sim?.alphaTarget(FORCE_CONFIG.alphaTarget).restart()
}

Expand Down
27 changes: 25 additions & 2 deletions packages/memory-graph/src/canvas/viewport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,17 @@ export class ViewportState {
private static readonly MAX_ZOOM = 5.0
private minZoom = ViewportState.DEFAULT_MIN_ZOOM

// Cached once so the per-frame check stays cheap; `.matches` still reflects
// live changes to the OS setting.
private readonly reducedMotionQuery: MediaQueryList | null =
typeof globalThis.matchMedia === "function"
? globalThis.matchMedia("(prefers-reduced-motion: reduce)")
: null

private get reducedMotion(): boolean {
return this.reducedMotionQuery?.matches ?? false
}

constructor(initialPanX = 0, initialPanY = 0, initialZoom = 0.5) {
this.panX = initialPanX
this.panY = initialPanY
Expand Down Expand Up @@ -50,6 +61,8 @@ export class ViewportState {
}

releaseWithVelocity(vx: number, vy: number): void {
// No fling/momentum under reduced-motion — the pan simply stops.
if (this.reducedMotion) return
this.velocityX = vx
this.velocityY = vy
}
Expand Down Expand Up @@ -118,6 +131,7 @@ export class ViewportState {
}

tick(): boolean {
const reduced = this.reducedMotion
let moving = false

if (Math.abs(this.velocityX) > 0.5 || Math.abs(this.velocityY) > 0.5) {
Expand All @@ -134,7 +148,8 @@ export class ViewportState {
const zoomDiff = this.targetZoom - this.zoom
if (Math.abs(zoomDiff) > 0.001) {
const world = this.screenToWorld(this.zoomAnchorX, this.zoomAnchorY)
this.zoom += zoomDiff * this.zoomSpring
// Reduced-motion snaps straight to the target zoom instead of easing.
this.zoom += reduced ? zoomDiff : zoomDiff * this.zoomSpring
this.panX = this.zoomAnchorX - world.x * this.zoom
this.panY = this.zoomAnchorY - world.y * this.zoom
moving = true
Expand All @@ -143,11 +158,19 @@ export class ViewportState {
if (this.targetPanX !== null && this.targetPanY !== null) {
const dx = this.targetPanX - this.panX
const dy = this.targetPanY - this.panY
if (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5) {
if (!reduced && (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5)) {
this.panX += dx * this.panLerp
this.panY += dy * this.panLerp
moving = true
} else {
// Snap to the target. Under reduced-motion this is the only branch,
// so report movement when the position actually changed.
if (
reduced &&
(this.panX !== this.targetPanX || this.panY !== this.targetPanY)
) {
moving = true
}
this.panX = this.targetPanX
this.panY = this.targetPanY
this.targetPanX = null
Expand Down
Loading