VectoJS Knowledge Graph (2D)
Patterns for canvas-native 2D knowledge-graph apps (node-link graphs with
search, drawers, hover tooltips, pan/zoom) on @vectojs/core. Read
vectojs-graph-layout for 2D physics, vectojs-graph3d for the 3D instanced
package, and vectojs-performance for profiling method.
Renderer-neutral model and paging
Use KnowledgeGraphModel from @vectojs/knowledge-graph/model when the app
needs a bounded materialized cut of a large source graph. The subpath exposes
domain types, MemoryDataSource, and model state without importing the
session/rendering-facing package surface.
const model = new KnowledgeGraphModel({
source,
pageSize: 100,
direction: "both",
lang: "en",
});
await model.bootstrap(focusIds, false);
await model.expand(focusIds[0]);
draw(model.getGraphData());
KgDataSource.getNeighbors(id, { limit, cursor, direction, signal }) returns
one page plus optional total, nextCursor, and hasMore. Treat cursors as
opaque and propagate signal through backend I/O.
expand(id) loads one page. Same-ID concurrent calls share one promise;
different IDs may load concurrently. Call again from partial to resume. In
the session layer, repeated selects on an id whose expansion is still in
flight are swallowed — one fetch produces at most one onExpand/onError.
- Expanding an id the source does not know fails loudly with a targeted
error; no placeholder
'Unknown' entity is materialized and the model's
entity order stays clean.
- Expansion status is
idle, loading, partial, complete, failed, or
cancelled. cancelExpand(id) aborts active work; a later expand resumes at
the last completed cursor. Failed calls reject and can be retried.
- Entities deduplicate by ID and merge labels. Facts deduplicate by the ordered
(source, predicate, target) triple. Use model counts and list methods rather
than maintaining a second materialized truth.
exportSnapshot() / importSnapshot() persist versioned graph and pagination
state. Import aborts requests and suppresses stale completions. Snapshots do
not serialize in-flight work or error objects.
- Call
dispose() to abort work and release state. Late completions cannot
repopulate a disposed model.
The optional model layout is the XYZ GraphLayout contract from
@vectojs/graph3d, not the XY ForceLayout2D. For this skill's 2D architecture,
normally omit it and feed model.getGraphData() into your own ForceLayout2D
adapter. Keep model entity order aligned with the layout and rendering arrays.
Warm-start positions come from model.captureLayoutPositions(), captured at
rebuild and when the layout settles — never per frame. Mind the stack
divergences: the 3D contract pins by node index while ForceLayout2D pins by
node ID, and parallel-edge identity differs between them (see
vectojs-graph-layout).
Architecture: one scene node batch-paints the graph
The graph itself is ONE Entity whose render() runs an immediate-mode loop
over your node/link arrays. Do not give each node its own Entity — the
engine's per-node transform/cull/save()/restore() scene walk was the dominant
cost at 5,000 items (~12fps) and is rebuilt every frame for nothing.
getBounds(): null — the layer fills the viewport; opt out of engine culling.
isPointInside(): false — the layer is never the pointer target; the App does
hit-testing itself (it owns the badge rects the render pass computed anyway).
- UI panels (header, drawer, minimap) are sibling entities ABOVE the graph
layer; the graph layer sits directly on the background.
- State lives in
KnowledgeGraphModel or one equivalent application model; the
entity reads it every frame. This is the inverse of DOM habits but keeps one
copy of the truth and makes culling/hit-test loops trivial.
Reference implementation: DanmakuLayer in vectojs-native/danmaku/bakudan
(240Hz @ 5,000 danmaku, ~90Hz @ 20,000).
The interaction contract (the #1 cause of "very laggy" graph apps)
The engine's idle management assumes state changes REACH it. When you draw
imperatively inside render() and mutate your model from window/canvas
listeners, nothing is marked dirty, so:
| Symptom |
Mechanism |
Correct fix |
| Everything runs at ~2 FPS even while hovering/panning/zooming |
autoThrottle (default on) throttles an idle scene to its idleFPS floor — a hard 2 FPS before core 1.36.0, 60 FPS since — and dirty/frameHadAnimation never become true because you never called markDirty() and don't override hasPendingAnimations() |
renderMode: 'onDemand' + scene.markDirty() on state mutations (hover, pan, zoom, expansion, active physics/camera/ripple steps) — but do not re-arm a cooled Scene-driven physics loop; or override hasPendingAnimations() only while work remains |
"Fixed" with autoThrottle: false but CPU burns forever |
Always-mode renders the full canvas every frame, idle included |
That option exists for genuinely continuous content (danmaku). A settled graph must sleep |
| Hover tooltip lags one interaction behind |
Hover state set but frame never scheduled |
markDirty() inside the hover setter |
Measured on a real deployment (omm, 2026-08-15): a graph app whose hover/pan/
zoom never mark dirty rendered 16–17 frames per 8s (~2 FPS) while the user
was actively interacting, with rAF itself at 240Hz and per-frame render cost
~1ms. One markDirty() renders exactly the frames needed; onDemand idles at
0 frames. Verify with scene.dirty, scene._renderedFrames/_skippedFrames and
@vectojs/devtools/headless diagnoseDirty().
Camera (pan/zoom)
- Keep
panX/panY/zoom as plain numbers; worldToScreen = x*zoom + pan.
zoomAt(factor, cx, cy): anchor the point under the cursor —
pan = cursor - (cursor - pan) * (newZoom/zoom).
- Hit radius is in WORLD units: divide the screen-pixel radius by
zoom.
- Clamp zoom (e.g. 0.15–3.5); snapping to a target with a per-frame eased
interpolation in
update() (or render()) is a camera animation and counts
as hasPendingAnimations().
- Wheel must be
preventDefault() + { passive: false }; canvas needs
touch-action: none or mobile drag breaks.
Physics (force layout)
Default to ForceLayout2D from @vectojs/graph-layout for 2D. It is
renderer-agnostic, dependency-free, uses a true 2D Barnes-Hut quadtree, supports
collision/accessor forces, and preserves simulation state across
appendGraph() and removeNodes(). Read vectojs-graph-layout for the full API,
input rules, d3 migration, complexity, and measurement guidance.
- Step once per host frame.
step() returns true while active. When a
Scene update() drives physics, call scene.markDirty() only while true;
marking dirty from every cooled update prevents renderMode: 'onDemand' from
sleeping. An external scheduler may render the final false-returning mutation
once, but it must then stop invoking the physics callback.
- Use
appendGraph() for expansion, removeNodes() for node deletion,
removeLinks() for edge deletion, and updateLinks() for force-property
changes. Reacquire the live positions view after node topology methods. Use
getNodeIndex() for current mappings; append and link-only mutation preserve
existing indices, while node removal compacts them.
- Drag with
setNodePin(id, { x, y }) / clearNodePin(id) (pins are
ID-addressed), call reheat(), and markDirty() on every move or pin-state
change.
- Existing d3-force apps can migrate incrementally: stop d3's timer, retain
host-controlled ticks, convert negative charge to positive
repulsion, use
primitive endpoint IDs, and map fx/fy to pins. d3's velocityDecay is
loss while graph-layout's is retention, so start with 1 - d3VelocityDecay.
- If d3 remains temporarily, keep its quadtree
forceManyBody/forceCollide,
rebuild only on topology changes, and preserve this same VectoJS
markDirty()/onDemand contract. Do not use @vectojs/graph3d physics as a
2D substitute.
Rendering ladder (pick by measured draw cost, not by guess)
Draw cost with the Canvas2D immediate-mode loop, color-batched, with per-node
text pills: ~2.5ms p50 / 5.4ms p95 per frame at 448 nodes / 437 links
(60fps, DPR 1.6, 240Hz panel, measured 2026-08-15). So Canvas2D is NOT your
first suspect at this scale — measure before migrating backends.
- Canvas2D + batching: group primitives by color to kill state changes;
cull off-screen with a margin; reuse scratch arrays with
length = 0;
never allocate per frame (no per-node {x,y} objects, no per-frame Maps,
no per-link template/string keys). Text pills are the first wall:
fillText re-shapes CJK/emoji per frame.
TextRasterCache (core ≥ 1.12): pre-rasterize each (font, color, text)
once and blit with drawImage — removes per-frame shaping; perfect for
node labels. Font-size buckets (integer px) reduce ctx.font churn.
- WebGL point layer:
pointBackend: 'webgl' stacks a WebGL2 layer whose
pointRenderer batches addCircle/addRect/addSprite/MSDF addGlyph
into ~1 draw call. There is no line primitive — edges stay Canvas2D.
MSDF glyphs need an atlas (setMSDFTexture + MSDFFont.layout); emoji /
out-of-atlas glyphs fall back to TextRasterCache. Auto-falls back to
Canvas2D when WebGL2 is unavailable. This is how danmaku holds 240Hz at
5,000 labels. For omm-style graphs, the win starts at thousands of labeled
nodes, not hundreds.
- WebGPU:
particleBackend: 'auto' only accelerates
ComputeParticleEntity simulation (WebGPU → CPU fallback). It is NOT a
scene renderer — there is no whole-scene WebGPU→WebGL→Canvas2D cascade.
Canvas2D is the scene renderer; GL/WebGPU are optional stacked layers for
specific primitive classes.
Cap maxDPR (e.g. 1–2): backing-store cost scales with logical × dpr², and a
DPR-3 machine silently quadruples your fill rate vs the dev box.
Hit-testing
- Reuse the geometry the render pass computed (pill rects, node screen
positions) instead of recomputing — draw and hit-test must read the same
numbers.
- Test pills topmost-first, then a world-space radius check via
screenToWorld (divide the radius by zoom); a linear scan is fine to ~10k
nodes, a spatial hash beyond.
- Rebuild hit rects only when positions/pills change; hover changes must
markDirty() (see the contract above).
Frame budget
Never quote FPS — vsync saturates it. Report frame-time p50/p99 and the share
of frames inside budget, and check scene._lastFrameMs per frame. A 2 FPS
scene can show "60fps" rAF on a 240Hz panel; the scene's rendered/skipped
counters tell the truth.
1---2name: vectojs-knowledge-graph3description: Use when building or debugging a 2D node-link knowledge graph with @vectojs/knowledge-graph/model and @vectojs/core - paginated expansion, cancellation, snapshots, batch-painted graph layers, @vectojs/graph-layout physics, interaction, and idle scheduling.4---56# VectoJS Knowledge Graph (2D)78Patterns for canvas-native 2D knowledge-graph apps (node-link graphs with9search, drawers, hover tooltips, pan/zoom) on `@vectojs/core`. Read10`vectojs-graph-layout` for 2D physics, `vectojs-graph3d` for the 3D instanced11package, and `vectojs-performance` for profiling method.1213## Renderer-neutral model and paging1415Use `KnowledgeGraphModel` from `@vectojs/knowledge-graph/model` when the app16needs a bounded materialized cut of a large source graph. The subpath exposes17domain types, `MemoryDataSource`, and model state without importing the18session/rendering-facing package surface.1920```ts21const model = new KnowledgeGraphModel({22 source,23 pageSize: 100,24 direction: "both",25 lang: "en",26});2728await model.bootstrap(focusIds, false);29await model.expand(focusIds[0]);30draw(model.getGraphData());31```3233- `KgDataSource.getNeighbors(id, { limit, cursor, direction, signal })` returns34 one page plus optional `total`, `nextCursor`, and `hasMore`. Treat cursors as35 opaque and propagate `signal` through backend I/O.36- `expand(id)` loads one page. Same-ID concurrent calls share one promise;37 different IDs may load concurrently. Call again from `partial` to resume. In38 the session layer, repeated selects on an id whose expansion is still in39 flight are swallowed — one fetch produces at most one `onExpand`/`onError`.40- Expanding an id the source does not know **fails loudly** with a targeted41 error; no placeholder `'Unknown'` entity is materialized and the model's42 entity order stays clean.43- Expansion status is `idle`, `loading`, `partial`, `complete`, `failed`, or44 `cancelled`. `cancelExpand(id)` aborts active work; a later expand resumes at45 the last completed cursor. Failed calls reject and can be retried.46- Entities deduplicate by ID and merge labels. Facts deduplicate by the ordered47 `(source, predicate, target)` triple. Use model counts and list methods rather48 than maintaining a second materialized truth.49- `exportSnapshot()` / `importSnapshot()` persist versioned graph and pagination50 state. Import aborts requests and suppresses stale completions. Snapshots do51 not serialize in-flight work or error objects.52- Call `dispose()` to abort work and release state. Late completions cannot53 repopulate a disposed model.5455The optional model `layout` is the XYZ `GraphLayout` contract from56`@vectojs/graph3d`, not the XY `ForceLayout2D`. For this skill's 2D architecture,57normally omit it and feed `model.getGraphData()` into your own `ForceLayout2D`58adapter. Keep model entity order aligned with the layout and rendering arrays.59Warm-start positions come from `model.captureLayoutPositions()`, captured at60rebuild and when the layout settles — never per frame. Mind the stack61divergences: the 3D contract pins by node index while `ForceLayout2D` pins by62node ID, and parallel-edge identity differs between them (see63`vectojs-graph-layout`).6465## Architecture: one scene node batch-paints the graph6667The graph itself is ONE `Entity` whose `render()` runs an immediate-mode loop68over your node/link arrays. Do **not** give each node its own `Entity` — the69engine's per-node transform/cull/`save()/restore()` scene walk was the dominant70cost at 5,000 items (~12fps) and is rebuilt every frame for nothing.7172- `getBounds(): null` — the layer fills the viewport; opt out of engine culling.73- `isPointInside(): false` — the layer is never the pointer target; the App does74 hit-testing itself (it owns the badge rects the render pass computed anyway).75- UI panels (header, drawer, minimap) are sibling entities ABOVE the graph76 layer; the graph layer sits directly on the background.77- State lives in `KnowledgeGraphModel` or one equivalent application model; the78 entity reads it every frame. This is the inverse of DOM habits but keeps one79 copy of the truth and makes culling/hit-test loops trivial.8081Reference implementation: `DanmakuLayer` in `vectojs-native/danmaku/bakudan`82(240Hz @ 5,000 danmaku, ~90Hz @ 20,000).8384## The interaction contract (the #1 cause of "very laggy" graph apps)8586The engine's idle management assumes state changes REACH it. When you draw87imperatively inside `render()` and mutate your model from window/canvas88listeners, nothing is marked dirty, so:8990| Symptom | Mechanism | Correct fix |91| ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |92| Everything runs at ~2 FPS even while hovering/panning/zooming | `autoThrottle` (default on) throttles an idle scene to its `idleFPS` floor — a hard 2 FPS before core 1.36.0, 60 FPS since — and `dirty`/`frameHadAnimation` never become true because you never called `markDirty()` and don't override `hasPendingAnimations()` | `renderMode: 'onDemand'` + `scene.markDirty()` on state mutations (hover, pan, zoom, expansion, active physics/camera/ripple steps) — but do not re-arm a cooled Scene-driven physics loop; or override `hasPendingAnimations()` only while work remains |93| "Fixed" with `autoThrottle: false` but CPU burns forever | Always-mode renders the full canvas every frame, idle included | That option exists for genuinely continuous content (danmaku). A settled graph must sleep |94| Hover tooltip lags one interaction behind | Hover state set but frame never scheduled | `markDirty()` inside the hover setter |9596Measured on a real deployment (omm, 2026-08-15): a graph app whose hover/pan/97zoom never mark dirty rendered **16–17 frames per 8s (~2 FPS)** while the user98was actively interacting, with rAF itself at 240Hz and per-frame render cost99~1ms. One `markDirty()` renders exactly the frames needed; `onDemand` idles at1000 frames. Verify with `scene.dirty`, `scene._renderedFrames/_skippedFrames` and101`@vectojs/devtools/headless` `diagnoseDirty()`.102103## Camera (pan/zoom)104105- Keep `panX/panY/zoom` as plain numbers; `worldToScreen = x*zoom + pan`.106- `zoomAt(factor, cx, cy)`: anchor the point under the cursor —107 `pan = cursor - (cursor - pan) * (newZoom/zoom)`.108- Hit radius is in WORLD units: divide the screen-pixel radius by `zoom`.109- Clamp zoom (e.g. 0.15–3.5); snapping to a target with a per-frame eased110 interpolation in `update()` (or `render()`) is a camera animation and counts111 as `hasPendingAnimations()`.112- Wheel must be `preventDefault()` + `{ passive: false }`; canvas needs113 `touch-action: none` or mobile drag breaks.114115## Physics (force layout)116117Default to `ForceLayout2D` from `@vectojs/graph-layout` for 2D. It is118renderer-agnostic, dependency-free, uses a true 2D Barnes-Hut quadtree, supports119collision/accessor forces, and preserves simulation state across120`appendGraph()` and `removeNodes()`. Read `vectojs-graph-layout` for the full API,121input rules, d3 migration, complexity, and measurement guidance.122123- Step once per host frame. `step()` returns `true` **while active**. When a124 Scene `update()` drives physics, call `scene.markDirty()` only while true;125 marking dirty from every cooled update prevents `renderMode: 'onDemand'` from126 sleeping. An external scheduler may render the final false-returning mutation127 once, but it must then stop invoking the physics callback.128- Use `appendGraph()` for expansion, `removeNodes()` for node deletion,129 `removeLinks()` for edge deletion, and `updateLinks()` for force-property130 changes. Reacquire the live `positions` view after node topology methods. Use131 `getNodeIndex()` for current mappings; append and link-only mutation preserve132 existing indices, while node removal compacts them.133- Drag with `setNodePin(id, { x, y })` / `clearNodePin(id)` (pins are134 ID-addressed), call `reheat()`, and `markDirty()` on every move or pin-state135 change.136- Existing d3-force apps can migrate incrementally: stop d3's timer, retain137 host-controlled ticks, convert negative charge to positive `repulsion`, use138 primitive endpoint IDs, and map `fx`/`fy` to pins. d3's `velocityDecay` is139 loss while graph-layout's is retention, so start with `1 - d3VelocityDecay`.140- If d3 remains temporarily, keep its quadtree `forceManyBody`/`forceCollide`,141 rebuild only on topology changes, and preserve this same VectoJS142 `markDirty()`/`onDemand` contract. Do not use `@vectojs/graph3d` physics as a143 2D substitute.144145## Rendering ladder (pick by measured draw cost, not by guess)146147Draw cost with the Canvas2D immediate-mode loop, color-batched, with per-node148text pills: **~2.5ms p50 / 5.4ms p95 per frame at 448 nodes / 437 links**149(60fps, DPR 1.6, 240Hz panel, measured 2026-08-15). So Canvas2D is NOT your150first suspect at this scale — measure before migrating backends.1511521. **Canvas2D + batching**: group primitives by color to kill state changes;153 cull off-screen with a margin; reuse scratch arrays with `length = 0`;154 never allocate per frame (no per-node `{x,y}` objects, no per-frame Maps,155 no per-link template/string keys). Text pills are the first wall:156 `fillText` re-shapes CJK/emoji per frame.1572. **`TextRasterCache`** (core ≥ 1.12): pre-rasterize each `(font, color, text)`158 once and blit with `drawImage` — removes per-frame shaping; perfect for159 node labels. Font-size buckets (integer px) reduce `ctx.font` churn.1603. **WebGL point layer**: `pointBackend: 'webgl'` stacks a WebGL2 layer whose161 `pointRenderer` batches `addCircle`/`addRect`/`addSprite`/MSDF `addGlyph`162 into ~1 draw call. **There is no line primitive — edges stay Canvas2D.**163 MSDF glyphs need an atlas (`setMSDFTexture` + `MSDFFont.layout`); emoji /164 out-of-atlas glyphs fall back to `TextRasterCache`. Auto-falls back to165 Canvas2D when WebGL2 is unavailable. This is how danmaku holds 240Hz at166 5,000 labels. For omm-style graphs, the win starts at thousands of labeled167 nodes, not hundreds.1684. **WebGPU**: `particleBackend: 'auto'` only accelerates169 `ComputeParticleEntity` simulation (WebGPU → CPU fallback). It is NOT a170 scene renderer — there is no whole-scene WebGPU→WebGL→Canvas2D cascade.171 Canvas2D is the scene renderer; GL/WebGPU are optional stacked layers for172 specific primitive classes.173174Cap `maxDPR` (e.g. 1–2): backing-store cost scales with `logical × dpr²`, and a175DPR-3 machine silently quadruples your fill rate vs the dev box.176177## Hit-testing178179- Reuse the geometry the render pass computed (pill rects, node screen180 positions) instead of recomputing — draw and hit-test must read the same181 numbers.182- Test pills topmost-first, then a world-space radius check via183 `screenToWorld` (divide the radius by `zoom`); a linear scan is fine to ~10k184 nodes, a spatial hash beyond.185- Rebuild hit rects only when positions/pills change; hover changes must186 `markDirty()` (see the contract above).187188## Frame budget189190Never quote FPS — vsync saturates it. Report frame-time p50/p99 and the share191of frames inside budget, and check `scene._lastFrameMs` per frame. A 2 FPS192scene can show "60fps" rAF on a 240Hz panel; the scene's rendered/skipped193counters tell the truth.