Three hot paths in the dashboard ran `layer.nodeIds.includes(node.id)`,
which is O(K) per check. Combined with their enclosing loops they
collectively spent quadratic time per render of the overview / per
filter recompute / per node-selection event. On the 4.8 MB knowledge
graph reported in #102, the overview render alone took ~470 ms of
synchronous main-thread work before ELK / React Flow ran — long
enough for the page to register as unresponsive.
Fix: precompute two indexes once when a graph is loaded.
- `nodesById: Map<string, GraphNode>`
- `nodeIdToLayerId: Map<string, string>` (first layer wins, matching
prior `findNodeLayer` semantics)
Both live in `useDashboardStore` and are rebuilt by `setGraph`. The
three call sites:
1. `useOverviewGraph` (GraphView.tsx) — per-layer complexity aggregation
moved into a new `computeLayerStats(layer, nodesById)` helper that
walks `layer.nodeIds` instead of filtering all `graph.nodes`. Search
match counts now read straight from `nodeIdToLayerId` instead of
rebuilding a layer index on every searchResults change.
2. `filterNodes` (utils/filters.ts) — takes `nodeIdToLayerId` instead of
`Layer[]`; the layer-membership check is one Map.get() per node.
Updated `ExportMenu.tsx` caller to pass the store-level index.
3. `findNodeLayer` (store.ts) — replaced with `nodeIdToLayerId.get()` at
the four call sites. `navigateTourToLayer` helper updated to take the
index rather than the whole graph.
Behavior is preserved exactly:
- "First layer wins" semantics for nodes that appear in multiple
layers (#102 schema doesn't forbid this).
- 30 % aggregate-complexity threshold pinned by tests.
- Layer filter that excludes layer-less orphans, but ungated when
no layers are selected.
Verified locally:
Bench (`scripts/benchmark-aggregations.mjs`, node 22):
100 layers × 200 nodes (#102 shape): 475 ms → 2 ms (232× faster)
50 layers × 200 nodes: 116 ms → 0.6 ms (190× faster)
30 layers × 100 nodes: 12 ms → 0.2 ms (63× faster)
Tests: `pnpm --filter @understand-anything/dashboard test`
24 → 41 pass (+17 new tests across `layerStats.test.ts` and
`filters.test.ts`, including a #102 perf-regression guard at
100 layers × 100 nodes < 50 ms).
`pnpm --filter @understand-anything/core test` — 654 / 654 pass.
`pnpm --filter @understand-anything/dashboard exec tsc -b` — clean.
`pnpm --filter @understand-anything/dashboard build` — clean.
Pre-existing on master and not from this branch: `pnpm lint` errors
out with "eslint: command not found" — `eslint` isn't installed by any
package and the root `lint` script is bare `eslint .`. Out of scope here.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>