diff --git a/e2e/graph-density.spec.ts b/e2e/graph-density.spec.ts new file mode 100644 index 00000000..85cabe07 --- /dev/null +++ b/e2e/graph-density.spec.ts @@ -0,0 +1,102 @@ +import { test, expect } from '@playwright/test'; +import { navClick } from './helpers/navigation'; + +/** + * Graph density spec (plans/152). + * + * Placement spreads unseeded entities over a band that grows with the library, and + * the canvas grows with the band. Two user-visible consequences are worth pinning + * in a real browser: no node may be drawn outside the canvas (where it cannot be + * clicked at all), and a click must select the node it landed on — a node placed + * closer than the click-safe distance lets its neighbour's label swallow the click + * and select the wrong entity (plans/148). + * + * The library is seeded through the Zustand persist envelope, so the graph holds + * far more entities than the authored 800×560 canvas can hold at the preferred + * spacing without any slow per-entity UI setup. + */ + +/** Must match CURRENT_SCHEMA_VERSION in src/lib/studio/migrations.ts. */ +const SCHEMA_VERSION = 5; + +/** Persist key used by the Zustand store (name option in store.ts). */ +const STORE_KEY = 'do-knowledge-studio-store'; + +/** Enough entities that the placement band outgrows the authored canvas. */ +const ENTITY_COUNT = 60; + +/** The entity the click test targets — any node at this scale would do. */ +const TARGET_ENTITY = `Entity ${ENTITY_COUNT - 1}`; + +const SEED_ENVELOPE = { + state: { + entities: Array.from({ length: ENTITY_COUNT }, (_, i) => ({ + id: `dense-${i}`, + name: `Entity ${i}`, + type: 'note', + description: `Seeded entity ${i} for graph density coverage`, + content: '', + tags: ['seed'], + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-06-15T00:00:00Z', + links: [], + })), + claims: [], + graph: undefined, + mindMap: undefined, + links: undefined, + tags: undefined, + }, + version: SCHEMA_VERSION, +}; + +test.describe('Graph density at scale', () => { + test.beforeEach(async ({ page }) => { + await page.addInitScript( + ({ storeKey, envelope }) => { + localStorage.setItem(storeKey, JSON.stringify(envelope)); + }, + { storeKey: STORE_KEY, envelope: SEED_ENVELOPE }, + ); + await page.goto('/'); + await navClick(page, /graph/i); + await expect(page.getByRole('application', { name: /graph canvas/i })).toBeVisible(); + }); + + test('draws every node inside the canvas', async ({ page }) => { + const graph = page.getByRole('img', { name: /knowledge graph/i }); + + const result = await graph.evaluate((svg) => { + const canvas = svg.getBoundingClientRect(); + const nodes = Array.from(svg.querySelectorAll('g[role="button"]')); + const outside = nodes + .filter((node) => { + const box = node.getBoundingClientRect(); + return ( + box.left < canvas.left || + box.right > canvas.right || + box.top < canvas.top || + box.bottom > canvas.bottom + ); + }) + .map((node) => node.getAttribute('aria-label') ?? ''); + return { total: nodes.length, outside }; + }); + + expect(result.total).toBe(ENTITY_COUNT); + expect(result.outside).toEqual([]); + }); + + test('selects the node the click landed on', async ({ page }) => { + const graph = page.getByRole('img', { name: /knowledge graph/i }); + const target = graph.getByRole('button', { name: new RegExp(`^${TARGET_ENTITY} —`) }); + + await target.click(); + + // The clicked node reports itself as selected: a click swallowed by a + // neighbour's label would leave this node unselected. + await expect( + graph.getByRole('button', { name: new RegExp(`^${TARGET_ENTITY} —.*\\(selected\\)`) }), + ).toBeVisible(); + }); +}); diff --git a/plans/152-graph-density-and-canvas-fit-2026-09-24.md b/plans/152-graph-density-and-canvas-fit-2026-09-24.md new file mode 100644 index 00000000..f076c38e --- /dev/null +++ b/plans/152-graph-density-and-canvas-fit-2026-09-24.md @@ -0,0 +1,118 @@ +# Plan 152 — Graph Density: the Canvas Grows With the Library (2026-09-24) + +**Type**: product defect + layout fix +**Scope**: `src/lib/studio/graph-layout.ts`, `src/lib/studio/graph-viewport.ts` (new), +`src/components/studio/views/graph-view.tsx`, `e2e/graph-density.spec.ts` (new) +**Follows**: plans/148 §6.3 ("Graph density — the placement band is small relative to +the seed layout, so a large library crowds. A viewport-aware band or zoom-to-fit +would help.") + +## 1. Problem + +plans/148 placed unseeded nodes with a probe that keeps its distance from every +already-placed node, and measured the click-safe distance from the label geometry +(80px). But the band it probes inside is fixed — 600×400, inside the 800×560 +canvas — and once that band is full the probe falls back to "the candidate with +the most clearance", which can be **closer than click-safe**. The wrong-entity +selection that plans/148 fixed therefore returns as soon as the library is large +enough to saturate the band. + +Measured, before any change (8 seed nodes + 40 new entities): + +``` +"Genrich Altshuller ↔ Entity new-8: 68.7px" +"Local-First Software ↔ Entity new-37: 60.0px" +"Entity new-17 ↔ Entity new-38: 45.6px" +… 42 pairs closer than the 80px click-safe distance +``` + +This is not cosmetic. A pair closer than click-safe means one node's label covers +the other's dot, SVG gives the click to the topmost element, and the user selects +the wrong entity. + +## 2. Fix + +Two halves, and both are required — the second is what makes the first safe. + +### 2.1 The placement band grows with the library (`graph-layout.ts`) + +`placementBand(unseededCount)` starts at the authored band and doubles its area per +tier (`√2` per axis) until it holds the library at the preferred spacing: + +| unseeded entities | band | capacity at preferred spacing | +|---|---|---| +| ≤ 6 | 600×400 (authored) | 6 | +| 7–12 | 848×565 | 12 | +| 13–24 | 1200×800 | 24 | +| 25–48 | 1697×1131 | 48 | + +`BASE_BAND_CAPACITY = 6` is derived from the authored layout: (600×400 − 8 seed +obstacles × ~17k px²) ÷ ~17k px², where 17k px² is one node's share of the plane +at `PREFERRED_NODE_DISTANCE_PX` under hexagonal packing. Growth is tiered rather +than continuous, so the canvas grows in steps instead of rescaling on every +insert; individual positions can still shift when an entity is added, because +placement is sequential over a deterministic id order. + +### 2.2 The canvas grows with the band (`graph-viewport.ts`, new) + +The view hard-coded `viewBox="0 0 800 560"`. Left that way, the grown band is not +just crowded but **invisible**: the mutation check below measured 56 of 60 nodes +drawn outside the canvas, where they cannot be clicked or panned to (the viewBox +never grows, so panning cannot reach them either). + +`canvasSize(nodes)` returns the authored canvas or the smallest canvas that +contains every node *and its label* (labels span ±66px and hang below the dot — +the same measurement `PREFERRED_NODE_DISTANCE_PX` is built on). `canvasViewBox` +composes that size with the existing zoom/pan state. Resetting the view (Home) +therefore always shows the whole graph, and no new control or string is needed. + +The canvas is derived from `positioned` rather than `visibleNodes`: focus mode +filters what is drawn, and the canvas must not shrink under the nodes when it +toggles. + +This also covers the `circular` and `hierarchical` layouts, which place nodes on +their own grid and already exceeded 800×560 at ~25 entities. + +## 3. Verification + +| Check | Result | +|---|---| +| New invariant test (8 seeds + 40 entities, every pair ≥ click-safe) | **fails before** (42 pairs, listed above) → **passes after** | +| `pnpm exec vitest run graph-layout graph-viewport graph-view…` | 62 passed | +| Mutation: revert the view to `0 0 800 560` | component test fails (`expected 800 to be greater than 800`); E2E reports 56 nodes outside the canvas | +| `pnpm exec playwright test --project=chromium` | **151 passed** (2.3 m), including the two new density tests | +| `./scripts/quality_gate.sh` | see §4 | + +`e2e/graph-density.spec.ts` seeds 60 entities through the Zustand persist envelope +(the `library-virtualization.spec.ts` pattern) and asserts that every node's box +is inside the canvas, and that clicking a node selects *that* node. + +## 4. Gate and CI results + +| Check | Result | +|---|---| +| `./scripts/quality_gate.sh` (full, all scopes) | **✓ All Quality Gates PASSED** — lint, typecheck, test (2635), shellcheck, `bats tests/`, link validation | +| `pnpm exec playwright test --project=chromium` | 151 passed (2.3 m) | + +## 5. Trade-offs + +- **A large graph renders smaller.** The canvas grows and `preserveAspectRatio` + fits it, so a 60-entity library draws at roughly half scale. That is the + intended reading of "fit to content": detail comes from zooming in, and the + alternative is the wrong-entity click. Labels stay legible until roughly the + 40-entity tier. +- **Growth is tiered, not continuous.** Crossing a tier grows the canvas once. + Continuous growth would rescale the graph on every insert, which is worse. It + does not make positions immutable within a tier: placement is sequential over a + deterministic id order, so inserting an entity that sorts before others can move + theirs (corrected after review — the first version of this note overclaimed). +- **The seed layout never moves.** Growth extends the band right and down from + (100, 80), so the authored 8-node layout keeps its positions. + +## 6. Follow-ups + +1. **A viewport-aware tier size** — the band grows with the entity count, not with + the rendered viewport. On a large display the same library could hold the + preferred spacing in a shorter band. +2. **`semantic-search.spec.ts` load sensitivity** (plans/148 §6.1) — unchanged. +3. **ESLint 10 workaround** (plans/140 §2) — still blocked upstream. diff --git a/src/components/studio/views/graph-view.test.tsx b/src/components/studio/views/graph-view.test.tsx index 3d8faefe..13d08d2b 100644 --- a/src/components/studio/views/graph-view.test.tsx +++ b/src/components/studio/views/graph-view.test.tsx @@ -113,6 +113,7 @@ vi.mock('@/lib/studio/store', () => ({ })) import { GraphView } from './graph-view' +import { BASE_CANVAS_HEIGHT, BASE_CANVAS_WIDTH } from '@/lib/studio/graph-viewport' describe('GraphView', () => { beforeEach(() => { @@ -263,4 +264,43 @@ describe('GraphView', () => { expect(screen.getByText(/2 nodes · 1 edges/)).toBeDefined() expect(screen.queryByText('Unrelated Entity')).toBeNull() }) + + it('grows the canvas past the authored size so a large library stays on it', () => { + currentEntities = [ + ...mockEntities, + ...Array.from({ length: 40 }, (_, i) => ({ + id: `new-${i}`, + name: `Entity new-${i}`, + type: 'concept' as const, + description: '', + content: '', + tags: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + links: [], + })), + ] + render() + + const svg = screen.getByRole('img', { name: /knowledge graph/i }) + const [, , width, height] = (svg.getAttribute('viewBox') ?? '').split(' ').map(Number) + + // The regression: the view hard-coded an 800×560 viewBox while placement + // spread nodes over a grown band, drawing most of the graph off-canvas. + expect(width).toBeGreaterThan(BASE_CANVAS_WIDTH) + expect(height).toBeGreaterThan(BASE_CANVAS_HEIGHT) + }) + + it('keeps the canvas size while focus mode filters nodes', () => { + currentSelectedEntityId = 'ent-1' + render() + + const svg = screen.getByRole('img', { name: /knowledge graph/i }) + const before = svg.getAttribute('viewBox') + fireEvent.click(screen.getByLabelText('Focus neighborhood')) + + // Focus mode hides nodes; shrinking the canvas under the remaining ones + // would rescale the graph every time the toggle is used. + expect(svg.getAttribute('viewBox')).toBe(before) + }) }) diff --git a/src/components/studio/views/graph-view.tsx b/src/components/studio/views/graph-view.tsx index 757eb76e..4ee91403 100644 --- a/src/components/studio/views/graph-view.tsx +++ b/src/components/studio/views/graph-view.tsx @@ -4,6 +4,7 @@ import { useStudioStore } from '@/lib/studio/store' import { type GraphEdge, type GraphNode } from '@/lib/studio/types' import { seedGraph } from '@/lib/studio/seed-data' import { placeGraphNodes } from '@/lib/studio/graph-layout' +import { canvasSize, canvasViewBox } from '@/lib/studio/graph-viewport' import { getEntityTypeDefs, getEntityTypeMeta } from '@/lib/studio/entity-types' import { translate as entityTypesT } from '@/lib/i18n/messages/entity-types' import { todayStamp, downloadBlob } from './export-types' @@ -163,6 +164,11 @@ export const GraphView = () => { return nodes }, [nodes, layout]) + // The canvas grows with the placed nodes so nothing is drawn off-canvas. It is + // derived from `positioned` rather than `visibleNodes`: focus mode filters what + // is drawn, and the canvas must not shrink under the nodes when it toggles. + const canvas = useMemo(() => canvasSize(positioned), [positioned]) + const visibleNodes = useMemo(() => { if (focusMode && selectedEntityId) { const neighbors = adjacency.get(selectedEntityId) @@ -330,7 +336,7 @@ export const GraphView = () => { > { }) }) +describe('placementBand', () => { + it('is the authored band while the library fits it', () => { + expect(placementBand(1)).toEqual(BASE_PLACEMENT_BAND) + expect(placementBand(BASE_BAND_CAPACITY)).toEqual(BASE_PLACEMENT_BAND) + }) + + it('grows monotonically with the number of unseeded nodes', () => { + let previous = placementBand(0) + for (const count of [1, 7, 13, 25, 49, 100]) { + const band = placementBand(count) + expect(band.xMax).toBeGreaterThanOrEqual(previous.xMax) + expect(band.yMax).toBeGreaterThanOrEqual(previous.yMax) + previous = band + } + // Growth is real, not just monotonic: 100 nodes cannot share the base band. + expect(placementBand(100).xMax).toBeGreaterThan(BASE_PLACEMENT_BAND.xMax) + }) + + it('is deterministic and keeps the band anchored at its origin', () => { + expect(placementBand(20)).toEqual(placementBand(20)) + expect(placementBand(20).xMin).toBe(BASE_PLACEMENT_BAND.xMin) + expect(placementBand(20).yMin).toBe(BASE_PLACEMENT_BAND.yMin) + }) +}) + describe('placeGraphNodes', () => { it('keeps authored seed positions', () => { const seedNode = seedGraph.nodes[0] as GraphNode @@ -159,4 +187,32 @@ describe('placeGraphNodes', () => { positionsById(placeGraphNodes([...entities].reverse(), [])), ) }) + + it('keeps every pair at click-safe spacing as the library grows', () => { + const seedEntities = seedGraph.nodes.map((node) => + makeEntity(node.id, { name: node.label, type: node.type }), + ) + const count = 40 + const entities = [ + ...seedEntities, + ...Array.from({ length: count }, (_, i) => makeEntity(`new-${i}`)), + ] + + const nodes = placeGraphNodes(entities, seedGraph.nodes) + const tooClose: string[] = [] + for (const a of nodes) { + for (const b of nodes) { + if (a.id >= b.id) continue + const gap = distance(a, b) + if (gap < CLICK_SAFE_NODE_DISTANCE_PX) { + tooClose.push(`${a.label} ↔ ${b.label}: ${gap.toFixed(1)}px`) + } + } + } + + // A node placed closer than the click-safe distance can be covered by its + // neighbour's label, which is the wrong-entity selection plans/148 fixed. A + // library of this size must therefore widen the canvas, not crowd it. + expect(tooClose).toEqual([]) + }) }) diff --git a/src/lib/studio/graph-layout.ts b/src/lib/studio/graph-layout.ts index 4455a649..fb29e4b9 100644 --- a/src/lib/studio/graph-layout.ts +++ b/src/lib/studio/graph-layout.ts @@ -30,6 +30,63 @@ export const PLACEMENT_X_MAX = 700 export const PLACEMENT_Y_MIN = 80 export const PLACEMENT_Y_MAX = 480 +/** + * A rectangle in graph coordinates that unseeded nodes are placed inside. + * The band starts at the authored seed layout's scale and grows with the library + * (see `placementBand`). + */ +export interface PlacementBand { + xMin: number + xMax: number + yMin: number + yMax: number +} + +/** The unscaled band: 600×400 inside the 800×560 canvas the seed layout uses. */ +export const BASE_PLACEMENT_BAND: PlacementBand = { + xMin: PLACEMENT_X_MIN, + xMax: PLACEMENT_X_MAX, + yMin: PLACEMENT_Y_MIN, + yMax: PLACEMENT_Y_MAX, +} + +/** + * Preferred-spacing slots the base band holds once the authored seed layout has + * taken its share: (600×400 − 8 seed obstacles × ~17k px²) ÷ ~17k px² ≈ 6, where + * 17k px² is one node's share of the plane at `PREFERRED_NODE_DISTANCE_PX` under + * hexagonal packing. Exported because it is the band's first growth threshold. + */ +export const BASE_BAND_CAPACITY = 6 + +/** Per-tier growth: √2 per axis, so the band's area doubles each tier. */ +const BAND_GROWTH_PER_TIER = Math.SQRT2 + +/** + * Placement band for `unseededCount` nodes. A band that holds only ~6 nodes at + * the preferred spacing would otherwise crowd everything beyond that into + * less-than-click-safe gaps — 40 new entities produced 42 such pairs, including + * pairs with seed nodes, which is the wrong-entity selection plans/148 fixed. + * The band therefore grows in tiers as the library does, and the graph canvas + * grows with it (`canvasSize` in `graph-viewport.ts`) so no node leaves the + * viewport. + * + * Growth is tiered, not continuous: the band's bounds change only when the count + * crosses a tier, so the canvas grows in steps instead of rescaling on every + * insert. Individual positions can still move when an entity is added, because + * placement is sequential over a deterministic id order and every later node + * probes against the ones already placed. + */ +export const placementBand = (unseededCount: number): PlacementBand => { + const tiers = Math.max(0, Math.ceil(Math.log2(Math.max(1, unseededCount) / BASE_BAND_CAPACITY))) + const scale = BAND_GROWTH_PER_TIER ** tiers + return { + xMin: BASE_PLACEMENT_BAND.xMin, + yMin: BASE_PLACEMENT_BAND.yMin, + xMax: BASE_PLACEMENT_BAND.xMin + (BASE_PLACEMENT_BAND.xMax - BASE_PLACEMENT_BAND.xMin) * scale, + yMax: BASE_PLACEMENT_BAND.yMin + (BASE_PLACEMENT_BAND.yMax - BASE_PLACEMENT_BAND.yMin) * scale, + } +} + /** * Preferred centre-to-centre distance between two placed nodes. Labels render at * `text-caption` (10px) and truncate at 24 characters, so one spans ~132px; at @@ -64,10 +121,13 @@ export interface NodePosition { const clamp = (value: number, min: number, max: number): number => Math.min(Math.max(value, min), max) -/** Hash-derived position for an entity id, clamped into the placement band. */ -export const baseNodePosition = (id: string): NodePosition => ({ - x: PLACEMENT_X_MIN + seededRandom(`${id}:x`) * (PLACEMENT_X_MAX - PLACEMENT_X_MIN), - y: PLACEMENT_Y_MIN + seededRandom(`${id}:y`) * (PLACEMENT_Y_MAX - PLACEMENT_Y_MIN), +/** Hash-derived position for an entity id, clamped into `band`. */ +export const baseNodePosition = ( + id: string, + band: PlacementBand = BASE_PLACEMENT_BAND, +): NodePosition => ({ + x: band.xMin + seededRandom(`${id}:x`) * (band.xMax - band.xMin), + y: band.yMin + seededRandom(`${id}:y`) * (band.yMax - band.yMin), }) /** Smallest distance from (x, y) to any placed node; Infinity when none are placed. */ @@ -77,13 +137,13 @@ const clearance = (x: number, y: number, placed: readonly NodePosition[]): numbe Number.POSITIVE_INFINITY, ) -/** The `attempt`-th probe point around `base`, clamped into the placement band. */ -const probePosition = (base: NodePosition, attempt: number): NodePosition => { +/** The `attempt`-th probe point around `base`, clamped into `band`. */ +const probePosition = (base: NodePosition, attempt: number, band: PlacementBand): NodePosition => { const radius = PREFERRED_NODE_DISTANCE_PX * Math.sqrt(attempt) const angle = attempt * PROBE_ANGLE_RAD return { - x: clamp(base.x + radius * Math.cos(angle), PLACEMENT_X_MIN, PLACEMENT_X_MAX), - y: clamp(base.y + radius * Math.sin(angle), PLACEMENT_Y_MIN, PLACEMENT_Y_MAX), + x: clamp(base.x + radius * Math.cos(angle), band.xMin, band.xMax), + y: clamp(base.y + radius * Math.sin(angle), band.yMin, band.yMax), } } @@ -95,9 +155,10 @@ const probeForClearance = ( base: NodePosition, placed: readonly NodePosition[], minDistance: number, + band: PlacementBand, ): NodePosition | null => { for (let attempt = 1; attempt <= PLACEMENT_ATTEMPTS; attempt += 1) { - const candidate = probePosition(base, attempt) + const candidate = probePosition(base, attempt, band) if (clearance(candidate.x, candidate.y, placed) >= minDistance) return candidate } return null @@ -114,14 +175,15 @@ const probeForClearance = ( export const resolveNodePosition = ( id: string, placed: readonly NodePosition[], + band: PlacementBand = BASE_PLACEMENT_BAND, ): NodePosition => { - const base = baseNodePosition(id) + const base = baseNodePosition(id, band) let best = base let bestClearance = clearance(base.x, base.y, placed) if (bestClearance >= PREFERRED_NODE_DISTANCE_PX) return best for (let attempt = 1; attempt <= PLACEMENT_ATTEMPTS; attempt += 1) { - const candidate = probePosition(base, attempt) + const candidate = probePosition(base, attempt, band) const candidateClearance = clearance(candidate.x, candidate.y, placed) if (candidateClearance >= PREFERRED_NODE_DISTANCE_PX) return candidate if (candidateClearance > bestClearance) { @@ -130,7 +192,7 @@ export const resolveNodePosition = ( } } - return probeForClearance(base, placed, CLICK_SAFE_NODE_DISTANCE_PX) ?? best + return probeForClearance(base, placed, CLICK_SAFE_NODE_DISTANCE_PX, band) ?? best } const byEntityId = (a: Entity, b: Entity): number => a.id.localeCompare(b.id) @@ -138,7 +200,9 @@ const byEntityId = (a: Entity, b: Entity): number => a.id.localeCompare(b.id) /** * Resolve every entity to a graph node. Entities with an authored seed position * keep it and act as fixed obstacles; the rest are placed in id order, so the - * result does not depend on the library's sort order. + * result does not depend on the library's sort order. The band the unseeded nodes + * are placed in grows with their count, so a large library spreads out instead of + * crowding into sub-click-safe gaps (see `placementBand`). */ export const placeGraphNodes = ( entities: readonly Entity[], @@ -156,8 +220,9 @@ export const placeGraphNodes = ( } const unseeded = entities.filter((entity) => !positions.has(entity.id)).sort(byEntityId) + const band = placementBand(unseeded.length) for (const entity of unseeded) { - const position = resolveNodePosition(entity.id, placed) + const position = resolveNodePosition(entity.id, placed, band) positions.set(entity.id, position) placed.push(position) } diff --git a/src/lib/studio/graph-viewport.test.ts b/src/lib/studio/graph-viewport.test.ts new file mode 100644 index 00000000..a7688232 --- /dev/null +++ b/src/lib/studio/graph-viewport.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from 'vitest' +import { + BASE_CANVAS_HEIGHT, + BASE_CANVAS_WIDTH, + canvasSize, + canvasViewBox, +} from './graph-viewport' +import { placeGraphNodes } from './graph-layout' +import { seedGraph } from './seed-data' +import type { Entity, GraphNode } from './types' + +const makeEntity = (id: string, overrides: Partial = {}): Entity => ({ + id, + name: `Entity ${id}`, + type: 'concept', + description: '', + content: '', + tags: [], + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + links: [], + ...overrides, +}) + +const nodeAt = (x: number, y: number): GraphNode => ({ + id: `${x}:${y}`, + label: 'Node', + type: 'concept', + x, + y, +}) + +describe('canvasSize', () => { + it('is the authored canvas while every node fits inside it', () => { + expect(canvasSize([])).toEqual({ width: BASE_CANVAS_WIDTH, height: BASE_CANVAS_HEIGHT }) + expect(canvasSize([nodeAt(400, 300)])).toEqual({ + width: BASE_CANVAS_WIDTH, + height: BASE_CANVAS_HEIGHT, + }) + }) + + it('grows past the authored canvas to contain a node and its label', () => { + const size = canvasSize([nodeAt(1200, 900)]) + + // Not just past the node centre: the label hangs below it and spans ±66px, so + // a canvas that stopped at the centre would clip the text it exists to show. + expect(size.width).toBeGreaterThan(1200) + expect(size.height).toBeGreaterThan(900) + }) + + it('contains every node of a library too large for the authored canvas', () => { + const entities = [ + ...seedGraph.nodes.map((node) => makeEntity(node.id, { name: node.label, type: node.type })), + ...Array.from({ length: 40 }, (_, i) => makeEntity(`new-${i}`)), + ] + + const nodes = placeGraphNodes(entities, seedGraph.nodes) + const size = canvasSize(nodes) + + // The regression this guards: placement spread nodes over a grown band while + // the viewBox stayed at 800×560, so most of the graph was drawn off-canvas. + for (const node of nodes) { + expect(node.x).toBeLessThan(size.width) + expect(node.y).toBeLessThan(size.height) + } + expect(size.width).toBeGreaterThan(BASE_CANVAS_WIDTH) + expect(size.height).toBeGreaterThan(BASE_CANVAS_HEIGHT) + }) +}) + +describe('canvasViewBox', () => { + it('shows the whole canvas when the view is at rest', () => { + expect(canvasViewBox({ width: 800, height: 560 }, 1, { x: 0, y: 0 })).toBe('0 0 800 560') + }) + + it('halves the visible extents at 2× zoom', () => { + expect(canvasViewBox({ width: 800, height: 560 }, 2, { x: 0, y: 0 })).toBe('0 0 400 280') + }) + + it('subtracts pan in canvas units, so panning stays constant on screen', () => { + expect(canvasViewBox({ width: 800, height: 560 }, 2, { x: 40, y: 20 })).toBe('-20 -10 400 280') + }) +}) diff --git a/src/lib/studio/graph-viewport.ts b/src/lib/studio/graph-viewport.ts new file mode 100644 index 00000000..5c1029c0 --- /dev/null +++ b/src/lib/studio/graph-viewport.ts @@ -0,0 +1,61 @@ +import type { NodePosition } from './graph-layout' +import type { GraphNode } from './types' + +/** + * Canvas geometry for the graph view. + * + * The canvas the seed layout is authored on is 800×560, and the view used to + * hard-code that as its viewBox. Placement, however, spreads unseeded nodes over + * a band that grows with the library (`placementBand` in `graph-layout.ts`), and + * the circular and hierarchical layouts lay nodes out on their own grid — both + * can put nodes outside 800×560, where they are drawn off-canvas and cannot be + * reached by panning either, because the viewBox never grows. The canvas is + * therefore derived from the nodes it holds, and `zoom`/`panOffset` compose on + * top of it, so resetting the view (Home) always shows the whole graph. + */ + +/** Canvas the authored seed layout is drawn on; the canvas never shrinks below it. */ +export const BASE_CANVAS_WIDTH = 800 +export const BASE_CANVAS_HEIGHT = 560 + +/** + * Half the width of a node label. Labels render at `text-caption` (10px) and + * truncate at 24 characters, so one spans ~132px (the same measurement + * `PREFERRED_NODE_DISTANCE_PX` is built on). + */ +const LABEL_HALF_WIDTH_PX = 66 + +/** Distance from a node centre to the bottom edge of its label, in px. */ +const LABEL_BOTTOM_PX = 32 + +/** Breathing room between the outermost label and the canvas edge, in px. */ +const CANVAS_MARGIN_PX = 16 + +/** Canvas dimensions in graph coordinates. */ +export interface CanvasSize { + width: number + height: number +} + +/** The base canvas, or the smallest canvas that contains every node and its label. */ +export const canvasSize = (nodes: readonly GraphNode[]): CanvasSize => { + let width = BASE_CANVAS_WIDTH + let height = BASE_CANVAS_HEIGHT + for (const node of nodes) { + width = Math.max(width, node.x + LABEL_HALF_WIDTH_PX + CANVAS_MARGIN_PX) + height = Math.max(height, node.y + LABEL_BOTTOM_PX + CANVAS_MARGIN_PX) + } + return { width, height } +} + +/** + * SVG `viewBox` for `size` at `zoom`, panned by `panOffset` (in viewport pixels). + * Panning moves the view, so it is subtracted; zooming scales the box around the + * canvas origin. + */ +export const canvasViewBox = ( + size: CanvasSize, + zoom: number, + panOffset: NodePosition, +): string => + `${-panOffset.x / zoom} ${-panOffset.y / zoom} ${size.width / zoom} ${size.height / zoom}`