feat(web): Document Explorer adopts the unified engine (ADR-702 phase 4)#368
Merged
Conversation
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.
Document Explorer's hover dim read much harsher than Force Graph's. Two causes, both fixed: 1. Double-dim on labels: Document Explorer pre-baked dimAlpha into labelColors AND NodeLabels also drops out-of-set label opacity to DIM_LABEL_OPACITY. Stop baking dim into labelColors — labels now dim via the engine's single activeIds/opacity path, same as Force Graph. 2. Per-file magic numbers: hover alpha was 0.2 in Force Graph but 0.25 in Document Explorer. Hoisted HOVER_DIM_ALPHA / FOCUS_DIM_ALPHA / DIM_LABEL_OPACITY into a shared scene/dimModel.ts so every engine consumer recedes by the same amount. Single seam if these become user-configurable later. Typecheck clean, 10 web tests pass.
Hover-dim at 0.2 still read as a mode change. Drop the fade to ~10% (HOVER_DIM_ALPHA 0.2 -> 0.9): non-active nodes step back subtly while the hovered neighborhood stays the visual focus. One constant in the shared dim model — lands in Force Graph and Document Explorer together. Focus tier (right-click) unchanged at 0.05.
Replace three independent constants (HOVER_DIM_ALPHA, FOCUS_DIM_ALPHA,
DIM_LABEL_OPACITY) with DIM_MODEL: Record<DimTier, DimSpec> where each
tier is ONE node carrying every property it affects ({nodeAlpha,
labelOpacity}). A tier's dot and its label can no longer drift apart —
tune one entry, both move. This is what dissolves the hover-vs-focus
label asymmetry structurally instead of by a second hand-tuned knob.
Engine stays primitive: Scene/NodeLabels/EdgeLabels gain a
dimLabelOpacity number prop (default 1 — a caller that wires activeIds
but forgets it fails visibly, not silently on an old magic number).
DIM_MODEL lives only in the consumers; a future consumer with a
different interaction vocabulary isn't forced to adopt ours.
Net simplification: the duplicated 'focused ? FOCUS : HOVER' ternary
in both explorers collapses to a tier lookup; NodeLabels/EdgeLabels no
longer carry an opaque module constant; EdgeLabels' separate 0.15 is
gone (folded into the same pass so node + edge labels dim together).
Typecheck clean, 10 web tests pass.
…ogether
The {nodeAlpha, labelOpacity} split let dots and labels drift to
different strengths (focus was 0.05 vs 0.15). Collapse DimSpec to a
single number per tier: DIM_MODEL: Record<DimTier, number> =
{ hover: 0.9, focus: 0.1 }. One value drives the node/edge color
multiply and the node/edge label opacity alike.
On the dark scene background, color-multiply toward black and opacity
toward background are perceptually the same, so a single alpha reads
as one consistent recede across nodes, edges, and both label kinds.
focus is 0.1 (not 0.05) so focused-out label text stays barely legible
while the active set still pops hard.
If a tier ever needs to be gentler because labels vanish before dots,
raise the one number — that softens everything together by design.
Don't re-split the fields. DimTier (the tiers themselves) stays.
Typecheck clean, 10 web tests pass.
hoverActiveIds traversed engineData.edges, which carries the invisible document->concept clustering scaffold (kept for the force sim, collapsed in render) alongside visible relationship edges. Hovering any node lit up its whole clustering hairball — nearly the entire graph — so the shared 0.9 hover dim landed on almost nothing and Document Explorer looked un-dimmed next to Force Graph. Walk only visible edges (engineData.edgeVisible[i]) so the lit neighborhood matches what the user sees connected. Same dim model, same values as Force Graph — now the same visible behaviour. Also return edgeVisible:[] from the no-data branch for a uniform engineData type. Typecheck clean, 10 web tests pass.
… edge list
'Dims a lot more' was a count problem, not a strength one — same
DIM_MODEL, but the active set was wrong. Edge-list traversal fails at
both extremes of Document Explorer's two overlaid graphs: a document's
edges are all the invisible clustering scaffold (visible-only → zero
neighbors → entire graph dims on document hover), and concept↔concept
relationship edges are sparse by design.
Compute the neighborhood from the typed structural fields instead, so
membership isn't muddled by the render-time visibility flag. Asymmetric
by node type:
- document → document + all its concepts (the visual cluster made
literal)
- concept → concept + its documents (via documentIds) + visible
concept relationship neighbors
Factor neighborhoodOf(); focus and hover now light the SAME set and
differ only in dim strength — matching Force Graph, where focus/hover
also share the 1-hop neighborhood.
Typecheck clean, 10 web tests pass.
0.9 read as 'barely dims at all'. Settle at 0.6 (non-active elements fade to 60%): definite focus pull toward the hovered neighborhood, rest still legible, not harsh. One number, both explorers.
…lack
Screenshots showed the real divergence: identical multiplyScalar(0.6)
on Force Graph's bright lime palette barely changed it, while Document
Explorer's indigo/amber collapsed into the dark bg and vanished. Linear
color-multiply toward black is luminance-dependent — it dims bright and
dark palettes by visibly different perceived amounts.
Replace with lerp toward THIS scene's background:
- Force Graph -> canvasBg (explorerTheme.canvas3D, #1f1b19/#ede8e4)
- Doc Explorer -> wrapper bg (Canvas is transparent over
bg-gray-900/50: #111827/#f9fafb)
Every colour now loses the same fraction of its contrast against the
background, so the perceived recede is uniform across palettes and
explorers under the one shared DIM_MODEL value. The alpha's meaning is
now 'fraction of contrast retained vs background', not 'scale toward
black' — same knob, palette-independent, honest semantics.
Labels unchanged: opacity-toward-rendered-bg is already the equivalent
of lerp-to-bg, so that path was always correct. Edge/arrow color dim
(engine-side multiplyScalar) still scales toward black — separate
follow-up; nodes dominate the visual and were the reported issue.
Typecheck clean, 10 web tests pass.
Leftover from the d3 Document Explorer's double-click path, not ported to the engine version. Fails npm run lint (no-unused-vars). Flagged by pre-merge code review of PR #368.
Pre-merge code review of PR #368 flagged a documented-decision deviation: ADR-702 scoped phase 4 as optional, à-la-carte trait adoption with 'no commitment to complete migration', but #368 retired Document Explorer's d3 composition entirely and put it on the full engine <Scene> path. Record the deviation rather than carry it in memory: a forward-pointer in the Phase 4 section (original decision text preserved) plus a tail amendment with rationale — the engine boundary held cleanly, shared rendering compounds (one dimModel.ts change fixed both explorers), and full adoption gave the 2D/3D toggle for free. ADR-085's 'keep it distinct' intent is preserved at the UX layer, not the rendering layer. Bumped frontmatter updated: 2026-05-15. adr lint: 0 errors.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
`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.
`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).
`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:
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:
`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
Deferred (carried through props for API stability)
References