⚠️ Reconciled 2026-06-30 (#88). Settled approach: imperative + pure-core, no framework (CLAUDE.md rule 5). Before implementing, extract the interaction surface so this doesn't grow inside explain-graph.js: src/core/graph-selection.js (pure — selection set, nodesInRect marquee hit-test, select-all/connected, batch move + align/distribute, batch undo op) and src/ui/graph-surface.js (pan/zoom, pointer capture, marquee rect, keybindings, selection CSS, toolbar — shared with the EXPLAIN pipeline graph where possible). The Implementation notes below still apply, but land in those extracted modules rather than directly in attachSchemaInteractions. See #68 Phase 5.
Problem
In the full-screen schema/data-flow view, layout is hand-tunable by ⌘/Ctrl-dragging a card — but one node at a time. Re-arranging a group of related tables means dragging each individually, which is slow and tedious. There's no way to select several nodes and move them together.
Proposal
Add a selection set of nodes and let the whole set be dragged as one unit, with light group-layout actions. Build entirely on the existing single-node move machinery (no new runtime deps).
Selecting nodes (all additive to today's bindings):
- Marquee: Shift-drag on empty canvas draws a selection rectangle; cards it touches join the selection. (Plain drag and ⌘/Ctrl-drag stay pan, so Shift-drag is the free gesture for marquee.)
- Shift-click a card: toggle it in/out of the selection (doesn't open the detail pane).
- ⌘A: select every node (when the view has focus).
- Select a whole database / connected lineage component: a one-shot action — e.g. Alt/double-click a card, or a small "select DB / select connected" control — so a related cluster can be grabbed at once.
- Deselect: plain-click empty canvas, or Esc.
Moving the group: with ≥1 node selected, ⌘/Ctrl-drag any selected card moves the entire selection by the same delta; edges within and touching the set re-route live. ⌘/Ctrl-drag on an unselected card keeps today's single-node behavior.
Group layout actions (shown only when ≥2 are selected): align (left/right/top/bottom), distribute evenly, and optionally re-tidy (re-run dagre on just the selected subgraph). Layout math stays pure in src/core/.
Persistence & undo: the group move persists per-node like single moves (savedPositions) and is undone/redone as one step (a single ⌘Z reverts the whole drag / align).
Current implementation (reuse what exists)
The single-node move path is fully built and is the template — multi-node is additive on top of it.
- Interaction wiring:
attachSchemaInteractions(...) — src/ui/explain-graph.js:423. onDown (:478) gates plain-press (pan / open detail pane) vs ⌘/Ctrl-drag (move one node); onMove (:496) → placeAt (:443); onUp (:502) records one undo op.
- Pure helpers,
src/core/graph-layout.js (all reusable for a group): dragDeltaToSvg (:47, viewBox-aware — the same delta applies to every selected node), straightEdgePoints (:31), incidentEdges (:36), applyPositions (:55, already loops), recordPosition (:65), createMoveHistory (:77, op shape {id,from,to}).
- Persistence: positions map
{ "db.table": {x,y} } is savedPositions on the result (app.js:586), reloaded via applyPositions (explain-graph.js:257).
- Existing single "selected" ring (
markSelected/clearSchemaSelection, .eg-card--selected + .eg-card-ring) in src/ui/schema-detail.js + styles.css:703 is coupled to the detail pane and mutually-exclusive. A multi-select set is a distinct concept and must be reconciled with it.
- No marquee / multi-select infrastructure exists yet. Modifier conventions today: plain-click node → detail pane; ⌘/Ctrl-drag node → move; plain/⌘ drag empty canvas → pan; ⌘/Ctrl+wheel → zoom; ⌘Z/⌘⇧Z/⌘Y → undo/redo.
Implementation notes
src/core/graph-layout.js (pure, 100% covered): extend createMoveHistory to a batch op { moves: [{id,from,to}] } so undo/redo applies to the whole set in one step; add nodesInRect(nodes, rect) for marquee hit-testing and pure align/distribute helpers. Reuse dragDeltaToSvg, straightEdgePoints, incidentEdges, recordPosition, applyPositions as-is.
src/ui/explain-graph.js attachSchemaInteractions: hold a Set of selected ids; add Shift-click toggle, the Shift-drag marquee (draw an SVG rect, hit-test on mouseup), ⌘A, and select-DB/component; in onDown/onMove loop placeAt over the selection and straighten the union of incident edges once per frame; record one batch undo op in onUp.
src/ui/schema-detail.js + styles.css: reconcile the existing detail-pane ring with the new multi-select highlight — either unify into one selection set (pane shows the most-recently-clicked) or give the move-selection its own class. Add a marquee-rectangle style.
- No new runtime deps (DOM/SVG only); tests in the same change (pure helpers to 100%, integration for the wiring) per the CLAUDE.md coverage gate.
Acceptance criteria
Open design questions
- Exact bindings (Shift-drag marquee vs a toggled "select mode"; how "select DB/component" is triggered).
- Marquee hit policy: intersect vs full-containment.
- Unify the detail-pane ring with the move-selection, or keep them visually distinct?
- Scope of "auto-layout": just align/distribute, or also re-dagre the selected subset?
Problem
In the full-screen schema/data-flow view, layout is hand-tunable by ⌘/Ctrl-dragging a card — but one node at a time. Re-arranging a group of related tables means dragging each individually, which is slow and tedious. There's no way to select several nodes and move them together.
Proposal
Add a selection set of nodes and let the whole set be dragged as one unit, with light group-layout actions. Build entirely on the existing single-node move machinery (no new runtime deps).
Selecting nodes (all additive to today's bindings):
Moving the group: with ≥1 node selected, ⌘/Ctrl-drag any selected card moves the entire selection by the same delta; edges within and touching the set re-route live. ⌘/Ctrl-drag on an unselected card keeps today's single-node behavior.
Group layout actions (shown only when ≥2 are selected): align (left/right/top/bottom), distribute evenly, and optionally re-tidy (re-run dagre on just the selected subgraph). Layout math stays pure in
src/core/.Persistence & undo: the group move persists per-node like single moves (
savedPositions) and is undone/redone as one step (a single ⌘Z reverts the whole drag / align).Current implementation (reuse what exists)
The single-node move path is fully built and is the template — multi-node is additive on top of it.
attachSchemaInteractions(...)—src/ui/explain-graph.js:423.onDown(:478) gates plain-press (pan / open detail pane) vs ⌘/Ctrl-drag (move one node);onMove(:496) →placeAt(:443);onUp(:502) records one undo op.src/core/graph-layout.js(all reusable for a group):dragDeltaToSvg(:47, viewBox-aware — the same delta applies to every selected node),straightEdgePoints(:31),incidentEdges(:36),applyPositions(:55, already loops),recordPosition(:65),createMoveHistory(:77, op shape{id,from,to}).{ "db.table": {x,y} }issavedPositionson the result (app.js:586), reloaded viaapplyPositions(explain-graph.js:257).markSelected/clearSchemaSelection,.eg-card--selected+.eg-card-ring) insrc/ui/schema-detail.js+styles.css:703is coupled to the detail pane and mutually-exclusive. A multi-select set is a distinct concept and must be reconciled with it.Implementation notes
src/core/graph-layout.js(pure, 100% covered): extendcreateMoveHistoryto a batch op{ moves: [{id,from,to}] }so undo/redo applies to the whole set in one step; addnodesInRect(nodes, rect)for marquee hit-testing and pure align/distribute helpers. ReusedragDeltaToSvg,straightEdgePoints,incidentEdges,recordPosition,applyPositionsas-is.src/ui/explain-graph.jsattachSchemaInteractions: hold aSetof selected ids; add Shift-click toggle, the Shift-drag marquee (draw an SVG rect, hit-test on mouseup), ⌘A, and select-DB/component; inonDown/onMoveloopplaceAtover the selection and straighten the union of incident edges once per frame; record one batch undo op inonUp.src/ui/schema-detail.js+styles.css: reconcile the existing detail-pane ring with the new multi-select highlight — either unify into one selection set (pane shows the most-recently-clicked) or give the move-selection its own class. Add a marquee-rectangle style.Acceptance criteria
savedPositionsand is undone/redone as a single step.npm testgreen at the per-file coverage gate; no new runtime dependency.Open design questions