feat(web): add 2D projection to the unified engine (force-2d-v2)#366
Merged
Conversation
ADR-702 phase 2 — extend the unified engine with an orthographic 2D projection. Same component, same scene composition, same physics; the camera, drag plane, and sim dimensionality dispatch on a new projection setting. The Projection union type stays open so future projections (hyperbolic, globe) become case additions rather than API breaks. The runtime toggle in the settings panel remounts the Canvas so r3f can swap the camera class — `orthographic` is a mount-time prop on @react-three/fiber. - types.ts: new Projection union (`'2D' | '3D'`); ForceGraph3DSettings gains a top-level `projection` field (default `'3D'`). - ForceGraph3D.tsx: Canvas keys on projection; orthographic + zoom when 2D, perspective + fov when 3D. Scene receives the projection prop. - Scene.tsx: in 2D, MapControls replaces OrbitControls (pan + zoom, no rotation). Sim and drag handlers receive the dispatched values. - useDragHandler: 2D drag uses a fixed Z=0 plane with +Z normal; the pointer unproject lands on the layout plane regardless of camera distance. 3D keeps its camera-perpendicular plane. - useForceSim (CPU): a zClamp factor multiplies the integrated Z position and velocity components, flattening the layout in one step. - useGpuForceSim + gpuShaders: a zClamp uniform on the velocity and position fragment shaders zeroes Z output in 2D. Pushed into the uniforms each frame so a runtime toggle takes effect immediately. - SettingsPanel: top-level Projection select for in-explorer switching. No new plugin yet — the new behavior is reachable through the existing 3D plugin's settings until the next commit adds the discoverable force-2d-v2 entry.
Two plugin registrations now share the ForceGraph3D component, transformer, and settings panel — they differ only in config metadata (id, sidebar name, icon) and the default projection. This is the discoverable surface for the 2D-on-unified-engine path: users land in 2D from the sidebar without needing to know about the projection toggle. The transitional `(V2)` suffix and `force-2d-v2` id parallel Phase A's coexistence shape. Once d3 `force-2d` retires in a follow-up PR, this entry gets promoted to canonical `force-2d` and the two plugin registrations consolidate into one with the projection toggle as the user-facing affordance. - types/explorer.ts: add `force-2d-v2` to VisualizationType. - explorers/ForceGraph3D/index.ts: extract a small factory so both plugin entries share the same plumbing. Registers both. - explorers/index.ts: re-export ForceGraph2DV2Explorer. - App.tsx: route /explore/2d-v2 → ForceGraph component with the force-2d-v2 plugin (projection='2D' default). - AppLayout.tsx: sidebar gains "2D Force Graph (V2)" between the d3 2D and the 3D entries. Map icon (mirrors MapControls metaphor — pan + zoom, no rotation).
- MapControls: explicitly disable rotation. The z-locked layout doesn't have anything to rotate, and disabling here keeps right-click from being consumed by the rotate path so the wrapper div's contextmenu handler runs cleanly. - AppLayout page title: distinguish "/explore/2d-v2" as "2D Force Graph (V2)" so the header reads correctly for the new entry. The startsWith branch still catches the d3 "/explore/2d" path.
MapControls' default screenSpacePanning=false assumes a Y-up ground-plane model: panUp moves along the world axis perpendicular to both the camera's local X and the world up vector. For our setup — camera at +Z looking down -Z, layout on the z=0 plane — that perpendicular IS the world Z axis, which is exactly perpendicular to the screen. Result: up/down pan produced zero visible motion. OrbitControls is the right abstraction here. Its screenSpacePanning default is true, which pans along the camera's screen-aligned up axis. With enableRotate=false it covers everything MapControls was meant to do without the ground-plane assumption.
Matches the d3 force-graph viewer's interaction pattern: left-click drag on the canvas pans the view, scroll-wheel zooms, right-click on a node opens the context menu. Rotation stays disabled (z-locked layout). OrbitControls' default mouseButtons (LEFT=ROTATE, RIGHT=PAN) was the wrong mapping for a 2D viewer — with enableRotate=false the LEFT binding became a no-op, leaving the user with no left-click pan. Remap to LEFT=PAN, MIDDLE=DOLLY, RIGHT=ROTATE (gated off by enableRotate=false so right-click stays unconsumed and the wrapper div's onContextMenu handler opens the explorer's context menu).
The label cull check was computing 3D distance from the camera. In 2D the orthographic camera is z-locked at +400 from the layout plane, so every node has dz=-400 and d2 = dx² + dy² + 160000 > 62500 (the default visibilityRadius² of 250²). Result: every label was culled and the 2D view rendered without any text. Thread `projection` through Scene to NodeLabels / EdgeLabels. In 2D, omit the Z term from the distance computation so `visibilityRadius` means "XY world units from the viewport centre" — labels now render the same way the 3D view shows them, with the same slider controlling how many appear.
In 3D the edge-aligned label basis is the right behavior — labels rotate to stay readable from arbitrary camera angles. In 2D the camera is top-down with rotation locked, so edge-aligned labels mean diagonal edges produce tilted text (and edges pointing right-to-left produce upside-down text). When projection === '2D', use a screen-aligned basis (world X, Y, Z axes) for edge labels. Position still sits above the edge midpoint along the screen-up direction. NodeLabels were already screen-aligned via their billboard logic.
The orientation branch added `const is2D` earlier in useFrame; the distance-cull branch redeclared it and Vite's babel parser rejected the file. Use the existing binding.
The d3 2D explorer offered edge colour modes for category / confidence / uniform. The unified engine had type / endpoint. Now it has all four: type / confidence / endpoint / uniform, available in both 2D and 3D since the projection is just a setting on the same component. Engine refactor: replace the per-edge-type palette function with a per-edge colour array. Edges, Arrows, and EdgeLabels all consume the same `edgeColors: string[]` (parallel to the input edges array via a filter-original-index map). When undefined, edges render the endpoint-category gradient; when defined, each edge takes its flat colour from the array. Mode dispatch lives once in ForceGraph3D: - 'type' → vocabulary category → categoryColors palette - 'confidence' → HSL hue 0→120 on edge.weight (matches d3 2D's scale) - 'endpoint' → array undefined → endpoint gradient - 'uniform' → single neutral grey for every edge dataTransformer threads the API edge confidence through as the engine's opaque `weight` so confidence colouring works without the rendering path re-reading source. Legend swatch now follows the rendered edge colour when one is computed, falling back to the category palette for endpoint mode. SettingsPanel dropdown gets the two new options. nodeColorBy already supported degree and centrality via computeNodeColors.
Continues the d3 2D parity port. Three additions, each landing in the
unified plugin so 2D and 3D pick them up together.
linkWidth slider
Wired to LineBasicMaterial.linewidth. WebGL clamps this to 1px on
most drivers, so the slider's visual effect is platform-dependent
for now; a thick-line shader is a follow-up. The setting exists so
the value persists when that lands.
nodeLabelSize / edgeLabelSize sliders
World-space height multipliers on the canvas-texture label meshes.
Default 1.0 == current behavior; range 0.5–3. EdgeLabels' offset
also scales so the label still sits above the line at any size.
minConfidence universal filter
Lives in the shared graph store (already had the field; previously
unused). Reading it via a zustand selector in ForceGraph3D's
filteredData useMemo means every explorer that consumes the same
store gets the filter for free — change it once, applies everywhere.
Settings panel writes via setFilters({ minConfidence }), with a hint
in the UI noting the universal behaviour.
Deferred:
relationshipTypes / ontologies filters — need a multi-select UI
pass; will land separately. showGrid / showShadows — explicitly
deferred per the d3 retirement plan.
Empirical: linkWidth > 1 renders thick lines fine on Linux Chrome /
Mesa. The WebGL spec doesn't require it, so the effect remains
platform-dependent, but the previous comment ('clamped to 1px on
most drivers') reads as broken when it isn't. State the situation
plainly.
Introspection from the PR #366 boundary surfaced patterns the human corrected or affirmed this session. Two new ways and one drift fix: - web/explorers/explorers.md (new) — concrete R3F/drei guidance for force-graph explorers. The d3 ForceGraph2D interaction model (left-pan, right-context, scroll-zoom) is the reference for any 2D explorer in this project. Top-down ortho with OrbitControls (not MapControls — its screenSpacePanning=false breaks vertical pan for z-locked layouts). Projection-aware label rules. WebGL feature documentation should be empirical-first, not worst-case. - web/evolution/evolution.md (new) — engineering judgement for refactors where two implementations coexist. Engine convergence before polish; scrub V1/V2 framing post-promotion; skip redirects for never-public transitional routes; two-plugin-one-component factory pattern during transition windows. - web/way.md (drift fix) — replace "./operator.sh restart web" with "kg-web-dev runs Vite with HMR" pointing at devmode/way.md. Tripped me up this session; aligned now. Both new ways apply to web/src/explorers/ and web/src/views/ work that's actively in flight (PR #365, PR #366, Phase C).
6 tasks
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.
Stacks on #365 — base branch is `refactor/promote-v2-canonical-force3d`. Re-target to `main` once #365 lands.
Summary
ADR-702 phase 2: extend the unified force-graph engine with an
orthographic 2D projection. Same component, same scene composition,
same physics — the camera, drag plane, sim dimensionality, and
camera-controls flavor dispatch on a new `projection` setting.
Registers a second plugin entry `force-2d-v2` that shares the engine
component with `force-3d` but defaults `projection: '2D'`. The
discoverable surface in the sidebar parallels Phase A: V2 enters
alongside the d3 `force-2d` while parity is verified, then promotes
to canonical `force-2d` in a follow-up PR retiring the d3 explorer.
Affirms
ADR-034 lets us register the same component twice with different
`defaultSettings` and config metadata; no engine-level abstraction
for projections needed, just case-style dispatches in the scene.
(hyperbolic, globe, the Utah teapot) become case additions, not API
breaks.
User-visible changes
entries. `Map` icon (matches MapControls metaphor: pan + zoom, no
rotation). Routes to `/explore/2d-v2`.
switching flips the Canvas to the appropriate camera type. (See
trade-off below.)
Mechanism changes
`projection` on `ForceGraph3DSettings`.
with the right camera class. Orthographic + zoom for 2D, perspective
2D; `OrbitControls` when 3D. Pipes `projection` to sim
(`dimensions: 2 | 3`) and drag handler.
normal; the pointer unproject lands on the layout plane regardless
of camera distance. 3D keeps the camera-perpendicular plane.
of integrated position and velocity each frame, flattening to z=0.
fragment shaders zeroes the Z output in 2D. Pushed each frame so a
runtime toggle takes effect immediately.
switching.
(default '3D') and `ForceGraph2DV2Explorer` (default '2D').
Trade-offs documented
makes the toggle work — r3f's `orthographic` prop is mount-time.
Cost: the user loses any drag-pinned layout when flipping the toggle.
Acceptable for an MVP; if it becomes painful, the fix is to persist
position snapshots across the remount.
seed radius (~120 units). Very large graphs may render as a tight
cluster on first load until the user scrolls to zoom out. A
fit-to-content first-render could land later.
the transitional shape, not the end state. When d3 `force-2d`
retires, this consolidates to one "Force Graph" plugin with the
projection toggle as the user-facing affordance.
Tests + author-verification
doesn't touch the action layer).
golden 2D path (search → explore → drag → pan/zoom → right-click
background → right-click node) and the in-explorer projection toggle.
Commits
```
2ddc932 chore(web): polish 2D map controls and page title
b03ca55 feat(web): register force-2d-v2 plugin entry alongside force-3d
8e00f65 feat(web): add 2D projection to the unified force-graph engine
```
Test plan
visible, edges/arrows/labels intact