Summary
The knowledge-graph view (/understand-knowledge → KnowledgeGraphView) still lays out the whole graph with a synchronous d3-force simulation on the main thread, inside a useMemo. On larger graphs this blocks the UI for several seconds on every layout/filter/search recompute. This looks like the remaining instance of #14 — the structural views moved to async ELK, but the force-directed knowledge-graph path didn't.
Evidence (benchmark)
Ran applyForceLayout (the function computeLayout calls) standalone over synthetic graphs — pure wall-clock of the synchronous call that runs on the main thread:
| nodes |
ticks |
blocking time |
| 200 |
200 |
~230 ms |
| 500 |
300 |
~570 ms |
| 1000 |
300 |
~1.2 s |
| 2000 |
300 |
~2.8 s |
| 3000 |
300 |
~4.3 s |
ticks = Math.min(300, Math.max(100, nodes.length)), so cost grows roughly linearly in ticks × Barnes-Hut forceManyBody per tick. (Happy to contribute the benchmark script — it mirrors the existing scripts/benchmark-layout.mjs / benchmark-aggregations.mjs.)
Root cause
src/components/KnowledgeGraphView.tsx:140-142 — useMemo(() => computeLayout(filteredGraph), …)
- →
computeLayout (:51) → applyForceLayout (src/utils/layout.ts:94)
- →
sim.tick(Math.min(300, Math.max(100, n))) (layout.ts:168-169) runs synchronously on the main thread.
There's no large-graph guard in KnowledgeGraphView (no node-count threshold / progressive disclosure), so the full simulation runs eagerly.
Two related observations
src/utils/layout.worker.ts is dead code — grep -rn "layout.worker\|new Worker" src/ returns nothing; it's imported nowhere. It's a dagre worker, so it can't host the force sim as-is, but it shows the worker scaffolding was started and never wired up.
- Non-deterministic layout —
applyForceLayout seeds initial positions with Math.random() (layout.ts:105-106), so the knowledge graph re-shuffles to a different arrangement on every recompute (selection, filter, tour). d3-force's default phyllotaxis seeding (or a fixed seed) would make layouts stable across renders — and would also make a worker version byte-for-byte verifiable.
Suggested fix
Mirror the async pattern the structural views already use (applyElkLayout via useOverviewGraph / useLayerDetailTopology round-trip positions through setState): move applyForceLayout into a web worker, post {nodes, edges, communityMap} in, post positions back, and render a lightweight placeholder until they arrive. Optionally pair with the deterministic-seed change above so the worker output is verifiable against the main-thread output.
Environment for the numbers: Node-class runtime, d3-force@^3, the repo's own layout.ts. I can open a PR for the deterministic-seed change (small, self-contained) if that's a welcome first step while the worker-ization is scoped.
Summary
The knowledge-graph view (
/understand-knowledge→KnowledgeGraphView) still lays out the whole graph with a synchronous d3-force simulation on the main thread, inside auseMemo. On larger graphs this blocks the UI for several seconds on every layout/filter/search recompute. This looks like the remaining instance of #14 — the structural views moved to async ELK, but the force-directed knowledge-graph path didn't.Evidence (benchmark)
Ran
applyForceLayout(the functioncomputeLayoutcalls) standalone over synthetic graphs — pure wall-clock of the synchronous call that runs on the main thread:ticks = Math.min(300, Math.max(100, nodes.length)), so cost grows roughly linearly in ticks × Barnes-HutforceManyBodyper tick. (Happy to contribute the benchmark script — it mirrors the existingscripts/benchmark-layout.mjs/benchmark-aggregations.mjs.)Root cause
src/components/KnowledgeGraphView.tsx:140-142—useMemo(() => computeLayout(filteredGraph), …)computeLayout(:51) →applyForceLayout(src/utils/layout.ts:94)sim.tick(Math.min(300, Math.max(100, n)))(layout.ts:168-169) runs synchronously on the main thread.There's no large-graph guard in
KnowledgeGraphView(no node-count threshold / progressive disclosure), so the full simulation runs eagerly.Two related observations
src/utils/layout.worker.tsis dead code —grep -rn "layout.worker\|new Worker" src/returns nothing; it's imported nowhere. It's a dagre worker, so it can't host the force sim as-is, but it shows the worker scaffolding was started and never wired up.applyForceLayoutseeds initial positions withMath.random()(layout.ts:105-106), so the knowledge graph re-shuffles to a different arrangement on every recompute (selection, filter, tour). d3-force's default phyllotaxis seeding (or a fixed seed) would make layouts stable across renders — and would also make a worker version byte-for-byte verifiable.Suggested fix
Mirror the async pattern the structural views already use (
applyElkLayoutviauseOverviewGraph/useLayerDetailTopologyround-trip positions throughsetState): moveapplyForceLayoutinto a web worker, post{nodes, edges, communityMap}in, post positions back, and render a lightweight placeholder until they arrive. Optionally pair with the deterministic-seed change above so the worker output is verifiable against the main-thread output.Environment for the numbers: Node-class runtime,
d3-force@^3, the repo's ownlayout.ts. I can open a PR for the deterministic-seed change (small, self-contained) if that's a welcome first step while the worker-ization is scoped.