Skip to content
Merged
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
102 changes: 102 additions & 0 deletions e2e/graph-density.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
118 changes: 118 additions & 0 deletions plans/152-graph-density-and-canvas-fit-2026-09-24.md
Original file line number Diff line number Diff line change
@@ -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.
40 changes: 40 additions & 0 deletions src/components/studio/views/graph-view.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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(<GraphView />)

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(<GraphView />)

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)
})
})
8 changes: 7 additions & 1 deletion src/components/studio/views/graph-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -330,7 +336,7 @@ export const GraphView = () => {
>
<svg
ref={svgRef}
viewBox={`${-panOffset.x / zoom} ${-panOffset.y / zoom} ${800 / zoom} ${560 / zoom}`}
viewBox={canvasViewBox(canvas, zoom, panOffset)}
className="h-full w-full"
preserveAspectRatio="xMidYMid meet"
role="img"
Expand Down
56 changes: 56 additions & 0 deletions src/lib/studio/graph-layout.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { describe, it, expect } from 'vitest'
import {
BASE_BAND_CAPACITY,
BASE_PLACEMENT_BAND,
baseNodePosition,
placeGraphNodes,
placementBand,
resolveNodePosition,
seededRandom,
CLICK_SAFE_NODE_DISTANCE_PX,
Expand Down Expand Up @@ -104,6 +107,31 @@ describe('resolveNodePosition', () => {
})
})

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
Expand Down Expand Up @@ -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([])
})
})
Loading
Loading