Documentation source
Entity Graph
Interactive relationship graph with deterministic force layout (off-thread for large graphs), three switchable layout modes, a single shared hover card, and a context-aware navigator that replaces the workspace map.
# Entity Graph
The Entity Graph renders all entity records and their relationships as an interactive node-link diagram. Nodes represent records (colored by type), edges represent relations, and the layout is computed via a D3 force-directed simulation or one of two alternative algorithms. The graph supports filtering by type and relation, searching by title, focusing on a single entity's neighborhood, switching layout modes, hovering for rich per-node detail cards, and embedding as a block in entity detail views.
## Overview
The graph is accessible at `/graph` as a full-bleed, full-page experience and can be embedded in entity detail pages via the `entity-graph` block type. The module lives in `features/graph/` and uses `@xyflow/react` for pan/zoom canvas rendering with custom node components. The layout engine dispatches to one of three algorithms — force, radial, or hierarchical — all running synchronously to convergence before rendering.
The workspace map modal (sidebar map icon / `Cmd/Ctrl+.`) is powered by the context-aware `ContextNavigator` component, which defaults to a mini graph centered on the current page but includes a List fallback that preserves all previous workspace-map behavior.
## Key Concepts
**GraphNode** — A node in the graph: `id`, `title`, `type` (human-readable type name), `typeSlug`, and optional `icon`.
**GraphEdge** — A directed edge: `id`, `source` (entity ID), `target` (entity ID), and `label` (relationship type string).
**GraphData** — The container type holding arrays of `GraphNode` and `GraphEdge`.
**GraphConfig** — Controls what the graph shows and how:
- `focusEntityId` — center on this entity and highlight its neighborhood
- `typeFilter` — array of entity type slugs to include
- `relationFilter` — array of relationship types to include
- `searchQuery` — highlights matching nodes
- `limit` — max nodes to display (default 50, API max 1000)
- `showToolbar` — whether to show filter/search controls
- `showLegend` — whether to show the type color legend
- `compact` — smaller nodes for sidebar/block use; also suppresses hover cards
- `height` — CSS height override
- `layoutMode` — `"force" | "radial" | "hierarchical"` (defaults to value from `localStorage` → `"force"`)
**LayoutMode** — `"force" | "radial" | "hierarchical"`. Exported from `features/graph/lib/layout-registry.ts` along with `LAYOUT_MODES` (label/value pairs for the toolbar control).
**SimNode / SimLink** — Extended types used by the layout engines, adding position (`x`, `y`), velocity, and `connectionCount` to `GraphNode`.
**NodeSummary** — Lightweight identity passed through selection callbacks: `title`, `typeSlug`, `typeName`.
**NodeDetail** — Per-node hover card payload: `id`, `title`, `typeSlug`, `typeName`, `icon`, `description`, `imageUrl`, `status`, `connectionCount`, `fields[]`. Fetched lazily from `GET /api/graph/node/[id]` with a 5-minute stale time.
**ViewportAction** — Decision type returned by `decideViewportAction()`: `"none" | "fit" | "fitSelection" | "center"`. The viewport gate only triggers viewport changes on meaningful content changes, never on resize or background refetch.
## How It Works
### Data Fetching
The `GET /api/graph` route supports two modes:
1. **Neighborhood mode** (`?entityId=...`) — fetches the specified entity and all entities one hop away via `entity_relations`. Returns a compact subgraph.
2. **Full graph mode** (optional `?types=...&limit=...`) — fetches up to `limit` entities (default 500, max 1000), optionally filtered by comma-separated type slugs. Relations are fetched with a limit of `3×` the entity limit.
The `GET /api/graph/node/[id]` route returns a single entity's hover-card payload: title, type, key display fields (from the entity type's `json_schema`), connection count, and image URL. Both routes are tenant-scoped and require authentication.
### Layout Engine
`computeLayout(mode, nodes, edges, opts)` in `features/graph/lib/layout-registry.ts` dispatches to the correct algorithm based on `mode`:
#### Organic / Force (default)
`computeForceLayout()` in `features/graph/lib/force-layout.ts` runs a D3 force simulation synchronously:
- **Deterministic seeding.** Initial positions are derived from node IDs via an FNV-1a hash placed on a phyllotaxis (golden-angle) spiral. The same node set always produces identical seed positions — `Math.random()` is replaced by a seeded LCG passed to `simulation.randomSource()`.
- **Fixed coordinate space.** The simulation centers at `(0, 0)` and is independent of container dimensions. `forceCenter(0, 0)` plus gentle `forceX(0)` / `forceY(0)` keep nodes near the origin. Container width and height are excluded from the layout's dependency array; a `ResizeObserver` firing cannot trigger a relayout.
- **Forces:** charge (repulsion, default −300), link (attraction, default 120 px), center, collision (radius scales with connection count), gentle X/Y anchoring.
- Runs 300 ticks synchronously before returning.
#### Radial
`computeRadialLayout()` in `features/graph/lib/layouts/radial.ts` places nodes on concentric rings by BFS distance from the most-connected node (or `focusId` if provided). Nodes on the same ring are spread evenly around their circumference, sorted deterministically by ID for stability. Ring spacing defaults to 140 px.
#### Hierarchy
`computeHierarchicalLayout()` in `features/graph/lib/layouts/hierarchical.ts` delegates to `dagre` for a top-down (or left-right via `rankdir`) DAG layout. Node sizes feed into dagre's rank/separation calculations so wider/taller nodes don't overlap.
All three algorithms share the same `LayoutResult` shape (`{ nodes: SimNode[], links: SimLink[] }`) and produce deterministic output for the same input — no `Math.random()` is used in any path.
### Viewport Stability Gate
`decideViewportAction(input: ViewportGateInput): ViewportAction` in `features/graph/lib/viewport-gate.ts` is a pure function that decides whether and how to change the viewport after a render cycle. The calling component tracks the previous state and only calls `fitView` / `setCenter` when the gate returns a non-`"none"` action.
| Condition | Action |
| ----------------------------------------------- | ---------------- |
| First render (never fit before) | `"fit"` |
| Selected node changed | `"fitSelection"` |
| Focus entity set or changed | `"center"` |
| Focus entity cleared | `"fit"` |
| Visible node set changed (different entity IDs) | `"fit"` |
| Container resize with same node set | `"none"` |
| Background refetch, same node set | `"none"` |
`computeNodeSignature(nodeIds)` produces a stable string fingerprint (sorted IDs joined by `|`) that the gate uses to detect whether the visible set actually changed.
### Node Aesthetics
`GraphNode` renders circular discs sized by connection count (`nodeRadius(count) = max(20, 20 + sqrt(count) * 6)`), with the label beneath the node. Hover applies a soft glow and triggers neighbor highlighting / non-neighbor dimming via xyflow class names. Transitions are 200 ms. No hardcoded colors — type colors come from `getTypeColor(typeSlug)` which maps to CSS token variables.
### Hover Cards
The whole canvas shares **one** hover card, not one per node. `GraphHoverLayer` renders a single instance — positioned over the hovered node via `flowToScreenPosition` and portaled to `<body>` (so xyflow's transformed viewport can't capture its `fixed` positioning) — replacing the previous per-node Radix `HoverCard` (which mounted one popover root per node, up to 1000 at the top node-limit setting). Hover-intent timing (250 ms open delay, ~140 ms close grace, instant switch when a card is already open) lives in the `useHoverCard` controller; pointer enter/leave on the card itself keep it open so it's clickable. The card body, `NodeHoverCard`, shows the record's title, type badge, key display fields, and connection count. Data comes from `useNodeDetail(nodeId)` — a React Query hook that fetches `GET /api/graph/node/[id]` with `staleTime: 5min` and `refetchOnWindowFocus: false`. The header renders immediately from data already on the node (fallback title + type); the detail fields appear once the fetch resolves. Panning/zooming dismisses the card, and it is suppressed entirely in `compact` mode (which keeps its lightweight per-node tooltip).
### Layout Mode Toolbar
`GraphToolbar` exposes a segmented control with the three mode labels (Organic / Radial / Hierarchy). Selecting a mode updates `GraphConfig.layoutMode` in the parent, triggers a new `computeLayout()` call, and resets `fitView`. The chosen mode is persisted to `localStorage` under the key `graph.layoutMode` in `FullGraph`, so it survives page reloads.
### Context-Aware Navigator (Workspace Map)
`ContextNavigator` (`features/graph/components/context-navigator.tsx`) is the component mounted inside the workspace-map dialog. It replaces the flat indented list with two modes, toggled by a Graph / List control:
- **Graph mode** — renders a mini `GraphCanvas` (cap 30 nodes) centered on the current entity if the path matches `/{typeSlug}/{uuid}`, otherwise an overview graph. Hovering nodes shows brief tooltips; clicking calls `onNavigate`.
- **List mode** — the original workspace-map outline: hierarchical nav rows, search/filter, "You are here" highlight, keyboard navigation, `aria-current`, scoped href generation. All previous workspace-map features are preserved here verbatim.
Both modes share one search input. `workspace-map.tsx` keeps its original export name and props (`nodes`, `currentNodeId`, `currentPath`, `scopeHref`, `onNavigate`, `onClose`) so `app-sidebar.tsx` required no changes.
### Rendering Pipeline
1. `computeLayout(mode, nodes, edges, opts)` produces positioned `SimNode[]` and `SimLink[]`
2. `decideViewportAction(gateInput)` determines whether to call `fitView`, `setCenter`, or nothing
3. `buildFlowNodes()` and `buildFlowEdges()` convert to `@xyflow/react` `Node[]` and `Edge[]`, applying interaction state (hover highlighting, search matches, focus dimming, selection)
4. `GraphCanvas` renders via `ReactFlow` with custom `GraphNode` components — `onlyRenderVisibleElements` culls off-screen nodes/edges, and drag/connect/select are disabled (read-only graph)
5. On node hover (after debounce), `NodeHoverCard` fetches and renders per-node detail
### Graph Variants
| Component | Use case |
| ---------------------- | ------------------------------------------------------------------------------------- |
| `FullGraph` | Full-page `/graph` experience with toolbar, legend, layout switcher, and detail panel |
| `GraphCanvas` | Reusable canvas (pan/zoom, node/edge rendering) |
| `MiniGraph` | Compact embedded version (sidebar, block) |
| `EntityGraphBlockView` | Block renderer for entity detail views |
| `ContextNavigator` | Workspace-map dialog: Graph + List modes |
## API Reference
### API Routes
| Endpoint | Method | Parameters |
| ---------------------- | ------ | ------------------------------------------------------------------------------------ |
| `/api/graph` | GET | `entityId?` (neighborhood mode), `types?` (comma-separated slugs), `limit?` (1–1000) |
| `/api/graph/node/[id]` | GET | Path param `id` (UUID) — returns `NodeDetail` |
### Core Types
| Type | Location | Purpose |
| -------------------- | ----------------------------------------- | --------------------------------------------- |
| `GraphNode` | `features/graph/types.ts` | Node data |
| `GraphEdge` | `features/graph/types.ts` | Edge data |
| `GraphConfig` | `features/graph/types.ts` | Display configuration (includes `layoutMode`) |
| `GraphData` | `features/graph/types.ts` | Container for nodes + edges |
| `SimNode`, `SimLink` | `features/graph/types.ts` | Layout engine types |
| `LayoutMode` | `features/graph/lib/layout-registry.ts` | `"force" \| "radial" \| "hierarchical"` |
| `ViewportAction` | `features/graph/lib/viewport-gate.ts` | Fit decision output |
| `NodeDetail` | `features/graph/hooks/use-node-detail.ts` | Hover card payload |
### Key Functions
| Function | Location | Purpose |
| ----------------------------------------------- | -------------------------------------------- | ---------------------------------------- |
| `computeLayout(mode, nodes, edges, opts)` | `features/graph/lib/layout-registry.ts` | Dispatch to the correct layout algorithm |
| `computeForceLayout(nodes, edges, options)` | `features/graph/lib/force-layout.ts` | D3 force simulation (deterministic) |
| `computeRadialLayout(nodes, edges, opts)` | `features/graph/lib/layouts/radial.ts` | BFS concentric-ring layout |
| `computeHierarchicalLayout(nodes, edges, opts)` | `features/graph/lib/layouts/hierarchical.ts` | dagre DAG layout |
| `nodeRadius(connectionCount)` | `features/graph/lib/force-layout.ts` | Node size from connection count |
| `decideViewportAction(input)` | `features/graph/lib/viewport-gate.ts` | Pure viewport-change decision |
| `computeNodeSignature(nodeIds)` | `features/graph/lib/viewport-gate.ts` | Stable fingerprint of a node set |
| `buildFlowNodes(ctx)` | `features/graph/lib/build-flow-elements.ts` | Convert to xyflow nodes |
| `buildFlowEdges(ctx)` | Same | Convert to xyflow edges |
| `filterGraphData(...)` | `features/graph/lib/graph-utils.ts` | Apply type/relation filters |
| `capGraphData(data, focusId?, max?)` | Same | Cap nodes for performance |
| `getTypeColor(typeSlug)` | `features/graph/lib/colors.ts` | Consistent color per type |
### Hooks
| Hook | Location | Purpose |
| ------------------------------ | ----------------------------------------- | ------------------------------------------------ |
| `useGraphData(config)` | `features/graph/hooks/use-graph-data.ts` | React Query wrapper for `/api/graph` |
| `useGraphLayout(opts)` | `features/graph/hooks/use-graph-layout.ts` | Computes positions; off-thread (web worker) above 150 nodes, sync below |
| `useHoverCard()` | `features/graph/hooks/use-hover-card.ts` | Hover-intent state machine for the single hover card (open delay + grace) |
| `useNodeDetail(nodeId, opts?)` | `features/graph/hooks/use-node-detail.ts` | Lazy hover-card detail fetch (`staleTime: 5min`) |
### Barrel Exports
The `features/graph/index.ts` barrel exports: `FullGraph`, `GraphCanvas`, `MiniGraph`, `EntityGraphBlockView`, `ContextNavigator`, `useGraphData`, `useNodeDetail`, `getTypeColor`, `getTypeBgColor`, and all core types.
## For Agents
Agents interact with graph data indirectly through entity and relation tools:
- **`searchEntities`** — find nodes
- **`getEntity`** — get entity details (richer than the hover-card endpoint)
- **`createRelation`** — add edges to the graph
- **`listEntityTypes`** — understand the type landscape
The graph visualization is a read-only rendering of the entity-relation data that agents create and modify through these tools. The hover-card endpoint (`GET /api/graph/node/[id]`) is a user-facing read path; agents should prefer `getEntity` for full field access.
## Design Decisions
**Deterministic layout, not cached layout.** Rather than persisting computed positions to the server or localStorage, positions are derived deterministically from node IDs. The same node set always produces the same layout across page loads, devices, and refetches — with no storage overhead and no stale-cache invalidation problem.
**Viewport gate extracted as a pure function.** The "should we fit?" decision has three inputs (node signature, selection, focus entity) and four outputs. Extracting it to `viewport-gate.ts` means the entire viewport stability guarantee is unit-testable in Vitest without a browser or a React component tree.
**Fixed coordinate space, not container-relative.** Force layout centers at `(0, 0)` regardless of container size. xyflow handles the mapping from graph coordinates to screen coordinates at render time. This severs the `ResizeObserver → layout recompute → fitView` chain that caused the snap-back bug.
**List fallback in the Navigator.** The context-aware graph navigator replaces the workspace map's flat outline as the default view, but the List mode is a first-class toggle — not a "fallback for when the graph fails." All keyboard navigation, search, and accessibility features live in List mode; Graph mode is additive.
**Layout mode in localStorage, not the server.** Layout preference is a per-device UI setting, not user or tenant data. Storing it in localStorage keeps it simple, avoids a round-trip, and means different devices can have different preferences.
**Web worker for large graphs.** Synchronous layout was measured at ~2 s (force) and ~16 s (hierarchy, dagre) at the selectable 1000-node setting — a hard main-thread freeze. `useGraphLayout` runs layout in a web worker (`layout.worker.ts`) above 150 nodes so the canvas stays interactive (a non-blocking spinner shows while it computes); small graphs, SSR, and test runs compute synchronously. The worker calls the same `computeLayout()`, so positions are byte-identical regardless of thread (determinism, K2, is preserved). Stale jobs are ignored by id, and the viewport gate keys off the **applied** layout's signature so an async layout fits exactly once — when its positions land — preserving the snap-back fix. (Hierarchy/dagre is still slow to *produce* a layout for very large graphs even off-thread — see `followups.md`; Radial and Organic both scale fine.)
**Viewport-bounded rendering (K11).** Layout runs off-thread, but _rendering_ 1000 nodes is a separate cost: every node stays mounted, and a single hover — which dims every non-neighbor — rebuilds and re-renders the whole set. `ReactFlow` is given `onlyRenderVisibleElements`, so only nodes/edges inside the current viewport rect mount; a hover then re-renders just the on-screen subset, and edges off-screen are culled too. Because the graph is **read-only** (clicks focus a node and hover highlights neighbors, both driven by our own state), xyflow's `nodesDraggable` / `nodesConnectable` / `elementsSelectable` / `edgesFocusable` are all disabled — dropping per-node drag/connect listeners and selection-state churn. `onNodeClick` still fires (it is independent of `elementsSelectable`). One caveat this introduces: `useNodesInitialized()` requires _every_ node measured, which culling prevents, so it would never go true for a graph larger than the viewport — the snap-back gate's readiness signal is instead a pane-measured store selector (`useStore((s) => s.width > 0 && s.height > 0)`), and `fitView` frames from node positions (its padding absorbs the small unmeasured node-size error). K1 (no snap-back) is preserved.
## Knowledge Graph Growth
The `/graph/growth` page is a tenant-generic analytics surface that makes the knowledge graph's evolution observable — entity counts over time, activity heatmaps, growth-by-type charts, connection growth, composition breakdowns, a type-pair coverage matrix, and a recent-additions feed.
### Overview
Accessible at `/graph/growth` (nav route `graph-growth`, registered for all tenants). Driven entirely by server-side queries against `entities` and `entity_relations`; no new migrations or RPCs. The page is a React Server Component that fans out its data fetches in parallel via `Promise.all`, passes the results to client chart components lazy-loaded with `next/dynamic`, and renders the heatmap / coverage matrix as server-rendered CSS grids.
**Surfaces rendered (top → bottom):**
1. **Summary scorecard** — total records, total connections, Δ records and connections in range, type count, edges/node ratio.
2. **Activity heatmap** — reuses the `calendar-heatmap` block's server fetcher (source=entities), respecting the record-type filter. Provenance-tag (`tag`) filtering is supported by the fetcher/block but not yet surfaced as a page control.
3. **Growth by type** — `entity-growth-series` block, cumulative stacked-area, top 8 types + "Other".
4. **Connection growth** — area/line chart of edge count over time; hidden when `entity_relations.created_at` is absent.
5. **Composition breakdown** — per-type cards showing current count, Δ in range, and 7-day sparkline.
6. **Type-pair coverage matrix** — generic density heat table of connection counts between the two most-connected record types (auto-selected via `getMostConnectedTypePair`); rendered as a page-local tokenized CSS grid.
7. **Recent additions feed** — latest 12 records with icon, title, type, and relative timestamp.
URL params: `?range=30|90|365` (days) and `?type=<typeSlug>`. Changing either emits `graph.growth.filter_changed` to PostHog.
### Data Layer
**`features/entities/server/growth-stats.ts`** — pure server module (not `"use server"`) with four exports:
| Function | Purpose |
|---|---|
| `getEntityGrowthSeries(opts?)` | Daily/weekly bucket series with per-type breakdown and cumulative running totals. Gap-fills every bucket to zero. Returns `EntityGrowthSeries`. |
| `getRelationGrowthSeries(opts?)` | Edge count series. Returns `available: false` if `entity_relations` lacks `created_at` (graceful degradation, no throw). |
| `getGraphGrowthSummary(opts?)` | Scalar metrics for the scorecard row. |
| `getRecentEntities(opts?)` | Latest N records ordered by `created_at DESC`. |
**`features/entities/server/type-pair-matrix.ts`** — two exports:
| Function | Purpose |
|---|---|
| `getTypePairMatrix(opts)` | Edge count matrix for `rowTypeSlug × colTypeSlug`, capped to top-N rows/cols by connection count. |
| `getMostConnectedTypePair()` | Returns the two record types with the most edges between them — used to default the coverage matrix. |
All functions use the authenticated Supabase client so RLS tenant-scoping is automatic.
### Key Types
```ts
// growth-stats.ts
type GrowthBucket = "day" | "week";
interface GrowthSeriesPoint {
date: string; // ISO bucket-start "YYYY-MM-DD"
total: number; // records in this bucket
byType: Record<string, number>; // typeSlug → count
}
interface EntityGrowthSeries {
points: GrowthSeriesPoint[]; // per-bucket (gap-filled)
cumulativePoints: GrowthSeriesPoint[]; // running totals
types: GrowthSeriesType[]; // types in range, desc by total
bucket: GrowthBucket;
rangeStart: string;
rangeEnd: string;
}
interface RelationGrowthSeries {
points: RelationGrowthPoint[];
available: boolean; // false → hide the connection-growth chart
totalRelations: number;
...
}
interface GraphGrowthSummary {
totalEntities: number;
totalRelations: number;
entitiesAddedInRange: number;
relationsAddedInRange: number;
typeCount: number;
edgesPerNode: number;
rangeDays: number;
}
```
### New Platform Block: `entity-growth-series`
Registered in `PLATFORM_V2_BLOCKS`. Config schema (`EntityGrowthSeriesConfigSchema`):
| Field | Default | Purpose |
|---|---|---|
| `days` | 90 | Lookback window |
| `bucket` | `"day"` | Granularity (`"day"` or `"week"`) |
| `cumulative` | `true` | Stacked cumulative vs per-bucket |
| `mode` | `"area"` | `"area"` or `"line"` |
| `entityTypeSlug` | — | Filter to one type |
| `tag` | — | Filter by provenance tag |
The display component pivots the series into Recharts-friendly rows, caps to 8 type series + an "Other" bucket, and applies `pickChartRamp('categorical')` from `lib/chart-colors.ts` (no hardcoded colours).
### Extended Block: `calendar-heatmap`
Added an optional `tag?: string` field to `CalendarHeatmapConfigSchema`. When `source === 'entities'`, the fetcher applies `.contains('tags', [tag])` to filter by provenance tag. Useful for isolating heatmap activity from a specific KG seed run or import batch.
### Design Decisions
**Query `entities.created_at` directly, not the activity log.** Seed/bundle writes use `swallow()` to suppress activity events, so the activity log undercounts. `entities.created_at` is NOT NULL, indexed on `(tenant_id, created_at)`, and is authoritative.
**TypeScript bucketing, not Postgres `date_trunc`.** Bucketing and gap-filling are done in the server module rather than SQL. This keeps the module migration-free, matches the existing `calendar-heatmap.fetch.ts` pattern, and is simple to test with a mocked client. Add a Postgres materialized view if any tenant exceeds ~100k records.
**Tenant scope via RLS, not param.** The authenticated client + PostgREST RLS hook pins the tenant automatically. No `tenant_id` param is passed to these functions — the same pattern used by `getEntityTypeStats()`.
**Graceful degradation for missing `entity_relations.created_at`.** Rather than throwing, `getRelationGrowthSeries` returns `{ available: false, points: [] }`. The page hides the connection-growth chart when `available` is false, so older schemas don't break the page.
**Charts lazy-loaded.** Recharts is heavy; all chart components live in `kg-growth-charts.tsx` loaded via `next/dynamic({ ssr: false })`. The page skeleton renders immediately; charts hydrate client-side.
## Related Modules
- **Entity System** (`features/entities/`) — provides the data (entities + relations)
- **Block System** (`features/blocks/`) — `entity-graph` block type embeds the graph in views; `entity-growth-series` and `calendar-heatmap` blocks power the growth surface
- **Views** (`features/views/`) — graph blocks can be included in detail and workspace views
- **Navigation** (`features/navigation/`) — `NavNode` tree consumed by `ContextNavigator` for the List mode
- **Analytics** (`features/analytics/`) — `graph.growth.filter_changed` event fired on range/type/source changes