2 Commits

  • fix(dashboard): O(N+K) per-layer aggregations, kill quadratic Array.includes (#102)
    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>