Skip to content

feat(web): Document Explorer adopts the unified engine (ADR-702 phase 4)#368

Open
aaronsb wants to merge 8 commits into
mainfrom
feat/document-explorer-on-unified-engine
Open

feat(web): Document Explorer adopts the unified engine (ADR-702 phase 4)#368
aaronsb wants to merge 8 commits into
mainfrom
feat/document-explorer-on-unified-engine

Conversation

@aaronsb
Copy link
Copy Markdown
Owner

@aaronsb aaronsb commented May 14, 2026

Summary

Document Explorer now renders on the same r3f + GPU engine as Force Graph. It keeps its visual experience (amber document glyphs, query/extended concept dots, focus dimming, info legend, NodeInfoBox) and gains the 2D ↔ 3D projection toggle. The d3 + SVG implementation that preceded this port is gone.

ADR-702 phase 4 — was framed as "optional/opportunistic" — now landed.

Commits

  1. `a1b49e3b` `feat(web): per-node geometry class in the unified engine` — adds three optional, backward-compatible props to ``/``: `nodeClasses` (per-node class key), `geometryByClass` (geometry per class → one InstancedMesh per class), `nodeScales` (per-node base scale override). Force Graph doesn't pass any of these, so it renders identically to before.

  2. `ede92ecb` `feat(web): port Document Explorer to the unified engine` — replaces the 604-line d3+SVG implementation with a ~340-line wrapper around ``. Documents render as boxGeometry, concepts as icosahedronGeometry. `DocumentExplorerWorkspace` keeps its direct-mount pattern (no changes to the workspace's prop shape).

  3. `e93ad5ea` `fix(web): preserve document-concept clustering and bake focus dim into colors` — advisor-driven fixes after the first cut: adds `edgeVisible` to the engine so document→concept clustering hints can stay in the physics sim while rendering collapsed; bakes the focus-dim multiplier into the per-node color array so document and concept meshes recede in focus mode (engine `` doesn't read `activeIds`/`dimAlpha` directly).

Engine surface (the discriminator)

The advisor's discriminator for "is the abstraction right" was: can you delete the d3 implementation and replace it with a thin shim, without piling on engine hooks? Two engine-level additions did the job:

  • `nodeClasses` + `geometryByClass` + `nodeScales` — orthogonal axes of variation (shape, scale formula, class identity), composable in any combination.
  • `edgeVisible` — render-only visibility; sim is unaffected. Mirrors how `hiddenIds` works for nodes (zero-scale render, still in the sim).

If a third explorer ever needs per-node opacity or per-class force tuning, those become the next hooks. For now, the engine surface stays bounded.

Manual verification (recommended before merge)

The vitest suite and `tsc --noEmit` are clean, but the runtime behaviour can't be unit-tested:

  • Document Explorer renders documents as larger amber squares, concepts as smaller dots
  • Concepts visibly cluster around their parent documents (verifies `edgeVisible` keeps clustering hints in the sim)
  • Clicking a document focuses it — non-focused documents, concepts, and edges all dim (verifies the baked color dim + activeIds path)
  • Clicking the focused document again clears focus
  • Double-click on a document opens the document viewer
  • Single-click on a concept opens the NodeInfoBox (top-left corner-pinned — see notes)
  • Projection toggle in the settings panel switches 2D ↔ 3D and the camera dispatches correctly
  • Reheat button restarts the sim
  • Hover highlighting works when the setting is enabled
  • Force Graph (`/explore/graph`) still behaves identically — it doesn't pass the new engine props

`kg-web-dev` HMR usually picks this up cleanly; if anything looks stale after pulling, `docker restart kg-web-dev`.

Behavior changes vs. d3 implementation

  • NodeInfoBox position: corner-pinned at top-left (was: positioned over the node in the d3 SVG). Engine→screen projection math is non-trivial for r3f; deferred to a follow-up. User-visible regression — flagging explicitly.
  • Reheat indicator: fixed 2.5s spinner duration (was: tied to d3 sim's settle event). The engine doesn't yet expose a settle-end callback; spinner is a visual hint, not a real signal.
  • Per-type force tuning: d3 had custom charge/distance/collision per node type. Engine uses unified defaults — the document-as-hub clustering links (now `edgeVisible: false`) carry the layout intent without needing per-type forces.

Deferred (carried through props for API stability)

  • Passage rings: `passageRings` and `queryColorLabels` props are still passed through by `DocumentExplorerWorkspace` but not rendered. Concentric arcs around concept hits need their own scene-children pattern — flagged for a follow-up.

References

aaronsb added 8 commits May 14, 2026 09:54
Extends the engine's `<Nodes>` and `<Scene>` to support per-node
geometry classes. When the plugin passes `nodeClasses` (a class key per
node) and `geometryByClass` (a JSX geometry per class), the engine
partitions nodes by class and renders one InstancedMesh per class.
Pointer events are mapped class-local → global node id via per-class
index maps.

Also adds `nodeScales` (optional per-node base scale, replaces the
degree-based default when present). Document Explorer documents need a
large constant scale; concepts use the engine default — this prop lets
the plugin opt out of the degree formula without forking the math.

All three props are optional and backward-compatible. When omitted
(Force Graph today), behaviour is identical to the previous single
InstancedMesh + icosahedron + degree-based scales — same draw count,
same pointer event path. Verified by typecheck and the unit suite.

This is the engine surface the Document Explorer port (next commit)
needs to render documents-as-glyphs alongside concepts-as-dots.
…e 4)

Replaces the 604-line d3+SVG implementation with a ~340-line thin
wrapper around the engine's `<Scene>`. Document Explorer keeps its
visual experience — amber document glyphs, query-concept dots,
extended-concept dots, focus dimming, info legend, NodeInfoBox — but
inherits physics, drag, hover/select, projection (2D/3D), and labels
from the engine.

Engine surface used:
- `nodeClasses` + `geometryByClass` distinguish documents (boxGeometry)
  from concepts (icosahedronGeometry) — one InstancedMesh per class.
- `nodeScales` gives documents a large constant scale relative to
  concepts; the engine no longer needs to know which is which.
- `activeIds` + `dimAlpha` drive focus-mode dimming.
- `simHandleRef` exposes reheat to the overlay button.
- `projection` setting toggles 2D/3D via the engine's camera dispatch.

What carries over unchanged: `DocumentExplorerWorkspace` keeps its
direct-mount pattern (workspace builds data, passes `focusedDocumentId`,
`onFocusChange`, `onViewDocument` as extra props). Settings shape adds
`projection` but keeps all existing fields.

Click model:
- Single-click on document → toggle focus
- Double-click on document → onViewDocument (manual dblclick detection
  in the plugin since the engine fires one onSelect per click)
- Single-click on concept → toggle NodeInfoBox
- Background click → clear selection (engine default)

Deferred (carried as props through the component but not yet rendered;
follow-up issues will land them):
- Passage rings (concentric arcs around concept hits)
- NodeInfoBox positioned over the node (currently corner-pinned)
- Per-edge visibility for document→concept clustering hints
- Type-specific force tuning (engine uses unified defaults)

Force Graph behaviour is unaffected — it doesn't pass `nodeClasses`,
`geometryByClass`, or `nodeScales`, so the engine renders the single
icosahedron mesh exactly as before.
…o colors

Two issues the advisor flagged from a review of the port:

1. **Concepts drift from their documents**: the d3 implementation relied
   on invisible document→concept links to pull concept dots toward
   their parent document (concepts of one doc rarely link directly to
   each other in the data). The first cut of the port dropped those
   links, which left clustering to the engine's center gravity alone —
   not enough to keep documents and their concepts visibly grouped.

   Engine fix: `<Edges>` and `<Scene>` accept `edgeVisible?: boolean[]`
   parallel to `edges`. When false, the edge stays in the physics sim
   (which consumes the same `edges` array) but renders collapsed to a
   point. Plugin fix: Document Explorer now passes ALL links to the
   engine, marking `l.visible === false` clustering hints with
   `edgeVisible[i] = false`.

2. **Focus dim didn't dim node meshes**: engine `<Nodes>` doesn't read
   `activeIds`/`dimAlpha` (only `<Edges>` and labels do). With the
   first-cut port, focus mode dimmed edges and labels but left
   documents and concepts at full brightness — visible regression from
   the d3 version. Fix: Document Explorer now bakes the dim multiplier
   into the per-node color array, matching the pattern Force Graph uses
   for hover focus.

Also: honest comment on `physicsActive` setTimeout — the engine doesn't
expose a settle-end callback yet, so the spinner is a fixed-duration
visual hint, not a real signal.

`frameloop="demand"` is fine — both engine `<Edges>` and the new dim
path in `nodeColors` flow into `useEffect`s that call `invalidate()`,
so prop-change-driven renders pump a frame.
…abel offset

Three regressions from the engine port that the user flagged against
the d3 reference screenshot:

1. **Document shape**: documents rendered as cubes / squares
   (boxGeometry). Switched to the same icosahedron geometry as
   concepts, distinguished only by scale and color — the d3 original
   drew them as larger circles too. Legend swatch follows (rounded-sm
   → rounded-full).

2. **Label color**: labels painted in the node's own color, so text on
   top of an amber node visually merged into the disc and the indigo
   extended-concept dots became unreadable — making them appear
   "missing" at a glance. Engine extension: NodeLabels accepts an
   optional `labelColors?: string[]` override (parallel to nodes),
   plumbed through Scene. Document Explorer paints all labels in
   `#e5e7eb` so they read against any node colour. Focus dim is baked
   into label colors the same way it is for node colors.

3. **Label position**: labels sat above nodes, hidden behind the
   larger document glyphs. Engine extension: NodeLabels accepts a
   signed `labelOffsetY` (plumbed via Scene as `nodeLabelOffsetY`);
   Document Explorer passes -2.2 to place labels below. Force Graph
   inherits the previous default (+1.4, above).

Both engine props are optional; Force Graph behaviour is unchanged.

The "missing extended-concept nodes" report should now resolve — they
were there but their indigo labels visually merged into their indigo
discs, making them look like a single coloured smear at concept-dot
scale. Whitish labels + below-offset makes both the dots and their
text readable.
Hoist the per-node scale resolution into `<Scene>` so node meshes and
node labels consume the same `Float32Array`. The plugin override
(`nodeScales`) still wins when provided; otherwise Scene derives the
default from degree using the previously-internal formula.

Effect: scale-aware label offsets now apply to Force Graph too, not
just Document Explorer. Labels for high-degree hub nodes float past
the node surface instead of sometimes sitting on top of it. The
default `labelOffsetY = 1.4` is now interpreted as padding past
radius rather than absolute world-units, so smaller nodes stay close
and bigger nodes still clear cleanly.

`<Nodes>` keeps its fallback path for the undefined-scales case so it
remains usable as a leaf component if someone bypasses Scene, but in
practice Scene always passes the resolved array now.
The workspace held its loaded graph (`explorerData`, `sidebarDocs`,
`focusedDocId`) in local React state, so navigating away and back
unmounted the workspace and reset to "Load a saved exploration query"
empty state.

Move those three slots into a small `documentExplorerStore`. The store
lives in memory for the session and is not persisted to localStorage —
mirrors Force Graph's policy for `rawGraphData` (stale snapshots are
worse than no snapshot: a re-ingested DB or a different deploy makes
node ids unreachable and the user clicks into a dead view). If we want
cross-session persistence later, the saved exploration query is the
durable record and the pipeline can replay it on next load, same as
Force Graph's autosave.

Pipeline state (`isLoading`, `error`, `loadingMessage`), settings, and
the document viewer modal stay local — they're either truly ephemeral
or workspace-mount-scoped UI.
…ttle, context menu

Four UX regressions from the engine port, all in one commit since
they share the click-handler flow:

1. **Constant "Settling..." indicator**: `physicsActive` initialized
   to `true` and only flipped off via the Reheat timeout — so the
   spinner ran forever on a fresh mount. The engine sim has no
   "settled" event (GPU loop keeps running by design), so the spinner
   is really just a Reheat acknowledgement. Default off; Reheat
   briefly turns it on.

2. **Info card invisible on left-click**: the port used the 2D corner
   `NodeInfoBox` instead of the engine's in-scene `NodeInfoOverlay`
   that Force Graph uses. Switched to the same `activeNodeInfos` /
   `onDismissNodeInfo` pattern — left-click on a concept pins an
   in-scene info card next to the node; clicking again dismisses it.

3. **Click on document didn't open the viewer**: the port required a
   double-click to open the viewer. Single-click now opens the viewer
   AND toggles focus (clicking the focused document again clears
   focus). The dblclick branch and timestamp bookkeeping are gone.

4. **Right-click context menu missing**: the wrapper div was
   `preventDefault`-ing all right-clicks, suppressing both the
   browser's native menu and anything else. Dropped the handler — the
   d3 original didn't override right-click either, so right-click
   falls through to the browser menu like before. A custom
   Document-Explorer context menu (e.g. "View document" / "Focus") is
   left for a follow-up if needed.

Selected-concept state and the dblclick timestamp ref are gone — the
info-overlay set is now the source of truth for "what concepts the
user has pinned open".
…ck menu

Match Force Graph's two-tier dim model and click semantics so muscle
memory carries between the two explorers.

**Hover** (transient, 0.25 dim): hovering any node activates the
engine's `activeIds` to the node plus its graph neighbours. Edges and
labels fade for everything else, baked into node colours the same way
Force Graph does. Local-topology inspection without clicks.

**Focus** (persistent, 0.05 dim): toggled via right-click → context
menu. Documents focus to "document + all its concepts" (the previous
behaviour); concepts can't be focused (no useful concept-level dim
model yet — flagged for follow-up if needed). Focus wins over hover
when both are active.

**Left-click** is now pure inspection:
- Click a document → opens the viewer (no longer toggles focus too)
- Click a concept → toggles its in-scene `NodeInfoOverlay`

**Right-click**:
- On a document → "View document" / "Focus on document" / "Unfocus"
- On a concept → "Unfocus" (when something is focused)
- On background (when something is focused) → "Unfocus"

`nodeContextConsumedRef` guards the wrapper's `onContextMenu` from
opening a second menu after the node-mesh handler has already done so
(same pattern Force Graph uses).

The previous click-also-focuses behaviour was a double-event: clicking
a document opened the viewer AND dimmed the rest of the graph, which
the user reported as confusing.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant