Skip to content

feat(web): add 2D projection to the unified engine (force-2d-v2)#366

Merged
aaronsb merged 12 commits into
mainfrom
feat/force-2d-on-unified-engine
May 14, 2026
Merged

feat(web): add 2D projection to the unified engine (force-2d-v2)#366
aaronsb merged 12 commits into
mainfrom
feat/force-2d-on-unified-engine

Conversation

@aaronsb
Copy link
Copy Markdown
Owner

@aaronsb aaronsb commented May 14, 2026

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-702 — one engine, two projections. The plugin contract from
    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.
  • The `Projection` union type stays open so future projections
    (hyperbolic, globe, the Utah teapot) become case additions, not API
    breaks.

User-visible changes

  • New sidebar entry 2D Force Graph (V2) between the d3 2D and the 3D
    entries. `Map` icon (matches MapControls metaphor: pan + zoom, no
    rotation). Routes to `/explore/2d-v2`.
  • A Projection select in the settings panel of either entry —
    switching flips the Canvas to the appropriate camera type. (See
    trade-off below.)
  • Existing `/explore/3d` and `/explore/2d` unchanged.

Mechanism changes

  • `types.ts` — `Projection` union (`'2D' | '3D'`); top-level
    `projection` on `ForceGraph3DSettings`.
  • `ForceGraph3D.tsx` — Canvas `key`s on projection so r3f remounts
    with the right camera class. Orthographic + zoom for 2D, perspective
    • fov for 3D.
  • `Scene.tsx` — `MapControls` (pan + zoom, rotation disabled) when
    2D; `OrbitControls` when 3D. Pipes `projection` to sim
    (`dimensions: 2 | 3`) and drag handler.
  • `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 the camera-perpendicular plane.
  • `useForceSim` (CPU) — `zClamp` factor multiplies the Z component
    of integrated position and velocity each frame, flattening to z=0.
  • `useGpuForceSim` + `gpuShaders` — `zClamp` uniform on both
    fragment shaders zeroes the Z output in 2D. Pushed each frame so a
    runtime toggle takes effect immediately.
  • `SettingsPanel` — top-level Projection select for in-explorer
    switching.
  • `index.ts` — factory wraps both plugin entries; `ForceGraph3DExplorer`
    (default '3D') and `ForceGraph2DV2Explorer` (default '2D').
  • `App.tsx` — `/explore/2d-v2` route.
  • `AppLayout.tsx` — sidebar entry + page-title branch.

Trade-offs documented

  • Canvas remount on projection toggle. The `key` strategy is what
    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.
  • Orthographic zoom heuristic. `zoom: 2.5` is reasonable for the
    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.
  • Plugin coexistence. Two registrations of the same component is
    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

  • `tsc --noEmit` clean.
  • 10-test `useExplorationActions` suite green (projection logic
    doesn't touch the action layer).
  • HMR reloads cleanly through the work.
  • UI verification handed to the author at `/explore/2d-v2`:
    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

  • /explore/2d-v2 renders 2D layout — flat in the XY plane, nodes
    visible, edges/arrows/labels intact
  • Left-click + drag on background pans
  • Mouse wheel zooms
  • Left-click on node selects + opens NodeInfoOverlay
  • Left-click + drag on a node moves it (drag plane Z=0)
  • Right-click on node opens context menu
  • Right-click on background opens background context menu
  • Settings panel Projection select flips 2D ↔ 3D (Canvas remounts)
  • /explore/3d still works unchanged
  • /explore/2d (d3) still works unchanged
  • 10-test useExplorationActions suite green

aaronsb added 12 commits May 13, 2026 23:02
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).
@aaronsb aaronsb deleted the branch main May 14, 2026 05:07
@aaronsb aaronsb closed this May 14, 2026
@aaronsb aaronsb reopened this May 14, 2026
@aaronsb aaronsb changed the base branch from refactor/promote-v2-canonical-force3d to main May 14, 2026 05:08
@aaronsb aaronsb merged commit 7891124 into main May 14, 2026
@aaronsb aaronsb deleted the feat/force-2d-on-unified-engine branch May 14, 2026 05:08
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