Skip to content

Feat canvas edge drag layout - #54

Merged
dbose merged 14 commits into
mainfrom
feat-canvas-edge-drag-layout
Oct 12, 2026
Merged

dbose merged 14 commits into
mainfrom
feat-canvas-edge-drag-layout

Conversation

@dbose

@dbose dbose commented Oct 12, 2026

Copy link
Copy Markdown
Owner

Canvas draggable relationship edges + saved layout (visual-only, team-shareable)

Branch: feat-canvas-edge-drag-layout → main · 13 commits · +2,467 / −119 across 31 files · not yet pushed

What it does

Makes relationship edges on the ER canvas directly manipulable, and remembers the whole arrangement in a visual-only sidecar that a team can optionally commit — without ever touching the model.

  1. Draggable edges

Drag the vertical segment of an edge to reroute it (routeX); drag either endpoint to slide it along the entity border (Sirius-style “adjust the extremity, never reconnect”).
Custom side-aware orthogonal router (edgeRouting.ts, pure + unit-tested): the edge enters the target from whichever side the route approaches, crow’s-foot always on the border, no line piercing a card.
UX fixes from several rounds of your feedback: zoom-invariant grips, grab-anywhere handle, single-click selects an edge so its handles persist (no hover-fight), double-click opens the editor, endpoints track their node when it moves (live-rect resolution fixes the dislodge bug).

  1. Saved layout sidecar

Node positions + edge routes persist per view in /.mdl/layout.json (JSON, not YAML — invisible to every *.yaml glob by construction).
No layout found → dagre auto-layout (hierarchical / clustered / grid), verified live.

  1. Opt-in team sharing (latest commit)

layout: {shared: true} in mdl-project.yaml stops git-ignoring the sidecar so a team can commit it; default stays ignored.
The setting is read straight from the project file, and dir_fingerprint now canonicalises mdl-project.yaml with the layout key stripped — so toggling the flag does not move the model fingerprint.
The isolation guarantee (the core invariant)

Layout is visual only. Whatever the sharing setting, validate / drift / diff / every emitter / the model doc / git status are byte-identical. Proven by tests on the full anchor-bearing sidecar, plus the reverse-prune isolation test.

Verification
Python: 990 passed, 1 skipped, 0 failed (all packages, not just server)
Canvas Vitest: 50 passed, 0 failed (4 files, under Node 20 — default Node 16 fails per the known build-node issue)

dbose added 14 commits October 11, 2026 20:07
Two gaps against erwin's diagram editor, both visual-only and fully isolated
from the model.

Part A — draggable edges. A relationship's vertical segment can be dragged
sideways (its x is the only free degree of freedom — ports are on card left/right
only). Hovering or selecting an edge shows an ew-resize grip at the segment's
midpoint; dragging slides it while both ends stay anchored. The route snaps to a
10px grid (Shift bypasses), magnetises within 6px to sibling segments and card
edges, and is clamped out of both endpoint cards (forward edges to the corridor,
backward edges to any x outside both). Double-click resets to automatic routing.
A sub-3px press is a click (opens the editor); a real drag is swallowed. The live
route is kept in local edge state and committed once on release, so a 100-entity
model does not rebuild every edge per move. Geometry is a pure, unit-tested module
(edgeRouting.ts); getSmoothStepPath's centerX carries the route.

Part B — saved layout. Node positions and edge routes persist per view in a
<model>/.mdl/layout.json sidecar (JSON, so it stays out of every *.yaml glob).
New server routes GET/PUT/DELETE /api/layout: PUT is atomic, caps the body at
1MB, drops ids not in the model, rejects non-finite/over-long keys (422), and
returns 403 under read-only. Layout is never a model mutation — no fingerprint
bump, no cache bust, no audit. On first write it creates .mdl/.gitignore inside a
git tree so layout.json is ignored. The canvas loads saved positions over
auto-layout (new entities, e.g. Copilot's, get an overlap nudge; no re-fit when a
layout exists), saves on node-drag / route-commit / re-layout (debounced 800ms),
and offers Reset layout in the View menu. The LayoutStore is injected like exec,
so the SME/catalog shells pass none and never persist.

Isolation (tested): mdl validate, drift, every emitter and /api/export are
byte-identical with and without the sidecar; git status is never dirty from it
and discard/propose-rollback never delete it (git clean -e). The reverse prune
already excludes .mdl/ (regression test added).

Tests: edgeRouting (15), RelationshipEdge component (6), layout API + isolation
(14), writer-prune guard (1). npm run build, vitest (39), server pytest and ruff
all green; VS Code gates unaffected. Verified live: the handle renders on select
with an ew-resize cursor and a node PUT round-trips to the sidecar.
The route grip was drawn at a fixed SVG size inside React Flow's scaled edge
layer, so at the default 0.33x zoom a 3px grip rendered ~1px wide on screen —
invisible and impossible to grab ("no drag icon, nothing works"). Divide the
grip, its hit target and the edge's invisible click path by the live zoom
(useStore transform[2]) so they keep a constant on-screen size (grip ~9x18px,
hit target ~22x32px) at any zoom. The grip is now a rounded amber pill with two
dots as a drag affordance. Test asserts the grip has a real, non-collapsed size.
The grip's x/y came from the naive endpoint midpoint ((sourceX+targetX)/2,
(sourceY+targetY)/2), but getSmoothStepPath routes the vertical segment through
its OWN center, which on a real elbow is nowhere near that midpoint. So the grip
floated in empty space beside the line and looked absent ("no drag icon"). Use
the path's center point (getSmoothStepPath's labelX/labelY) for the handle and
label position when no route is set; ride routeX once one is. The component test
now uses an elbow geometry (the straight-line case hid the bug) and asserts the
grip sits on the path's vertical segment.
…e-click

The handle only appeared on `selected`, but selecting an edge meant single-
clicking it, which opened the relationship editor — so the handle was never
reachable ("on hover I don't see the drag icon; it just opens the editor").

Reserve hover/single-click for the handle and move editing to double-click:
- Hovering an edge now reveals its drag grip (a `hovered` flag threaded through
  edge data from the existing hoveredEdge state). Hover handlers sit on the whole
  edge <g> so moving from the line onto the grip keeps it mounted.
- The relationship editor opens on `onEdgeDoubleClick` instead of onEdgeClick.

Tests: the grip shows on hover alone (no selection); existing double-click-reset
and position/zoom assertions unchanged. Verified live: hover reveals the grip,
single-click no longer opens the editor, double-click does.
…e cards

Polish from real use, against standard diagram-editor UX patterns:

1. Grab zone (was: had to aim at the exact grip). The drag target now spans the
   WHOLE vertical segment (~40px wide on screen, full segment height) per React
   Flow's generous edge-interaction guidance, so you can grab the line anywhere
   along its run. Grip is a clearer 3-dot pill.
2. Occlusion (was: route/crow's-foot hidden behind a card). The hovered or
   selected edge is lifted above the node cards (zIndex 1000 vs nodes' 0) so its
   route and endpoint markers stay visible while you work it; other edges stay
   behind cards for a clean diagram.
3. Routing: the existing snap/magnetise/clamp keeps the segment out of its
   endpoint cards (so it can't be dropped behind the target), which with the
   elevation keeps the line readable during a drag.

Verified live: grab zone 40x217px, hovered edge layer z=1000 over nodes z=0,
glyphs stay rendered when the route nears a card, drag commits + persists.
Two issues from real use:

1. Cursor. The ew-resize cursor only showed on the grip pill (the edge's own
   `cursor: pointer` won elsewhere), so you had to hunt for the exact spot. Now
   the whole handle group — grip + its full-segment grab zone — forces ew-resize.

2. Crow's-foot on a rerouted edge. getSmoothStepPath always attaches the edge to
   the left/right port, so a route dragged over a card still entered from the side
   and the crow's-foot pierced the card. Added a custom orthogonal router
   (edgeRouting.orthogonalPath, unit-tested) used when a route is set: when the
   run aligns over the target card, the line drops onto the target's top/bottom
   border and the crow's-foot reorients to that side. The route-clamp was relaxed
   to ALLOW alignment over the target card (vertical entry) while still barring the
   source card. EndGlyph was generalized to draw along any outward vector.

Also fixed a real timing bug this surfaced: the edge card-rects were read AFTER
setNodes returned, but React doesn't run the updater synchronously, so edges got
stale (0,0) cards and the router never saw a real target. Edges are now emitted
from inside the updater where the final positions exist.

Verified live: a route dragged over a node enters from its top/bottom border with
the crow's-foot on that edge; resize cursor shows anywhere on the segment. 43
canvas tests pass (incl. orthogonalPath + relaxed-clamp cases).
…t always left)

When the route was dragged to the RIGHT of the target, the edge still looped back
to the left port, cutting across the card body and through the row label. Pick the
target's entry side from where the run actually is: right port when routeX is right
of the card, left port when left, top/bottom border when aligned over it. The
crow's-foot anchor + direction follow that side, so the marker lands on the border
the line crosses and the line never pierces the card or its labels.

Tests: orthogonalPath right-port and left-port entry cases. 45 canvas tests pass.
Verified live: a route dragged right of dm enters its right edge with the glyph on
that edge, clear of the usubjid label.
Beyond dragging the vertical segment (routeX), each edge end can now be dragged
ALONG its entity's border, with the crow's-foot following smoothly — the Eclipse
Sirius "adjust the extremity, never reconnect" pattern. Visual only: the model
still links the real attribute rows; this is only where the line meets the box.

- edgeRouting: anchorOnBorder (resolve a drop point to the nearest border + clamp)
  and resolveAnchor (border point + outward vector, re-clamped to the current card).
  Unit-tested.
- RelationshipEdge: a draggable grip at each endpoint (hover/select reveals it,
  move cursor); the effective source/target point overrides the attribute-row port
  so the path + glyph follow the anchor; double-click an endpoint resets it.
- Persistence: EdgeRoute gains optional sourceAnchor/targetAnchor {side,y,x?};
  saved/loaded via the same layout store and debounced save. Server layout_store
  validates + rounds anchors (422 on a bad side/non-finite). Both ends draggable.

Verified live: endpoint grips appear on hover, dragging one slides the connection
point down the border (e.g. dm's left edge) with the glyph tracking, and it
persists. 49 canvas + 16 server layout tests pass; ruff clean.
…e move)

A dragged endpoint anchor was resolved against the card rect baked into edge data
(a snapshot from the last derivation). Moving the entity updated node positions
but not that snapshot, so the anchored endpoint stayed at the card's OLD position
— a line floating in empty space.

Resolve anchors (and the orthogonal router) against the LIVE node rect read from
the React Flow store (nodeInternals: positionAbsolute + measured size), with a
shallow-equality selector so an edge re-renders only when its own node's rect
changes. Falls back to the data snapshot before a node is measured. Now an
anchored endpoint, its crow's-foot and the route all follow the node as it moves.

Verified live: anchored the ex->dm edge on dm's left edge, moved dm +200/+100 via
the store — the endpoint followed cleanly to dm's new left edge, no dislodge.
49 canvas tests pass.
…l routing

Two usability fixes from real use.

1. Handles were hard to find — they showed only while hovering the thin line, so
   you had to hover-fight to grab them. A single click now SELECTS the edge: its
   route grip and both endpoint grips stay visible (and the line glows) until you
   click elsewhere; double-click still opens the editor. Grips are brighter with a
   glow on hover. No more hover-fighting.

2. A horizontal edge (source and target at the same height) produced a degenerate
   orthogonal path — zero-length corner curves that could render as a kink/stray
   mark. polyline() now simplifies the waypoints (dropping duplicate and collinear
   points), so a horizontal edge is a clean straight line.

Tests: a horizontal-edge path assertion (straight line, no corners) + the existing
49. Full backend suite (0 failures), ruff, and all VS Code gates green.
…hors + drift)

The isolation tests predated the endpoint-anchor fields and did not cover drift —
the user-named backend feature. Strengthen them so the "layout is UI-only" promise
is pinned for every field:

- A rich-layout helper (node positions + edge routeX + source/target endpoint
  anchors), used to prove:
  - EVERY export format is byte-identical with/without the sidecar (new, covers all
    emitters at once, not just sql).
  - drift (model vs compiled manifest) is identical with/without the sidecar (new).
  - /api/model is identical.
- The reverse-prune guard now writes a rich sidecar and asserts its content is
  untouched by a re-reverse.

Verified manually on the real SDTM model too: dbt generate byte-identical,
all 6 exports identical, validate identical, fingerprint identical. Test-only
change; full backend suite + ruff green.
…rue})

A team can now commit the canvas layout to git. Set `layout: {shared: true}`
in mdl-project.yaml and the server stops ignoring .mdl/layout.json (dropping
its own auto-added ignore line), so the sidecar is committable and a teammate
who opens the canvas on that checkout gets the same arrangement.

The sharing flag is a visual-tooling preference, never model content. The
setting is read straight from the project file (not through the model graph),
and dir_fingerprint now canonicalises mdl-project.yaml with the `layout` key
stripped, so toggling the flag does NOT move the model fingerprint. Whatever
the setting is, validate / drift / diff / every emitter / the model doc are
byte-identical — the same isolation the JSON sidecar already has by living
outside every *.yaml glob.

Tests: shared -> not gitignored; solo->shared drops the auto ignore line; and
the guardrail that with shared:true + a rich sidecar every export, the model
doc and the fingerprint are identical to a model with no layout.
Two gaps in git_api.py were exercised by no test:

- discard preserves the sidecar: /api/git/discard reverts MODEL edits
  (git checkout + clean) but keeps .mdl/layout.json via `clean -e`. The test
  writes a stray untracked model file + a layout, discards, and asserts the
  stray is gone while the layout survives and still reads back.
- collapsed untracked .mdl/ dirtiness: when the whole .mdl/ dir is new, git
  porcelain collapses it to `?? .mdl/`. _mdl_only_layout must read that as
  CLEAN when it holds only layout artifacts, but DIRTY once real state
  (decisions.yaml) is present. The test pins both directions.

Full suite 990 passed, 1 skipped.
@dbose
dbose merged commit 99dc150 into main Oct 12, 2026
2 checks passed
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