From 070194d91337eb93723396ffcb3627eee5bda75b Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Tue, 20 Oct 2026 23:18:32 -0400 Subject: [PATCH 1/2] feat(graph): add tree layout and arrangement tools Large models scattered related tables and offered no way to undo a move, move several tables at once, or hide a noisy hub. Hierarchical now lays out each colour group on its own, a Tree mode draws the model as an outline, and the top-row search bars clear the branding when narrow. --- NOTICE | 21 + docs/design/graph-arrangement.md | 75 +++ docs/design/graph-position-persistence.md | 3 + docs/design/graph-tree-layout.md | 79 +++ docs/guides/commands.md | 8 +- docs/guides/themes-and-branding.md | 4 +- docs/spec/branding.md | 7 +- docs/spec/dd-spotlight-grid.md | 3 +- docs/spec/graph-arrangement.md | 155 +++++ docs/spec/graph-flow-search.md | 11 + docs/spec/graph-position-persistence.md | 27 +- docs/spec/graph-tree-layout.md | 163 +++++ docs/spec/help-overlay.md | 16 +- docs/spec/keyboard-nav-shortcuts.md | 35 +- docs/wiki/feature-map.md | 4 +- docs/wiki/frontend.md | 16 +- docs/wiki/index.md | 2 +- docs/wiki/scripts.md | 2 +- scripts/perf-harness.ts | 6 +- .../references/model-flow.md | 2 +- src/app/App.tsx | 41 +- src/app/components/ui/FabMenu.tsx | 3 +- src/app/components/ui/GraphEditBar.tsx | 110 ++++ src/app/components/ui/HelpModal.tsx | 14 +- src/app/hooks/useKeyboardShortcuts.ts | 22 +- src/app/logic/shortcuts.ts | 20 +- src/app/styles.css | 259 +++++++- src/app/views/graph/GraphView.tsx | 584 +++++++++++++++--- src/app/views/graph/graph-edits.ts | 77 +++ src/app/views/graph/grouped-layered.ts | 197 ++++++ src/app/views/graph/layout-store.ts | 39 +- src/app/views/graph/markers.ts | 10 +- src/app/views/graph/organic-layout.ts | 29 +- src/app/views/graph/styles.ts | 54 ++ src/app/views/graph/tree-layout.ts | 565 +++++++++++++++++ src/flow-view/FlowChrome.tsx | 11 +- .../test-dict-search-branding-overlap.ts | 148 ++--- test/checks/test-graph-edits.ts | 90 +++ test/checks/test-grouped-layered.ts | 133 ++++ test/checks/test-keyboard-shortcuts.ts | 5 +- test/checks/test-layout-store.ts | 56 ++ test/checks/test-shortcuts.ts | 35 ++ test/checks/test-tree-layout.ts | 520 ++++++++++++++++ .../test-viewer-search-branding-overlap.ts | 198 ++++++ test/chrome-geometry.ts | 40 ++ test/visual/test-graph-edits.ts | 360 +++++++++++ 46 files changed, 4011 insertions(+), 248 deletions(-) create mode 100644 docs/design/graph-arrangement.md create mode 100644 docs/design/graph-tree-layout.md create mode 100644 docs/spec/graph-arrangement.md create mode 100644 docs/spec/graph-tree-layout.md create mode 100644 src/app/components/ui/GraphEditBar.tsx create mode 100644 src/app/views/graph/graph-edits.ts create mode 100644 src/app/views/graph/grouped-layered.ts create mode 100644 src/app/views/graph/tree-layout.ts create mode 100644 test/checks/test-graph-edits.ts create mode 100644 test/checks/test-grouped-layered.ts create mode 100644 test/checks/test-tree-layout.ts create mode 100644 test/checks/test-viewer-search-branding-overlap.ts create mode 100644 test/chrome-geometry.ts create mode 100644 test/visual/test-graph-edits.ts diff --git a/NOTICE b/NOTICE index 8d9193e..b7e35ad 100644 --- a/NOTICE +++ b/NOTICE @@ -1,2 +1,23 @@ ignatius Copyright 2026 Danilo Alonso + +This product includes icon paths from Lucide (https://lucide.dev), +used in src/app/components/ui/GraphEditBar.tsx, under the ISC License: + +ISC License + +Copyright (c) for portions of Lucide are held by Cole Bemis 2013-2022 as part +of Feather (MIT). All other copyright (c) for Lucide are held by Lucide +Contributors 2022. + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/docs/design/graph-arrangement.md b/docs/design/graph-arrangement.md new file mode 100644 index 0000000..c18efd7 --- /dev/null +++ b/docs/design/graph-arrangement.md @@ -0,0 +1,75 @@ +# Graph arrangement + + +## Problem + + +On a large model the Graph view fights the user in three ways. + +**Hierarchical mode scatters colour groups.** ELK `layered` sees one flat graph. It puts every table in a layer by FK depth and orders each layer to cut crossings, so a group's tables end up spread across every layer and across the full width. Subtypes land far from their basetype's other relatives, and a classifier lands wherever its layer has room, far from the tables in its own group. + +**Any model edit throws the arrangement away.** Positions are stored under a structural fingerprint of the model. Adding one entity changes the fingerprint, so the next load lays out from scratch and every node the user placed by hand moves. + +**There is no way back and no way to work on more than one node.** A mis-drag is permanent, nodes move one at a time, and a hub table that references everything (a `User` table) draws lines across the whole picture with no way to set it aside. + + +## Goals / Non-goals + + +- Goals: hierarchical mode keeps each colour group in one block; a model edit keeps the user's arrangement and only places the new nodes; undo and redo for moves, hides, and layout changes; select and move several nodes at once; take nodes out of the view and bring them back. +- Non-goals: pinning a node so a re-layout keeps it in place; sharing arrangements across browsers; changing the organic layout; per-group manual block placement. + + +## Grouped hierarchical layout + + +Each colour group is laid out on its own with ELK `layered`, then the group blocks are packed into one picture: + +1. `buildScratchCore(cy, true, hidden)` builds a headless mirror with one compound per group. +2. `layoutByGroup` runs ELK `layered` on each group. +3. `orderRegions` puts groups that others reference first. +4. `packRegions` skyline-packs the blocks at about 16:10. +5. The positions are applied to the live graph. + +Organic mode at 500 or more entities already falls back to the layered path, so it gets the blocks too. + +| # | Approach | Pros | Cons | +|---|----------|------|------| +| A | One compound per group, ELK `layered` with `INCLUDE_CHILDREN` on the whole graph | One ELK call; cross-group edges routed | Groups stack in one tall column; wide empty bands where cross-group edges pass | +| B | Lay out each group alone, pack the blocks (chosen) | Each group is compact and readable; picture fills the screen | Cross-group edges are long; block order is a heuristic | +| C | Keep flat `layered`, post-process to pull groups together | No new layout stage | Fights ELK's layering; overlaps to repair | + +**B** because the complaint is cohesion. A was built and rendered first: groups held together but the picture was a single column three screens tall. Block order puts a group that others reference first, the same top-down reading the layered layout gives inside a block. Skyline packing drops a short block into the gap beside a tall one instead of opening a new row under it. + +Joiner diamonds carry no group, so each goes in the block of the cluster it feeds. Ungrouped tables form one block of their own. + + +## Arrangement survives model edits + + +The layout store holds the last ten arrangements, one per fingerprint, shared by every model the browser has opened. On a load whose fingerprint has no entry, the newest entry whose shared tables are at least half of the current tables and half of its own is taken as the same model before its edit. Its positions are applied, and each table it lacks is seated next to its placed neighbours at the nearest clear spot. Without such an entry the graph lays out from scratch as before. + +The two-sided half-overlap test is what keeps another model's arrangement out: two unrelated models rarely share half their table names, and a larger model that contains these tables shares less than half of its own. An entry from before the edit carries the hidden set too. + +This reverses the all-or-nothing rule in [`graph-position-persistence.md`](graph-position-persistence.md). That rule avoided stranding new nodes at the origin; placing them beside their neighbours removes that failure, and losing a hand-made arrangement on every edit was the larger cost. + + +## Edits, undo, selection, hiding + + +An edit history holds snapshots: every node's position, the hidden set, and the layout mode. A drag, a hide or show, Reset layout, and a layout-mode switch each push the state from before the change. Undo swaps the current state for the newest snapshot; redo swaps back. The history lives with the Graph view component, so a live reload keeps it. An edit made while a Reset or mode switch is still computing drops that layout; otherwise the layout would land over the edit. + +A node is visible or hidden; hiding and showing are undoable, and a Reset leaves a hidden node hidden: + +```mermaid +stateDiagram-v2 + [*] --> Visible + Visible --> Hidden: right-click / H on selection + Hidden --> Visible: Show / Show all / undo + Hidden --> Hidden: Reset layout lays out without it +``` + +- Select mode turns off canvas panning and makes clicks additive, so a plain drag draws a selection box and a click toggles a node. Shift+drag boxes in either mode. Dragging any selected node moves the selection; cytoscape does this natively. +- A hidden node gets a class with `display: none`; cytoscape hides its edges with it, and a subtype joiner goes too once all its members are hidden. Layouts run on a mirror without hidden nodes, so a Reset closes the space they took. A node shown again returns to its old spot unless something now sits there, in which case it is seated beside its neighbours. Deciding by overlap at show time, not by remembering which layouts ran, holds across reloads and view switches. +- Navigating to a hidden entity (search, links, panel rows, deep links) shows it first. +- The hidden list and positions are saved together, so a reload and a model edit keep both. diff --git a/docs/design/graph-position-persistence.md b/docs/design/graph-position-persistence.md index a9c8b4e..546921a 100644 --- a/docs/design/graph-position-persistence.md +++ b/docs/design/graph-position-persistence.md @@ -1,6 +1,9 @@ # Graph node position persistence +The all-or-nothing restore below was later replaced: a structural change now carries the previous arrangement over and places only the new nodes. See [`graph-arrangement.md`](graph-arrangement.md) for why. + + ## Problem diff --git a/docs/design/graph-tree-layout.md b/docs/design/graph-tree-layout.md new file mode 100644 index 0000000..8a675de --- /dev/null +++ b/docs/design/graph-tree-layout.md @@ -0,0 +1,79 @@ +# Graph tree layout + + +## Problem + + +The Organic and Hierarchical layouts place tables in two dimensions and draw straight or curved lines between them. On a model with deep key inheritance the reader has to trace lines to answer "what does this table belong to". Hand-drawn IDEF1X diagrams answer it by position: each table sits below and to the right of the table whose key it inherits, like an outline, and every line is horizontal or vertical. + + +## Goals / Non-goals + + +- Goals: a third layout mode that reads as an indented outline, left to right and top to bottom; a tree or lookup parent always above and left of its child; every line leaves its parent's bottom edge and enters its child's left side; each parent→child line keeps its own lane; the relationship name on the run into the child. +- Non-goals: column lists or icons inside the boxes; free-floating notes on the canvas; hand-tuned per-child offsets. + + +## Building the tree + + +A table's tree parent is the table whose key it inherits. With several, the deepest one wins, so `Unit` hangs under `Building` rather than a sibling lookup; ties go to the id. A table with no key parent takes a plain FK parent, except a classifier or a hub. A hub is a table that the larger of 8 tables or 10% of the model reference without inheriting its key, such as the `users` table behind every `created_by`; without the rule every independent table hangs under it. + +A cycle is cut at a plain FK link, never at a key link while one exists. + +A root with nothing under it that some table references (a classifier, a table whose consumers all have other parents) is a lookup. It is never hung under the table that uses it: there the line would run from parent back to child, right to left and bottom to top. It is assigned to one consumer that is not itself a lookup, same colour group first, and placed above that consumer's tree. Hubs, and lookups used only by other lookups, stay roots. + + +## Placing it + + +1. Each tree is laid out depth first: a node takes the next row, its children follow in rows below it, indented to its right. +2. Children are ordered smallest subtree first, so the lanes that run furthest down pass the fewest rows. +3. A child's indent is the parent's lane fan plus the widest relationship label among its siblings, so every label fits on its run. +4. A tree's lookups form a staircase above it. Each drops its lanes from its bottom-left, past the lookups below it (each starts right of the lanes above), and turns right into its consumer. The lookup feeding the deepest table is top-left, so a lookup's run into its deepest table crosses no other lookup's lane; a lookup that also feeds a shallower table crosses the lanes of the lookups after it on that run, which a staircase cannot avoid. The tree starts right of the last lane, under the staircase. +5. Trees are stacked into columns about as tall as makes the picture 16:10. A tree that other trees reference goes first, colour groups first and then the trees inside each group, so links between trees also run right and down. +6. A parent whose children would run past the bottom of the column keeps those that fit under it. The rest wrap into columns to its right, the way an outline wraps. Each wrapped column starts one row below the parent, so its cross links still run down and right. Wrapped children stay part of the parent's tree, so the tree order never moves them above or left of it. +7. Children wrap only when a fresh column has room for two of the parent's child rows. With less room, wrapping would make a strip of one-table columns, so the tree runs past the column instead. +8. A subtype joiner never wraps: it stays under its basetype, and only its members wrap. When a basetype's children wrap, the joiner goes first, so the cluster gets the column's room. +9. A lookup's line into a table in a wrapped column is a cross link. A straight run from the staircase would pass behind the boxes that share that row in the columns to its left. + +The staircase above `order`: each lookup drops past the ones below it into its consumer's left side. + +``` +order status ┐ + order kind ┐│ + region ──┐ ││ + │ ││ order + │ ││ ├─── note + └─┼┼───► … + │└──────► shipment every line: out of a bottom edge, + └───────► order line into a left side +``` + +A wide parent wraps: the children that fit stay under it, the rest continue in columns to its right, one row below it. + +``` +Party + │└──────────────────────┐ Party's street + ├─► Business ├─► SalesOrder kept column: tree lines + ├─► Person │ └─► SO Line wrapped column: cross links + ├─► PaymentMethod └─► Identity from Party's street, + └─► SalesInvoice └─► ITIN entering from above +``` + + +## Drawing the lines + + +| # | Approach | Pros | Cons | +|---|----------|------|------| +| A | cytoscape `taxi` edges | Built in, adapts to drags | Leaves and enters on whichever side the shape picks; no per-edge lanes | +| B | `segments` edges with computed bends (chosen) | Exact control of where a line leaves and enters, its own lane | Bends are relative to the endpoints, so they are recomputed when a node moves | + +**B** for every line, with both ends pinned (`source-endpoint`/`target-endpoint`) so a line leaves exactly on its lane and enters exactly on its row. A `down` line (tree lines and lookup lines) bends once, at (lane, entry row): straight down its lane, right into the child's left edge. If the child no longer sits below the parent and right of the lane (after a hide, a reveal, a reload or a drag), the line is drawn with cross geometry instead. Tree lanes use the left half of the parent's bottom edge. A `cross` line (any other link) leaves on a lane in the right half of the parent's bottom edge, runs along a short street just under the parent, down or up an approach column just left of the child, and into the child's left side. + +Lines share the child's left side: cross links from above enter highest, then `down` lines from the rightmost lane to the leftmost, then cross links from below. Approach columns follow the same order (from above, the further-left column enters lower; from below, higher), so no two last turns into one child cross. + +Routing is separate from placement. `routeTree` takes the positions on screen and the lines the last Tree layout drew as tree lines, and derives every lane, street, entry row and approach column from them. After a hide, a reveal, an undo or the end of a drag, the graph routes again on the boxes as they stand, so nothing reorders; a tree line whose child no longer sits below and right of its parent becomes a cross link with a slot of its own. Across parents and children there is no global channel routing: lines into stacked siblings can share an approach column, and a long street can run along another line. Bends are stored relative to the parent's edges and the child's left edge and centre, and recomputed on every node move, so a dragged node keeps right-angled lines. + +A `down` line's predicate sits on the run into the child (`target-label`, or `source-label` when the edge runs child→parent). A `cross` line's predicate sits centred on the street: cytoscape puts a segments edge's mid label between the two central bends when their count is even, so the first bend is repeated. Rows are 20 px apart to leave the street and its label room under the parent. Crow's-foot markers take their angle from the bend next to each end. diff --git a/docs/guides/commands.md b/docs/guides/commands.md index b80ba2d..080ad92 100644 --- a/docs/guides/commands.md +++ b/docs/guides/commands.md @@ -163,14 +163,18 @@ The app responds to single-key shortcuts while no text field is focused and no m | `g` | Switch to the Data Graph | | `d` | Switch to the Dictionary | | `f` | Switch to the Data Flows | -| `l` | Toggle graph layout (organic ↔ hierarchical) — Graph view | +| `l` | Step through graph layouts (organic → hierarchical → tree) — Graph view | +| `s` | Toggle select mode: a plain drag draws a selection box, a click adds or removes — Graph view | +| `h` | Hide the selected entities — Graph view | +| `Cmd`/`Ctrl` + `Z` | Undo the last move, hide, or layout change — Graph view | +| `Cmd`/`Ctrl` + `Shift` + `Z`, `Ctrl` + `Y` | Redo it — Graph view | | `b` | Toggle dictionary lens (read ↔ browse) — Dictionary view | | `i` | Open or close the flow index — Flows view | | `/` or `Cmd`/`Ctrl` + `K` | Focus the search bar — Graph, Dictionary, Flows | | `?` | Open the help overlay for the current view | | `←` `→` `↑` `↓` | Scroll the canvas 10px — Graph and Flows views; hold `Shift` for 50px | -Shortcuts are ignored while typing in a search box or any other input, and when a modifier key is held. `?` is the one exception to the modifier rule — it needs Shift to type, so Shift does not suppress it (but it is still ignored while typing in a field). `Cmd`/`Ctrl` + `K` is a second exception: like the zoom chords below, it focuses search even while a text field is already focused elsewhere. The arrow keys are a third: `Shift` selects their faster stride instead of suppressing them, and holding an arrow scrolls continuously. Arrows keep their normal meaning while typing in a field, and `Cmd`/`Ctrl`/`Alt` arrow chords are left to the OS and browser. +Shortcuts are ignored while typing in a search box or any other input, and when a modifier key is held. `?` is the one exception to the modifier rule — it needs Shift to type, so Shift does not suppress it (but it is still ignored while typing in a field). `Cmd`/`Ctrl` + `K` is a second exception: like the zoom chords below, it focuses search even while a text field is already focused elsewhere. The arrow keys are a third: `Shift` selects their faster stride instead of suppressing them, and holding an arrow scrolls continuously. Arrows keep their normal meaning while typing in a field, and `Cmd`/`Ctrl`/`Alt` arrow chords are left to the OS and browser. The undo and redo chords act on the Graph only while no field is focused; in a field they undo the text. ### Help overlay diff --git a/docs/guides/themes-and-branding.md b/docs/guides/themes-and-branding.md index 8680abc..e849262 100644 --- a/docs/guides/themes-and-branding.md +++ b/docs/guides/themes-and-branding.md @@ -75,8 +75,8 @@ branding: | Field | Default | Notes | |---|---|---| | `logo` | embedded default logo | A single path applies to both modes, or use `{ dark, light }` for per-mode logos | -| `title` | built-in | Max 50 characters | -| `subtitle` | built-in | Max 50 characters | +| `title` | built-in | Max 50 characters; shown on one line, cut with `…` past 200px (less on a phone) | +| `subtitle` | built-in | Max 50 characters; shown on one line, cut with `…` past 240px (less on a phone) | | `copyright` | built-in holder + current year | `{ holder, year }` | | `poweredBy` | `true` | Set `false` to hide the footer attribution | diff --git a/docs/spec/branding.md b/docs/spec/branding.md index f0e0f11..25d5467 100644 --- a/docs/spec/branding.md +++ b/docs/spec/branding.md @@ -25,6 +25,7 @@ Render configurable logo + title + subtitle in the upper-left and a fixed footer - [ ] When a logo value is a filepath relative to the models directory, it is read from disk and base64-inlined for static outputs; served via `/api/asset?path=` for the interactive viewer - [ ] `/api/asset?path=` rejects absolute paths and `..` traversal — refuses with a 400 - [ ] `title` and `subtitle` render top-left across all three surfaces, `position: fixed`, unaffected by graph pan/zoom or dict scroll +- [x] `title` and `subtitle` each stay on one line, cut with `…` past 200px and 240px (less on narrow viewports, so the block stops short of the help and theme toggles); the search bars' clearances rely on this (`docs/spec/graph-flow-search.md` SC13) - [ ] `title` and `subtitle` are validated at parse time: ≤50 characters each; longer values throw with a clear error - [ ] Footer renders bottom-center, `position: fixed`, displaying `© {year} {holder}` - [ ] When `poweredBy` is true (default), the footer additionally renders `powered by Noorm` as an `` link below the copyright line @@ -68,7 +69,11 @@ Render configurable logo + title + subtitle in the upper-left and a fixed footer ## Change log - +### 2026-10-20 — One-line title and subtitle + +**What changed:** new criterion: the title and subtitle each stay on one line, cut with `…` past 200px and 240px, shrinking on narrow viewports so the block clears the toggles. + +**Why:** an unbounded subtitle could wrap the block or push it under the help toggle, and the search bars' clearances (graph-flow-search SC13) assume a one-row block. ## Implementation log diff --git a/docs/spec/dd-spotlight-grid.md b/docs/spec/dd-spotlight-grid.md index 683352e..f92d396 100644 --- a/docs/spec/dd-spotlight-grid.md +++ b/docs/spec/dd-spotlight-grid.md @@ -135,7 +135,7 @@ Two layout fixes to the Dictionary chrome, applying to BOTH lenses: - **Overlay anchoring is a requirement, not a hope.** After the restructure, `SpotlightOverlay` MUST still anchor every leader line to its card correctly under scroll and resize — the existing CP4/CP8 anchor-tracking assertions must still pass unchanged. (It measures viewport-space card rects, so absolute positioning should be unaffected, but this is a verified contract, not an assumption.) - **Search + lens toggle become a fixed top bar.** Move `.dict-search` (input) and `.dict-lens-toggle` (Read/Browse) OUT of the scrolled content into a fixed bar at the top of the viewport, rendered by `DictionaryView` (so it only appears in the dict view) but positioned `fixed`. The prior sticky-search bleed CSS (negative margins, `top: -72px`) is removed. `.dict-view` top padding is adjusted so the first cards clear the fixed bar. - **Frosted background.** The bar carries `backdrop-filter: blur(10px)` (+ `-webkit-` prefix) like `.branding-block`, PLUS a *semi-transparent* background-color (e.g. `color-mix(in srgb, var(--color-background) 75%, transparent)`, or an rgba) — NOT a fully opaque fill and NOT fully transparent. The semi-transparent fill keeps the content readable through the blur and keeps the bar's computed `background-color` non-`rgba(0,0,0,0)` (so the sticky-search test's non-transparent-background check still holds). - - **No collision with branding / theme toggle.** The bar is centered with a bounded width matching the content column (`max-width ≤ 1100px`, `margin: 0 auto`) so its edges never reach the branding block (fixed top-left) or the theme toggle (fixed top-right). Its z-index sits above the scrolled content but does not need to exceed the branding/toggle layer (`z-index: 50`) — branding and toggle remain on top and clickable. If the bounded-width approach still risks overlap at some widths, inset the bar's left past the branding and right past the toggle/FAB instead. + - **No collision with branding / help and theme toggles.** The bar is centered with a bounded width matching the content column (`max-width ≤ 1100px`, `margin: 0 auto`). Where the centring leaves too little room, its content is inset on the left past the branding block's measured width (`--branding-gutter`, published by `App.tsx`) and on the right past the help and theme toggles (96px from the edge), so the input, the Read/Browse lens and the `B` hint never sit under them. At 768px and below the bar's controls take their own row under the branding, and `.dict-view-inner`'s top padding grows to clear it. `.dict-view`'s `scroll-padding-top` matches that top padding, so anchor jumps land below the bar. Its z-index sits above the scrolled content but does not need to exceed the branding/toggle layer (`z-index: 50`) — branding and toggle remain on top and clickable. Proof: `test/checks/test-dict-search-branding-overlap.ts`, from 1920px to 375px, with the default branding and with a 50-character title and subtitle. - **Focus bar** (CP15) is a normal-flow element rendered as the first child of the scrolled content (not positioned), so the `.dict-view` top padding that clears the fixed search bar already pushes it below the bar. Just confirm the top padding is sufficient that the focus bar (and first cards) never sit under the fixed bar. - **Read-lens behavior unchanged**: the search input keeps the SAME React state binding and the CP-era debounce — CP9 highlight keys off the controlled state value (not the input's DOM location), and CP10 print still clears + restores it. The DOM move is purely presentational. @@ -204,6 +204,7 @@ CP18 (complete store population + dead-chip fix): the browse-grid Data Stores se - 2026-06-13 — DD chrome layout amendment (CP17). Two layout fixes: the scroll container moves from the 1100px-wide content box to the full viewport width (scrollbar at the window edge, not mid-page), with content centered in an inner max-width wrapper; the search input + Read/Browse toggle lift from sticky-inside-content to a fixed top bar with the frosted `backdrop-filter` background used by the branding/floating chrome, between branding and the theme toggle. Sticky-bleed CSS removed; read-lens behavior unchanged. - 2026-06-13 — Store-population fix amendment (CP18). A user spotted that `queue:OrderIntake` (a flow-referenced store with no `_stores/*.md` doc file) was missing from the browse grid's Data Stores section yet the spotlight still drew a dead chip to it. Root cause: the grid used the `bodyHtml`-filtered `allNonDbStores`. Fixed: the browse grid now shows EVERY non-`db` store referenced by the flows (full `storeByName`), so undocumented stores appear as compact cards; and the chip/line rendering enforces the absent-card guard so no dead chip ever renders. The earlier CP10/CP9 notes that said undocumented stores are "not a grid card" are superseded. Read lens unchanged. - 2026-06-13 — CP17 spec-review round: overlay anchoring restated as a verified requirement (not a remark); frosted bar pinned to semi-transparent bg + `backdrop-filter` (keeps the non-transparent-bg test valid; `.branding-block` is blur-only); collision rule made concrete (centered, bounded ≤1100px, branding/toggle stay on top at z-50); focus bar clarified as normal-flow first child (top padding handles clearance); CP17 verify assertions made falsifiable (clientWidth ≥ viewport−20px, backdrop-filter regex, focus-bar-below-search boundingBox check, anchor assertions still pass). A scale test (140-node synthetic model, hub pinned) confirmed always-on label pills pile up and that high-degree nodes on the arbitrary grid scatter their neighbors. Label-pill contract changed from always-on to **hover-reveal** (default hidden; revealed for the hovered connected card's connection(s); bundled pills collision-nudged). Added a **focus/isolate mode** contract: pinning + Focus collapses the rendered grid to the active node's neighborhood (cards removed, not dimmed), so lines stay few/short and nothing goes off-screen at any model size. CP14 (hover-reveal), CP15 (focus mode), CP16 (final review) added. +- 2026-10-20 — CP17 non-collision now states the delivered layout: the bar's content is inset past the measured branding width (the existing `--branding-gutter` indent) and past the help and theme toggles; at 768px and below its controls take their own row; `scroll-padding-top` keeps anchor jumps clear of the bar. Superseded: a centred bounded-width bar, with insets only as a fallback. Why: at ≤900px the Read/Browse lens ran under the help toggle. ## Implementation log diff --git a/docs/spec/graph-arrangement.md b/docs/spec/graph-arrangement.md new file mode 100644 index 0000000..e654b38 --- /dev/null +++ b/docs/spec/graph-arrangement.md @@ -0,0 +1,155 @@ +# Graph arrangement + + +## Goal + + +Hierarchical mode keeps each colour group in one block, a model edit keeps the user's arrangement, and the Graph view gains undo/redo, multi-node selection and moving, and hiding nodes from view. + + +## Non-goals + + +- Pinning nodes so a re-layout keeps them in place. +- Changing the organic layout below `ORGANIC_FALLBACK_THRESHOLD` (500 entities). At or above it organic mode already falls back to the layered path, so it gets the per-group blocks too. +- Undo history across page reloads. +- Hiding non-entity nodes (joiner diamonds, cluster boxes). + + +## Success criteria + + +- [x] Hierarchical layout (and the organic fallback at 500+ entities) runs ELK `layered` once per colour group on a headless mirror (`buildScratchCore(cy, true, hidden)`), orders the group blocks with `orderRegions` (a group others reference comes first; cycles broken by least unplaced incoming weight, then most outgoing, then id) and places them with `packRegions` (skyline, width for ~16:10, gap = layer spacing). Joiners go in their cluster's block; ungrouped nodes form one block. +- [x] `layout-store` entries carry an optional `hidden: string[]`; `save(key, positions, hidden = [])` omits it when empty; `hidden(key)` reads it; `previous(exceptKey, nodeIds)` returns, with its hidden list, the newest other entry whose shared ids are at least half of `nodeIds` and half of the entry's own entities; null for an empty key. +- [x] A load with no entry for its key and a `previous` entry applies those positions and hidden set, seats each node the entry lacks beside its placed neighbours (centroid, then the nearest clear ring spot; a row under the graph when it has none), saves under the new key, and runs no layout. +- [x] `createEditHistory(limit = 100)` in `graph-edits.ts`: `push(before)` clears redo; `undo(current)` / `redo(current)` swap snapshots; `canUndo` / `canRedo`. A snapshot holds positions, the hidden set, and the layout mode; restoring one whose mode differs reports it through `onLayoutModeChange`, which the shell stores in `ignatius-layout-mode`. Restoring re-places change-review ghosts. +- [x] A drag pushes the pre-drag snapshot once per gesture, only if a node moved. A multi-node drag is one undo step. +- [x] Reset layout and the layout-mode switch push the pre-layout snapshot (with the mode from before the switch) and save the result, hidden set included, instead of clearing the stored entry. Asked for before the first layout lands, they run after it. +- [x] An edit (a drag that moves a node, hide, show, redo) made while a Reset or mode switch is computing cancels that layout, so it never lands over the edit. A cancelled layout leaves no trace: the mode goes back to the one before it and its undo step is dropped. Undo during a pending layout cancels it and counts as that undo. A plain click does not cancel. Edits before the first layout lands are ignored. A second request while one is pending replaces it and keeps the first's undo step and earlier mode. +- [x] Select mode: panning off, `selectionType('additive')`; a click on a node toggles it and opens no modal. Shift+drag boxes in normal mode (cytoscape's default box selection). Focus tiers pin only when exactly one node is selected. +- [x] Right-click (mouse only; a two-finger touch tap is ignored) on an entity hides it, or the whole selection when it is part of one; `h` hides the selection. A hidden node has class `user-hidden` (`display: none`) and is unselected; a hover or pinned focus on it is cleared at once. A joiner whose members are all hidden is hidden too. Layouts exclude hidden nodes, those joiners, and clusters left empty; edge spans are graded over visible edges. +- [x] A node shown again keeps its spot unless that spot overlaps a visible node, in which case it is seated beside its neighbours. +- [x] Navigating to a hidden entity (search Enter, entity links, panel rows, `entity=` deep links) shows it first. +- [x] Shortcuts on the Graph, never while typing: `Cmd`/`Ctrl`+`Z` undo, `Cmd`/`Ctrl`+`Shift`+`Z` and `Ctrl`+`Y` redo (`Cmd`+`Y` is left to the browser), `s` select mode, `h` hide selection. +- [x] `GraphEditBar` above the zoom control, the same 130 px wide: undo and redo arrows (disabled when empty), the select-mode toggle (dashed box with pointer, `aria-pressed`), and a crossed-out eye with the hidden count (disabled at 0) that opens a "Hidden" popover: "Show all" in its header, an eye button per entity; the popover closes when its last entity is shown. Icons are Lucide stroke paths. Hidden in print and under 768px like the zoom control. +- [x] A selected entity carries a 3 px ring in the theme link blue (`semanticColors[mode].link`), offset 3 px from its border. Cluster frames and joiner diamonds are unselectable, so a box selection holds entities only. +- [x] Checks: `test-grouped-layered.ts` (incl. a headless `layoutByGroup` run), `test-graph-edits.ts`, `test-layout-store.ts` 7–8, `test-shortcuts.ts` T31–T32; Playwright `test/visual/test-graph-edits.ts`. + + +## Approach + + +Per-group layered layout packed into one picture, and an arrangement that carries over to the next layout key. See [`docs/design/graph-arrangement.md`](../design/graph-arrangement.md). + + +## Change tree + + +``` +src/app/ +├── App.tsx ................................ M (graphEdits state, GraphEditBar, shortcut callbacks, stored mode on undo) +├── styles.css ............................. M (.graph-edit-bar) +├── hooks/useKeyboardShortcuts.ts .......... M (onUndo, onRedo, onToggleSelectMode, onHideSelection) +├── logic/shortcuts.ts ..................... M (undo, redo, toggleSelectMode, hideSelection) +├── components/ui/ +│ ├── GraphEditBar.tsx ................... A +│ └── HelpModal.tsx ...................... M (move several, undo, hide rows; S, H, ⌘Z keys) +└── views/graph/ + ├── GraphView.tsx ...................... M (carry-over, hidden set, history, select mode) + ├── graph-edits.ts ..................... A (history, snapshot, state, LayoutMode types) + ├── grouped-layered.ts ................. A (orderRegions, packRegions, layoutByGroup) + ├── layout-store.ts .................... M (hidden, previous) + ├── organic-layout.ts .................. M (buildScratchCore exclude, joiner region) + └── styles.ts .......................... M (node.user-hidden) +test/checks/ +├── test-graph-edits.ts .................... A +├── test-grouped-layered.ts ................ A +├── test-layout-store.ts ................... M +└── test-shortcuts.ts ...................... M +test/visual/test-graph-edits.ts ............ A +docs/ +├── design/ +│ ├── graph-arrangement.md ............... A +│ └── graph-position-persistence.md ...... M (pointer to graph-arrangement) +├── guides/commands.md ..................... M (s, h, undo/redo keys) +├── spec/ +│ ├── graph-arrangement.md ............... A +│ ├── graph-position-persistence.md ...... M (carry-over, Reset saves) +│ ├── help-overlay.md .................... M (new Graph rows and keys) +│ └── keyboard-nav-shortcuts.md .......... M (s, h, undo/redo) +└── wiki/ + ├── feature-map.md ..................... M + └── frontend.md ........................ M +``` + + +## Outline + + +``` +grouped-layered.ts + orderRegions — group block order from cross-group FK weight + packRegions — skyline placement of block boxes + layoutByGroup — per-group ELK runs, then order and pack +graph-edits.ts + USER_HIDDEN_CLASS — class on a hidden node + GraphSnapshot — positions, hidden ids, layout mode + LayoutMode — organic, hierarchical, or tree + GraphEditState — what the edit bar draws + createEditHistory — undo/redo stacks, discardLast +layout-store.ts + hidden — stored hidden ids for a key + previous — newest other entry sharing half of each side +GraphView.tsx + GraphViewHandle — undo, redo, toggleSelectMode, hideSelection, showHidden + snapshot / persist / reportEdits — state capture, save, shell report + placeNewNodes — seat nodes beside placed neighbours + cancelPendingRelayout — drop a pending relayout, restore its mode, discard its undo step + readyForEdit — first layout landed; cancel any pending relayout + relayout — Reset and mode switch, with the undo step + hideNodes / showNodes / setSelectMode — the edits +GraphEditBar.tsx + GraphEditBar — undo, redo, select toggle, hidden list +``` + + +## Flows + + +**Hierarchical layout** + +1. `runLayout('hierarchical')` builds a mirror with one compound per group, leaving out hidden nodes. +2. `layoutByGroup` runs ELK `layered` on each group, orders the blocks, packs them. +3. Positions are applied to the live graph; a newer layout run discards a stale result. + +**Load after a model edit** + +1. No entry for the new key; `previous` finds the entry from before the edit. +2. Its hidden set is applied, then its positions; nodes it lacks are seated beside their neighbours. +3. The result is saved under the new key. + +**Hide and undo** + +1. User right-clicks `User`; the pre-hide snapshot is pushed, `User` gets `user-hidden`, the bar shows "1 hidden". +2. User presses `Cmd`+`Z`; the snapshot is restored and the current state becomes redoable. +3. Each change saves positions and the hidden set under the current key. + + +## Risks + + +| Risk | Likelihood | Mitigation | +|------|-----------|------------| +| Another model shares half the table names both ways and lends its arrangement | low | Reset layout starts fresh; the carry-over records no undo step, so Reset is the escape | +| Long cross-group edges in hierarchical mode | med | Inherent to block packing; block order puts referenced groups first to shorten them | +| Many hidden nodes shown at once after a re-layout crowd one spot | low | Each is placed in turn against the already placed ones, so none overlap | + + +## Change log + + +### 2026-10-20 — Tree is a third layout mode + +**What changed:** `LayoutMode` gains `tree` (see `docs/spec/graph-tree-layout.md`); a snapshot's mode may be any of the three. + +**Why:** the Tree layout shipped. diff --git a/docs/spec/graph-flow-search.md b/docs/spec/graph-flow-search.md index 4fcdda5..21fb0bf 100644 --- a/docs/spec/graph-flow-search.md +++ b/docs/spec/graph-flow-search.md @@ -39,6 +39,7 @@ Dim-don't-filter with pure matchers and per-view wiring, per `docs/design/graph- - SC10 — `bun run test` and `bunx tsc --noEmit` exit 0: all existing checks stay green and the new checks pass. New Playwright checks follow the existing skip-if-dist-absent pattern. - SC11 — Bundle-only: no file under `src/server/` or `src/generators/` changes, and the search code paths perform no network requests — live serve and static export share the identical code path by construction. - SC12 — Chrome non-collision on the Flows view: the search bar never overlaps the DFD breadcrumb chips, at any breadcrumb depth (proven against the 4-level `test/fixtures/flows-leveling` fixture) — same standing as the graph view's banner non-collision. +- SC13 — Chrome non-collision on the Graph view: at any viewport width from 375px up, the search bar and its count readout (up to "123 of 456") never overlap the branding block or the help and theme toggles, and stay inside the viewport. The bar is centred while that clears the branding; otherwise its left edge stays right of the measured `--branding-gutter` and its width stops short of the toggles and the count. At 1100px and below both the Graph and Flows bars compact: the switch label stacks onto two lines in smaller type and the input narrows. At 768px and below the Graph bar takes its own row under the branding (or under the global banner, which covers it) and its count drops below it. In every view the side panels start below the top-row search bar (and below the banner when it shows), so they never cover a bar or the count. Above 768px the Flows breadcrumbs start right of the measured branding block and wrap short of the toggles. Out of scope: the Flows results dropdown can still open under the side panels. The branding subtitle is capped at 240px like the title (200px), so the block stays one row tall; on narrow viewports both caps shrink so the block also stops short of the toggles. ## Checkpoints @@ -51,6 +52,7 @@ Dim-don't-filter with pure matchers and per-view wiring, per `docs/design/graph- | CP3 | Flow wiring: shell flow-search state, `searchTokens` threaded `FlowsView` → `FlowDiagramSvg` opacity rules, results dropdown + `selectDiagramById` navigation | `src/app/App.tsx`, `src/app/views/flow/FlowsView.tsx`, `src/flow-view/FlowDiagramSvg.tsx`, `src/app/components/flow/FlowSearchResults.tsx`, `src/app/styles.css`, `test/checks/test-flow-search.ts`, `test/visual/test-flow-search.ts` | SC6/SC7 asserted in the Playwright check, including a sub-DFD navigation; SC9's flow half asserted there (hash gains no search param; flow layout store unchanged) | | CP4 | `/` shortcut end-to-end: resolver action, `useKeyboardShortcuts` `onSearch`, shell focus routing, `DictionaryViewHandle.focusSearch()`, HelpModal search + `/` rows, guide/feature-map rows, keymap amendments to the shortcut and help-overlay specs (the repo requires those specs stay current with the keymap) | `src/app/logic/shortcuts.ts`, `src/app/hooks/useKeyboardShortcuts.ts`, `src/app/App.tsx`, `src/app/views/dict/DictionaryView.tsx`, `src/app/components/ui/HelpModal.tsx`, `test/checks/test-shortcuts.ts`, `docs/guides/commands.md`, `docs/spec/keyboard-nav-shortcuts.md`, `docs/spec/help-overlay.md`, `CLAUDE.md` | SC8 resolver cases green in `test-shortcuts.ts`; docs rows present; both keymap specs amended with change-log entries | | CP5 | Search-bar refinement: the description toggle becomes a labeled switch, the bar and dropdown get a visual tightening pass within the existing design language (theme tokens, radii, frosted chrome, focus ring, switch styling, count treatment, row hover states — verified by screenshots in both themes), Cmd/Ctrl+K joins `/` as a search-focus chord on all views, and the flow bar no longer collides with the DFD breadcrumb chrome | `src/app/components/ui/SearchBar.tsx`, `src/app/styles.css`, `src/app/logic/shortcuts.ts`, `src/app/components/ui/HelpModal.tsx`, `test/checks/test-shortcuts.ts`, `test/checks/test-graph-search.ts`, `test/checks/test-flow-search.ts`, `docs/guides/commands.md`, `docs/spec/keyboard-nav-shortcuts.md`, `CLAUDE.md` | SC5's switch semantics + SC8's Cmd/Ctrl+K cases asserted in the checks; SC12's non-collision asserted against the deep-nesting fixture; both-theme screenshots reviewed; docs rows updated | +| CP6 | Narrow-viewport Graph bar: left edge held right of the branding, width capped short of the toggles and the count, compact switch label and input at 1100px and below (Flows bar too), own row with the count below it and the side panels after it at 768px and below, subtitle capped | `src/app/styles.css`, `src/app/App.tsx`, `src/flow-view/FlowChrome.tsx`, `test/checks/test-viewer-search-branding-overlap.ts`, `test/chrome-geometry.ts` | SC13 asserted at 12 widths from 1920px to 375px with the default branding and a 50-character subtitle, a three-digit count, the Flows bar and breadcrumbs at 1440–375px, deep breadcrumbs (test/fixtures/flows-leveling) beside a 50-character subtitle at 1440–800px, and open side panels (models/broken-demo with and without its banner, test/fixtures/broken-flows-model) never covering a bar or the count; screenshots reviewed in both themes and in a static export | ## Change tree @@ -75,6 +77,9 @@ A test/checks/test-graph-search.ts — Playwright: dim/highlight/count/Ent A test/checks/test-flow-search.ts — Playwright: dropdown, sub-DFD navigation, in-diagram dim A test/visual/test-graph-search.ts — screenshot: graph search active A test/visual/test-flow-search.ts — screenshot: flow search active + dropdown +A test/checks/test-viewer-search-branding-overlap.ts — Playwright: Graph bar vs branding and toggles across widths +A test/chrome-geometry.ts — shared viewport settle, box and overlap helpers for the chrome checks +M src/flow-view/FlowChrome.tsx — breadcrumb row comment: the row wraps short of the toggles M docs/guides/commands.md — '/' keyboard shortcut row M docs/spec/keyboard-nav-shortcuts.md — '/' in the keymap/resolver contract + change-log entry M docs/spec/help-overlay.md — search rows in the view branches + change-log entry @@ -139,6 +144,12 @@ M CLAUDE.md — feature-map row (and '/' added to t ## Change log +### 2026-10-20 — SC13: Graph bar clears the branding on narrow viewports + +**What changed:** new SC13 and CP6. The Graph bar keeps its left edge right of `--branding-gutter`, with its width capped short of the help and theme toggles and the count readout. At 1100px and below the Graph and Flows bars compact (stacked switch label, narrower input). At 768px and below the Graph bar takes its own row under the branding, with its count below it. The side panels start below the top-row bar in every view; above 768px the Flows breadcrumbs start right of the measured branding block and wrap short of the toggles. The branding subtitle is capped at 240px. + +**Why:** user report: in an export embedded at ~800px the branding block painted over the search input. + ### 2026-09-18 — SC12 no longer names the nav card **What changed:** SC12 drops "or the diagram nav card"; the Flows view has no nav card (`docs/spec/large-model-nav.md`). A breadcrumb level menu drops over the search bar on purpose, since the crumb row stacks above it. diff --git a/docs/spec/graph-position-persistence.md b/docs/spec/graph-position-persistence.md index 7b3a49f..9a22574 100644 --- a/docs/spec/graph-position-persistence.md +++ b/docs/spec/graph-position-persistence.md @@ -4,17 +4,16 @@ ## Goal -Persist user-dragged graph node positions in the browser and restore them on reload — but only while the model's structure is unchanged. On any structural change, fall back to ELK auto-layout. Backend derives a structural `layoutKey`; frontend keys localStorage by it. +Persist user-dragged graph node positions in the browser and restore them on reload. Backend derives a structural `layoutKey`; frontend keys localStorage by it. A structural change carries the previous arrangement over (see [`graph-arrangement.md`](graph-arrangement.md)). ## Non-goals - Cross-browser / cross-machine sharing or committing arrangements to the repo. -- Multiple named layouts, or layout history/undo. +- Multiple named layouts. - IndexedDB (documented as a later migration trigger, not built now). - Changing zoom/pan persistence (the hash-router already owns that). -- Reconciling a changed structure onto old positions — re-layout instead. ## Success criteria @@ -26,8 +25,8 @@ Persist user-dragged graph node positions in the browser and restore them on rel - [ ] Static `graph` output injects `window.__LAYOUT_KEY__` (beside `window.__MODEL__`), and a static reload restores a previously-saved arrangement for the same model. - [ ] No hashing code ships in the frontend bundle — `App.tsx` only reads the key from the payload / window global. - [ ] Dragging a node and reloading (live and static) restores the dragged positions when the structure is unchanged. -- [ ] After a structural change, reload uses ELK auto-layout (saved positions for the old key are ignored, never partially applied). -- [ ] A FAB menu item ("Reset layout") clears the saved arrangement for the current key and re-runs auto-layout (re-fit included). +- [ ] After a structural change, reload applies the previous key's arrangement and places only the new nodes, as [`graph-arrangement.md`](graph-arrangement.md) specifies; with no previous arrangement for the model, ELK auto-layout runs. +- [ ] A FAB menu item ("Reset layout") re-runs auto-layout (re-fit included), saves the result under the current key, and is undoable. - [ ] Saved arrangements are pruned to the last N fingerprints so localStorage does not grow unbounded. - [ ] `bun run typecheck` and `bun run test` pass. @@ -39,14 +38,14 @@ Persist user-dragged graph node positions in the browser and restore them on rel |---|----------|--------|------|------| | A | Backend-derived structural-fingerprint key + frontend localStorage (chosen) | Pure `layoutFingerprint` in Bun; ship via payload + window global; FE save-on-drag / restore-on-layoutstop keyed by it | low | fingerprint must capture exactly the layout-determining inputs | | B | Frontend computes fingerprint | Hash fn in browser bundle | low-med | two definitions of "structural" can drift; bundle bloat | -| C | Per-node reconciliation on reload | Restore matched nodes, place new ones | high | fragile partial-restore states; the problem we designed out | +| C | Per-node reconciliation on reload | Restore matched nodes, place new ones | high | built later as the carry-over in `graph-arrangement.md` | | D | IndexedDB store | Async DB keyed by fingerprint | med | async ceremony in sync cy-init path; quota unused at this size | ## Recommendation -**A.** The fingerprint is a pure function of the `Model`, and both model-delivery paths (`/api/model` in `server.ts:79-82`, `generateGraph` injection in `generators/graph.ts:95`) originate in Bun — so derive once server-side, ship the key, keep the browser dumb. All-or-nothing restore (key match → restore all; mismatch → re-layout) removes reconciliation (C) entirely. localStorage over IndexedDB (D) because position data is ~25KB at 1000 entities vs a ~5MB budget, and localStorage is synchronous — fitting the synchronous `layoutstop` restore path. See `docs/design/graph-position-persistence.md` for the fingerprint contract and the IndexedDB migration trigger. +**A.** The fingerprint is a pure function of the `Model`, and both model-delivery paths (`/api/model` in `server.ts:79-82`, `generateGraph` injection in `generators/graph.ts:95`) originate in Bun — so derive once server-side, ship the key, keep the browser dumb. A key match restores every position; a mismatch carries the previous key's arrangement over and places only the new nodes (`graph-arrangement.md`). localStorage over IndexedDB (D) because position data is ~25KB at 1000 entities vs a ~5MB budget, and localStorage is synchronous — fitting the synchronous `layoutstop` restore path. See `docs/design/graph-position-persistence.md` for the fingerprint contract and the IndexedDB migration trigger. ## Checkpoints @@ -56,8 +55,8 @@ Persist user-dragged graph node positions in the browser and restore them on rel |---|------------|-------------|-------|------------|----------| | 1 | Pure `layoutFingerprint(model)` module (FNV-1a over sorted node ids + sorted `source>target` pairs) + unit tests for structural-vs-cosmetic sensitivity | new `src/layout-fingerprint.ts`, `test/checks/test-layout-fingerprint.ts` | atomic-builder | ~2 | `bun test/checks/test-layout-fingerprint.ts` green; same-topology→same-key, moved-edge→different-key, predicate/column/description edits→same-key | | 2 | Ship the key from both backends | `src/server.ts` (`/api/model` adds `layoutKey`), `src/generators/graph.ts` (inject `window.__LAYOUT_KEY__`), window typing | atomic-builder | ~3 | `bun run typecheck`; `/api/model` JSON has `layoutKey`; static graph.html contains `window.__LAYOUT_KEY__` | -| 3 | Frontend persistence: read key, save positions on drag-end (debounced), restore in the existing `layoutstop` block when the stored key matches, localStorage helper with last-N pruning | `src/App.tsx`, optional small `src/layout-store.ts` helper | atomic-builder | ~2 | Playwright visual check in `test/visual/`: drag node → reload → position restored; structural change → ELK layout | -| 4 | "Reset layout" FAB menu item — clears saved entry for current key, re-runs auto-layout + re-fit | `src/App.tsx` | atomic-surgeon | 1 | reset clears the arrangement and returns to auto-layout (visual check) | +| 3 | Frontend persistence: read key, save positions on drag-end (debounced), restore in the existing `layoutstop` block when the stored key matches, localStorage helper with last-N pruning | `src/App.tsx`, optional small `src/layout-store.ts` helper | atomic-builder | ~2 | Playwright visual check in `test/visual/`: drag node → reload → position restored; structural change → previous arrangement carried over | +| 4 | "Reset layout" FAB menu item — re-runs auto-layout + re-fit and saves the result under the current key | `src/App.tsx` | atomic-surgeon | 1 | reset returns to auto-layout (visual check) | ## Risks @@ -65,7 +64,7 @@ Persist user-dragged graph node positions in the browser and restore them on rel | Risk | Likelihood | Mitigation | |------|-----------|------------| -| Fingerprint omits a real layout-determining input → stale positions restored onto a shifted graph | low | Contract table in design doc; tests assert the in/out set; failure mode is benign (re-layout), not a broken graph | +| Fingerprint omits a real layout-determining input → stale positions restored onto a shifted graph | low | Contract table in design doc; tests assert the in/out set; Reset layout recovers | | `layoutstop` restore races ELK / fires before positions settle | low | Reuse the existing `cy.one('layoutstop', …)` seam (App.tsx:1220) already proven for hash zoom/pan restore | | Restoring positions skips `cy.fit`, leaving nodes off-screen | med | Decide fit behavior on restore (fit-to-restored-bounds) explicitly during checkpoint 3 | | localStorage growth across many structural revisions | low | Last-N pruning on save (checkpoint 3) | @@ -75,7 +74,13 @@ Persist user-dragged graph node positions in the browser and restore them on rel ## Change log - +### 2026-10-20 — Arrangement carries over; Reset saves instead of clearing + +**What changed:** A structural change now applies the previous fingerprint's arrangement and seats the new nodes beside their neighbours. Reset layout saves the new layout (with the hidden set) instead of deleting the entry, and pushes an undo step. Undo is no longer a non-goal; it is specified in `graph-arrangement.md`. + +**Why:** Losing every hand-placed node on each model edit was the main complaint on large models, and placing new nodes beside their neighbours removes the stranded-at-origin failure the all-or-nothing rule guarded against. + +**Superseded:** structural change → full ELK re-layout, never a partial restore; Reset clears the stored entry; layout history/undo as a non-goal. ## Implementation log diff --git a/docs/spec/graph-tree-layout.md b/docs/spec/graph-tree-layout.md new file mode 100644 index 0000000..f1c34ab --- /dev/null +++ b/docs/spec/graph-tree-layout.md @@ -0,0 +1,163 @@ +# Graph tree layout + + +## Goal + + +The Graph view gains a third layout mode, Tree: an indented outline where each table sits below and to the right of the table whose key it inherits, a tree or lookup parent is always above and left of its child, and every line leaves its parent's bottom edge and enters its child's left side. + + +## Non-goals + + +- Column lists or icons inside the boxes. +- Free-floating notes on the canvas. +- Changing the Organic or Hierarchical layouts. + + +## Success criteria + + +- [x] `LayoutMode` is `'organic' | 'hierarchical' | 'tree'`. `L` and the FAB item step organic → hierarchical → tree → organic (`nextLayoutMode`); the FAB item names the next mode. A stored `ignatius-layout-mode` that is not one of the three reads as organic (`parseLayoutMode`). +- [x] `buildForest(nodes, edges)` in `tree-layout.ts`: a node's parent is its deepest key-inheritance parent (ties by id); without one, a plain FK parent that is neither a classifier nor a hub (referenced without key inheritance by at least max(8, 10% of the nodes) tables). A cycle is cut at a non-key link, smallest id first. A childless, non-hub root referenced by some table is a lookup (`Forest.lookups`): it gets no tree parent and is assigned to one consumer that is not itself a lookup, same colour group first, then id; a lookup used only by other lookups stays a root. +- [x] `layoutTree(nodes, edges)` returns positions and a route per edge. Depth first; children smallest subtree first; a child's indent = lane fan + widest sibling label (6 px per character + 28 px), at least 28 px. Tree lanes fan across the left half of the parent's bottom edge from 10 px in, at most 6 px apart (every lane fan uses this gap) and squeezed to fit the half; a node whose half is 10 px or narrower sends them all down one trunk from its centre. The first child's lane is rightmost. +- [x] A tree's lookups form a staircase above it: each lookup's lanes drop from its bottom-left, fanned like tree lanes (at most 6 px apart, squeezed to fit the left half), and the next lookup starts right of them. The lookup feeding the deepest table is top-left, so a lookup's run into its deepest table crosses no other lookup's lane (a run into a shallower table may). The tree starts right of the last lane plus the widest lookup label run, below the staircase. A lookup joins only its assigned consumer's tree; its links into other trees, and into tables placed in a wrapped column (a run there would pass behind the boxes on that row), are cross links. +- [x] `routeTree(nodes, positions, lines)` routes `lines` (`TreeLine`: which lines are tree lines) at the given positions; `layoutTree` calls it on its own result. A tree line stays `down` only while its child sits below its parent and right of its lane (otherwise `cross`). `down` lanes (`laneDx` from the parent's left edge) fan across the left half, the topmost child on the rightmost lane, at most 6 px apart. Every `cross` route has a `slot` (lane from the parent's right edge, street below its bottom, approach column left of the child): per parent, the farthest child takes the outer lane and top street (so lines to children right of and below the parent nest; lines to children left or above can cross inside the street band), each line its own lane fanned across the right half and its own street spread 10–16 px below the bottom (at most 2 px apart). Rows are 20 px apart. Lanes on both halves stop 2 px short of the centre. Ties break by edge id. +- [x] Into each child: cross links from above enter highest, then `down` lines from the rightmost lane to the leftmost, then cross links from below; `entryDy` at most 6 px apart and within the child's height. Approach columns follow that order (12 px left of the child, 6 px apart): from above, the further-left column enters lower; from below (numbered after those from above), higher. No two last turns into one child cross. +- [x] Trees are stacked into columns of height √(area / 1.6), counting lookup staircases. Order: `orderRegions` over colour groups by cross-tree links, then over the trees in each group, so a referenced tree comes first. +- [x] Wrapping measures from the top of the tree, below its lookup staircase, with the column height as the limit; a child "fits" when its unwrapped subtree height (`stackHeight`) does. A parent whose subtree would run past the limit keeps its first child and those that fit under it; the rest wrap into columns to its right (80 px apart), each starting at the parent's first child row, 20 px below its bottom edge, and their lines are cross links. Children wrap only when a fresh column has room below that row for two of the parent's child rows (tallest child box + 20 px each); otherwise they stay under it and the tree runs past the column. A subtype joiner (`joiner`) never wraps: it stays under its basetype and goes first among its siblings when they wrap, and its members wrap instead. +- [x] `routeGeometry(route, parentBox, childBox)` gives the line drawn now: its parent end on the parent's bottom edge, its child end on the child's left edge, and the bends between. `down`: one bend at (lane, entry row). While a drag moves a `down` route's child out of place, the line is drawn with cross geometry; when the drag ends the graph re-routes. `cross`: bends at (lane, street) twice, (approach, street), (approach, entry row). +- [x] In Tree mode every routed edge is a `segments` edge (`edge-distances: node-position`) with its ends pinned through `source-endpoint`/`target-endpoint` offsets. A line drawn `down` carries its label as the `target-label` (`source-label` when the edge runs child→parent), horizontal, 9 px above the run, halfway along it. A line drawn `cross` carries its mid label centred on the street. Bends are recomputed whenever a node moves. After a hide, a reveal, an undo or a drag the graph calls `routeTree` on the boxes as they stand, keeping which lines the last Tree layout drew as tree lines, so no line reorders. +- [x] Leaving Tree mode removes the routing classes (`tree-edge`, `tree-label-source`, `tree-cross`) and only the routing style bypasses (`segment-weights`, `segment-distances`, `source-endpoint`, `target-endpoint`, `source-text-offset`, `target-text-offset`). Bypasses survive a stylesheet swap, so a retheme does not re-route. Undoing a pending switch restores the mode before the routing is re-applied. Routing follows the mode the on-screen positions belong to, so a switch still computing keeps the old routing. +- [x] Crow's-foot markers take each end's angle from the adjacent bend point on segmented edges; with pinned ends that is vertical at the parent and horizontal at the child. +- [x] Checks: `test-tree-layout.ts` (including chained lookups, wrapped children starting a row below their parent and overlapping no box, lookup lines into wrapped tables as cross links, a lookup feeding only wrapped tables, a deep chain under 3:1, a parent with room for under two rows keeping its children and one with room for two wrapping, a joiner going first under a wrapping basetype and keeping its place when nothing wraps, two clusters on one basetype, `routeGeometry` fallback, no crossing last turns, distinct lanes and streets), `test-graph-edits.ts` case 6; Playwright `test/visual/test-graph-edits.ts` steps 6a (right-angled within 0.5 px before and after a drag) and 6a2 (undo during a pending switch keeps the routing). + + +## Approach + + +Depth-first outline; lookups in a staircase above each tree; every line routed bottom-to-left as `segments`. See [`docs/design/graph-tree-layout.md`](../design/graph-tree-layout.md). + + +## Change tree + + +``` +src/app/ +├── App.tsx ................................ M (parseLayoutMode, nextLayoutMode) +├── components/ui/ +│ ├── FabMenu.tsx ........................ M (next mode label) +│ └── HelpModal.tsx ...................... M (Tree in Layouts and L rows) +└── views/graph/ + ├── GraphView.tsx ...................... M (tree branch in runLayout, edge routing) + ├── graph-edits.ts ..................... M (tree mode, nextLayoutMode, parseLayoutMode) + ├── markers.ts ......................... M (angle from adjacent bend) + ├── styles.ts .......................... M (tree-edge, tree-cross classes) + └── tree-layout.ts ..................... A +scripts/perf-harness.ts .................... M (--mode tree) +test/checks/ +├── test-graph-edits.ts .................... M +├── test-keyboard-shortcuts.ts ............. M (L steps with nextLayoutMode) +└── test-tree-layout.ts .................... A +test/visual/test-graph-edits.ts ............ M +docs/ +├── design/graph-tree-layout.md ............ A +├── guides/commands.md ..................... M +├── spec/ +│ ├── graph-arrangement.md ............... M (LayoutMode includes tree) +│ ├── graph-tree-layout.md ............... A +│ ├── help-overlay.md .................... M +│ └── keyboard-nav-shortcuts.md .......... M +└── wiki/ + ├── feature-map.md ..................... M + ├── frontend.md ........................ M + ├── index.md ........................... M (Tree engine in the stack line) + └── scripts.md ......................... M (--mode tree) +``` + + +## Outline + + +``` +tree-layout.ts + TreeNodeInput / TreeEdgeInput — size, group, classifier, joiner; parent→child edges + CrossSlot — lane, street and approach of the cross geometry + TreeRoute — down or cross; lane, street, approach and entry offsets + Box / RouteGeometry — a node's box; the ends, bends and label of a line drawn now + routeGeometry — a route's line between two boxes, falling back to cross geometry + buildForest — tree parents, hubs, cycle cuts, lookups and their consumers + layoutTree — depth-first rows, lanes, wrapping wide parents, lookup staircases, ordering, column packing + TreeLine — an edge to route and whether the last layout drew it as a tree line + routeTree — lanes, slots, entries and approach columns at given positions + crossSlots — a lane and street per cross link from each parent + assignEntries — entry rows and approach columns on each child's left side +graph-edits.ts + LayoutMode — organic, hierarchical, tree + nextLayoutMode / parseLayoutMode / LAYOUT_MODE_NAME — L cycle and stored mode +GraphView.tsx + treeInput — visible nodes and edges for the layout + applyEdgeRouting — tree-edge / tree-label-source / tree-cross classes for the positions' mode + routeTreeEdge — down and cross bends as segment weights and distances +markers.ts + updateMarkers — angle from the adjacent bend +``` + + +## Flows + + +**Switch to Tree** + +1. User presses `L` on Hierarchical; the mode is stored as `tree`. +2. `runLayout('tree')` builds the forest from the visible graph and applies `layoutTree` positions. +3. The layout settles; `applyEdgeRouting` marks tree, lookup and cross lines and sets every routed edge's bends. + +**Drag in Tree** + +1. User drags a table. +2. Each `position` event re-routes the dragged node's connected edges from their stored offsets. + + +## Risks + + +| Risk | Likelihood | Mitigation | +|------|-----------|------------| +| Cross-link labels pile up where several streets pass the same area | med | They are de-emphasised by span grading and hidden at overview zoom like every label | +| Cross links run behind unrelated boxes (no obstacle avoidance; most cross links on a real model pass at least one) | high | Lines are drawn under boxes, so a line never covers a table; the crow's feet mark the true ends | +| Cross links into stacked siblings can share an approach column, and long streets can run along other lines | med | Lanes, streets and columns are distinct per parent and per child; across them there is no global channel routing | +| A busy parent's lines (a hub such as `users`) fan into lanes and streets under 4 px apart, which the 3 px edge casing draws as one band | med | Lanes and streets stay distinct in position; hiding the hub removes the band | +| The hub threshold misses a hub or catches a real parent | low | Key-inheritance parents are never excluded; the user can hide a hub | +| A deep chain whose last child wraps at every level steps right one column per level, wider than 16:10 (the 120-entity synthetic model lays out at w/h 2.25) | med | Every line still runs down and right; check 6i pins a 9-level chain under 3:1 | +| A second subtype cluster on the same basetype, or a cluster under a basetype without room for two rows below it, runs past the column | med | Under a basetype whose children wrap, the joiner goes first, so one cluster gets the column's room and its members wrap; check 7g pins that case above 0.8 w/h | + + +## Checkpoints + + +| # | Checkpoint | Files/areas | Agent | Est. files | Verifies | +|---|------------|-------------|-------|------------|----------| +| 1 | Forest and placement: tree parents, hubs, cycle cuts, lookups and their staircase, depth-first rows and lanes, column packing | `src/app/views/graph/tree-layout.ts`, `test/checks/test-tree-layout.ts` | main | 2 | `test-tree-layout.ts` cases 1–5 | +| 2 | Wrapping wide parents into columns one row below them, the two-row room rule, joiners first, lookup lines into wrapped tables as cross links | `src/app/views/graph/tree-layout.ts`, `test/checks/test-tree-layout.ts` | main | 2 | `test-tree-layout.ts` cases 6a–7j | +| 3 | Routing: tree and cross lines, lanes, streets, entry rows and approach columns, re-routing on the boxes as they stand | `src/app/views/graph/tree-layout.ts`, `test/checks/test-tree-layout.ts` | main | 2 | `test-tree-layout.ts` cases 8–10 | +| 4 | Tree mode in the Graph view: `L` cycle, segments edges with pinned ends, re-route after hide, reveal, undo and drag, marker angles | `GraphView.tsx`, `graph-edits.ts`, `styles.ts`, `markers.ts`, `App.tsx`, `FabMenu.tsx`, `HelpModal.tsx`, `scripts/perf-harness.ts` | main | 8 | `test-graph-edits.ts` case 6, `test-keyboard-shortcuts.ts`, Playwright `test/visual/test-graph-edits.ts` steps 6a, 6a2, 6b | +| 5 | Surfaces: design, spec, guide, help overlay, keymap, wiki | `docs/design/graph-tree-layout.md`, `docs/spec/*.md`, `docs/guides/commands.md`, `docs/wiki/*.md` | main | 10 | Feature-map row and every surface describe the same behaviour | + + +## Change log + + +### 2026-10-20 — Correction: checkpoints table + +**What changed:** added the `## Checkpoints` table; the contract it lists was already in the success criteria. + +**Why:** `atomic validate spec` failed S5 (missing `## Checkpoints`). + +### 2026-10-20 — Wide parents wrap into columns + +**What changed:** a parent whose children would run past the column keeps those that fit and wraps the rest into columns to its right, each starting one row below it, with cross links. Children wrap only when a fresh column has room for two of the parent's child rows. A subtype joiner goes first among its siblings when they wrap. A lookup's line into a table in a wrapped column is a cross link. + +**Why:** split-off subtrees were ordered as trees of their own and could land above or left of their parent, so the line between them ran up (on orm-pure, `Identity` sat 316 px above `Party`). + +**Superseded:** a tree taller than a column became trees of their own, and a joiner shed the members that did not fit. diff --git a/docs/spec/help-overlay.md b/docs/spec/help-overlay.md index 4b81a48..4ddd58f 100644 --- a/docs/spec/help-overlay.md +++ b/docs/spec/help-overlay.md @@ -22,10 +22,10 @@ primitive, view-switched on `ViewName`, with static term→description content; ## Success criteria - [x] `HelpModal` (`src/app/components/ui/HelpModal.tsx`) renders on the shared `Modal` primitive with `className="help-modal"`, switches body content on `view: ViewName`, and titles per view ("About the Graph/Dictionary/Flows"). -- [x] Graph body covers: an ER-diagram intro, the five entity types (Independent, Dependent, Subtype, Associative, Classifier), how-to-explore (layouts, Shift+hover lineage, click/drag/zoom, search — term matching with a body-text toggle, Enter cycling through matches), and the key-inherited vs surrogate distinction. +- [x] Graph body covers: an ER-diagram intro, the five entity types (Independent, Dependent, Subtype, Associative, Classifier), how-to-explore (layouts — Hierarchical as one block per colour group, Tree as an outline with right-angled lines, Shift+hover lineage, click/drag/zoom with positions kept across model edits, moving several via box select and select mode, undo/redo, hiding entities and the hidden list, search — term matching with a body-text toggle, Enter cycling through matches), and the key-inherited vs surrogate distinction. - [x] Dictionary body covers: Read/Browse lenses, spotlight, Shift+hover lineage, search/focus. - [x] Flows body covers: a DFD intro, symbols (process/store/external), drill-down + inspect, the flow index (Process Flows chip or `I`), breadcrumb level switching (▾), cross-diagram search (results grouped by diagram, non-matches dimmed in the rendered diagram). -- [x] A Keyboard section is present on every view and tailored to it (only the keys active there), including `/` to focus that view's search input; Graph and Flows footnote a pointer to the Legend. +- [x] A Keyboard section is present on every view and tailored to it (only the keys active there), including `/` to focus that view's search input and, on the Graph, `S`, `H`, and `⌘/Ctrl Z` · `⌘/Ctrl ⇧ Z`; Graph and Flows footnote a pointer to the Legend. - [x] A `?` top-bar button sits just left of the theme toggle (shared chrome, all views), opens the overlay, and is hidden in `@media print`. - [x] `resolveShortcut` returns `{ type: 'help' }` for `?`: resolved after the editable guard, before the bare-key modifier guard (Shift is inherent), and gated off ctrl/meta/alt. `useKeyboardShortcuts` carries an `onHelp` callback; the shell opens the overlay. - [x] Editable guard holds: typing `?` in the search box inserts a literal `?` and does NOT open the overlay. @@ -68,3 +68,15 @@ primitive, view-switched on `ViewName`, with static term→description content; **What changed:** the Flows "How to explore" section gained a Flow index row (Process Flows chip or `I`, hover for descriptions, click to open) and a Switch levels row (the breadcrumb ▾ lists the other diagrams at that level). The Flows Keyboard section lists `I`. **Why:** the flow index and breadcrumb level menus shipped (`docs/spec/large-model-nav.md`); the overlay lists what a first-time viewer can do and the keys that work on each view. + +### 2026-10-20 — Graph arrangement rows + +**What changed:** the Graph "How to explore" section gained Move several, Undo, and Hide rows; the Layouts row says Hierarchical gives each colour group its own block; the click/drag row says positions survive model edits. The Graph Keyboard section lists `S`, `H`, and `⌘/Ctrl Z` · `⌘/Ctrl ⇧ Z`. + +**Why:** the graph arrangement edits shipped (`docs/spec/graph-arrangement.md`); the overlay lists what a viewer can do and the keys that work on each view. + +### 2026-10-20 — Tree layout in the Layouts and L rows + +**What changed:** the Graph Layouts row describes the Tree layout and says `L` steps through the three layouts; the Keyboard row for `L` reads "Step through the graph layouts (Organic → Hierarchical → Tree)". + +**Why:** the Tree layout shipped (`docs/spec/graph-tree-layout.md`). diff --git a/docs/spec/keyboard-nav-shortcuts.md b/docs/spec/keyboard-nav-shortcuts.md index a5466b7..c7b39a8 100644 --- a/docs/spec/keyboard-nav-shortcuts.md +++ b/docs/spec/keyboard-nav-shortcuts.md @@ -3,8 +3,8 @@ ## Goal Plain-key keyboard shortcuts in the unified SPA for the three high-frequency -navigation toggles: jump between Graph/Dictionary/Flows views, toggle the DG -layout (organic ↔ hierarchical), toggle the DD lens (read ↔ browse). Bare-key +navigation toggles: jump between Graph/Dictionary/Flows views, step the DG +layout (organic → hierarchical → tree), toggle the DD lens (read ↔ browse). Bare-key shortcuts never fire while typing or under a modifier chord. Implements issue #13. @@ -29,14 +29,16 @@ distinct guard class from the bare keys (see Keymap). | `g` | view → `graph` | any view | | `d` | view → `dict` | any view | | `f` | view → `flow` | any view | -| `l` | toggle DG layout `organic ↔ hierarchical` | `view === 'graph'` | +| `l` | step the DG layout `organic → hierarchical → tree → organic` | `view === 'graph'` | | `b` | toggle DD lens `read ↔ browse` | `view === 'dict'` | | `i` | open or close the flow index | `view === 'flow'` | +| `s` | toggle graph select mode | `view === 'graph'` | +| `h` | hide the selected graph nodes | `view === 'graph'` | | `/` | focus the active view's search input (graph bar, flow bar, or the Dictionary's search box) | any view | Bare keys bail (no action) when: `ctrlKey || metaKey || altKey || shiftKey`, OR focus is an editable target (`input` / `textarea` / `select` / -`contenteditable` / inside an open `.modal`). `l`, `b`, and `i` resolve to no action +`contenteditable` / inside an open `.modal`). `l`, `b`, `i`, `s`, and `h` resolve to no action when their view is not active. View jumps are idempotent. `/` needs no Shift (unlike `?`), so it resolves through the ordinary bare-key switch with no special guard slot; typing `/` inside any editable target inserts the literal @@ -75,6 +77,17 @@ Suppressed in editable context (typing a literal `?` in the search box never opens the overlay). The overlay content/component is owned by `docs/spec/help-overlay.md`; this resolver owns only the key binding. +**Graph undo/redo** — resolved *after* the editable guard (typing keeps the +native text undo) and *before* the bare-key modifier guard, because the chords +need `ctrl`/`meta`: + +| Chord | Action | Active when | +|-------|--------|-------------| +| `Cmd`/`Ctrl` + `z` | `undo` | `view === 'graph'`, not while typing | +| `Cmd`/`Ctrl` + `Shift` + `z`, `Ctrl` + `y` | `redo` | `view === 'graph'`, not while typing | + +`alt` held → null. `Cmd` + `y` → null: it is the browser's History shortcut on macOS. What undo covers is owned by `docs/spec/graph-arrangement.md`. + **Arrow-key panning** — resolved in the same slot as `?` (after the editable guard, before the bare-key modifier guard), because Shift is the step multiplier here, not a suppressor: @@ -194,3 +207,17 @@ real-browser Playwright check, per the project's "test the actual runtime" lesso **What changed:** `resolveShortcut` returns `{ type: 'flowIndex' }` for bare `i` on the flow view and `null` on Graph and Dictionary; it resolves in the ordinary bare-key switch, after both guards, like `l` and `b`. `useKeyboardShortcuts` carries an `onFlowIndex` callback; the shell routes it to `FlowsViewHandle.toggleIndex()`. `test-shortcuts.ts` T30 covers the flow-only binding, capslock, the editable guard, and every modifier. **Why:** the flow index (`docs/spec/large-model-nav.md`) is the way into a model with many flows; a key keeps it one stroke away. + +### 2026-10-20 — Graph undo/redo, select mode, hide + +**What changed:** `resolveShortcut` returns `undo` for `Cmd`/`Ctrl`+`z` and `redo` for `Cmd`/`Ctrl`+`Shift`+`z` or `Ctrl`+`y` on the graph, after the editable guard and before the modifier guard; bare `s` → `toggleSelectMode` and `h` → `hideSelection` on the graph only. `useKeyboardShortcuts` carries `onUndo`, `onRedo`, `onToggleSelectMode`, `onHideSelection`; the shell routes them to the `GraphViewHandle`. `test-shortcuts.ts` T31–T32. + +**Why:** the graph arrangement edits in `docs/spec/graph-arrangement.md` need keyboard routes, and the undo chord must leave text undo alone in inputs. + +### 2026-10-20 — `l` steps through three layouts + +**What changed:** `l` (and the FAB layout item) steps organic → hierarchical → tree → organic instead of toggling two modes; the resolver is unchanged, the shell's toggle uses `nextLayoutMode`. + +**Why:** the Tree layout shipped (`docs/spec/graph-tree-layout.md`). + +**Superseded:** `l` toggled organic ↔ hierarchical. diff --git a/docs/wiki/feature-map.md b/docs/wiki/feature-map.md index 65b51f8..fc60c30 100644 --- a/docs/wiki/feature-map.md +++ b/docs/wiki/feature-map.md @@ -32,7 +32,9 @@ Paths are relative to [`docs/design/`](../design), [`docs/spec/`](../spec), [`do | Keyboard navigation shortcuts (g/d/f view switch, l DG layout, b DD lens, `/` or `Cmd`/`Ctrl`+`k` search-focus, arrow-key canvas pan on graph/flow — 5px per keydown, 25px with Shift, viewport-movement `{type:'pan',dx,dy}` action routed to `panBy(dx,dy)` on both view handles, dict/editable/ctrl-meta-alt → null; pure resolver + global hook; editable/modifier guards, `Cmd`/`Ctrl`+`k` resolved in the same pre-editable slot as the zoom chords, arrows in the same post-editable/pre-modifier slot as `?` since Shift is their step multiplier) | keyboard-nav-shortcuts | keyboard-nav-shortcuts | commands | — | | Help overlay (view-aware orientation modal — "what am I looking at?"; `HelpModal` on the shared `Modal`, switched on `ViewName`; concise term→desc rows: Graph = entity types + layouts + Shift lineage + key-inherited vs surrogate; Dict = lenses + spotlight + Shift lineage + search/focus; Flow = DFD symbols + drill-down/inspect; per-view Keyboard section; footnote to Legend on Graph/Flow. Opened by a top-bar `?` button left of the theme toggle AND the `?` key — `resolveShortcut` returns `{type:'help'}`, resolved after the editable guard but before the bare-key modifier guard since `?` needs Shift, gated off ctrl/meta/alt; `useKeyboardShortcuts` `onHelp`; editable guard keeps `?` inert while typing. Distinct from the symbol `LegendModal`. Tests: `test-shortcuts.ts` T16 + [`test/checks/test-help-overlay.ts`](../../test/checks/test-help-overlay.ts) Playwright) | help-overlay | help-overlay, keyboard-nav-shortcuts | commands | — | | Graph and Flows search (dim-don't-filter title-first matching with a per-bar body-text toggle; Graph: `search-match`/`search-dim` cytoscape classes, `n of N` count readout, Enter cycles ascending-id matches wrapping via `navigateToEntity`, survives hover/lineage/relayout/SSE refresh; Flows: `searchFlowDiagrams` walks every non-synthetic diagram incl. sub-DFDs, results dropdown grouped by diagram navigates via `selectDiagramById`, in-diagram dim keys off suffix-stripped base token; `/` focuses the active view's search bar (Dictionary via `DictionaryViewHandle.focusSearch()`, unchanged otherwise); bundle-only, no model/layout-store/hash/export-payload writes) | graph-flow-search | graph-flow-search | commands | — | -| Graph node position persistence (drag-to-save, reset) | graph-position-persistence | graph-position-persistence | — | — | +| Graph node position persistence (drag-to-save, reset; a structural change carries the arrangement over — see Graph arrangement) | graph-position-persistence, graph-arrangement | graph-position-persistence, graph-arrangement | — | — | +| Graph arrangement (hierarchical layout one block per colour group — per-group ELK `layered`, `orderRegions` + skyline `packRegions` in [`grouped-layered.ts`](../../src/app/views/graph/grouped-layered.ts); arrangement + hidden set carry over to a new layout key via `layout-store` `previous`, new nodes seated beside neighbours; undo/redo history of position + hidden snapshots (`graph-edits.ts`, `Cmd`/`Ctrl`+`Z`, `Cmd`/`Ctrl`+`Shift`+`Z`, `Ctrl`+`Y`); select mode `s` + Shift+drag box select, group drag; hide by right-click or `h`, `GraphEditBar` "N hidden" list, layouts exclude hidden nodes) | graph-arrangement | graph-arrangement, keyboard-nav-shortcuts, help-overlay | commands | — | +| Graph tree layout (third layout mode, `L` steps organic → hierarchical → tree; outline where each table sits below and right of its key-inheritance parent, deepest parent wins, hubs and classifiers never fallback parents; lookups in a staircase above the tree they serve; every line leaves its parent's bottom edge and enters its child's left side — lane-separated `down` lines, `cross` links via a street under the parent and an approach column; referenced trees placed first; re-routed on drag; a wide parent's children wrap into columns to its right; [`tree-layout.ts`](../../src/app/views/graph/tree-layout.ts)) | graph-tree-layout | graph-tree-layout, keyboard-nav-shortcuts, help-overlay | commands | — | | Business-narrative body + existence/cascade rules | markdown-driven-erd | ignatius-modeling-skill | modeling-skill | entity-flow E9, templates (body sections) | | Entity body wiki-links `[[Entity]]` | wiki-entity-links | wiki-entity-links, schema-lint-and-error-ux | folder-format | entity-flow E9 (body authoring) | | The modeling skill itself | ignatius-modeling-skill | ignatius-modeling-skill | modeling-skill | SKILL + all references | diff --git a/docs/wiki/frontend.md b/docs/wiki/frontend.md index acf5c9b..a020078 100644 --- a/docs/wiki/frontend.md +++ b/docs/wiki/frontend.md @@ -180,7 +180,7 @@ A removed entity exists only as a ghost, so clicking it with the overlay off tur |---|---| | [`src/app/App.tsx`](../../src/app/App.tsx) | State, view-switch, modal hosting, composition. Owns `openEntityById` (with `fromFlow` flag), `modelIndex`/`modelIndexRef` useMemo/ref pair, `appErrorsByEntityId` Map, `appAllFlowNodeIds`, `entityUsageIndex` useMemo, and `pendingScrollProcessIdRef` for dict process-scroll (its `scrollIntoView` call uses `scrollBehaviorWithin(el)` from [`src/app/logic/motion.ts`](../../src/app/logic/motion.ts), not a hardcoded `'smooth'`). `dictViewRef` (`DictionaryViewHandle`) and `handleToggleLayoutMode` are shared between the FAB button and the keyboard shortcut `l`. `showHelp` boolean state wires the `?` top-bar button (`.help-toggle`) and `onHelp` passed to `useKeyboardShortcuts`; renders `HelpModal` when true. Wires `onFlowIndex: () => flowsViewRef.current?.toggleIndex()` for the `i` shortcut (flow view only). Owns `flowView`/`collapseLevel` state (seeded hash → localStorage → default `per-process`/`clusters`), `handleToggleFlowView`/`handleCycleCollapseLevel`, and `writeFlowViewHash` (a `history.replaceState` write beside the existing hash params, mirroring how `useHashRoute` writes `view`); both are threaded as props into `FlowsView` and `FabMenu`. **Keyboard pan:** `handleKeyboardPan(dx, dy)` routes the resolver's `{type:'pan'}` action to the active canvas — `graphViewRef.current?.panBy(dx, dy)` on graph, `flowsViewRef.current?.panBy(dx, dy)` on flow, no-op on dict. Interacts with `GraphView`, `DictionaryView`, and `FlowsView` exclusively through typed imperative handles (`GraphViewHandle`, `DictionaryViewHandle`, `FlowsViewHandle`). **Change review:** destructures `diff`/`refreshDiff` from `useModelData`; owns `changeOverlay` (default on) and `changesCollapsed`; derives `shownChanges`/`shownEntityChanges`; passes `changeReview={shownChanges}` to `GraphView`, `DictionaryView`, and `FlowsView`, and `changes={shownEntityChanges?.get(selected.id)}` to `EntityModal`; hosts the parked-target refs and `handleChangeEntity`/`handleChangeDiagram` (see How it works). `ChangesPanel` and `FindingsPanel` render inside one `.side-panel-stack` div, the Changes panel only when `diff` is non-null. | | [`src/app/App.tsx`](../../src/app/App.tsx) (cross-view search) | The shell owns per-surface search state that survives view switches. Graph search: `graphSearchTerm`/`graphSearchIncludeBody` plus `graphSearchCursorRef` (Enter-to-cycle cursor, reset on term/toggle change); `graphSearchMatches` is `null` when inactive or a `Set` (possibly empty) when active — `entityMatches` from [`src/app/logic/search.ts`](../../src/app/logic/search.ts) runs over `model.nodes`. Flow search: `flowSearchTerm`/`flowSearchIncludeBody`; `flowSearchResults` calls `searchFlowDiagrams`; `flowSearchTokens` is threaded into `FlowsView`'s `searchTokens` prop. Neither graph nor flow search state touches the model, layout fingerprint, layout-store, or URL hash. `bannerRef` measures the global-error banner's rendered height via `ResizeObserver` and writes it into the `--search-bar-top` CSS custom property so `SearchBar` sits below the banner. | -| [`src/app/App.tsx`](../../src/app/App.tsx) (branding gutter, fix 2026-08-01) | The branding block is `position: fixed`, out of document flow, so no stylesheet can know how much room to leave for it. A `brandingRef`-measuring `useEffect` publishes the block's measured width as the `--branding-gutter` CSS custom property (via `ResizeObserver`, re-published on `[branding, logoSrc]` change since a theme swap can change logo width) so the Dictionary view's full-bleed search bar can indent past it below ~1920px viewport width, where the two previously shared a row and the z-50 branding block painted over the z-30 search input. [`src/app/styles.css`](../../src/app/styles.css)'s `.dict-search-bar-inner` computes `padding-left: max(2rem, calc(16px + var(--branding-gutter, 0px) + 12px - var(--dict-bar-left-slack)))`. | +| [`src/app/App.tsx`](../../src/app/App.tsx) (branding gutter, fix 2026-08-01) | The branding block is `position: fixed`, out of document flow, so no stylesheet can know how much room to leave for it. A `brandingRef`-measuring `useEffect` publishes the block's measured width as the `--branding-gutter` CSS custom property (via `ResizeObserver`, re-published on `[branding, logoSrc]` change since a theme swap can change logo width) so the top-row search bars can clear it, since the z-50 branding block otherwise paints over them. [`src/app/styles.css`](../../src/app/styles.css)'s `.dict-search-bar-inner` computes `padding-left: max(2rem, calc(16px + var(--branding-gutter, 0px) + 12px - max(0px, (100vw - 1100px) / 2)))` and the matching `padding-right` past the help and theme toggles (96px from the edge). The Graph `.viewer-search-bar--graph` holds its left edge right of the gutter with a `translateX(max(-50%, …))` clamp and caps its width short of the toggles and its count readout; at 1100px and below the Graph and Flows bars compact. At 768px and below the Dictionary and Graph bars take their own row under the branding. `.side-panel-stack` starts below the top-row bar in every view (`--search-bar-top`, `--flow-search-bar-top`), and above 768px `.flow-crumbs` starts right of the gutter and wraps short of the toggles. `:root` names the clearances: `--toggle-region` (96px, the help and theme toggles), `--below-branding` (68px, the row under the branding) and `--dict-bar-clearance` (76px; 136px at 768px and below); `.side-panel-stack` sets `--panel-top` per breakpoint. `.branding-subtitle` is capped at 240px like the title (200px), so the block stays one row tall; both caps are `min(…, 100vw - 194px)` so on a phone the block stops short of the toggles. Checks: `test-dict-search-branding-overlap.ts`, `test-viewer-search-branding-overlap.ts`. | | [`src/app/main.tsx`](../../src/app/main.tsx) | React root mount, reads `window.__MODEL__`/`__THEME_MODE__` globals in static mode. | | [`src/app/hash-router.ts`](../../src/app/hash-router.ts) | Exports `parseHash`/`serializeHash`, `ViewName`, `FlowViewMode`, `FlowCollapseLevel`, `HashState`, `nextCollapseLevel`. `HashState` carries `view?: 'graph'\|'dict'\|'flow'`, `entity?`, `zoom?`, `pan?`, `dfd?`, `flowview?: 'per-process'\|'connected'`, `collapse?: 'stores'\|'clusters'\|'groups'`. Format: `#view=&entity=&zoom=&pan=,&dfd=&flowview=&collapse=`. `dfd`'s value is a diagram reference (a bare id, or an id path such as `invoicing/Submit-PCI`) resolved by the flow-view domain's `findDiagramByRef`; `serializeHash` encodes it with `encodeURIComponent` then restores any `%2F` back to `/`, so a path reference stays readable in the address bar instead of percent-encoding its separator. All params optional; unknown/malformed values are silently dropped (`VALID_VIEWS`/`VALID_FLOW_VIEWS`/`VALID_COLLAPSE_LEVELS` lookup tables). | | [`src/app/globals.d.ts`](../../src/app/globals.d.ts) | `window.__MODEL__`, `__THEME_MODE__`, `__IGNATIUS_MODE__` (`'live'\|'static'`), `__LAYOUT_KEY__`, `__FLOW_MODEL__`, `__FLOW_LAYOUT_KEYS__`, `__CHANGE_REVIEW__` (the export's baked-in `ChangeReview`; absent without a baseline), `__FLOW_CLUSTERS__` (the model-root `clusters/` registry, sibling to `__FLOW_MODEL__`, read directly by `FlowsView`'s `initFlowGraphCore` to build `buildFlowData`'s connected-view cluster grouping), `__IGNATIUS_CY__`, `__IGNATIUS_CY_GEN__`, `__IGNATIUS_FLOW_READY__`, `__IGNATIUS_FLOW_GEN__`, `__IGNATIUS_ACTIVE_FLOW_DFD__`, `__IGNATIUS_PERF__`. | @@ -193,7 +193,7 @@ A removed entity exists only as a ghost, so clicking it with the overlay off tur | [`src/app/hooks/useModelData.ts`](../../src/app/hooks/useModelData.ts) | Exports `useModelData(opts?)`. Unified SSE subscription + model/flow fetch + findings state. Static mode reads `window.__MODEL__`/`__FLOW_MODEL__`/`__FLOW_CLUSTERS__`/`__CHANGE_REVIEW__` once on mount. Live mode boots with parallel `/api/model` + `/api/flow`, then re-fetches on every `model-changed` SSE event. Returns `{ model, findings, flowDiagrams, flowClusters, flowFindings, diff, refreshDiff, layoutKeyRef, bannerDismissed, setBannerDismissed }`. `diff: ChangeReview | null` comes from the `/api/model` payload's `diff` field and stays `null` in an export with no baseline or when the server could not build a review; `refreshDiff()` refetches `/api/model` and replaces `diff` only. `ChangeReview` is imported type-only, because [`src/model/change-review.ts`](../../src/model/change-review.ts) imports `flow-markdown` at runtime and that pulls shiki into the bundle. `doModelFetch`/`doFlowFetch` are module-level and throw on a non-OK response; the boot and SSE refetches wrap the flow fetch in `fetchFlowOrNull`, so a failed `/api/flow` logs and leaves the model update applied instead of discarding both. `applyFlowPayload` mirrors a non-empty `/api/flow` `clusters` field into both React state (`flowClusters`, returned by the hook) and the `window.__FLOW_CLUSTERS__` global; `App.tsx` does not destructure `flowClusters` from the hook's return, so the live consumer of the cluster registry is `FlowsView`'s `initFlowGraphCore`, which reads `window.__FLOW_CLUSTERS__` directly — the same global-read pattern `__FLOW_MODEL__` uses for the imperative (non-React-tree) renderer. **StrictMode double-fetch guard:** the boot `useEffect` declares a local `let ignore = false`, set `true` in its cleanup; every `setState` inside the boot and SSE-handler promise chains is gated on the flag to survive React StrictMode's dev-mode mount→cleanup→mount double-invoke. | | [`src/app/hooks/useHashRoute.ts`](../../src/app/hooks/useHashRoute.ts) | Exports `useHashRoute(opts?)`. Owns hash read/write and `popstate` back/forward restoration. `entity=` in the hash is the single source of truth for the modal stack: `openEntity(id)` pushes history (deduped when the hash already carries the same entity), `closeEntity()` replaces it. `popstate` invokes `onEntityChange(id \| null)` to reconcile the shell without pushing another history entry; it also invokes `onRestoreFlowView(view)`/`onRestoreCollapseLevel(level)` whenever the reconciled hash carries a `flowview=`/`collapse=` value, unconditional on whether it differs from the shell's live state (mirroring how `dfd=` restoration works). Returns `{ view, setView, openEntity, closeEntity }`. | | [`src/app/hooks/useThemeMode.ts`](../../src/app/hooks/useThemeMode.ts) | Exports `useThemeMode(themeConfig?, model?)`. Seeds from `window.__THEME_MODE__` or localStorage; calls `applyThemeCssVars` on change (also on `model` identity change). Returns `{ themeMode, toggleTheme }`. | -| [`src/app/hooks/useKeyboardShortcuts.ts`](../../src/app/hooks/useKeyboardShortcuts.ts) | Registers exactly ONE global `keydown` listener for the unified SPA shortcuts (g/d/f/l/b/i/?//`/Cmd-Ctrl-k/arrows). `onFlowIndex` (`i`, flow view only) is one of the config callbacks alongside `onView`/`onToggleLayout`/`onToggleLens`/etc. Stale-closure hazard avoided via a `configRef` updated each render. Editable guard returns true when focus is in `INPUT`/`TEXTAREA`/`SELECT`/`contenteditable` or inside `.modal`. Dispatches through `resolveShortcut` from [`src/app/logic/shortcuts.ts`](../../src/app/logic/shortcuts.ts). Imported only by `App.tsx`. | +| [`src/app/hooks/useKeyboardShortcuts.ts`](../../src/app/hooks/useKeyboardShortcuts.ts) | Registers exactly ONE global `keydown` listener for the unified SPA shortcuts (g/d/f/l/b/i/?//`/Cmd-Ctrl-k/arrows). `onFlowIndex` (`i`, flow view only) is one of the config callbacks alongside `onView`/`onToggleLayout`/`onToggleLens`/etc. Stale-closure hazard avoided via a `configRef` updated each render. Editable guard returns true when focus is in `INPUT`/`TEXTAREA`/`SELECT`/`contenteditable` or inside `.modal`. Dispatches through `resolveShortcut` from [`src/app/logic/shortcuts.ts`](../../src/app/logic/shortcuts.ts). Imported only by `App.tsx`. Graph-only `s` (select mode), `h` (hide selection), and `Cmd`/`Ctrl`+`Z` / `Cmd`/`Ctrl`+`Shift`+`Z` / `Ctrl`+`Y` (undo/redo, not while typing) route through `onToggleSelectMode`, `onHideSelection`, `onUndo`, `onRedo`. | ### Logic (pure, no DOM/React) @@ -226,6 +226,7 @@ A removed entity exists only as a ghost, so clicking it with the overlay off tur |---|---| | `Modal.tsx` | Shared modal primitive (title + onClose + children + optional `headerExtra`). `.modal-backdrop` sits at z-index 210 and the stacked variant at 220, above the global findings banner. `JsonValue.tsx` portals its expanded dialog to `document.body`, because the dictionary view is its own stacking context and would trap the dialog under the banner (covered by [`test/checks/test-modal-above-banner.ts`](../../test/checks/test-modal-above-banner.ts)). | | `ZoomControl.tsx` | View-agnostic zoom readout. Props: `percent`, `onZoomIn`, `onZoomOut`, `onSetPercent(pct)`, `onReset`. Clicking the readout opens an inline commit-on-Enter/blur text input. | +| `GraphEditBar.tsx` | Graph arrangement controls above the zoom control, same width: undo/redo arrows, select-mode toggle (`aria-pressed`), and a crossed-out eye with the hidden count that opens the Hidden popover (Show all, an eye button per entity). Inline Lucide stroke icons. Props: `state: GraphEditState`, `onUndo`, `onRedo`, `onToggleSelectMode`, `onShowHidden(ids \| null)`. | | `FabMenu.tsx` | Per-view FAB menus, items view-gated. `kbd-hint` badges surface keyboard shortcuts inline. Flow-specific items (no `kbd-hint`): a per-process/connected toggle button labelled by the *current* mode's opposite (`"Connected view"` while `flowView === 'per-process'`, else `"Per-process view"`), and a collapse-level cycle button labelled by the *target* level via `collapseActionLabel` (`"Collapse to clusters"`, `"Collapse to groups"`, `"Expand to stores"`). | | `HelpModal.tsx` | View-aware orientation overlay built on `Modal`. Three branches (graph/dict/flow), each documenting that surface's search behavior and closing with a `shortcutRows(view)`-driven keyboard section. The flow branch's `FLOW_EXPLORE` rows cover the flow index ("Click Process Flows (or press I)…") and breadcrumb level switching ("A ▾ on a breadcrumb lists the other diagrams at that level…"); `shortcutRows('flow')` adds an `I` row ("Open or close the flow index"). | | `SearchBar.tsx` | Exports `SearchBar` (`forwardRef`) and `SearchBarHandle` (`{ focus(): void }`). Shared search-bar chrome for the Graph and Flows surfaces: debounced (200ms) ``, a `role="switch"` "Include descriptions" toggle, a match-count readout, and a `children` slot for a results dropdown. | @@ -291,14 +292,17 @@ Change-review checks: [`test/checks/test-change-review-graph.ts`](../../test/che | Path | Role | |---|---| -| `views/graph/GraphView.tsx` | Exports `GraphView` (forwardRef) and `GraphViewHandle`/`LayoutMode` types. Owns the full Cytoscape lifecycle, navigator lifecycle, zoom adapter, hash wiring, preset-layout cache-skip, ELK cost scaling. `GraphViewHandle` exposes `navigateToEntity`, `panelNavigate` (findings and changes panel rows; held until a running layout settles), `resetLayout`, `applyLayoutMode`, `zoomIn`, `zoomOut`, `setPercent`, `resetZoom`, `panBy`, `retheme`. `wheelSensitivity: 0.2`. **Change overlay:** takes `changeReview: ChangeReview | null`, mirrored into `changeReviewRef`; an effect on `[changeReview, isActive, cyReady]` calls `applyChangeOverlayRef` (see How it works). `nodeData`/`edgeData`/`graphEdgeId` are shared by the initial element build and the ghost builder. **Hover settles before it repaints:** `cy.on('mouseover'/'mouseout', 'node')` feed `graphHover` (a `createHoverIntent` from `logic/motion.ts`) instead of restyling directly; `showHover(nodeId)` is the `apply` callback — it restores the leaving node's forward predicates, then, for a settled non-null target, shows its reverse predicates plus (`shiftHeld`) either lineage via `enterLineageHover` or a plain `applyFocusTiers` fade, falling back to the selected node's tiers or a full clear on `null`. `shiftHeld` (a plain `let` local to the cy-init effect, updated by the `mouseover` handler's event and by document-level `onShiftKeyDown`/`onShiftKeyUp` listeners) and `hoveredNodeIdRef` (a ref) track live Shift and pointer state so `onShiftKeyDown` can call `graphHover.applyNow(hoveredId)` at once when Shift is pressed over a still-waiting hover. **3-tier focus opacity:** direct (full opacity), inherited/ancestral (`.inherited-dim`, 0.5), unrelated (`.faded`, 0.2). **Cross-view search:** accepts `searchMatches: ReadonlySet \| null`; `applySearchClasses` applies dedicated `.search-match`/`.search-dim` classes, kept separate from the hover-tier and lineage classes so search dimming survives hover/lineage/tap/relayout. | -| `views/graph/organic-layout.ts` | Organic (fCoSE-based) layout engine built on `cytoscape-fcose`. `ORGANIC_FALLBACK_THRESHOLD = 500` entities before falling back to layered. `buildScratchCore` runs the multi-seed layout search on a headless mirror core so only winning positions touch the live core; `groupRegions: true` (entity count ≥ 150) wraps each color family in an invisible compound parent so fCoSE decomposes large models into per-family sub-layouts. `arrangeOrganic(cy, iters)` is the post-settle local-polish pipeline (expand, fan subtype clusters, dock leaves/isolates, deoverlap). `gradeEdgeSpans(edges: cytoscape.EdgeCollection)` grades the given edges' `span` (`'near'\|'mid'\|'far'`) by percentile within that collection's own length distribution (`GraphView` passes every edge except ghosts), for `styles.ts`'s length-graded de-emphasis. | +| `views/graph/GraphView.tsx` | Exports `GraphView` (forwardRef) and `GraphViewHandle`/`LayoutMode` types. Owns the full Cytoscape lifecycle, navigator lifecycle, zoom adapter, hash wiring, preset-layout cache-skip, ELK cost scaling. `GraphViewHandle` exposes `navigateToEntity`, `panelNavigate` (findings and changes panel rows; held until a running layout settles), `resetLayout`, `applyLayoutMode`, `zoomIn`, `zoomOut`, `setPercent`, `resetZoom`, `panBy`, `retheme`. `wheelSensitivity: 0.2`. **Change overlay:** takes `changeReview: ChangeReview | null`, mirrored into `changeReviewRef`; an effect on `[changeReview, isActive, cyReady]` calls `applyChangeOverlayRef` (see How it works). `nodeData`/`edgeData`/`graphEdgeId` are shared by the initial element build and the ghost builder. **Hover settles before it repaints:** `cy.on('mouseover'/'mouseout', 'node')` feed `graphHover` (a `createHoverIntent` from `logic/motion.ts`) instead of restyling directly; `showHover(nodeId)` is the `apply` callback — it restores the leaving node's forward predicates, then, for a settled non-null target, shows its reverse predicates plus (`shiftHeld`) either lineage via `enterLineageHover` or a plain `applyFocusTiers` fade, falling back to the selected node's tiers or a full clear on `null`. `shiftHeld` (a plain `let` local to the cy-init effect, updated by the `mouseover` handler's event and by document-level `onShiftKeyDown`/`onShiftKeyUp` listeners) and `hoveredNodeIdRef` (a ref) track live Shift and pointer state so `onShiftKeyDown` can call `graphHover.applyNow(hoveredId)` at once when Shift is pressed over a still-waiting hover. **3-tier focus opacity:** direct (full opacity), inherited/ancestral (`.inherited-dim`, 0.5), unrelated (`.faded`, 0.2). **Cross-view search:** accepts `searchMatches: ReadonlySet \| null`; `applySearchClasses` applies dedicated `.search-match`/`.search-dim` classes, kept separate from the hover-tier and lineage classes so search dimming survives hover/lineage/tap/relayout. Arrangement edits ([`docs/spec/graph-arrangement.md`](../spec/graph-arrangement.md)): the handle also exposes `undo`, `redo`, `toggleSelectMode`, `hideSelection`, `showHidden(ids | null)`, and the view reports `GraphEditState` through `onGraphEditsChange`; a load with no entry for its key carries the previous arrangement over (`layoutStore.previous`) and seats new nodes beside their neighbours. | +| `views/graph/organic-layout.ts` | Organic (fCoSE-based) layout engine built on `cytoscape-fcose`. `ORGANIC_FALLBACK_THRESHOLD = 500` entities before falling back to layered. `buildScratchCore` runs the multi-seed layout search on a headless mirror core so only winning positions touch the live core; `groupRegions: true` (entity count ≥ 150) wraps each color family in an invisible compound parent so fCoSE decomposes large models into per-family sub-layouts. `arrangeOrganic(cy, iters)` is the post-settle local-polish pipeline (expand, fan subtype clusters, dock leaves/isolates, deoverlap). `gradeEdgeSpans(edges: cytoscape.EdgeCollection)` grades the given edges' `span` (`'near'\|'mid'\|'far'`) by percentile within that collection's own length distribution (`GraphView` passes every visible edge except ghosts), for `styles.ts`'s length-graded de-emphasis. `buildScratchCore(live, groupRegions, exclude)` also feeds the hierarchical layout, which always uses regions; `exclude` leaves hidden nodes off the mirror. | | `views/graph/navigator.ts` | `mountNavigator`/`teardownNavigator`/`NavigatorInstance`, cytoscape-navigator lifecycle helpers. `teardownNavigator` calls `nav._removeCyListeners?.()` before `nav.destroy()` to avoid a resize-listener leak that fires on a destroyed core. | | `views/graph/change-glow.ts` | `createChangeGlow(cy, container, colors)` → `ChangeGlow` with `destroy()`: canvas behind cytoscape for pulsing blurred node glows (clipped out of the node, viewport-culled, one rAF loop, `GLOW_PERIOD_MS` 1800, reduced-motion `matchMedia` listener) and canvas above it for the ghost ✕ badge. Exports `glowColors`, `GLOW_STEADY`, `EDGE_GLOW_SCALE`, `UNEXPLAINED_STEADY_MIX`, `TIER_OPACITY` for `styles.ts`. | | `views/graph/styles.ts` | `buildStyles(groups, theme, mode)` → cytoscape stylesheet array. `.faded` (0.2), `.inherited-dim` (0.5), `edge.inherited` dotted at 0.5 opacity. Length-graded de-emphasis on `edge[span]`. Graph search: `SEARCH_MATCH_BORDER` gold/yellow border on `.search-match`, `.search-dim` at 0.2 opacity, both pushed last in the stylesheet array to win the cascade. Change review: `node[change=...]`/`edge[change=...]` rules use `glowColors(theme, mode)` from `change-glow.ts`, edge glow underlays at `GLOW_STEADY * EDGE_GLOW_SCALE` (node glows live in `change-glow.ts`), the removed-ghost dashed border, and are pushed before the focus and search classes so those still dim changed elements; `[unexplained = "true"]` blends the underlay toward the unexplained color (`UNEXPLAINED_STEADY_MIX`). | -| `views/graph/markers.ts` | Exports `drawWarningBadges(cy, svg, entityIds: Set)`, `createMarkerOverlay(container)`, `updateMarkers(cy, svg, theme, mode?)`. | +| `views/graph/markers.ts` | Exports `drawWarningBadges(cy, svg, entityIds: Set)`, `createMarkerOverlay(container)`, `updateMarkers(cy, svg, theme, mode?)`. On segmented and taxi edges each crow's-foot takes its angle from the adjacent bend point. | | `views/graph/wrap-label.ts` | `wrapEntityLabel`: underscores → spaces; names longer than ~13 chars break at PascalCase/acronym/digit boundaries. | -| `views/graph/layout-store.ts` | Exports `PositionMap`, `StorageLike`, `LayoutStoreHandle`, `createLayoutStore(storage?, now?)`. Single localStorage key `ignatius-layout-positions`; newest-10 pruning on save. `PositionMap` is also imported directly by [`src/flow-view/FlowDiagramSvg.tsx`](../../src/flow-view/FlowDiagramSvg.tsx). `LayoutStoreHandle.lastPosition(nodeId, exceptKey, neighbourIds)` returns the newest saved position of `nodeId` from any entry other than `exceptKey` that also saved at least one of `neighbourIds`; the store is shared by every model, and the neighbour test keeps same-named nodes of other models out. Only `GraphView`'s ghost placement calls it. | +| `views/graph/layout-store.ts` | Exports `PositionMap`, `StorageLike`, `LayoutStoreHandle`, `createLayoutStore(storage?, now?)`. Single localStorage key `ignatius-layout-positions`; newest-10 pruning on save. `PositionMap` is also imported directly by [`src/flow-view/FlowDiagramSvg.tsx`](../../src/flow-view/FlowDiagramSvg.tsx). `LayoutStoreHandle.lastPosition(nodeId, exceptKey, neighbourIds)` returns the newest saved position of `nodeId` from any entry other than `exceptKey` that also saved at least one of `neighbourIds`; the store is shared by every model, and the neighbour test keeps same-named nodes of other models out. Only `GraphView`'s ghost placement calls it. Entries carry an optional `hidden` list: `save(key, positions, hidden)`, `hidden(key)`; `previous(exceptKey, nodeIds)` finds the same model before its last edit. | +| `views/graph/grouped-layered.ts` | Hierarchical layout by colour group: `layoutByGroup` runs ELK `layered` per group region of a `buildScratchCore(cy, true)` mirror, `orderRegions` puts referenced groups first, `packRegions` skyline-packs the blocks. | +| `views/graph/tree-layout.ts` | Tree layout ([`docs/spec/graph-tree-layout.md`](../spec/graph-tree-layout.md)): `buildForest` picks each table's tree parent (deepest key-inheritance parent; hubs and classifiers never fallback parents) and assigns each lookup to one consumer; `layoutTree` places trees depth first with lookups in a staircase above each tree, orders trees so referenced ones come first, packs them into columns, and returns a route per edge (`TreeRoute`: `down` lane or `cross` lane/street/approach, plus `entryDy`). Every line leaves its parent's bottom and enters the child's left side. `routeGeometry` gives a line's pinned ends and bends for the current boxes, drawing a `down` route as a cross link when its child no longer sits below and right of its lane. GraphView applies it as `segments` bypasses and re-routes on node moves. | +| `views/graph/graph-edits.ts` | `createEditHistory` (undo/redo over `GraphSnapshot` = positions + hidden + `LayoutMode`), `GraphEditState`, `USER_HIDDEN_CLASS`, and the `LayoutMode` type `GraphView` re-exports. `nextLayoutMode` / `parseLayoutMode` / `LAYOUT_MODE_NAME` drive the `L` cycle and the stored mode. | | `views/dict/DictionaryView.tsx` | `DictionaryView` is a `forwardRef` exposing `DictionaryViewHandle { toggleLens(); focusSearch(); }`. Keep-mounted via CSS `display:none`. Imports `SYNTHETIC_DIAGRAM_IDS` from [`src/flows/flow-derive-levels.ts`](../../src/flows/flow-derive-levels.ts) to exclude synthesized context/L1 diagrams from the DD sidebar. Owns DD CSS Custom Highlight search, `beforeprint`/`afterprint` print handling, DD sidebar process nesting. **Browse lens** (`'read'\|'browse'`, persisted to localStorage): entity groups + Processes/External Entities/Data Stores sections; spotlight state `hoverId`, `pinnedId`, `labelHoverCardId`, `focusId`. A card's hover goes through `cardHover`, a `createHoverIntent` instance whose `apply` callback sets `hoverId` and, when the settled card is a spotlit-but-not-active card, `labelHoverCardId`; switching lens calls `cardHover.applyNow(null)` to clear at once. The root `.dict-view` gets `data-motion="off"` when `animationsAllowed(totalVisible + totalFlowVisible)` is `false` (see How it works), and every `scrollIntoView` call in the file (`scrollToEntity`, `scrollToSection`, `scrollToMissing`, `scrollToProcess`) uses `scrollBehaviorWithin(el)` instead of a hardcoded `'smooth'`. FK connections from `buildSpotlightConnections`; flow connections from `buildFlowSpotlightConnections`; inherited (lineage) connections from `buildInheritedConnections`, gated behind a document-level `shiftHeld` boolean. **Change review:** props `changeReview` and `entityChanges`; passes `change={entityChanges?.get(n.id)?.mark}` to each `GridCard` and `changes=` to each `EntityCard`. `removedNodes` (from `changeReview.removedNodes`, filtered by the active search) feeds a "N removed since approval" link in the browse lens, whose `showRemoved` switches to the read lens inside `flushSync` and scrolls to `#dict-removed`, and a read-lens "Removed since approval" section of `EntityCard`s built from `changeReview.removedEdges`. | | `views/flow/FlowsView.tsx` | Exports `FlowsView` (forwardRef), `FlowsViewHandle`, `FlowSurface` (the stateful dialog-hosting wrapper around `FlowDiagramSvg`, see How it works), `FlowChromeCallbacks`, and `initFlowGraphCore` (the imperative renderer lifecycle both static and live modes call). `FlowsViewProps` carries `flowView: FlowViewMode` and `collapseLevel: FlowCollapseLevel`, both owned by the shell; a change to either is an effect dependency of the renderer-mount effect, so toggling either re-mounts `initFlowGraphCore` and every diagram in the session picks up the new opts. `renderDiagram` calls `computeElkLayout(diagram, flowDataOpts)` from [`src/flow-view/elk-flow-layout.ts`](../../src/flow-view/elk-flow-layout.ts) and passes `elkPositions`/`elkEdgeRoutes` into `FlowDiagramSvg`; falls back to a banded `computeFlowLayout` only on ELK failure. `FlowsViewHandle` exposes `selectDiagramById(ref)` (resolves a `dfd=`-style diagram reference — a bare id or a path such as `invoicing/Submit-PCI` — via `findDiagramByRef` from [`src/flow-view/flow-nav.ts`](../../src/flow-view/flow-nav.ts)), `toggleIndex()` (delegates to `flowChromeRef.current?.toggleIndex()`, the `i`-key entry point), `resetLayout`, `zoomIn`, `zoomOut`, `setPercent`, `resetZoom`, `panBy`, `openFlowToken`. `initFlowGraphCore` exposes both `selectDiagramById(ref)` and `selectDiagramPath(ids)` (walks `resolveDiagramPath`, exact — so two flows sharing a process file name resolve to the right one); both funnel into an internal `showPath(path)` that replaces the breadcrumb stack via `stackFor(path)` (each step's label is the owning process's dotted number + label, or the diagram's own title at the root) and renders the path's last diagram (see How it works). On mount, the starting diagram resolves the seeded `dfd=` reference the same way, falling back to `defaultDiagramPath(allDiagrams)` (flow-view domain — opens the System overview) when it is absent or unresolvable. **Change review:** `FlowsViewProps.changeReview` seeds `initFlowGraphCore`'s `initialChangeReview` parameter; later changes arrive through `FlowChromeCallbacks.onRegisterChangeReview` (the same register-a-setter pattern as `onRegisterSearchTokens`), so a toggle re-renders the current diagram without re-mounting. Each render computes `diagramPath` from every id in the breadcrumb stack (synthetic levels included, matching how change items key diagrams) and passes `changeMarks: flowNodeMarks(review, diagram, diagramPath)` to `FlowDiagramSvg`. `onRegisterHandlers(drillUp, selectDiagram, selectPath)` hands all three selectors up to `FlowChrome` (flow-view domain) for the breadcrumb level menus and the flow index. `FlowChrome` receives `modelName`/`modelDescription` from `getEntityModel()?._meta` for the index's whole-system description fallback. `window.__IGNATIUS_ACTIVE_FLOW_DFD__` is set to the bare `diagram.id` on every render; `onActiveDiagramChange` fires separately with the path reference (`diagramRef(stack...)`) that `App.tsx` writes into the `dfd=` hash. **`disposed` teardown guard:** `initFlowGraphCore` declares `let disposed = false`; the async `renderDiagram` checks it before and after every `await computeElkLayout(...)` to guard against an orphaned continuation resuming after React StrictMode's dev-mode mount→cleanup→mount. | | `views/flow/LegendModal.tsx` | `LegendModal` component; imports `DARK_PALETTE`/`LIGHT_PALETTE`/`FlowPalette` from [`src/flow-view/FlowDiagramSvg.tsx`](../../src/flow-view/FlowDiagramSvg.tsx). | diff --git a/docs/wiki/index.md b/docs/wiki/index.md index a2c6bb2..ebc9d00 100644 --- a/docs/wiki/index.md +++ b/docs/wiki/index.md @@ -13,7 +13,7 @@ description: Ignatius — Bun/TypeScript markdown-driven ERD modeler with a unif - Runtime: Bun (all scripts, server, test runner, binary compiler) - Language: TypeScript (strict, ESM modules, `type: "module"` in package.json) -- Frontend: React 19, Cytoscape.js 3.31 + cytoscape-elk 2.2 + cytoscape-fcose 2.2 + cytoscape-navigator 2.0 — ELK drives layered/hierarchical layout (and the organic fallback above `ORGANIC_FALLBACK_THRESHOLD`), fCoSE drives the organic layout's multi-seed force-directed search +- Frontend: React 19, Cytoscape.js 3.31 + cytoscape-elk 2.2 + cytoscape-fcose 2.2 + cytoscape-navigator 2.0 — ELK drives layered/hierarchical layout (and the organic fallback above `ORGANIC_FALLBACK_THRESHOLD`), fCoSE drives the organic layout's multi-seed force-directed search, and the in-house [`tree-layout.ts`](../../src/app/views/graph/tree-layout.ts) drives the Tree layout - Markdown parsing: markdown-it 14; YAML parsing: yaml 2.8 - No Express, no Vite, no webpack — Bun.serve + Bun HTML imports only - Dev tools: Playwright (screenshot/SSE tests), webview-bun diff --git a/docs/wiki/scripts.md b/docs/wiki/scripts.md index 5cd9c22..1b16c96 100644 --- a/docs/wiki/scripts.md +++ b/docs/wiki/scripts.md @@ -39,7 +39,7 @@ flowchart TD | [`scripts/probe.ts`](../../scripts/probe.ts) | ad-hoc diagnostic script. Opens a native `webview-bun` window against a URL (default `http://localhost:3777`), waits for a `.react-flow__node` element to appear, then serializes visible node positions (parsed from each element's CSS `transform`) and edge count into a JSON report printed to stdout. Has a 60-second safety-quit timer and a 30-second wait-for-node timeout. Exits 0 only if a `report` payload was produced. | | [`scripts/screenshot.ts`](../../scripts/screenshot.ts) | snapshot helper that opens a native `webview-bun` window against a URL (default `http://localhost:3777`), waits up to 8 seconds for `.card svg` to appear, then serializes every `` inside a `.card` on the page (wrapped with an XML declaration and a dark background ``) and writes each to a file under an output directory (default `/tmp/ignatius-shots`, second CLI arg). Filenames are sanitized to `[a-z0-9._-]` and prefixed with a zero-padded index. Uses `writeFileSync` synchronously inside the `webview.bind` callback specifically to avoid an IPC deadlock in `webview-bun` that affects `await`ed writes from a bind handler. Exits 0 only if at least one file was written. | | [`scripts/gen-synthetic-model.ts`](../../scripts/gen-synthetic-model.ts) | synthetic IDEF1X model generator for perf testing. CLI: `bun scripts/gen-synthetic-model.ts [--n 300] [--out tmp/my-model]`. Writes a complete model root: `ignatius.yml` (with a hardcoded dark/light theme), one `groups/.md` file per group across fixed groups (`core`, `catalog`, `sales`, `finance`, `ops`, `reporting`, distributed via fixed weights `[0.18, 0.20, 0.22, 0.18, 0.16, 0.06]`), and one entity markdown file per generated entity under `data//`. Always includes hardcoded subtype clusters (`Party` → `Person`/`Organization`, `Product` → `PhysicalProduct`/`DigitalProduct`, `Channel` → `WebChannel`/`PartnerChannel`). Non-cluster entities get 1–3 FK relationships wired via a hub-and-peer strategy targeting ~1.4–1.8× edges per node while avoiding cycles. Default output directory: `tmp/synthetic-model-/`. | -| [`scripts/perf-harness.ts`](../../scripts/perf-harness.ts) | Playwright-based render latency measurement harness. CLI: `bun scripts/perf-harness.ts [--model ] [--n ] [--port ] [--runs ] [--mode organic\|hierarchical]`. Without `--model`, first spawns `bun scripts/gen-synthetic-model.ts --n --out tmp/synthetic-model-` as a subprocess. Measures `parseModels()` timing directly via a dynamic import of `../src/model/parse`, spawns `bun src/cli/cli.ts serve --port ` as a subprocess server, fetches `/api/model` for payload byte size, then drives a headless Chromium page via `playwright` to measure `time-to-layoutstop` (navigation start until `window.__IGNATIUS_PERF__` is stamped) and `time-to-interactive` (layoutstop + a 200ms settle). Clears the `ignatius-layout-positions` `localStorage` key before each measured run so ELK always runs cold; `--mode` optionally forces `ignatius-layout-mode` in `localStorage` before navigation. Uses a 10-minute `waitForFunction` timeout to accommodate large (300+ node) graphs. Prints a fixed-width ASCII report table to stdout. Not run by `bun run test` or CI. | +| [`scripts/perf-harness.ts`](../../scripts/perf-harness.ts) | Playwright-based render latency measurement harness. CLI: `bun scripts/perf-harness.ts [--model ] [--n ] [--port ] [--runs ] [--mode organic\|hierarchical\|tree]`. Without `--model`, first spawns `bun scripts/gen-synthetic-model.ts --n --out tmp/synthetic-model-` as a subprocess. Measures `parseModels()` timing directly via a dynamic import of `../src/model/parse`, spawns `bun src/cli/cli.ts serve --port ` as a subprocess server, fetches `/api/model` for payload byte size, then drives a headless Chromium page via `playwright` to measure `time-to-layoutstop` (navigation start until `window.__IGNATIUS_PERF__` is stamped) and `time-to-interactive` (layoutstop + a 200ms settle). Clears the `ignatius-layout-positions` `localStorage` key before each measured run so ELK always runs cold; `--mode` optionally forces `ignatius-layout-mode` in `localStorage` before navigation. Uses a 10-minute `waitForFunction` timeout to accommodate large (300+ node) graphs. Prints a fixed-width ASCII report table to stdout. Not run by `bun run test` or CI. | | [`docs/guides/building-from-source.md`](../guides/building-from-source.md) | Documents the `build:cli` sequence (`build:bundle`, `build:stable-names`, compile), naming `stable-names.ts` as the file-renaming stage | ## Constraints diff --git a/scripts/perf-harness.ts b/scripts/perf-harness.ts index e0bfc0f..fd52616 100644 --- a/scripts/perf-harness.ts +++ b/scripts/perf-harness.ts @@ -38,7 +38,7 @@ const N = parseInt(argValue('--n', '300'), 10); const PORT = parseInt(argValue('--port', '7799'), 10); const RUNS = parseInt(argValue('--runs', '1'), 10); const MODEL_ARG = argValue('--model', ''); -// --mode organic|hierarchical — forces layout mode before measuring; default keeps +// --mode organic|hierarchical|tree — forces layout mode before measuring; default keeps // whatever the browser has stored (usually 'organic' on a cold profile). const MODE_ARG = argValue('--mode', ''); @@ -132,10 +132,10 @@ type PerfResult = { async function measureRound(): Promise { // Clear any cached layout positions so every run is a cold ELK run. - // Optionally override the layout mode via --mode organic|hierarchical. + // Optionally override the layout mode via --mode organic|hierarchical|tree. await page.evaluate((modeArg: string) => { try { localStorage.removeItem('ignatius-layout-positions'); } catch {} - if (modeArg === 'organic' || modeArg === 'hierarchical') { + if (modeArg === 'organic' || modeArg === 'hierarchical' || modeArg === 'tree') { try { localStorage.setItem('ignatius-layout-mode', modeArg); } catch {} } }, MODE_ARG); diff --git a/skills/ignatius-modeling/references/model-flow.md b/skills/ignatius-modeling/references/model-flow.md index 8a6d600..2e2fad2 100644 --- a/skills/ignatius-modeling/references/model-flow.md +++ b/skills/ignatius-modeling/references/model-flow.md @@ -55,7 +55,7 @@ Ask: "Custom branding? (y/n, default n — uses built-in Noorm branding)" If yes, collect: -- `title`, optional `subtitle` (max 50 characters each). +- `title`, optional `subtitle` (max 50 characters each). The page shows each on one line and cuts it with `…` past 200px (title) or 240px (subtitle), about 25 and 40 characters, so suggest text that fits. - `logo` — optional path to an SVG, resolved relative to the model root. A single path applies to both modes, or `{ dark, light }` for per-mode logos. - `copyright.holder` (the name on the © line) and `copyright.year` (default current year). - `poweredBy` flag (default true; `false` hides the footer attribution). diff --git a/src/app/App.tsx b/src/app/App.tsx index 868a34f..ecbbf48 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -19,6 +19,9 @@ import { entityMatches, searchFlowDiagrams } from './logic/search'; import { Modal } from './components/ui/Modal'; import { HelpModal } from './components/ui/HelpModal'; import { ZoomControl } from './components/ui/ZoomControl'; +import { GraphEditBar } from './components/ui/GraphEditBar'; +import { nextLayoutMode, parseLayoutMode } from './views/graph/graph-edits'; +import type { GraphEditState } from './views/graph/graph-edits'; import { SearchBar } from './components/ui/SearchBar'; import type { SearchBarHandle } from './components/ui/SearchBar'; import { FlowSearchResults } from './components/flow/FlowSearchResults'; @@ -193,10 +196,11 @@ export function App() { // Zoom control state for the Graph view. // zoomPercent: live readout (100 = fit-to-view baseline). Updated by GraphView via onZoomPercentChange. const [zoomPercent, setZoomPercent] = useState(100); + const [graphEdits, setGraphEdits] = useState({ canUndo: false, canRedo: false, selectMode: false, hidden: [] }); // Layout algorithm mode — shell owns it for FAB display; GraphView reads it via prop. const [layoutMode, setLayoutMode] = useState(() => { const stored = localStorage.getItem('ignatius-layout-mode'); - return stored === 'hierarchical' ? 'hierarchical' : 'organic'; + return parseLayoutMode(stored); }); // Flow view + collapse level (docs/spec/dfd-store-clusters.md) — global, @@ -351,10 +355,14 @@ export function App() { // Shared layout-mode toggle — used by BOTH the FAB button and the keyboard shortcut (l). // Extracted so both paths invoke exactly the same logic with no duplication. + function storeLayoutMode(mode: LayoutMode) { + setLayoutMode(mode); + localStorage.setItem('ignatius-layout-mode', mode); + } + function handleToggleLayoutMode() { - const next = layoutMode === 'organic' ? 'hierarchical' : 'organic'; - setLayoutMode(next); - localStorage.setItem('ignatius-layout-mode', next); + const next = nextLayoutMode(layoutMode); + storeLayoutMode(next); graphViewRef.current?.applyLayoutMode(next); } @@ -405,6 +413,10 @@ export function App() { onHelp: () => setShowHelp(true), onSearch: handleKeyboardSearchFocus, onPan: handleKeyboardPan, + onUndo: () => graphViewRef.current?.undo(), + onRedo: () => graphViewRef.current?.redo(), + onToggleSelectMode: () => graphViewRef.current?.toggleSelectMode(), + onHideSelection: () => graphViewRef.current?.hideSelection(), }); // NOTE: cy-init effect, navigator toggle effect, and all cy-specific refs have been @@ -419,13 +431,8 @@ export function App() { ? (themeMode === 'dark' ? branding.logo.dark : branding.logo.light) : undefined; - // The branding block is position:fixed, so it is out of flow and no stylesheet - // can know how much room to leave for it. Publish its measured width as - // `--branding-gutter` so the DD search bar can indent past it at viewport - // widths where the two would otherwise share a row (the bar is full-bleed; - // the graph/flow pill is narrow and centred, so it never collides). Measured - // rather than hard-coded because title and subtitle come from the model's - // branding and can be any length. + // The fixed branding block is out of flow and its text comes from the model, so its + // measured width is published as `--branding-gutter` for the chrome that must clear it. const brandingRef = useRef(null); useEffect(() => { const el = brandingRef.current; @@ -643,7 +650,7 @@ export function App() { changeReview={shownChanges} onCyReadyChange={setCyReady} onZoomPercentChange={setZoomPercent} - onLayoutModeChange={setLayoutMode} + onLayoutModeChange={storeLayoutMode} onCyInitError={setCyInitError} onSelectEntity={(node) => { // Route graph node-tap through the shared opener so it pushes ONE @@ -661,6 +668,7 @@ export function App() { setShowEntityModal(true); openEntity(node.id); }} + onGraphEditsChange={setGraphEdits} onDeselectEntity={() => { // Background tap closes the modal and clears entity= from the URL // (replaceState — clean URL, not a Back step). @@ -764,6 +772,15 @@ export function App() { onReset={() => graphViewRef.current?.resetZoom()} /> )} + {view === 'graph' && cyReady && ( + graphViewRef.current?.undo()} + onRedo={() => graphViewRef.current?.redo()} + onToggleSelectMode={() => graphViewRef.current?.toggleSelectMode()} + onShowHidden={(ids) => graphViewRef.current?.showHidden(ids)} + /> + )} {/* Zoom control — Flows view: delegates to FlowsView handle (CP23) */} {view === 'flow' && (flowDiagrams?.length ?? 0) > 0 && ( { setMenuOpen(false); onToggleLayoutMode(); }} > - {layoutMode === 'organic' ? 'Hierarchical layout' : 'Organic layout'} L + {LAYOUT_MODE_NAME[nextLayoutMode(layoutMode)]} layout L
+ + +
+ ); +} diff --git a/src/app/components/ui/HelpModal.tsx b/src/app/components/ui/HelpModal.tsx index 2283873..97d4cc1 100644 --- a/src/app/components/ui/HelpModal.tsx +++ b/src/app/components/ui/HelpModal.tsx @@ -36,9 +36,12 @@ const GRAPH_TYPES: Row[] = [ ]; const GRAPH_EXPLORE: Row[] = [ - { term: 'Layouts', desc: 'Organic clusters related entities by force; Hierarchical stacks them in dependency layers. Toggle with L.' }, + { term: 'Layouts', desc: 'Organic clusters related entities by force; Hierarchical gives each colour group its own block, stacked in dependency layers; Tree lays the model out as an outline, each table below and right of the table whose key it inherits, with right-angled lines. L steps through them.' }, { term: 'Shift + hover', desc: 'Reveals dotted lines to an entity’s whole key-inheritance family — every relative that shares its primary key, however far apart.' }, - { term: 'Click · drag · zoom', desc: 'Click an entity for full detail; drag to rearrange (positions are saved); scroll or ⌘/Ctrl +/−/0 to zoom.' }, + { term: 'Click · drag · zoom', desc: 'Click an entity for full detail; drag to rearrange (positions are saved, and survive edits to the model); scroll or ⌘/Ctrl +/−/0 to zoom.' }, + { term: 'Move several', desc: 'Shift+drag on the background draws a selection box; press S for select mode, where a plain drag boxes and a click adds or removes. Drag any selected entity to move them all.' }, + { term: 'Undo', desc: '⌘/Ctrl Z takes back a move, a hide, or a layout change; ⌘/Ctrl Shift Z redoes it. The undo and redo arrows above the zoom control do the same.' }, + { term: 'Hide', desc: 'Right-click an entity, or press H with entities selected, to take it out of the view. The crossed-out eye above the zoom control counts and lists them to bring back; Reset layout lays out without them.' }, { term: 'Search', desc: 'Type to highlight matches and dim the rest; flip Include descriptions to also match markdown text; Enter cycles through matches.' }, ]; @@ -74,7 +77,12 @@ const FLOW_EXPLORE: Row[] = [ function shortcutRows(view: ViewName): Row[] { const rows: Row[] = [{ term: 'G · D · F', desc: 'Switch between Graph, Dictionary, and Flows.' }]; - if (view === 'graph') rows.push({ term: 'L', desc: 'Toggle the graph layout (Organic / Hierarchical).' }); + if (view === 'graph') { + rows.push({ term: 'L', desc: 'Step through the graph layouts (Organic → Hierarchical → Tree).' }); + rows.push({ term: 'S', desc: 'Toggle select mode.' }); + rows.push({ term: 'H', desc: 'Hide the selected entities.' }); + rows.push({ term: '⌘/Ctrl Z · ⌘/Ctrl ⇧ Z', desc: 'Undo or redo a move, hide, or layout change.' }); + } if (view === 'dict') rows.push({ term: 'B', desc: 'Toggle the dictionary lens (Read / Browse).' }); if (view === 'flow') rows.push({ term: 'I', desc: 'Open or close the flow index.' }); if (view === 'graph' || view === 'dict') { diff --git a/src/app/hooks/useKeyboardShortcuts.ts b/src/app/hooks/useKeyboardShortcuts.ts index 00524a6..0d95b77 100644 --- a/src/app/hooks/useKeyboardShortcuts.ts +++ b/src/app/hooks/useKeyboardShortcuts.ts @@ -23,11 +23,19 @@ interface KeyboardShortcutsConfig { * delta; graph/flow only — the resolver returns null on dict). Fires on every * keydown, so OS auto-repeat gives continuous scrolling while held. */ onPan: (dx: number, dy: number) => void; + /** Cmd/Ctrl+Z — undo the last graph arrangement change (graph only). */ + onUndo: () => void; + /** Cmd/Ctrl+Shift+Z or Ctrl+Y — redo it (graph only). */ + onRedo: () => void; + /** `s` — toggle graph select mode (graph only). */ + onToggleSelectMode: () => void; + /** `h` — hide the selected graph nodes (graph only). */ + onHideSelection: () => void; } /** * useKeyboardShortcuts — registers exactly ONE global keydown listener for the - * unified SPA keyboard shortcuts (g/d/f/l/b/i). + * unified SPA keyboard shortcuts (g/d/f/l/b/i/s/h). * * Stale-closure hazard: the listener is registered once on mount. To avoid * capturing a stale `view`/callbacks reference, we keep the latest config in @@ -48,11 +56,15 @@ export function useKeyboardShortcuts({ onHelp, onSearch, onPan, + onUndo, + onRedo, + onToggleSelectMode, + onHideSelection, }: KeyboardShortcutsConfig): void { // Latest config ref — updated synchronously on every render so the stable // listener closure never reads stale values. - const configRef = useRef({ view, onView, onToggleLayout, onToggleLens, onFlowIndex, onZoomIn, onZoomOut, onZoomReset, onHelp, onSearch, onPan }); - configRef.current = { view, onView, onToggleLayout, onToggleLens, onFlowIndex, onZoomIn, onZoomOut, onZoomReset, onHelp, onSearch, onPan }; + const configRef = useRef({ view, onView, onToggleLayout, onToggleLens, onFlowIndex, onZoomIn, onZoomOut, onZoomReset, onHelp, onSearch, onPan, onUndo, onRedo, onToggleSelectMode, onHideSelection }); + configRef.current = { view, onView, onToggleLayout, onToggleLens, onFlowIndex, onZoomIn, onZoomOut, onZoomReset, onHelp, onSearch, onPan, onUndo, onRedo, onToggleSelectMode, onHideSelection }; useEffect(() => { function handleKeyDown(e: KeyboardEvent): void { @@ -94,6 +106,10 @@ export function useKeyboardShortcuts({ // preventDefault above also stops the browser from scrolling the page // on arrow keys while a canvas view is active. case 'pan': cfg.onPan(action.dx, action.dy); break; + case 'undo': cfg.onUndo(); break; + case 'redo': cfg.onRedo(); break; + case 'toggleSelectMode': cfg.onToggleSelectMode(); break; + case 'hideSelection': cfg.onHideSelection(); break; } } diff --git a/src/app/logic/shortcuts.ts b/src/app/logic/shortcuts.ts index 7398c07..8dd8872 100644 --- a/src/app/logic/shortcuts.ts +++ b/src/app/logic/shortcuts.ts @@ -11,6 +11,8 @@ * l → toggleLayout (view==='graph' only; null otherwise) * b → toggleLens (view==='dict' only; null otherwise) * i → flowIndex (view==='flow' only; null otherwise) + * s → toggleSelectMode (view==='graph' only; null otherwise) + * h → hideSelection (view==='graph' only; null otherwise) * / → search (any view; ordinary bare key — unlike '?' it needs no * Shift, so it resolves in the normal switch below. * Cmd/Ctrl+k is a second, always-on route to the same @@ -25,6 +27,8 @@ * * Exception: '?' (Shift+/) is resolved after guard 1 but before guard 2, since * the character itself requires Shift; it is still suppressed in editable context. + * Graph undo/redo (Cmd/Ctrl+Z, Cmd/Ctrl+Shift+Z, Ctrl+Y) sits in the same slot, + * so typing keeps the native text undo. * * Arrow-key panning (graph/flow views): each keydown pans the active canvas by * PAN_STEP screen px, or PAN_STEP_FAST with Shift held — keydown auto-repeat @@ -74,7 +78,11 @@ export type ShortcutAction = | { type: 'zoomReset' } | { type: 'help' } | { type: 'search' } - | { type: 'pan'; dx: number; dy: number }; + | { type: 'pan'; dx: number; dy: number } + | { type: 'undo' } + | { type: 'redo' } + | { type: 'toggleSelectMode' } + | { type: 'hideSelection' }; /** Arrow-key pan step per keydown, in screen px (viewport-movement delta). */ export const PAN_STEP = 10; @@ -128,6 +136,14 @@ export function resolveShortcut( // Guard 1: typing context (bare keys only) if (editable) return null; + // Undo/redo of a graph arrangement change. After guard 1 so the chord keeps + // undoing text in an input. + if (view === 'graph' && (e.ctrlKey || e.metaKey) && !e.altKey) { + if (key === 'z') return e.shiftKey ? { type: 'redo' } : { type: 'undo' }; + // Cmd+Y is the browser's History shortcut on macOS. + if (key === 'y' && e.ctrlKey && !e.metaKey && !e.shiftKey) return { type: 'redo' }; + } + // Help overlay: '?' (Shift+/ on most layouts). Resolved AFTER the editable // guard but BEFORE the modifier guard, because '?' inherently needs Shift — // guard 2 would otherwise swallow it. Gated off ctrl/meta/alt so it never @@ -154,6 +170,8 @@ export function resolveShortcut( case 'd': return { type: 'view', view: 'dict' }; case 'f': return { type: 'view', view: 'flow' }; case 'l': return view === 'graph' ? { type: 'toggleLayout' } : null; + case 's': return view === 'graph' ? { type: 'toggleSelectMode' } : null; + case 'h': return view === 'graph' ? { type: 'hideSelection' } : null; case 'b': return view === 'dict' ? { type: 'toggleLens' } : null; case 'i': return view === 'flow' ? { type: 'flowIndex' } : null; case '/': return { type: 'search' }; diff --git a/src/app/styles.css b/src/app/styles.css index 3e51d39..8d62970 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -64,7 +64,8 @@ a { font-weight: 700; color: var(--color-text); line-height: 1.2; - max-width: 200px; + /* 86px: the block's left offset, padding, logo and gap; then the toggles and a 12px gap. */ + max-width: min(200px, calc(100vw - 86px - var(--toggle-region) - 12px)); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -73,11 +74,21 @@ a { font-size: 11px; color: var(--color-text-muted); line-height: 1.2; + /* Capped like the title, so the block stays one row tall and the search bars' clearances hold. */ + max-width: min(240px, calc(100vw - 86px - var(--toggle-region) - 12px)); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } /* Footer — bottom-center, fixed */ /* --fab-region matches the FAB's right offset + width so the footer stays clear */ :root { --fab-region: 160px; } +/* --toggle-region is the help and theme toggles' span from the right edge (.help-toggle right + width). */ +:root { --toggle-region: 96px; } +/* --below-branding: the row under the branding block (12px top + 48px block + 8px gap). + --dict-bar-clearance: the fixed Dictionary search bar's height plus a gap. */ +:root { --below-branding: 68px; --dict-bar-clearance: 76px; } .branding-footer { position: fixed; bottom: 12px; @@ -717,8 +728,12 @@ code { .flow-crumbs { position: absolute; top: 18px; - left: 240px; + /* Right of the measured branding block, wrapping short of the toggles; max-width (not right) + keeps the row as wide as its chips, so it never covers the canvas beside them. */ + left: max(240px, calc(28px + var(--branding-gutter, 0px))); + max-width: calc(100vw - max(240px, calc(28px + var(--branding-gutter, 0px))) - var(--toggle-region) - 12px); display: flex; + flex-wrap: wrap; align-items: center; gap: 8px; z-index: 46; @@ -1123,7 +1138,7 @@ button.flow-crumb__label:hover { top: 64px; left: 16px; right: 16px; - flex-wrap: wrap; + max-width: none; row-gap: 8px; } @@ -1233,8 +1248,164 @@ button.flow-crumb__label:hover { outline: none; } +.graph-edit-bar { + position: fixed; + bottom: 120px; + right: 24px; + /* Same footprint as .zoom-control directly below it. */ + width: 130px; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: space-between; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 6px; + padding: 2px 4px; + z-index: 55; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + opacity: 0.75; + transition: opacity 0.15s; +} + +.graph-edit-bar:hover, +.graph-edit-bar:focus-within { + opacity: 1; +} + +.graph-edit-btn { + background: none; + border: none; + border-radius: 4px; + color: var(--color-text-muted); + cursor: pointer; + font-family: inherit; + height: 24px; + min-width: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 3px; + padding: 0 4px; + transition: background 0.1s, color 0.1s; +} + +.graph-edit-btn:hover:not(:disabled) { + background: var(--color-surface-alt); + color: var(--color-text); +} + +.graph-edit-btn:disabled { + opacity: 0.35; + cursor: default; +} + +.graph-edit-btn[aria-pressed='true'], +.graph-edit-btn[aria-expanded='true'] { + background: var(--color-surface-alt); + color: var(--color-link); +} + +.graph-edit-icon { + width: 14px; + height: 14px; + flex: none; +} + +.graph-edit-count { + font-size: 11px; + font-variant-numeric: tabular-nums; + line-height: 1; +} + +.graph-edit-sep { + width: 1px; + height: 14px; + background: var(--color-border); +} + +.graph-edit-popover { + position: absolute; + bottom: calc(100% + 6px); + right: 0; + width: 220px; + max-height: 320px; + overflow-y: auto; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 6px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); + font-size: 12px; + color: var(--color-text); +} + +.graph-edit-popover-head { + position: sticky; + top: 0; + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 8px 6px 10px; + background: var(--color-surface); + border-bottom: 1px solid var(--color-border); + color: var(--color-text-muted); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.graph-edit-popover-count { + margin-left: 4px; + font-variant-numeric: tabular-nums; +} + +.graph-edit-link { + background: none; + border: none; + color: var(--color-link); + cursor: pointer; + font-family: inherit; + font-size: 12px; + font-weight: 500; + letter-spacing: normal; + text-transform: none; + padding: 2px 4px; + border-radius: 4px; +} + +.graph-edit-link:hover { + background: var(--color-surface-alt); +} + +.graph-edit-popover ul { + list-style: none; + margin: 0; + padding: 4px; +} + +.graph-edit-popover li { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 2px 2px 2px 6px; + border-radius: 4px; +} + +.graph-edit-popover li:hover { + background: var(--color-surface-alt); +} + +.graph-edit-popover-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + @media (max-width: 768px) { - .zoom-control { + .zoom-control, + .graph-edit-bar { display: none; } } @@ -1330,10 +1501,11 @@ button.flow-crumb__label:hover { /* Holds the Changes and Findings panels, stacked so they never overlap. */ .side-panel-stack { position: fixed; - /* Top offset clears the theme-toggle (top:16px, 36px tall, 12px gap). */ - top: 64px; + /* 52px below the top of the top-row bar (12px with no banner or Flows bar), clearing the toggles and the bar. */ + --panel-top: calc(max(var(--search-bar-top, 12px), var(--flow-search-bar-top, 0px)) + 52px); + top: var(--panel-top); right: 1rem; - max-height: calc(100vh - 96px); + max-height: calc(100vh - var(--panel-top) - 32px); display: flex; flex-direction: column; align-items: flex-end; @@ -1986,6 +2158,8 @@ button.changes-panel-item:hover { z-index: 10; overflow-y: auto; overflow-x: hidden; + /* Anchor jumps land below the fixed search bar. */ + scroll-padding-top: var(--dict-bar-clearance); } /* CP17: Inner content wrapper — centers and bounds the readable column. */ @@ -1994,7 +2168,7 @@ button.changes-panel-item:hover { margin: 0 auto; /* Top clears the fixed search bar (~56px bar + 12px gap = 68px). Right clears theme-toggle + FAB; bottom/left give breathing room. */ - padding: 76px 2rem 5rem 2rem; + padding: var(--dict-bar-clearance) 2rem 5rem 2rem; } /* CP17: Fixed top search bar — frosted, centered, bounded. @@ -2031,6 +2205,8 @@ button.changes-panel-item:hover { 2rem, calc(16px + var(--branding-gutter, 0px) + 12px - max(0px, (100vw - 1100px) / 2)) ); + /* The same indent on the right clears the help and theme toggles. */ + padding-right: max(2rem, calc(var(--toggle-region) + 12px - max(0px, (100vw - 1100px) / 2))); display: flex; align-items: center; gap: 0.75rem; @@ -2074,8 +2250,8 @@ button.changes-panel-item:hover { /* graph-flow-search CP2: shared Graph/Flows search bar (SearchBar.tsx). Compact frosted pill, top-center — visually consistent with .dict-search-bar - but deliberately NOT full-width, so it never collides with the top-right - help/theme toggles + FindingsPanel or the top-left branding block. The + but NOT full-width. On narrow viewports the Graph bar is held clear of the + branding block and the toggles (see .viewer-search-bar--graph below). The full-width global error banner is handled separately via --search-bar-top (see below) since width alone doesn't avoid a top-anchored, full-width banner. */ @@ -2209,9 +2385,9 @@ button.changes-panel-item:hover { /* Count readout — absolutely positioned OUTSIDE the flex flow so its width never contributes to .viewer-search-bar-inner's own size. The bar is - horizontally centered (`.viewer-search-bar`'s left:50%+translateX(-50%)), - so any width change ripples into a visible shift of the whole pill (the - input included) — this keeps the bar's width (and therefore its centered + horizontally centered while there is room (`.viewer-search-bar`'s left:50% + + translateX), so any width change ripples into a visible shift of the whole + pill (the input included) — this keeps the bar's width (and therefore its position) constant whether or not the count is showing. Still mounted only when matchCount !== null (SearchBar.tsx) — clearing the term removes it from the DOM entirely, not just visually. */ @@ -2227,6 +2403,49 @@ button.changes-panel-item:hover { white-space: nowrap; } +/* The Graph bar is centred while that clears the branding; otherwise its left edge stays + right of --branding-gutter, and its width short of the toggles and an 80px count. */ +.viewer-search-bar--graph { + transform: translateX(max(-50%, calc(28px + var(--branding-gutter, 0px) - 50vw))); + max-width: calc(100vw - 28px - var(--branding-gutter, 0px) - var(--toggle-region) - 12px - 80px); +} +.viewer-search-bar--graph .viewer-search-bar-inner { + width: 100%; +} + +@media (max-width: 1100px) { + .viewer-search-input { + width: 160px; + min-width: 60px; + flex: 1 1 auto; + } + .viewer-search-switch-label { + width: min-content; + white-space: normal; + font-size: 0.7rem; + line-height: 1.15; + } +} + +/* No room beside the branding: the Graph bar takes its own row under it (or under the + banner, which covers the branding), its count drops below it, and the side panels start after both. */ +@media (max-width: 768px) { + .viewer-search-bar--graph { + top: max(var(--search-bar-top, 12px), var(--below-branding)); + transform: translateX(-50%); + max-width: calc(100vw - 32px); + } + .viewer-search-bar--graph .viewer-search-count { + left: 50%; + top: calc(100% + 4px); + transform: translateX(-50%); + margin-left: 0; + } + .side-panel-stack { + --panel-top: calc(max(var(--search-bar-top, 12px), var(--flow-search-bar-top, 0px), var(--below-branding)) + 76px); + } +} + /* graph-flow-search CP3: Flows results dropdown, rendered into the SearchBar's results slot below .viewer-search-bar-inner. Same frosted-pill language as the bar itself. Nested under .viewer-search-bar, so it's already covered by @@ -3077,8 +3296,17 @@ a.dict-link-missing:hover { } @media (max-width: 768px) { + /* No room beside the branding: the bar's controls take their own row under it. */ + :root { + --dict-bar-clearance: 136px; + } + + .dict-search-bar-inner { + padding: var(--below-branding) 0.75rem 8px; + } + .dict-view-inner { - padding: 76px 0.75rem 4rem 0.75rem; + padding: var(--dict-bar-clearance) 0.75rem 4rem 0.75rem; } .dict-group-header { @@ -3711,7 +3939,8 @@ a.dict-link-missing:hover { .dict-findings-panel, .dict-fab, .dict-theme-toggle, - .zoom-control { + .zoom-control, + .graph-edit-bar { display: none !important; } diff --git a/src/app/views/graph/GraphView.tsx b/src/app/views/graph/GraphView.tsx index a93356c..f499f8c 100644 --- a/src/app/views/graph/GraphView.tsx +++ b/src/app/views/graph/GraphView.tsx @@ -28,6 +28,11 @@ import type { OrganicIters } from './organic-layout'; import { mountNavigator, teardownNavigator } from './navigator'; import type { NavigatorInstance } from './navigator'; import { buildStyles } from './styles'; +import { layoutByGroup } from './grouped-layered'; +import { layoutTree, routeGeometry, routeTree } from './tree-layout'; +import type { Box, TreeEdgeInput, TreeLine, TreeNodeInput, TreeRoute } from './tree-layout'; +import { createEditHistory, USER_HIDDEN_CLASS } from './graph-edits'; +import type { GraphEditState, GraphSnapshot, LayoutMode } from './graph-edits'; import { createChangeGlow, glowColors } from './change-glow'; import { createMarkerOverlay, updateMarkers, drawWarningBadges } from './markers'; import { wrapEntityLabel } from './wrap-label'; @@ -106,7 +111,7 @@ function changeMarks(review: ChangeReview): { nodes: Map; ed // LayoutMode — graph-only concept, exported so App shell can read/display it. // --------------------------------------------------------------------------- -export type LayoutMode = 'hierarchical' | 'organic'; +export type { LayoutMode } from './graph-edits'; // --------------------------------------------------------------------------- // GraphViewHandle — shell↔view typed contract. @@ -122,6 +127,8 @@ export type LayoutMode = 'hierarchical' | 'organic'; * - `applyLayoutMode`: FAB layout-mode toggle → re-run ELK in new mode. * - zoom ops: ZoomControl wiring. * - `retheme`: theme-mode change → restyle cy without rebuild. + * - arrangement edits: undo/redo, select mode, hide/show nodes (GraphEditBar + * and keyboard), reported back through onGraphEditsChange. */ export interface GraphViewHandle { navigateToEntity(id: string): void; @@ -136,8 +143,17 @@ export interface GraphViewHandle { * rendered graph slides the opposite way. Keyboard arrow-key scrolling. */ panBy(dx: number, dy: number): void; retheme(mode: 'dark' | 'light', model: Model, entityErrors: EntityError[]): void; + undo(): void; + redo(): void; + toggleSelectMode(): void; + /** Remove the selected nodes from view. */ + hideSelection(): void; + /** Put hidden nodes back; null = all of them. */ + showHidden(ids: string[] | null): void; } +type GraphEditActions = Pick; + // --------------------------------------------------------------------------- // GraphViewProps // --------------------------------------------------------------------------- @@ -184,6 +200,8 @@ export interface GraphViewProps { onDeselectEntity(): void; /** Called by the findings-panel navigate path — select + center only, no modal open. */ onPanelSelect(node: ModelNode): void; + /** Called whenever undo/redo availability, select mode, or the hidden set changes. */ + onGraphEditsChange(state: GraphEditState): void; } // --------------------------------------------------------------------------- @@ -212,6 +230,7 @@ export const GraphView = forwardRef( onSelectEntity, onDeselectEntity, onPanelSelect, + onGraphEditsChange, }, ref, ) { @@ -253,7 +272,10 @@ export const GraphView = forwardRef( const navigateToEntityRef = useRef<(id: string) => void>(() => {}); const panelNavigateRef = useRef<(id: string) => void>(() => {}); const resetLayoutRef = useRef<(() => void) | null>(null); - const applyLayoutModeRef = useRef<((mode: LayoutMode) => void) | null>(null); + const applyLayoutModeRef = useRef<((mode: LayoutMode, before: LayoutMode) => void) | null>(null); + const graphEditsRef = useRef(null); + // Outlives a cy rebuild, so a live reload does not wipe what can be undone. + const editHistoryRef = useRef(createEditHistory()); // ── Shift+hover lineage state (DG lineage trigger = shift+hover) ────────── // The currently-hovered node id (set on mouseover, cleared on mouseout) and @@ -292,8 +314,9 @@ export const GraphView = forwardRef( resetLayoutRef.current?.(); }, applyLayoutMode(mode: LayoutMode) { + const before = layoutModeRef.current; layoutModeRef.current = mode; - applyLayoutModeRef.current?.(mode); + applyLayoutModeRef.current?.(mode, before); }, zoomIn() { cyZoomInRef.current?.(); @@ -322,6 +345,21 @@ export const GraphView = forwardRef( const badgeIds = new Set(entityErrors.map(e => e.entityId)); drawWarningBadges(cy, svg, badgeIds); }, + undo() { + graphEditsRef.current?.undo(); + }, + redo() { + graphEditsRef.current?.redo(); + }, + toggleSelectMode() { + graphEditsRef.current?.toggleSelectMode(); + }, + hideSelection() { + graphEditsRef.current?.hideSelection(); + }, + showHidden(ids: string[] | null) { + graphEditsRef.current?.showHidden(ids); + }, }), []); // ── Warning badge redraw on findings change (F-6) ───────────────────── @@ -478,7 +516,8 @@ export const GraphView = forwardRef( return LAYERED_THOROUGHNESS_LARGE; } - function effectiveAlgorithm(mode: LayoutMode): 'fcose' | 'layered' { + function effectiveAlgorithm(mode: LayoutMode): 'fcose' | 'layered' | 'tree' { + if (mode === 'tree') return 'tree'; if (mode === 'organic' && entityCount >= ORGANIC_FALLBACK_THRESHOLD) return 'layered'; return mode === 'organic' ? 'fcose' : 'layered'; } @@ -578,6 +617,12 @@ export const GraphView = forwardRef( const layoutStore = createLayoutStore(); const preloadedSavedKey = layoutKeyRef.current; const preloadedSaved = preloadedSavedKey ? layoutStore.load(preloadedSavedKey) : null; + // A model edit changes the layout key; the arrangement saved under the + // previous key carries over and only the new nodes need a spot. + const entityIds = new Set(model.nodes.map(n => n.id)); + const carried = preloadedSaved ? null : layoutStore.previous(preloadedSavedKey, [...entityIds]); + const savedHidden = preloadedSaved ? layoutStore.hidden(preloadedSavedKey) : carried?.hidden ?? []; + let hiddenIds = new Set(savedHidden.filter(id => entityIds.has(id))); // Apply a saved PositionMap onto a cy instance. // Skips compound parents (_cluster_*) — setting a parent translates its children, @@ -629,9 +674,24 @@ export const GraphView = forwardRef( // (then restore) so a Reset is reproducible while keeping the spectral // draft's quality. Non-fCoSE layouts run untouched. const runLayout = (m: LayoutMode, onDone: () => void) => { + if (effectiveAlgorithm(m) === 'tree') { + ++layoutSearchGen; + const { nodes, edges } = treeInput(); + const result = layoutTree(nodes, edges); + treeLines = linesOf(result.routes); + applySavedPositions(cy, result.positions); + onDone(); + return; + } if (effectiveAlgorithm(m) !== 'fcose') { - cy.one('layoutstop', onDone); - cy.layout(buildLayoutOpts(m)).run(); + const scratch = buildScratchCore(cy, true, hiddenNodeIds()); + const myGen = ++layoutSearchGen; + void layoutByGroup(scratch, buildLayoutOpts(m), layerSpacing).then((positions) => { + scratch.destroy(); + if (cy.destroyed() || layoutSearchGen !== myGen) return; + applySavedPositions(cy, positions); + onDone(); + }); return; } // The search runs on a HEADLESS scratch mirror of the graph: once the @@ -641,7 +701,7 @@ export const GraphView = forwardRef( // graph keeps its current picture until the winner is applied. // Above ~150 entities the flat force problem starts to tangle — // decompose by colour family via compound regions on the mirror. - const scratch = buildScratchCore(cy, entityCount >= 150); + const scratch = buildScratchCore(cy, entityCount >= 150, hiddenNodeIds()); // Snapshot degree-1 leaves from REAL degrees — the group-pull edges // added in pass 2 would otherwise give every grouped satellite +1 // degree and unmask it from the short-leash treatment. @@ -809,6 +869,7 @@ export const GraphView = forwardRef( function settleLayout(): void { layoutSettled = true; appliedReview = undefined; + applyEdgeRouting(); applyChangeOverlay(); } @@ -818,7 +879,7 @@ export const GraphView = forwardRef( } function gradeSpans(): void { - gradeEdgeSpans(cy.edges().not(`.${CHANGE_GHOST_CLASS}`)); + gradeEdgeSpans(cy.edges(':visible').not(`.${CHANGE_GHOST_CLASS}`)); } function markElement(el: cytoscape.CollectionReturnValue, mark: ChangeMark): void { @@ -835,14 +896,25 @@ export const GraphView = forwardRef( .map(neighbourId => cy.$id(neighbourId)) .filter(neighbour => neighbour.nonempty()); if (neighbours.length === 0) return nextRowSlot(); - const sum = neighbours.reduce((acc, n) => ({ x: acc.x + n.position('x'), y: acc.y + n.position('y') }), { x: 0, y: 0 }); - return { x: sum.x / neighbours.length, y: sum.y / neighbours.length }; + return centroid(neighbours.reduce((all, n) => all.union(n), cy.collection())); } - function moveToClearSpot(ghost: cytoscape.NodeSingular, start: cytoscape.Position, nextRowSlot: () => cytoscape.Position): void { + function centroid(nodes: cytoscape.NodeCollection): cytoscape.Position { + let x = 0; + let y = 0; + nodes.forEach((n) => { x += n.position('x'); y += n.position('y'); }); + return { x: x / nodes.length, y: y / nodes.length }; + } + + function moveToClearSpot( + ghost: cytoscape.NodeSingular, + start: cytoscape.Position, + nextRowSlot: () => cytoscape.Position, + blockers: cytoscape.NodeCollection = cy.nodes(':visible'), + ): void { ghost.position(start); const home = ghost.boundingBox(); - const blocked = cy.nodes().not(ghost).map(n => n.boundingBox()); + const blocked = blockers.not(ghost).map(n => n.boundingBox()); for (let ring = 0; ring <= GHOST_SEARCH_RINGS; ring++) { const spots = ring === 0 ? 1 : 8; for (let k = 0; k < spots; k++) { @@ -859,12 +931,17 @@ export const GraphView = forwardRef( ghost.position(nextRowSlot()); } - function addChangeGhosts(review: ChangeReview, nodeMarks: Map, edgeMarks: Map): void { - const box = cy.nodes().boundingBox(); + // Spots along a row under `placed`, for a node with nowhere better to go. + function rowSlotsBelow(placed: cytoscape.NodeCollection): () => cytoscape.Position { + const box = placed.boundingBox(); const left = Number.isFinite(box.x1) ? box.x1 : 0; const top = Number.isFinite(box.y2) ? box.y2 + GHOST_ROW_GAP : 0; let rowSlots = 0; - const nextRowSlot = () => ({ x: left + rowSlots++ * GHOST_SPACING, y: top }); + return () => ({ x: left + rowSlots++ * GHOST_SPACING, y: top }); + } + + function addChangeGhosts(review: ChangeReview, nodeMarks: Map, edgeMarks: Map): void { + const nextRowSlot = rowSlotsBelow(cy.nodes(':visible')); for (const node of review.removedNodes) { if (cy.$id(node.id).nonempty()) continue; @@ -882,6 +959,352 @@ export const GraphView = forwardRef( } } + // Hover state lives up here: the first layout can land synchronously (a + // saved arrangement) and reach the hover through an edit it triggers. + let hoverShownId: string | null = null; + // Read when a hover settles, after the pointer event that started it is gone. + let shiftHeld = false; + const graphHover = createHoverIntent(showHover); + + // ── Tree layout edge routing (docs/design/graph-tree-layout.md) ──────── + // Bends are relative to the endpoints, so they are recomputed whenever a node moves. + const TREE_EDGE_CLASSES = 'tree-edge tree-label-source tree-cross'; + const TREE_EDGE_STYLES = 'segment-weights segment-distances source-endpoint target-endpoint source-text-offset target-text-offset'; + let treeRoutes = new Map(); + // Which lines the last Tree layout drew as tree lines; later routing keeps + // them and works from the boxes as they stand, so nothing reorders. + let treeLines: Map | null = null; + + function linesOf(routes: Map): Map { + return new Map([...routes].map(([id, r]) => [id, { id, parent: r.parent, child: r.child, kind: r.kind }])); + } + + function treeInput(): { nodes: TreeNodeInput[]; edges: TreeEdgeInput[] } { + const hidden = hiddenNodeIds(); + const nodes: TreeNodeInput[] = []; + cy.nodes(':childless').not(`.${CHANGE_GHOST_CLASS}`).forEach((n) => { + if (hidden.has(n.id())) return; + nodes.push({ + id: n.id(), + width: n.outerWidth(), + height: n.outerHeight(), + group: String(n.data('group') ?? ''), + classifier: n.data('classification') === 'Classifier', + joiner: n.data('joiner') === 'true', + }); + }); + const present = new Set(nodes.map(n => n.id)); + const edges: TreeEdgeInput[] = []; + cy.edges().not(`.${CHANGE_GHOST_CLASS}, .${INHERITED_EDGE_CLASS}`).forEach((e) => { + if (!present.has(e.source().id()) || !present.has(e.target().id())) return; + edges.push({ + id: e.id(), + source: e.source().id(), + target: e.target().id(), + identifying: e.data('identifying') === 'true', + label: String(e.data('predicateFwd') ?? ''), + }); + }); + return { nodes, edges }; + } + + // The mode the on-screen positions belong to: a switch still computing has not landed. + function positionsMode(): LayoutMode { + return pendingRelayout?.before ?? layoutModeRef.current; + } + + function applyEdgeRouting(): void { + if (cy.destroyed()) return; + const edges = cy.edges().not(`.${CHANGE_GHOST_CLASS}, .${INHERITED_EDGE_CLASS}`); + if (positionsMode() !== 'tree') { + treeLines = null; + if (treeRoutes.size === 0) return; + treeRoutes = new Map(); + edges.removeClass(TREE_EDGE_CLASSES).removeStyle(TREE_EDGE_STYLES); + return; + } + const { nodes, edges: links } = treeInput(); + // Positions restored from a save or an undo: take the lines a layout of this graph would draw. + if (treeLines === null) treeLines = linesOf(layoutTree(nodes, links).routes); + const known = treeLines; + const lines: TreeLine[] = links.map(e => known.get(e.id) ?? { id: e.id, parent: e.source, child: e.target, kind: 'cross' }); + const positions: PositionMap = {}; + for (const n of nodes) positions[n.id] = cy.$id(n.id).position(); + treeRoutes = routeTree(nodes, positions, lines); + cy.batch(() => { + edges.removeClass(TREE_EDGE_CLASSES).removeStyle(TREE_EDGE_STYLES); + routeTreeEdges(edges); + }); + } + + function routeTreeEdges(edges: cytoscape.EdgeCollection): void { + edges.forEach((e) => { + const route = treeRoutes.get(e.id()); + if (route) routeTreeEdge(e, route); + }); + } + + const boxOf = (n: cytoscape.NodeSingular): Box => + ({ x: n.position('x'), y: n.position('y'), width: n.outerWidth(), height: n.outerHeight() }); + + // Ends are pinned so a line leaves on its lane and enters on its row; + // bends become segment weights and distances along source→target. + function routeTreeEdge(edge: cytoscape.EdgeSingular, route: TreeRoute): void { + const parentNode = cy.$id(route.parent); + const childNode = cy.$id(route.child); + if (parentNode.empty() || childNode.empty()) return; + const g = routeGeometry(route, boxOf(parentNode), boxOf(childNode)); + const reversed = edge.source().id() === route.child; + const bends = reversed ? [...g.bends].reverse() : g.bends; + const sourceEnd = reversed ? g.childEnd : g.parentEnd; + const targetEnd = reversed ? g.parentEnd : g.childEnd; + + const s = edge.source().position(); + const t = edge.target().position(); + const dx = t.x - s.x; + const dy = t.y - s.y; + const lengthSq = dx * dx + dy * dy; + if (lengthSq < 1) return; + const length = Math.sqrt(lengthSq); + const style: Record = { + 'segment-weights': bends.map(b => ((b.x - s.x) * dx + (b.y - s.y) * dy) / lengthSq).join(' '), + 'segment-distances': bends.map(b => ((b.x - s.x) * -dy + (b.y - s.y) * dx) / length).join(' '), + 'source-endpoint': `${sourceEnd.x - s.x}px ${sourceEnd.y - s.y}px`, + 'target-endpoint': `${targetEnd.x - t.x}px ${targetEnd.y - t.y}px`, + }; + // The geometry, not the stored kind, picks the label: a `down` route + // drawn as a cross link labels its street. + edge.removeClass(TREE_EDGE_CLASSES); + if (g.kind === 'cross') { + edge.addClass('tree-cross'); + } else { + edge.addClass(reversed ? 'tree-edge tree-label-source' : 'tree-edge'); + style[reversed ? 'source-text-offset' : 'target-text-offset'] = g.labelOffset; + } + edge.style(style); + } + + // ── Arrangement edits: undo/redo, select mode, hidden nodes ─────────── + let selectMode = false; + + // Hidden entities plus any joiner diamond whose members are all hidden. + function hiddenNodeIds(): Set { + const ids = new Set(hiddenIds); + cy.nodes('[joiner = "true"]').forEach((j) => { + const members = j.outgoers('node'); + if (members.nonempty() && members.every(m => hiddenIds.has(m.id()))) ids.add(j.id()); + }); + return ids; + } + + function applyHidden(): void { + const ids = hiddenNodeIds(); + cy.nodes(`.${USER_HIDDEN_CLASS}`).removeClass(USER_HIDDEN_CLASS); + cy.nodes().filter(n => ids.has(n.id())).unselect().addClass(USER_HIDDEN_CLASS); + } + + let initialLayoutDone = false; + // Reset and the mode switch asked for before the first layout lands run + // after it, so the first layout's deep-link and restyle steps still happen. + let queuedRelayout: { mode: LayoutMode; before: LayoutMode } | null = null; + // A Reset or mode switch still computing; `recorded` = it pushed an undo step. + let pendingRelayout: { before: LayoutMode; recorded: boolean } | null = null; + + // Drops a pending relayout so it never lands over an edit, leaving no + // trace: the mode label and the undo history go back to before it. + function cancelPendingRelayout(): boolean { + if (pendingRelayout === null) return false; + const { before, recorded } = pendingRelayout; + pendingRelayout = null; + layoutSearchGen++; + if (layoutModeRef.current !== before) { + layoutModeRef.current = before; + onLayoutModeChange(before); + } + settleLayout(); + if (recorded) editHistoryRef.current.discardLast(); + consumePendingPanelTarget(); + reportEdits(); + return true; + } + + // The first layout has to land before an edit means anything. + function readyForEdit(): boolean { + if (!initialLayoutDone) return false; + cancelPendingRelayout(); + return true; + } + + function snapshot(): GraphSnapshot { + const positions: PositionMap = {}; + cy.nodes(':childless').not(`.${CHANGE_GHOST_CLASS}`).forEach((n) => { + const p = n.position(); + positions[n.id()] = { x: p.x, y: p.y }; + }); + // While a switch computes, the positions on screen still belong to the earlier mode. + return { positions, hidden: [...hiddenIds].sort(), mode: pendingRelayout?.before ?? layoutModeRef.current }; + } + + function persist(): void { + const key = layoutKeyRef.current; + if (!key || cy.destroyed()) return; + const { positions, hidden } = snapshot(); + layoutStore.save(key, positions, hidden); + } + + function reportEdits(): void { + const history = editHistoryRef.current; + onGraphEditsChange({ + canUndo: history.canUndo(), + canRedo: history.canRedo(), + selectMode, + hidden: [...hiddenIds].sort(), + }); + } + + // Focus tiers follow one pinned node; a multi-node selection pins none. + function pinnedNode(): cytoscape.NodeSingular | null { + const selected = cy.nodes(':selected'); + return selected.length === 1 ? selected[0] : null; + } + + // Seat each node beside its placed neighbours, in an order that lets a + // chain of new nodes grow out from the placed part of the graph. + function placeNewNodes(ids: Set): void { + const pending = new Set(ids); + const placed = () => cy.nodes(':childless:visible').filter(n => !pending.has(n.id())); + const nextRowSlot = rowSlotsBelow(placed()); + while (pending.size > 0) { + const waiting = cy.nodes().filter(n => pending.has(n.id())); + const anchored = waiting.filter(n => n.neighborhood('node').some(m => !pending.has(m.id()))); + const node = anchored.nonempty() ? anchored[0] : waiting[0]; + if (!node) break; + const anchors = node.neighborhood('node:childless:visible').filter(m => !pending.has(m.id())); + const start = anchors.empty() ? nextRowSlot() : centroid(anchors); + moveToClearSpot(node, start, nextRowSlot, placed()); + pending.delete(node.id()); + } + } + + // A hidden node may have been the hovered or pinned one; its fade must go. + function refreshFocus(): void { + const hovered = graphHover.applied(); + if (hovered !== null && hiddenIds.has(hovered)) { + hoveredNodeIdRef.current = null; + graphHover.applyNow(null); + return; + } + if (hovered === null) showPinnedOrClear(); + } + + function showPinnedOrClear(): void { + const pinned = pinnedNode(); + if (pinned) { + applyFocusTiers(pinned); + return; + } + clearFocusTiers(); + redrawMarkers(); + } + + function afterArrangementEdit(): void { + applyEdgeRouting(); + refreshFocus(); + gradeSpans(); + redrawMarkers(); + persist(); + reportEdits(); + } + + function restoreSnapshot(target: GraphSnapshot): void { + hiddenIds = new Set(target.hidden.filter(id => entityIds.has(id))); + applyHidden(); + applySavedPositions(cy, target.positions); + if (target.mode !== layoutModeRef.current) { + layoutModeRef.current = target.mode; + onLayoutModeChange(target.mode); + } + // Ghosts sit against the positions that were just replaced. + appliedReview = undefined; + applyChangeOverlay(); + afterArrangementEdit(); + } + + function hideNodes(ids: string[]): void { + const fresh = ids.filter(id => entityIds.has(id) && !hiddenIds.has(id)); + if (fresh.length === 0 || !readyForEdit()) return; + editHistoryRef.current.push(snapshot()); + fresh.forEach(id => hiddenIds.add(id)); + applyHidden(); + // The pointer is still over the spot the node left, so cytoscape sends + // no mouseout: a hover still waiting on the node is dropped here. + if (hoveredNodeIdRef.current !== null && hiddenIds.has(hoveredNodeIdRef.current)) { + hoveredNodeIdRef.current = null; + graphHover.applyNow(null); + } + afterArrangementEdit(); + } + + function showNodes(ids: string[] | null): void { + const back = (ids ?? [...hiddenIds]).filter(id => hiddenIds.has(id)); + if (back.length === 0 || !readyForEdit()) return; + editHistoryRef.current.push(snapshot()); + back.forEach(id => hiddenIds.delete(id)); + applyHidden(); + // A layout that ran while they were hidden may have filled their old spots. + placeNewNodes(new Set(back.filter(id => overlapsVisible(cy.$id(id))))); + afterArrangementEdit(); + } + + function overlapsVisible(node: cytoscape.NodeSingular): boolean { + const box = node.boundingBox(); + return cy.nodes(':childless:visible').not(node).some(other => boxesOverlap(box, other.boundingBox(), 0)); + } + + function setSelectMode(on: boolean): void { + selectMode = on; + // Panning off makes a plain background drag draw a selection box; + // additive makes a click toggle a node in or out of the selection. + cy.userPanningEnabled(!on); + cy.selectionType(on ? 'additive' : 'single'); + reportEdits(); + } + + graphEditsRef.current = { + undo() { + if (!initialLayoutDone) return; + // Undo during a pending Reset or mode switch takes back that request. + if (cancelPendingRelayout()) return; + if (!editHistoryRef.current.canUndo()) return; + const previous = editHistoryRef.current.undo(snapshot()); + if (previous) restoreSnapshot(previous); + }, + redo() { + if (!editHistoryRef.current.canRedo() || !readyForEdit()) return; + const next = editHistoryRef.current.redo(snapshot()); + if (next) restoreSnapshot(next); + }, + toggleSelectMode() { + setSelectMode(!selectMode); + }, + hideSelection() { + hideNodes(cy.nodes(':selected').map(n => n.id())); + }, + showHidden(ids) { + showNodes(ids); + }, + }; + + cy.on('cxttap', 'node', (evt) => { + const node = evt.target; + // A two-finger tap also arrives as cxttap; on a phone the edit bar is + // hidden, so a node hidden there could never be brought back. + if (typeof TouchEvent !== 'undefined' && evt.originalEvent instanceof TouchEvent) return; + if (!entityIds.has(node.id())) return; + const selected = cy.nodes(':selected'); + hideNodes(node.selected() ? selected.map(n => n.id()) : [node.id()]); + }); + function applyChangeOverlay(): void { if (cy.destroyed() || !layoutSettled) return; const review = changeReviewRef.current; @@ -952,6 +1375,7 @@ export const GraphView = forwardRef( if (state.entity !== undefined) { const target = cy.$(`#${CSS.escape(state.entity)}`); if (target.length > 0) { + showNodes([state.entity]); cy.elements().unselect(); target.select(); if (state.pan === undefined) cy.center(target); @@ -973,8 +1397,13 @@ export const GraphView = forwardRef( // two-pass fCoSE run fires layoutstop per pass, so the event can't be // the completion signal there). const onInitialLayoutDone = () => { + initialLayoutDone = true; if (preloadedSaved) { applySavedPositions(cy, preloadedSaved); + } else if (carried) { + applySavedPositions(cy, carried.positions); + placeNewNodes(new Set(cy.nodes(':childless').filter(n => carried.positions[n.id()] === undefined).map(n => n.id()))); + persist(); } else { const savedKey = layoutKeyRef.current; const saved = savedKey ? layoutStore.load(savedKey) : null; @@ -984,15 +1413,7 @@ export const GraphView = forwardRef( // (Organic local polish — arrangeOrganic — now runs inside // runLayout's per-candidate loop, before crossing scoring.) - if (savedKey && !saved) { - const elkPositions: Record = {}; - cy.nodes().forEach((node) => { - if (node.isParent()) return; - const pos = node.position(); - elkPositions[node.id()] = { x: pos.x, y: pos.y }; - }); - layoutStore.save(savedKey, elkPositions); - } + if (savedKey && !saved) persist(); } settleLayout(); @@ -1011,7 +1432,7 @@ export const GraphView = forwardRef( layoutStopAt: performance.now(), nodes: cy.nodes(':childless').length, edges: cy.edges().length, - layoutMode: preloadedSaved ? 'preset' : 'elk', + layoutMode: preloadedSaved || carried ? 'preset' : 'elk', }; const initialState = parseHash(location.hash); @@ -1021,9 +1442,14 @@ export const GraphView = forwardRef( applyHashState(initialState); } consumePendingPanelTarget(); + reportEdits(); + if (queuedRelayout !== null) relayout(queuedRelayout.mode, queuedRelayout.before); }; - if (preloadedSaved) { + // A box selection should hold entities only, not the cluster frame or joiner. + cy.nodes('[cluster = "true"], [joiner = "true"]').unselectify(); + applyHidden(); + if (preloadedSaved || carried) { cy.one('layoutstop', onInitialLayoutDone); cy.layout({ name: 'preset', fit: false } satisfies cytoscape.LayoutOptions).run(); } else { @@ -1204,12 +1630,7 @@ export const GraphView = forwardRef( return; } } - const selected = cy.nodes(':selected'); - if (selected.nonempty()) { - applyFocusTiers(selected[0]); - } else { - redrawMarkers(); - } + showPinnedOrClear(); } enterLineageHoverRef.current = enterLineageHover; @@ -1220,9 +1641,6 @@ export const GraphView = forwardRef( // reads it); showHover renders the settled target: the node's reverse // predicates plus its lineage (Shift held) or direct-neighbour fade, or, // for null, the selected node's tiers or no fade at all. - let hoverShownId: string | null = null; - // Read when a hover settles, after the pointer event that started it is gone. - let shiftHeld = false; function showHover(nodeId: string | null): void { if (hoverShownId !== null) showPredicates(hoverShownId, 'fwd'); hoverShownId = nodeId; @@ -1238,15 +1656,8 @@ export const GraphView = forwardRef( else applyFocusTiers(node[0]); return; } - const selected = cy.nodes(':selected'); - if (selected.nonempty()) { - applyFocusTiers(selected[0]); - } else { - clearFocusTiers(); - redrawMarkers(); - } + showPinnedOrClear(); } - const graphHover = createHoverIntent(showHover); // A hovered node's incoming edges read their reverse predicate ('rev'); // leaving restores every connected edge to the forward one ('fwd'). @@ -1271,7 +1682,7 @@ export const GraphView = forwardRef( // panel navigate, entity-link hops) are never suppressed. let suppressTapSelect = false; cy.on('tapstart', 'node', (evt) => { - suppressTapSelect = evt.originalEvent?.shiftKey !== true; + suppressTapSelect = !selectMode && evt.originalEvent?.shiftKey !== true; }); cy.on('tapend', () => { setTimeout(() => { suppressTapSelect = false; }, 0); @@ -1298,6 +1709,8 @@ export const GraphView = forwardRef( // history entry). GraphView does not write entity= on tap. Lineage // (dotted inherited rays + 3-tier focus opacity) is a shift+hover // affordance (see showHover). + // Select mode: a click toggles the node in the selection instead. + if (selectMode) return; hoveredNodeIdRef.current = null; graphHover.applyNow(null); clearInheritedEdges(); @@ -1325,6 +1738,7 @@ export const GraphView = forwardRef( navigateToEntityRef.current = (id: string) => { const target = cy.$(`#${CSS.escape(id)}`); if (target.length === 0) return; + showNodes([id]); cy.elements().unselect(); target.select(); cy.center(target); @@ -1332,7 +1746,13 @@ export const GraphView = forwardRef( let saveTimer: ReturnType | null = null; - resetLayoutRef.current = () => { + // `before` is the mode the undo step returns to. + function relayout(mode: LayoutMode, before: LayoutMode = mode): void { + if (!initialLayoutDone) { + queuedRelayout = { mode, before: queuedRelayout?.before ?? before }; + return; + } + queuedRelayout = null; if (saveTimer !== null) clearTimeout(saveTimer); // Strip ephemeral inherited edges BEFORE re-layout so ELK never lays // them out and the saved ELK position set never captures them. Also @@ -1340,35 +1760,29 @@ export const GraphView = forwardRef( clearInheritedEdges(); clearFocusTiers(); lineageActiveRef.current = false; - unsettleLayout(); - layoutStore.clear(layoutKeyRef.current); - runLayout(layoutModeRef.current, () => { - settleLayout(); - gradeSpans(); - cy.fit(undefined, 30); - redrawMarkers(); - consumePendingPanelTarget(); - // fit fires a 'zoom' event → readout updates to the real percent. - }); - }; - - applyLayoutModeRef.current = (mode) => { - if (saveTimer !== null) clearTimeout(saveTimer); - // Strip ephemeral inherited edges BEFORE re-layout (never fed to ELK). - // Also clear focus tiers so the relaid-out graph returns to full opacity. - clearInheritedEdges(); - clearFocusTiers(); - lineageActiveRef.current = false; + // A second request while one is pending replaces it; the undo step + // and the mode to return to stay those of the first. + const recorded = pendingRelayout?.recorded ?? layoutSettled; + const firstBefore = pendingRelayout?.before ?? before; + if (pendingRelayout === null && layoutSettled) editHistoryRef.current.push({ ...snapshot(), mode: before }); + pendingRelayout = { before: firstBefore, recorded }; + reportEdits(); unsettleLayout(); runLayout(mode, () => { + pendingRelayout = null; settleLayout(); gradeSpans(); cy.fit(undefined, 30); redrawMarkers(); + persist(); + reportEdits(); consumePendingPanelTarget(); // fit fires a 'zoom' event → readout updates to the real percent. }); - }; + } + + resetLayoutRef.current = () => relayout(layoutModeRef.current); + applyLayoutModeRef.current = (mode, before) => relayout(mode, before); panelNavigateRef.current = (id: string) => { if (layoutSettled) panelNavigate(id); @@ -1385,6 +1799,7 @@ export const GraphView = forwardRef( function panelNavigate(id: string): void { const target = cy.$(`#${CSS.escape(id)}`); if (target.length === 0) return; + showNodes([id]); cy.elements().unselect(); target.select(); cy.center(target); @@ -1479,26 +1894,44 @@ export const GraphView = forwardRef( } cy.on('layoutstop', refreshArrows); + cy.on('position', 'node', (evt) => { + if (treeRoutes.size === 0) return; + routeTreeEdges(evt.target.connectedEdges()); + }); cy.on('position', 'node', refreshArrows); cy.on('drag', 'node', refreshArrows); cy.on('free', 'node', refreshArrows); setTimeout(refreshArrows, 0); + // A selection drag grabs and frees every selected node: snapshot at the + // first grab, record once at the first free, only if something moved. + let dragBefore: GraphSnapshot | null = null; + // mousedown alone fires grab, so a click must not cancel a pending layout. + cy.on('grab', 'node', () => { + if (dragBefore === null && initialLayoutDone) dragBefore = snapshot(); + }); + cy.on('free', 'node', () => { // Re-grade edge spans on drag release — a moved node changes which of // its edges count as long-haul. gradeSpans(); - if (saveTimer !== null) clearTimeout(saveTimer); - saveTimer = setTimeout(() => { - const key = layoutKeyRef.current; - if (!key) return; - const positions: Record = {}; - cy.nodes().not(`.${CHANGE_GHOST_CLASS}`).forEach((node) => { - const pos = node.position(); - positions[node.id()] = { x: pos.x, y: pos.y }; + if (dragBefore !== null) { + const before = dragBefore; + dragBefore = null; + const moved = cy.nodes(':childless').some((n) => { + const was = before.positions[n.id()]; + return was !== undefined && Math.hypot(was.x - n.position('x'), was.y - n.position('y')) > 0.5; }); - layoutStore.save(key, positions); - }, 400); + if (moved) { + readyForEdit(); + editHistoryRef.current.push(before); + reportEdits(); + // Once per gesture: every node of a dragged selection fires its own free. + if (positionsMode() === 'tree') applyEdgeRouting(); + } + } + if (saveTimer !== null) clearTimeout(saveTimer); + saveTimer = setTimeout(persist, 400); }); function collectLineage(start: cytoscape.NodeCollection) { @@ -1588,6 +2021,7 @@ export const GraphView = forwardRef( panelNavigateRef.current = () => {}; resetLayoutRef.current = null; applyLayoutModeRef.current = null; + graphEditsRef.current = null; redrawMarkersRef.current = null; applySearchClassesRef.current = null; applyChangeOverlayRef.current = null; diff --git a/src/app/views/graph/graph-edits.ts b/src/app/views/graph/graph-edits.ts new file mode 100644 index 0000000..cb98fe9 --- /dev/null +++ b/src/app/views/graph/graph-edits.ts @@ -0,0 +1,77 @@ +import type { PositionMap } from './layout-store'; + +/** Class on a node the user removed from view. */ +export const USER_HIDDEN_CLASS = 'user-hidden'; + +export type LayoutMode = 'hierarchical' | 'organic' | 'tree'; + +/** The order `L` and the FAB step through. */ +const LAYOUT_CYCLE: readonly LayoutMode[] = ['organic', 'hierarchical', 'tree']; + +export const LAYOUT_MODE_NAME: Record = { + organic: 'Organic', + hierarchical: 'Hierarchical', + tree: 'Tree', +}; + +export function nextLayoutMode(mode: LayoutMode): LayoutMode { + return LAYOUT_CYCLE[(LAYOUT_CYCLE.indexOf(mode) + 1) % LAYOUT_CYCLE.length] ?? 'organic'; +} + +/** A stored mode string, or organic when it is missing or unknown. */ +export function parseLayoutMode(value: string | null): LayoutMode { + return LAYOUT_CYCLE.find(mode => mode === value) ?? 'organic'; +} + +/** Where every node sits, which nodes the user removed from view, and the layout mode. */ +export type GraphSnapshot = { positions: PositionMap; hidden: string[]; mode: LayoutMode }; + +/** What the shell needs to draw the edit controls. */ +export type GraphEditState = { canUndo: boolean; canRedo: boolean; selectMode: boolean; hidden: string[] }; + +export interface EditHistory { + /** Record the state from before a change. Drops anything that could be redone. */ + push(before: GraphSnapshot): void; + /** The state before the last change, or null; `current` becomes redoable. */ + undo(current: GraphSnapshot): GraphSnapshot | null; + /** The state the last undo left, or null; `current` becomes undoable. */ + redo(current: GraphSnapshot): GraphSnapshot | null; + /** Drop the newest undo step without making it redoable. */ + discardLast(): void; + canUndo(): boolean; + canRedo(): boolean; +} + +export function createEditHistory(limit = 100): EditHistory { + const past: GraphSnapshot[] = []; + const future: GraphSnapshot[] = []; + + function record(stack: GraphSnapshot[], snapshot: GraphSnapshot): void { + stack.push(snapshot); + if (stack.length > limit) stack.shift(); + } + + return { + push(before) { + record(past, before); + future.length = 0; + }, + undo(current) { + const previous = past.pop(); + if (!previous) return null; + record(future, current); + return previous; + }, + redo(current) { + const next = future.pop(); + if (!next) return null; + record(past, current); + return next; + }, + discardLast() { + past.pop(); + }, + canUndo: () => past.length > 0, + canRedo: () => future.length > 0, + }; +} diff --git a/src/app/views/graph/grouped-layered.ts b/src/app/views/graph/grouped-layered.ts new file mode 100644 index 0000000..5717d12 --- /dev/null +++ b/src/app/views/graph/grouped-layered.ts @@ -0,0 +1,197 @@ +import cytoscape from 'cytoscape'; +import type { PositionMap } from './layout-store'; + +// Flat ELK layered spreads every colour family across every layer, and one +// compound per family stacks them in a tall column: see docs/design/graph-arrangement.md. + +export type RegionBox = { id: string; width: number; height: number }; +export type RegionEdge = { source: string; target: string }; + +const UNGROUPED_REGION = ''; + +// Referenced families first, so the picture reads top-down like each block. +export function orderRegions(ids: string[], edges: RegionEdge[]): string[] { + const incoming = new Map>(); + const outgoingWeight = new Map(); + for (const id of ids) { + incoming.set(id, new Map()); + outgoingWeight.set(id, 0); + } + for (const { source, target } of edges) { + if (source === target) continue; + const into = incoming.get(target); + if (!into || !incoming.has(source)) continue; + into.set(source, (into.get(source) ?? 0) + 1); + outgoingWeight.set(source, (outgoingWeight.get(source) ?? 0) + 1); + } + + const placed = new Set(); + const order: string[] = []; + const unplacedIncoming = (id: string) => { + let total = 0; + for (const [from, weight] of incoming.get(id) ?? []) { + if (!placed.has(from)) total += weight; + } + return total; + }; + while (order.length < ids.length) { + let best: string | null = null; + for (const id of [...ids].sort()) { + if (placed.has(id)) continue; + if (best === null) { best = id; continue; } + const byIncoming = unplacedIncoming(id) - unplacedIncoming(best); + const byOutgoing = (outgoingWeight.get(best) ?? 0) - (outgoingWeight.get(id) ?? 0); + if (byIncoming < 0 || (byIncoming === 0 && byOutgoing < 0)) best = id; + } + if (best === null) break; + placed.add(best); + order.push(best); + } + return order; +} + +// Skyline packing, so a short box fills the hole beside a tall one instead of +// opening a row under it. Returns top-left corners; `aspect` is width / height. +export function packRegions(boxes: RegionBox[], aspect: number, gap: number): Map { + const area = boxes.reduce((sum, b) => sum + (b.width + gap) * (b.height + gap), 0); + const widest = boxes.reduce((max, b) => Math.max(max, b.width + gap), 0); + const limit = Math.max(widest, Math.sqrt(area * aspect)); + + // The skyline: contiguous segments covering [0, limit), each with the y + // where free space starts. + let skyline = [{ x: 0, width: limit, y: 0 }]; + const corners = new Map(); + for (const box of boxes) { + const span = box.width + gap; + let bestX = 0; + let bestY = Infinity; + for (const start of skyline) { + if (start.x + span > limit + 1e-6) continue; + const top = skyline + .filter(s => s.x < start.x + span && s.x + s.width > start.x) + .reduce((max, s) => Math.max(max, s.y), 0); + if (top < bestY) { bestY = top; bestX = start.x; } + } + corners.set(box.id, { x: bestX, y: bestY }); + skyline = raiseSkyline(skyline, bestX, span, bestY + box.height + gap); + } + return corners; +} + +function raiseSkyline( + skyline: { x: number; width: number; y: number }[], + x: number, + width: number, + y: number, +): { x: number; width: number; y: number }[] { + const end = x + width; + const next: { x: number; width: number; y: number }[] = []; + for (const s of skyline) { + const sEnd = s.x + s.width; + if (sEnd <= x || s.x >= end) { next.push(s); continue; } + if (s.x < x) next.push({ x: s.x, width: x - s.x, y: s.y }); + if (sEnd > end) next.push({ x: end, width: sEnd - end, y: s.y }); + } + next.push({ x, width, y }); + return next.sort((a, b) => a.x - b.x); +} + +const REGION_PREFIX = '_group_'; + +function regionOf(node: cytoscape.NodeSingular): string { + const region = node.ancestors().filter(a => a.id().startsWith(REGION_PREFIX)); + return region.nonempty() ? region.first().id() : UNGROUPED_REGION; +} + +function layoutRegion( + members: cytoscape.NodeCollection, + layoutOptions: cytoscape.LayoutOptions, +): Promise { + const memberIds = new Set(members.map(n => n.id())); + const elements: cytoscape.ElementDefinition[] = []; + members.forEach((n) => { + const data = { ...n.data() }; + if (data.parent !== undefined && !memberIds.has(data.parent)) delete data.parent; + elements.push({ group: 'nodes', data }); + }); + members.connectedEdges().forEach((e) => { + if (memberIds.has(e.source().id()) && memberIds.has(e.target().id())) { + elements.push({ group: 'edges', data: { ...e.data() } }); + } + }); + const region = cytoscape({ + headless: true, + styleEnabled: true, + elements, + style: [{ selector: 'node[dw]', style: { 'width': 'data(dw)', 'height': 'data(dh)' } }], + }); + return new Promise((resolve) => { + region.one('layoutstop', () => { + const positions: PositionMap = {}; + region.nodes(':childless').forEach((n) => { + const p = n.position(); + positions[n.id()] = { x: p.x, y: p.y }; + }); + region.destroy(); + resolve(positions); + }); + region.layout(layoutOptions).run(); + }); +} + +// `scratch` must come from buildScratchCore(live, true): each top-level +// compound is a colour-family region, and ungrouped nodes sit at the top level. +export async function layoutByGroup( + scratch: cytoscape.Core, + layoutOptions: cytoscape.LayoutOptions, + gap: number, +): Promise { + const regions = new Map(); + scratch.nodes().forEach((n) => { + if (n.parent().nonempty()) return; + if (n.id().startsWith(REGION_PREFIX)) { + regions.set(n.id(), n.descendants()); + } else { + const loose = regions.get(UNGROUPED_REGION) ?? scratch.collection(); + regions.set(UNGROUPED_REGION, loose.union(n).union(n.descendants())); + } + }); + + const local = new Map(); + const boxes: RegionBox[] = []; + for (const [id, members] of regions) { + const positions = await layoutRegion(members, layoutOptions); + let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity; + for (const [nodeId, p] of Object.entries(positions)) { + const n = scratch.$id(nodeId); + const halfW = n.width() / 2; + const halfH = n.height() / 2; + x1 = Math.min(x1, p.x - halfW); x2 = Math.max(x2, p.x + halfW); + y1 = Math.min(y1, p.y - halfH); y2 = Math.max(y2, p.y + halfH); + } + if (!Number.isFinite(x1)) continue; + for (const p of Object.values(positions)) { p.x -= x1; p.y -= y1; } + local.set(id, positions); + boxes.push({ id, width: x2 - x1, height: y2 - y1 }); + } + + const crossEdges: RegionEdge[] = []; + scratch.edges().forEach((e) => { + const source = regionOf(e.source()); + const target = regionOf(e.target()); + if (source !== target) crossEdges.push({ source, target }); + }); + const order = orderRegions(boxes.map(b => b.id), crossEdges); + const byId = new Map(boxes.map(b => [b.id, b])); + const ordered = order.flatMap(id => byId.get(id) ?? []); + const corners = packRegions(ordered, 16 / 10, gap); + + const result: PositionMap = {}; + for (const [id, positions] of local) { + const corner = corners.get(id) ?? { x: 0, y: 0 }; + for (const [nodeId, p] of Object.entries(positions)) { + result[nodeId] = { x: p.x + corner.x, y: p.y + corner.y }; + } + } + return result; +} diff --git a/src/app/views/graph/layout-store.ts b/src/app/views/graph/layout-store.ts index ec23c6e..3174ecd 100644 --- a/src/app/views/graph/layout-store.ts +++ b/src/app/views/graph/layout-store.ts @@ -2,7 +2,7 @@ * layout-store.ts — localStorage helper for persisting graph node positions. * * Uses a single key (`ignatius-layout-positions`) holding a JSON map: - * { [layoutKey: string]: { positions: { [nodeId: string]: { x: number; y: number } }, savedAt: number } } + * { [layoutKey: string]: { positions: { [nodeId: string]: { x: number; y: number } }, savedAt: number, hidden?: string[] } } * * Dependency-injectable via the StorageLike interface so unit tests can run * without a real browser localStorage. @@ -22,17 +22,23 @@ export type PositionMap = Record; interface LayoutEntry { positions: PositionMap; savedAt: number; + /** Node ids the user removed from view; absent on entries saved before hiding existed. */ + hidden?: string[]; } type LayoutStore = Record; export interface LayoutStoreHandle { load(layoutKey: string): PositionMap | null; - save(layoutKey: string, positions: PositionMap): void; + hidden(layoutKey: string): string[]; + save(layoutKey: string, positions: PositionMap, hidden?: string[]): void; clear(layoutKey: string): void; /** Newest saved position of `nodeId` outside `exceptKey`, from an entry that also saved one of * `neighbourIds`; the store is shared by every model, so this keeps out same-named nodes. */ lastPosition(nodeId: string, exceptKey: string, neighbourIds: string[]): { x: number; y: number } | null; + /** The same model before its last edit: the newest entry outside `exceptKey` whose shared ids are + * at least half of `nodeIds` and half of its own entities. Null for an empty key. */ + previous(exceptKey: string, nodeIds: string[]): { positions: PositionMap; hidden: string[] } | null; } function readStore(storage: StorageLike, storageKey: string): LayoutStore { @@ -94,10 +100,17 @@ export function createLayoutStore( return entry.positions; }, - save(layoutKey: string, positions: PositionMap): void { + hidden(layoutKey: string): string[] { + if (!layoutKey) return []; + return readStore(storage, storageKey)[layoutKey]?.hidden ?? []; + }, + + save(layoutKey: string, positions: PositionMap, hidden: string[] = []): void { if (!layoutKey) return; const store = readStore(storage, storageKey); - store[layoutKey] = { positions, savedAt: clock() }; + store[layoutKey] = hidden.length > 0 + ? { positions, savedAt: clock(), hidden } + : { positions, savedAt: clock() }; writeStore(storage, pruneToNewest(store), storageKey); }, @@ -116,5 +129,23 @@ export function createLayoutStore( .sort(([, a], [, b]) => b.savedAt - a.savedAt); return entries[0]?.[1].positions[nodeId] ?? null; }, + + previous(exceptKey: string, nodeIds: string[]): { positions: PositionMap; hidden: string[] } | null { + // An empty key can never save, so borrowing for it would strand the result. + if (!exceptKey || nodeIds.length === 0) return null; + const candidates = Object.entries(readStore(storage, storageKey)) + .filter(([key, entry]) => key !== exceptKey && entry?.positions) + .map(([, entry]) => { + const shared = nodeIds.filter(id => entry.positions[id] !== undefined).length; + // Joiner diamonds are saved too; their ids start with `_`. + const saved = Object.keys(entry.positions).filter(id => !id.startsWith('_')).length; + return { entry, shared, saved }; + }) + // Half of each side, so a larger model holding these names does not qualify. + .filter(({ shared, saved }) => shared * 2 >= nodeIds.length && shared * 2 >= saved) + .sort((a, b) => b.entry.savedAt - a.entry.savedAt); + const entry = candidates[0]?.entry; + return entry ? { positions: entry.positions, hidden: entry.hidden ?? [] } : null; + }, }; } diff --git a/src/app/views/graph/markers.ts b/src/app/views/graph/markers.ts index 4821358..e49a664 100644 --- a/src/app/views/graph/markers.ts +++ b/src/app/views/graph/markers.ts @@ -105,8 +105,14 @@ export function updateMarkers(cy: cytoscape.Core, svg: SVGSVGElement, theme: The const len = Math.sqrt(dx * dx + dy * dy); if (len < 40) return; - const srcAngle = Math.atan2(dy, dx); - const tgtAngle = Math.atan2(-dy, -dx); + // A bent edge leaves each end along its own segment. renderedSegmentPoints + // throws on an edge that is not segmented. + const curve = String(edge.pstyle('curve-style').value); + const bends = curve.endsWith('segments') || curve.endsWith('taxi') ? edge.renderedSegmentPoints() ?? [] : []; + const firstBend = bends[0] ?? tgtPt; + const lastBend = bends[bends.length - 1] ?? srcPt; + const srcAngle = Math.atan2(firstBend.y - srcPt.y, firstBend.x - srcPt.x); + const tgtAngle = Math.atan2(lastBend.y - tgtPt.y, lastBend.x - tgtPt.x); const faded = edge.hasClass('faded'); drawEndMarker(svg, srcPt.x, srcPt.y, srcAngle, parentCard, color, scale, p.background, theme.spacing.markerOffset, faded); diff --git a/src/app/views/graph/organic-layout.ts b/src/app/views/graph/organic-layout.ts index d24a4be..a215baf 100644 --- a/src/app/views/graph/organic-layout.ts +++ b/src/app/views/graph/organic-layout.ts @@ -569,6 +569,14 @@ export function gradeEdgeSpans(edges: cytoscape.EdgeCollection) { } } +// A joiner diamond carries no group of its own; in a region layout it belongs +// with the cluster whose members it feeds, or it floats outside every region. +function joinerGroup(n: cytoscape.NodeSingular, clusterGroup: Map): string | undefined { + if (n.data('joiner') !== 'true') return undefined; + const memberParent = n.outgoers('node').parent(); + return memberParent.nonempty() ? clusterGroup.get(memberParent.id()) : undefined; +} + // Build a HEADLESS scratch mirror of the live graph for the layout search. // Once the live core has painted, every element read and write pays renderer // bookkeeping — label re-measures, compound-bounds upkeep, listener notify — @@ -580,14 +588,21 @@ export function gradeEdgeSpans(edges: cytoscape.EdgeCollection) { // any label machinery; the `layout-uniform` class gives the global fCoSE // passes their fixed structure-only box. // groupRegions: wrap each colour family in an invisible compound parent on the -// scratch mirror. fCoSE is compound-aware (the CoSE lineage is *designed* for +// scratch mirror. The hierarchical layout lays out each region on its own +// (grouped-layered.ts). fCoSE is compound-aware (the CoSE lineage is *designed* for // compound graphs): with regions, a large model decomposes into per-family // sub-layouts plus one small region-vs-region problem, instead of one flat // n-node force problem — which at ~400 nodes settles into an unreadable // hairball no matter the seed. The regions exist ONLY here; the live core // receives bare positions. Cluster compounds nest inside the region of their // majority member group; ungrouped nodes stay top-level. -export function buildScratchCore(live: cytoscape.Core, groupRegions = false): cytoscape.Core { +// exclude: hidden node ids, left off the mirror with their edges (and any +// cluster left empty) so the layout closes the space they took. +export function buildScratchCore( + live: cytoscape.Core, + groupRegions = false, + exclude: ReadonlySet = new Set(), +): cytoscape.Core { const regionId = (g: string) => `_group_${g}`; const usedRegions = new Set(); // Majority group per existing compound (subtype cluster) parent. @@ -607,14 +622,19 @@ export function buildScratchCore(live: cytoscape.Core, groupRegions = false): cy }); } const elements: cytoscape.ElementDefinition[] = []; - live.nodes().forEach((n) => { + const kept = live.nodes().filter((n) => { + if (exclude.has(n.id())) return false; + return !n.isParent() || n.children().some(c => !exclude.has(c.id())); + }); + const keptIds = new Set(kept.map(n => n.id())); + kept.forEach((n) => { const data = { ...n.data() }; if (!n.isParent()) { data.dw = n.outerWidth(); data.dh = n.outerHeight(); } if (groupRegions && !data.parent) { - const g = n.isParent() ? clusterGroup.get(n.id()) : n.data('group'); + const g = n.isParent() ? clusterGroup.get(n.id()) : (n.data('group') || joinerGroup(n, clusterGroup)); if (g) { data.parent = regionId(g); usedRegions.add(g); @@ -626,6 +646,7 @@ export function buildScratchCore(live: cytoscape.Core, groupRegions = false): cy elements.push({ group: 'nodes', data: { id: regionId(g) } }); }); live.edges().forEach((e) => { + if (!keptIds.has(e.source().id()) || !keptIds.has(e.target().id())) return; elements.push({ group: 'edges', data: { ...e.data() } }); }); return cytoscape({ diff --git a/src/app/views/graph/styles.ts b/src/app/views/graph/styles.ts index 22e2a39..6cbe843 100644 --- a/src/app/views/graph/styles.ts +++ b/src/app/views/graph/styles.ts @@ -1,9 +1,11 @@ import cytoscape from 'cytoscape'; import type { ThemeConfig, ThemeMode } from '../../../theme/theme-defaults'; +import { semanticColors } from '../../../theme/theme-defaults'; import type { GroupConfig } from '../../../model/parse'; import { blendHex, pastel, lighten } from '../../logic/color'; import { SPOTLIGHT_LINE_INHERITED } from '../../dom/theme-css-vars'; import { EDGE_GLOW_SCALE, GLOW_STEADY, TIER_OPACITY, UNEXPLAINED_STEADY_MIX, glowColors } from './change-glow'; +import { USER_HIDDEN_CLASS } from './graph-edits'; /** * Graph search match border (graph-flow-search CP2). Distinct gold/yellow so @@ -174,6 +176,19 @@ export function buildStyles(groups: Record, theme: ThemeCon }); } + // After the per-group rules so the ring wins over a group's border colour; a + // lightened border alone vanished at overview zoom. + base.push({ + selector: 'node:selected', + style: { + 'outline-width': 3, + 'outline-color': semanticColors[mode].link, + 'outline-offset': 3, + 'outline-opacity': 1, + 'overlay-opacity': 0, + }, + }); + // Node glows are drawn by createChangeGlow on a canvas behind cytoscape; edge // glows are steady underlays here, which createChangeGlow pulses. const glow = glowColors(theme, mode); @@ -285,5 +300,44 @@ export function buildStyles(groups: Record, theme: ThemeCon base.push({ selector: `edge[change].${tier}`, style: { 'underlay-opacity': GLOW_STEADY * EDGE_GLOW_SCALE * opacity } }); } + // Tree layout: GraphView sets each routed edge's bends; a tree line's + // predicate rides its run into the child, a cross link's sits mid-street. + base.push( + { + selector: 'edge.tree-edge', + style: { + 'curve-style': 'segments', + 'edge-distances': 'node-position', + 'label': '', + 'target-label': 'data(edgeLabel)', + 'target-text-margin-y': -9, + 'target-text-rotation': 'none', + }, + }, + { + selector: 'edge.tree-label-source', + style: { + 'target-label': '', + 'source-label': 'data(edgeLabel)', + 'source-text-margin-y': -9, + 'source-text-rotation': 'none', + }, + }, + { + selector: 'edge.tree-cross', + style: { + 'curve-style': 'segments', + 'edge-distances': 'node-position', + 'text-rotation': 'none', + // The street runs just under the parent: the label sits on it, not above it. + 'text-margin-y': 0, + 'text-background-padding': '2px', + }, + }, + ); + + // A node the user removed from view; cytoscape hides its edges with it. + base.push({ selector: `node.${USER_HIDDEN_CLASS}`, style: { 'display': 'none' } }); + return base; } diff --git a/src/app/views/graph/tree-layout.ts b/src/app/views/graph/tree-layout.ts new file mode 100644 index 0000000..9b8d31a --- /dev/null +++ b/src/app/views/graph/tree-layout.ts @@ -0,0 +1,565 @@ +import type { PositionMap } from './layout-store'; +import { orderRegions } from './grouped-layered'; + +// Outline layout with lane-separated right-angled lines: see +// docs/design/graph-tree-layout.md. + +export type TreeNodeInput = { + id: string; + width: number; + height: number; + group: string; + classifier: boolean; + /** A subtype joiner diamond: never wraps away from its basetype. */ + joiner?: boolean; +}; +/** `source` is the referenced (parent) table, `target` the table holding the FK. */ +export type TreeEdgeInput = { id: string; source: string; target: string; identifying: boolean; label: string }; + +export type Forest = { + parent: Map; + children: Map; + roots: string[]; + /** Tables nothing hangs under that a table references, each with the consumer whose tree they sit above. */ + lookups: Map; +}; + +/** A node's centre and size. */ +export type Box = { x: number; y: number; width: number; height: number }; +type Point = { x: number; y: number }; + +/** The line a route draws now: its two ends, the bends between, and where its label goes. */ +export type RouteGeometry = { + kind: 'down' | 'cross'; + parentEnd: Point; + bends: Point[]; + childEnd: Point; + /** `down` only: label distance from the child end, halfway along the run into it. */ + labelOffset: number; +}; + +/** Lane (from the parent's right edge, negative), street (below the parent's bottom) and approach column (from the child's left edge, negative). */ +export type CrossSlot = { laneDx: number; streetDy: number; approachDx: number }; + +/** A line to route: tree lines (`down`) as the last tree layout drew them, everything else `cross`. */ +export type TreeLine = { id: string; parent: string; child: string; kind: 'down' | 'cross' }; + +/** Where a line runs from its parent's bottom edge into its child's left side, relative to both so it survives a drag. */ +export type TreeRoute = { + kind: 'down' | 'cross'; + parent: string; + child: string; + /** `down` only: lane x from the parent's left edge. */ + laneDx: number; + /** The cross geometry: a `cross` route's own; for a `down` route, the fallback while a drag moves its child out of place. */ + slot: CrossSlot; + /** Row the line enters the child at, from the child's centre. */ + entryDy: number; +}; + +// Rows leave room for a cross link's street and its centred label under a parent. +const ROW_GAP = 20; +const TREE_GAP = 40; +const COLUMN_GAP = 80; +const LANE_GAP = 6; +const STREET_INSET = 10; +const STREET_GAP = 2; +const STREET_BAND = 6; +const APPROACH_INSET = 12; +const LANE_INSET = 10; +const ENTRY_GAP = 6; +const LABEL_CHAR_WIDTH = 6; +const LABEL_PAD = 14; +const MIN_RUN = 28; +const ASPECT = 16 / 10; +const HUB_MIN_REFERENCES = 8; +const HUB_SHARE = 0.1; + +const byId = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0); + +export function buildForest(nodes: TreeNodeInput[], edges: TreeEdgeInput[]): Forest { + const info = new Map(nodes.map(n => [n.id, n])); + const incoming = new Map(); + const outgoing = new Map(); + for (const e of edges) { + if (e.source === e.target || !info.has(e.source) || !info.has(e.target)) continue; + incoming.set(e.target, [...(incoming.get(e.target) ?? []), e]); + outgoing.set(e.source, [...(outgoing.get(e.source) ?? []), e]); + } + + // A table many others reference without inheriting its key (users behind + // every `created_by`) would gather every unrelated table under itself. + const referencedBy = new Map>(); + for (const e of edges) { + if (e.identifying || e.source === e.target) continue; + referencedBy.set(e.source, (referencedBy.get(e.source) ?? new Set()).add(e.target)); + } + const hubLimit = Math.max(HUB_MIN_REFERENCES, Math.ceil(nodes.length * HUB_SHARE)); + const isHub = (id: string) => (referencedBy.get(id)?.size ?? 0) >= hubLimit; + + // Key inheritance first; without it, an FK parent that is neither a classifier nor a hub. + const candidates = (id: string): string[] => { + const into = incoming.get(id) ?? []; + const identifying = into.filter(e => e.identifying).map(e => e.source); + if (identifying.length > 0) return [...new Set(identifying)]; + return [...new Set(into.filter(e => !info.get(e.source)?.classifier && !isHub(e.source)).map(e => e.source))]; + }; + + // Longest candidate chain above a node, so a table hangs under its most specific parent. + const rank = new Map(); + const onPath = new Set(); + const rankOf = (id: string): number => { + const known = rank.get(id); + if (known !== undefined) return known; + if (onPath.has(id)) return 0; + onPath.add(id); + const above = candidates(id).map(rankOf); + onPath.delete(id); + const r = above.length === 0 ? 0 : 1 + Math.max(...above); + rank.set(id, r); + return r; + }; + + const parent = new Map(); + const viaKey = new Set(); + for (const n of [...nodes].sort((a, b) => byId(a.id, b.id))) { + const options = candidates(n.id).sort((a, b) => rankOf(b) - rankOf(a) || byId(a, b)); + const chosen = options[0]; + if (chosen === undefined) continue; + parent.set(n.id, chosen); + if ((incoming.get(n.id) ?? []).some(e => e.identifying && e.source === chosen)) viaKey.add(n.id); + } + + // Break any cycle at a plain FK link (smallest id first); only a cycle made + // entirely of key links is cut at a key link. + for (const n of nodes) { + const seen: string[] = []; + let at: string | undefined = n.id; + while (at !== undefined && !seen.includes(at)) { + seen.push(at); + at = parent.get(at); + } + if (at !== undefined) { + const cycle = seen.slice(seen.indexOf(at)).sort(byId); + const breakAt = cycle.find(id => !viaKey.has(id)) ?? cycle[0]; + if (breakAt !== undefined) parent.delete(breakAt); + } + } + + const childCount = new Map(); + for (const p of parent.values()) childCount.set(p, (childCount.get(p) ?? 0) + 1); + + // A root nothing hangs under is a lookup: never hung under a table that uses + // it, where its line would run from parent back to child. + const isLoose = (id: string) => !parent.has(id) && (childCount.get(id) ?? 0) === 0 && !isHub(id) + && (outgoing.get(id)?.length ?? 0) > 0; + const lookups = new Map(); + for (const n of [...nodes].sort((a, b) => byId(a.id, b.id))) { + if (!isLoose(n.id)) continue; + // A lookup used only by other lookups has no tree to sit above; it stays a root. + const consumers = [...new Set((outgoing.get(n.id) ?? []).map(e => e.target))] + .filter(c => !isLoose(c)) + .sort((a, b) => Number(info.get(b)?.group === n.group) - Number(info.get(a)?.group === n.group) || byId(a, b)); + const host = consumers[0]; + if (host !== undefined) lookups.set(n.id, host); + } + + const children = new Map(); + for (const [child, p] of parent) children.set(p, [...(children.get(p) ?? []), child]); + const roots = nodes.map(n => n.id).filter(id => !parent.has(id) && !lookups.has(id)); + return { parent, children, roots, lookups }; +} + +// Each placed node (lookups included) mapped to the root of the tree it belongs to. +function treeRoots(roots: string[], children: Map, lookups: Map): Map { + const rootOf = new Map(); + const claim = (id: string, root: string) => { + rootOf.set(id, root); + for (const kid of children.get(id) ?? []) claim(kid, root); + }; + for (const root of roots) claim(root, root); + for (const [lookup, consumer] of lookups) { + const root = rootOf.get(consumer); + if (root !== undefined) rootOf.set(lookup, root); + } + return rootOf; +} + +// Lanes fan across half a bottom edge, stopping 2 px short of the centre so the +// halves never meet; a half with no room past the inset is one trunk at the centre. +function fan(halfWidth: number, count: number, gap: number): { base: number; gap: number } { + const roomy = halfWidth - LANE_INSET - 2; + if (roomy <= 0) return { base: halfWidth, gap: 0 }; + return { base: LANE_INSET, gap: count <= 1 ? 0 : Math.min(gap, roomy / (count - 1)) }; +} + +const NO_SLOT: CrossSlot = { laneDx: -LANE_INSET, streetDy: STREET_INSET, approachDx: -APPROACH_INSET }; + +export function layoutTree(nodes: TreeNodeInput[], edges: TreeEdgeInput[]): { positions: PositionMap; routes: Map } { + const info = new Map(nodes.map(n => [n.id, n])); + const forest = buildForest(nodes, edges); + const { children, roots } = forest; + + const sizeMemo = new Map(); + const subtreeSize = (id: string): number => { + const known = sizeMemo.get(id); + if (known !== undefined) return known; + const size = 1 + (children.get(id) ?? []).reduce((sum, c) => sum + subtreeSize(c), 0); + sizeMemo.set(id, size); + return size; + }; + // Smaller subtrees first: the lanes that run furthest down pass fewer rows. + const orderedChildren = (id: string) => + [...(children.get(id) ?? [])].sort((a, b) => subtreeSize(a) - subtreeSize(b) || byId(a, b)); + const heightMemo = new Map(); + const stackHeight = (id: string): number => { + const known = heightMemo.get(id); + if (known !== undefined) return known; + const kids = children.get(id) ?? []; + const height = (info.get(id)?.height ?? 0) + kids.reduce((sum, c) => sum + ROW_GAP + stackHeight(c), 0); + heightMemo.set(id, height); + return height; + }; + + const pairKey = (a: string, b: string) => (a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`); + const byPair = new Map(); + for (const e of [...edges].sort((a, b) => byId(a.id, b.id))) { + const key = pairKey(e.source, e.target); + byPair.set(key, [...(byPair.get(key) ?? []), e]); + } + const edgesBetween = (p: string, c: string) => byPair.get(pairKey(p, c)) ?? []; + const labelRun = (list: TreeEdgeInput[]) => + Math.max(MIN_RUN, Math.max(0, ...list.map(e => e.label.length * LABEL_CHAR_WIDTH)) + 2 * LABEL_PAD); + + let positions: PositionMap = {}; + let routes = new Map(); + + const inWrap = new Set(); + let wrapDepth = 0; + const isJoiner = (id: string) => info.get(id)?.joiner === true; + + // Lays the subtree out with its top-left at (left, top); returns its bottom and right. + // Children that would run past `limit` wrap into columns to the right. + const place = (id: string, left: number, top: number, limit: number): { bottom: number; right: number } => { + const self = info.get(id); + if (!self) return { bottom: top, right: left }; + positions[id] = { x: left + self.width / 2, y: top + self.height / 2 }; + if (wrapDepth > 0) inWrap.add(id); + let bottom = top + self.height; + let right = left + self.width; + + const firstRow = bottom + ROW_GAP; + const kids = orderedChildren(id); + // A fresh column must fit two of the parent's child rows, or wrapping makes a strip of one-table columns. + const childRow = Math.max(0, ...kids.map(k => info.get(k)?.height ?? 0)) + ROW_GAP; + const canWrap = limit - firstRow >= 2 * childRow; + const overflows = canWrap && top + stackHeight(id) > limit; + // When children wrap, a subtype cluster goes first so it gets the room under its basetype. + const order = overflows ? [...kids.filter(isJoiner), ...kids.filter(k => !isJoiner(k))] : kids; + const kept: string[] = []; + const wrapped: string[] = []; + let used = firstRow; + for (const kid of order) { + const height = stackHeight(kid); + if (kept.length === 0 || isJoiner(kid) || !canWrap || used + height <= limit) { + kept.push(kid); + used += height + ROW_GAP; + } else { + wrapped.push(kid); + } + } + if (kept.length === 0) return { bottom, right }; + + const kidEdges = kept.map(k => edgesBetween(id, k)); + const laneCount = kidEdges.reduce((sum, list) => sum + list.length, 0); + // Tree lanes use the left half of the parent's bottom edge; cross links the right. + const { base: laneBase, gap: laneGap } = fan(self.width / 2, laneCount, LANE_GAP); + const childLeft = left + laneBase + (laneCount - 1) * laneGap + labelRun(kidEdges.flat()); + + // The first child's lanes are rightmost, so each lower child's lane runs + // left of every earlier child's horizontal and never crosses it. + let lane = laneCount - 1; + let rowTop = firstRow; + kept.forEach((kid, i) => { + const placed = place(kid, childLeft, rowTop, limit); + for (const edge of kidEdges[i] ?? []) { + const laneDx = laneBase + lane * laneGap; + routes.set(edge.id, { kind: 'down', parent: id, child: kid, laneDx, slot: NO_SLOT, entryDy: 0 }); + lane--; + } + rowTop = placed.bottom + ROW_GAP; + bottom = placed.bottom; + right = Math.max(right, placed.right); + }); + + // Wrapped columns start at the first child row, so their cross links still run down and right. + let columnLeft = right + COLUMN_GAP; + let columnRight = columnLeft; + let inColumn = 0; + rowTop = firstRow; + for (const kid of wrapped) { + if (inColumn > 0 && rowTop + stackHeight(kid) > limit) { + columnLeft = columnRight + COLUMN_GAP; + rowTop = firstRow; + inColumn = 0; + } + wrapDepth++; + const placed = place(kid, columnLeft, rowTop, limit); + wrapDepth--; + columnRight = Math.max(columnRight, placed.right); + rowTop = placed.bottom + ROW_GAP; + inColumn++; + bottom = Math.max(bottom, placed.bottom); + right = Math.max(right, placed.right); + } + return { bottom, right }; + }; + + const feedsOf = new Map(); + for (const e of [...edges].sort((a, b) => byId(a.id, b.id))) { + if (forest.lookups.has(e.source) && e.source !== e.target) { + feedsOf.set(e.source, [...(feedsOf.get(e.source) ?? []), e]); + } + } + + // A tree's lookups sit in a staircase above it, the one feeding the deepest + // table top-left, so a lookup's run into its deepest table crosses no other lookup's lane. + const placeBlock = (root: string): { members: string[]; width: number; height: number } => { + const before = new Set(Object.keys(positions)); + const tree = place(root, 0, 0, columnHeight); + const members = Object.keys(positions).filter(id => !before.has(id)); + const inTree = new Set(members); + // A lookup joins the block of its assigned consumer only; its lines into other trees are cross links. + const blockLookups = [...forest.lookups].filter(([, consumer]) => inTree.has(consumer)).map(([l]) => l); + if (blockLookups.length === 0) return { members, width: tree.right, height: tree.bottom }; + + // A run into a wrapped table would pass behind the boxes on its row; that line is a cross link. + const feedsFor = (l: string) => (feedsOf.get(l) ?? []).filter(e => inTree.has(e.target) && !inWrap.has(e.target)) + .sort((a, b) => (positions[b.target]?.y ?? 0) - (positions[a.target]?.y ?? 0) || byId(a.id, b.id)); + const deepest = (l: string) => positions[feedsFor(l)[0]?.target ?? '']?.y ?? 0; + const stair = [...blockLookups].sort((a, b) => deepest(b) - deepest(a) || byId(a, b)); + + let stepLeft = 0; + let stepTop = 0; + let bandRight = 0; + const lanes: { edge: TreeEdgeInput; lookup: string; laneDx: number; x: number }[] = []; + for (const l of stair) { + const self = info.get(l); + const width = self?.width ?? 0; + const height = self?.height ?? 0; + positions[l] = { x: stepLeft + width / 2, y: stepTop + height / 2 }; + const own = feedsFor(l); + const spread = fan(width / 2, own.length, LANE_GAP); + own.forEach((edge, k) => { + const laneDx = spread.base + k * spread.gap; + lanes.push({ edge, lookup: l, laneDx, x: stepLeft + laneDx }); + }); + bandRight = Math.max(bandRight, stepLeft + width); + // The next lookup starts right of these lanes, so they drop past it. + stepLeft += spread.base + Math.max(0, own.length - 1) * spread.gap + LANE_INSET; + stepTop += height + ROW_GAP; + } + + const lastLane = Math.max(0, ...lanes.map(l => l.x)); + const treeLeft = lastLane + labelRun(lanes.map(l => l.edge)); + const treeTop = stepTop; + for (const id of members) { + const p = positions[id]; + if (p) positions[id] = { x: p.x + treeLeft, y: p.y + treeTop }; + } + for (const { edge, lookup, laneDx } of lanes) { + routes.set(edge.id, { kind: 'down', parent: lookup, child: edge.target, laneDx, slot: NO_SLOT, entryDy: 0 }); + } + return { + members: [...members, ...stair], + width: Math.max(bandRight, treeLeft + tree.right), + height: treeTop + tree.bottom, + }; + }; + + const measure = (root: string) => { + const saved = { positions, routes }; + positions = {}; + routes = new Map(); + const size = place(root, 0, 0, Infinity); + ({ positions, routes } = saved); + return { width: size.right, height: size.bottom }; + }; + + // Columns about as tall as makes the whole picture ~16:10; a tree's lookup + // band adds to its height. + const rootOf = treeRoots(roots, children, forest.lookups); + const bandHeight = new Map(); + for (const lookup of forest.lookups.keys()) { + const root = rootOf.get(lookup); + if (root !== undefined) bandHeight.set(root, (bandHeight.get(root) ?? 0) + (info.get(lookup)?.height ?? 0) + ROW_GAP); + } + const area = roots.reduce((sum, r) => { + const { width, height } = measure(r); + return sum + (width + COLUMN_GAP) * (height + (bandHeight.get(r) ?? 0) + TREE_GAP); + }, 0); + const columnHeight = Math.sqrt(area / ASPECT); + + // A tree other trees reference goes first (left, or above in its column), so + // links between trees run right and down: colour groups, then trees in each. + const groupOf = (root: string) => info.get(root)?.group ?? ''; + const treeLinks = edges.flatMap((e) => { + const source = rootOf.get(e.source); + const target = rootOf.get(e.target); + return source !== undefined && target !== undefined && source !== target ? [{ source, target }] : []; + }); + const groupOrder = orderRegions( + [...new Set(roots.map(groupOf))], + treeLinks.map(l => ({ source: groupOf(l.source), target: groupOf(l.target) })), + ); + const ordered = groupOrder.flatMap((g) => { + const inGroup = roots.filter(r => groupOf(r) === g); + return orderRegions(inGroup, treeLinks.filter(l => groupOf(l.source) === g && groupOf(l.target) === g)); + }); + + const trees = ordered.map(root => placeBlock(root)); + + let columnLeft = 0; + let columnWidth = 0; + let y = 0; + for (const tree of trees) { + if (y > 0 && y + tree.height > columnHeight) { + columnLeft += columnWidth + COLUMN_GAP; + columnWidth = 0; + y = 0; + } + for (const id of tree.members) { + const p = positions[id]; + if (p) positions[id] = { x: p.x + columnLeft, y: p.y + y }; + } + y += tree.height + TREE_GAP; + columnWidth = Math.max(columnWidth, tree.width); + } + + const lines: TreeLine[] = [...routes].map(([id, r]) => ({ id, parent: r.parent, child: r.child, kind: 'down' })); + for (const e of edges) { + if (routes.has(e.id) || e.source === e.target || !positions[e.source] || !positions[e.target]) continue; + lines.push({ id: e.id, parent: e.source, child: e.target, kind: 'cross' }); + } + return { positions, routes: routeTree(nodes, positions, lines) }; +} + +/** Lanes, slots, entry rows and approach columns for `lines` at `positions`; a tree line whose + * child is no longer below its parent and right of its lane is routed as a cross link. */ +export function routeTree(nodes: TreeNodeInput[], positions: PositionMap, lines: TreeLine[]): Map { + const info = new Map(nodes.map(n => [n.id, n])); + const left = (id: string) => (positions[id]?.x ?? 0) - (info.get(id)?.width ?? 0) / 2; + const top = (id: string) => (positions[id]?.y ?? 0) - (info.get(id)?.height ?? 0) / 2; + const bottom = (id: string) => (positions[id]?.y ?? 0) + (info.get(id)?.height ?? 0) / 2; + const placed = lines + .filter(l => l.parent !== l.child && positions[l.parent] && positions[l.child] && info.has(l.parent) && info.has(l.child)) + .sort((a, b) => byId(a.id, b.id)); + + const routes = new Map(); + const downsByParent = new Map(); + for (const l of placed) { + if (l.kind === 'down' && top(l.child) > bottom(l.parent)) { + downsByParent.set(l.parent, [...(downsByParent.get(l.parent) ?? []), l]); + } + } + for (const [parent, all] of downsByParent) { + // The topmost child takes the rightmost lane; a child left of its lane leaves the fan. + let list = [...all].sort((a, b) => (positions[a.child]?.y ?? 0) - (positions[b.child]?.y ?? 0) || byId(a.id, b.id)); + const laneDx = (i: number, count: number) => { + const spread = fan((info.get(parent)?.width ?? 0) / 2, count, LANE_GAP); + return spread.base + (count - 1 - i) * spread.gap; + }; + for (;;) { + const fits = list.filter((l, i) => left(l.child) > left(parent) + laneDx(i, list.length) + 2); + if (fits.length === list.length) break; + list = fits; + } + list.forEach((l, i) => { + routes.set(l.id, { kind: 'down', parent, child: l.child, laneDx: laneDx(i, list.length), slot: NO_SLOT, entryDy: 0 }); + }); + } + for (const l of placed) { + if (!routes.has(l.id)) routes.set(l.id, { kind: 'cross', parent: l.parent, child: l.child, laneDx: 0, slot: NO_SLOT, entryDy: 0 }); + } + crossSlots(info, positions, routes); + assignEntries(info, positions, routes); + return routes; +} + +// Each cross link from a parent gets its own lane and street, the farthest child +// the outer lane and top street: nearer children right and below nest inside. +function crossSlots(info: Map, positions: PositionMap, routes: Map): void { + const x = (id: string) => positions[id]?.x ?? 0; + const byParent = new Map(); + for (const [id, route] of routes) { + if (route.kind === 'cross') byParent.set(route.parent, [...(byParent.get(route.parent) ?? []), [id, route]]); + } + for (const [parent, list] of byParent) { + list.sort(([ia, a], [ib, b]) => Math.abs(x(b.child) - x(parent)) - Math.abs(x(a.child) - x(parent)) || byId(ia, ib)); + const spread = fan((info.get(parent)?.width ?? 0) / 2, list.length, LANE_GAP); + const streetGap = list.length <= 1 ? 0 : Math.min(STREET_GAP, STREET_BAND / (list.length - 1)); + list.forEach(([, route], k) => { + route.slot = { laneDx: -(spread.base + k * spread.gap), streetDy: STREET_INSET + k * streetGap, approachDx: -APPROACH_INSET }; + }); + } +} + +// Entry rows and approach columns share one order so no last turns cross: +// see docs/design/graph-tree-layout.md, Drawing the lines. +function assignEntries(info: Map, positions: PositionMap, routes: Map): void { + const into = new Map(); + for (const [id, route] of routes) into.set(route.child, [...(into.get(route.child) ?? []), [id, route]]); + for (const [child, entries] of into) { + const childY = positions[child]?.y ?? 0; + const height = info.get(child)?.height ?? 0; + const parentLeft = (route: TreeRoute) => (positions[route.parent]?.x ?? 0) - (info.get(route.parent)?.width ?? 0) / 2; + const streetY = (route: TreeRoute) => + (positions[route.parent]?.y ?? 0) + (info.get(route.parent)?.height ?? 0) / 2 + route.slot.streetDy; + const group = (route: TreeRoute) => (route.kind === 'down' ? 1 : streetY(route) < childY ? 0 : 2); + const key = (route: TreeRoute) => (route.kind === 'down' ? -(parentLeft(route) + route.laneDx) : streetY(route)); + entries.sort(([ia, a], [ib, b]) => group(a) - group(b) || key(a) - key(b) || byId(ia, ib)); + const list = entries.map(([, route]) => route); + const gap = list.length <= 1 ? 0 : Math.min(ENTRY_GAP, Math.max(0, height - 8) / (list.length - 1)); + list.forEach((route, k) => { route.entryDy = (k - (list.length - 1) / 2) * gap; }); + + const fromAbove = list.filter(r => group(r) === 0); + const fromBelow = list.filter(r => group(r) === 2); + const column = (i: number) => -(APPROACH_INSET + i * LANE_GAP); + fromAbove.forEach((route, i) => { route.slot = { ...route.slot, approachDx: column(i) }; }); + // From below after from above, so the two groups never share a column. + fromBelow.forEach((route, i) => { route.slot = { ...route.slot, approachDx: column(fromAbove.length + fromBelow.length - 1 - i) }; }); + } +} + +// A `down` route whose child no longer sits below and right of its lane (after +// a hide, reveal, reload or drag) is drawn with cross geometry instead. +export function routeGeometry(route: TreeRoute, parent: Box, child: Box): RouteGeometry { + const parentBottom = parent.y + parent.height / 2; + const childLeft = child.x - child.width / 2; + const entryY = child.y + route.entryDy; + const childEnd = { x: childLeft, y: entryY }; + if (route.kind === 'down') { + const laneX = parent.x - parent.width / 2 + route.laneDx; + if (entryY > parentBottom + 2 && childLeft > laneX + 2) { + return { + kind: 'down', + parentEnd: { x: laneX, y: parentBottom }, + bends: [{ x: laneX, y: entryY }], + childEnd, + labelOffset: (childLeft - laneX) / 2, + }; + } + } + const laneX = parent.x + parent.width / 2 + route.slot.laneDx; + const streetY = parentBottom + route.slot.streetDy; + const approachX = childLeft + route.slot.approachDx; + return { + kind: 'cross', + parentEnd: { x: laneX, y: parentBottom }, + // The first bend twice: an even bend count puts cytoscape's mid label on + // the street, between the two central bends. + bends: [{ x: laneX, y: streetY }, { x: laneX, y: streetY }, { x: approachX, y: streetY }, { x: approachX, y: entryY }], + childEnd, + labelOffset: 0, + }; +} diff --git a/src/flow-view/FlowChrome.tsx b/src/flow-view/FlowChrome.tsx index 8ffb12d..34b46a4 100644 --- a/src/flow-view/FlowChrome.tsx +++ b/src/flow-view/FlowChrome.tsx @@ -223,15 +223,8 @@ export const FlowChrome = forwardRef( // clear it (graph-flow-search CP5, SC12). const breadcrumbRef = useRef(null); - // Search-bar collision avoidance (SC12): the breadcrumb chip row grows - // WIDER with drill depth (single line, no wrap — see the div's flex rules - // below) but its HEIGHT never changes, so tracking its measured bottom - // edge is enough to guarantee the horizontally-centered flow search bar - // never overlaps it at any depth. Mirrors App.tsx's --search-bar-top - // banner-offset idiom (ResizeObserver → CSS custom property); scoped - // locally here — via its own --flow-search-bar-top var, not the banner's - // — since FlowChrome owns the breadcrumb DOM and the banner is never - // shown on the flow surface anyway (see styles.css .viewer-search-bar--flow). + // The crumb row grows with drill depth and wraps short of the toggles; its measured bottom, + // published as --flow-search-bar-top (not the banner's var), keeps the flow search bar below it (SC12). useLayoutEffect(() => { const el = breadcrumbRef.current; const root = document.documentElement; diff --git a/test/checks/test-dict-search-branding-overlap.ts b/test/checks/test-dict-search-branding-overlap.ts index a731b12..e592b65 100644 --- a/test/checks/test-dict-search-branding-overlap.ts +++ b/test/checks/test-dict-search-branding-overlap.ts @@ -1,6 +1,6 @@ /** * test-dict-search-branding-overlap.ts — the DD search bar must never sit under - * the branding block. + * the branding block, and its lens toggle never under the help and theme toggles. * * `.branding-block` is position:fixed at left:16px with z-index 50; the DD * search bar is fixed, full-bleed, z-index 30. Above ~1100px viewport the bar's @@ -13,67 +13,44 @@ * the real rendered geometry at several widths — including a 50-character title * (the parser's cap), which is what would break a hard-coded gutter. * - * Serves models/key-inherited via the source CLI — no compiled binary needed. + * Serves models/key-inherited in-process, so the server ends with the check; + * skips when dist/static is not built. */ import { chromium } from 'playwright'; import { resolve, join } from 'path'; -import { mkdirSync } from 'fs'; +import { existsSync, mkdirSync } from 'fs'; +import { serveCommand } from '../../src/server/server'; +import { box, LONG_SUBTITLE, overlaps, settleAt as settle } from '../chrome-geometry'; +import type { Box } from '../chrome-geometry'; const ROOT = resolve(import.meta.dir, '../..'); const TMP = join(ROOT, 'tmp', 'dict-search-branding-overlap'); +if (!existsSync(join(ROOT, 'dist/static/index.js'))) { + console.log('SKIP: dist/static/index.js not built (run `bun run build:bundle`). CI builds it before checks.'); + process.exit(0); +} mkdirSync(TMP, { recursive: true }); const PORT = 7462; const BASE = `http://localhost:${PORT}`; -const note = (m: string) => console.log(m); const fail = (m: string): never => { console.error(' FAIL ' + m); process.exit(1); }; const pass = (m: string) => console.log(' PASS ' + m); -note('Starting ignatius serve models/key-inherited…'); -const proc = Bun.spawn( - ['bun', 'src/cli/cli.ts', 'serve', 'models/key-inherited', '--port', String(PORT)], - { cwd: ROOT, stdout: 'pipe', stderr: 'pipe' }, -); - -async function waitForServer(url: string, timeout = 12_000): Promise { - const deadline = Date.now() + timeout; - while (Date.now() < deadline) { - try { - const r = await fetch(url); - if (r.ok) return true; - } catch {} - await Bun.sleep(200); - } - return false; -} - -if (!(await waitForServer(BASE, 12_000))) fail('Server did not start within 12 seconds'); -note(`Server ready at ${BASE}`); +const server = serveCommand(join(ROOT, 'models/key-inherited'), { port: PORT }); const browser = await chromium.launch(); const context = await browser.newContext({ viewport: { width: 1440, height: 900 } }); const page = await context.newPage(); -type Box = { x: number; y: number; width: number; height: number }; - /** Horizontal gap between the brand's right edge and the input's left edge. */ async function measureGap(): Promise<{ gap: number; brand: Box; input: Box }> { - const brandEl = page.locator('.branding-block'); - const inputEl = page.locator('.dict-search-input'); - const brand = await brandEl.boundingBox(); - const input = await inputEl.boundingBox(); - if (brand === null) fail('.branding-block has no bounding box'); - if (input === null) fail('.dict-search-input has no bounding box'); - return { gap: input.x - (brand.x + brand.width), brand, input }; -} - -/** True when the two boxes overlap on BOTH axes (i.e. actually cover each other). */ -function overlaps(a: Box, b: Box): boolean { - const xOverlap = a.x < b.x + b.width && b.x < a.x + a.width; - const yOverlap = a.y < b.y + b.height && b.y < a.y + a.height; - return xOverlap && yOverlap; + const brand = await box(page, '.branding-block'); + const input = await box(page, '.dict-search-input'); + if (brand === null) return fail('.branding-block has no bounding box'); + if (input === null) return fail('.dict-search-input has no bounding box'); + return { gap: input.left - brand.right, brand, input }; } await page.goto(`${BASE}/#view=dict`, { waitUntil: 'load' }); @@ -90,41 +67,49 @@ if (!/^\d+px$/.test(gutter) || parseInt(gutter, 10) <= 0) { pass(`--branding-gutter published as ${gutter}`); // ── 2. No overlap across widths — narrow ones are the regression ──────────── -const WIDTHS = [1920, 1440, 1280, 1100, 1050, 900, 800]; -// `setViewportSize` resolves before the page has necessarily re-laid-out, and -// the bar's indent is computed from 100vw — so a stale viewport yields the -// PREVIOUS width's padding and the boxes read as overlapping. Wait for the -// viewport the CSS actually sees, then for the geometry to settle; on timeout -// fall through so the assert below reports real numbers rather than a bare -// Playwright error. A fixed sleep here passed locally and twice in CI, then -// failed 3/3 on a loaded machine. -async function settleAt(width: number): Promise { - await page.setViewportSize({ width, height: 900 }); - await page.waitForFunction(w => window.innerWidth === w, width, { timeout: 5000 }); - try { - await page.waitForFunction(() => { - const b = document.querySelector('.branding-block')?.getBoundingClientRect(); - const i = document.querySelector('.dict-search-input')?.getBoundingClientRect(); - return b !== undefined && i !== undefined && i.x >= b.x + b.width; - }, undefined, { timeout: 5000 }); - } catch {} +const WIDTHS = [1920, 1440, 1280, 1100, 1050, 900, 800, 769, 768, 700, 480, 375]; +// A fixed sleep here once failed 3/3 on a loaded machine; settle() waits on the geometry's inputs. +const settleAt = (width: number) => settle(page, width, 900); + +async function toggleBoxes(): Promise<{ lens: Box; hint: Box; help: Box; theme: Box }> { + const lens = await box(page, '.dict-lens-toggle'); + const hint = await box(page, '.dict-search-bar-inner .kbd-hint'); + const help = await box(page, '.help-toggle'); + const theme = await box(page, '.theme-toggle'); + if (lens === null || hint === null || help === null || theme === null) return fail('lens, B hint, help or theme toggle has no bounding box'); + return { lens, hint, help, theme }; } -for (const width of WIDTHS) { - await settleAt(width); - - const { gap, brand, input } = await measureGap(); - if (overlaps(brand, input)) { - await page.screenshot({ path: join(TMP, `overlap-${width}.png`) }); - fail( - `${width}px: branding block covers the search input ` + - `(brand ends at ${Math.round(brand.x + brand.width)}, input starts at ${Math.round(input.x)})`, - ); +async function assertRow(label: string): Promise { + for (const width of WIDTHS) { + await settleAt(width); + + const { gap, brand, input } = await measureGap(); + if (overlaps(brand, input)) { + await page.screenshot({ path: join(TMP, `overlap-${width}.png`) }); + fail( + `${label} ${width}px: branding block covers the search input ` + + `(brand ends at ${Math.round(brand.right)}, input starts at ${Math.round(input.left)})`, + ); + } + const sameRow = input.top < brand.bottom; + if (sameRow && gap < 0) fail(`${label} ${width}px: negative horizontal gap (${Math.round(gap)}px)`); + const { lens, hint, help, theme } = await toggleBoxes(); + for (const [name, control] of [['lens', lens], ['B hint', hint]] as const) { + if (overlaps(control, help) || overlaps(control, theme)) { + await page.screenshot({ path: join(TMP, `toggle-overlap-${width}.png`) }); + fail(`${label} ${width}px: the ${name} runs under the help or theme toggle (ends at ${Math.round(control.right)}, help starts at ${Math.round(help.left)})`); + } + if (control.right > width) fail(`${label} ${width}px: the ${name} leaves the viewport`); + } + const inputWidth = input.right - input.left; + if (inputWidth < 120) fail(`${label} ${width}px: the search input is too narrow to use (${Math.round(inputWidth)}px)`); + pass(`${label} ${width}px: no overlap, ${sameRow ? `gap ${Math.round(gap)}px` : 'bar on its own row'}, input ${Math.round(inputWidth)}px`); } - if (gap < 0) fail(`${width}px: negative horizontal gap (${Math.round(gap)}px)`); - pass(`${width}px: no overlap, gap ${Math.round(gap)}px`); } +await assertRow('default branding'); + // ── 3. A 50-char title (the parser's cap) must still clear ────────────────── // This is the case a hard-coded gutter would fail: the block gets much wider, // and only a measured gutter grows with it. @@ -133,28 +118,29 @@ await page.evaluate(() => { const el = document.querySelector('.branding-title'); if (el !== null) el.textContent = 'A'.repeat(50); }); -// The widened title has to reach the ResizeObserver, then --branding-gutter, -// then the bar's padding — three hops, so poll for the outcome. -try { - await page.waitForFunction(() => { - const b = document.querySelector('.branding-block')?.getBoundingClientRect(); - const i = document.querySelector('.dict-search-input')?.getBoundingClientRect(); - return b !== undefined && i !== undefined && i.x >= b.x + b.width; - }, undefined, { timeout: 5000 }); -} catch {} +// The widened title reaches the bar through the ResizeObserver and --branding-gutter. +await settleAt(1050); const wide = await measureGap(); if (overlaps(wide.brand, wide.input)) { await page.screenshot({ path: join(TMP, 'overlap-long-title.png') }); fail( `50-char title at 1050px: branding block covers the search input ` + - `(brand ends at ${Math.round(wide.brand.x + wide.brand.width)}, input starts at ${Math.round(wide.input.x)})`, + `(brand ends at ${Math.round(wide.brand.right)}, input starts at ${Math.round(wide.input.left)})`, ); } pass(`50-char title at 1050px: no overlap, gap ${Math.round(wide.gap)}px`); await page.screenshot({ path: join(TMP, 'final-1050-long-title.png') }); +// ── 4. A 50-char subtitle too: the widest block the parser allows, at every width ── +await page.evaluate((text) => { + const el = document.querySelector('.branding-subtitle'); + if (el !== null) el.textContent = text; +}, LONG_SUBTITLE); +await assertRow('50-char title and subtitle'); + await browser.close(); -proc.kill(); +server.stop(true); console.log('\ntest-dict-search-branding-overlap: all assertions passed.'); +process.exit(0); diff --git a/test/checks/test-graph-edits.ts b/test/checks/test-graph-edits.ts new file mode 100644 index 0000000..de3ecf1 --- /dev/null +++ b/test/checks/test-graph-edits.ts @@ -0,0 +1,90 @@ +/** + * test-graph-edits.ts — undo/redo over graph arrangement snapshots. + * + * A snapshot is where every node sits plus which nodes are hidden. The + * history exists so an accidental drag, hide, or re-layout can be taken back. + * + * Coverage: + * 1. Undo returns the state before the last change and hands the current + * state to redo; redo returns it. + * 2. A new change after an undo drops the redo branch. + * 3. Undo and redo on an empty side return null. + * 4. The history keeps only the newest `limit` changes. + * 5. discardLast drops the newest undo step without making it redoable. + * 6. L cycles organic → hierarchical → tree; unknown stored modes read as organic. + */ + +import { createEditHistory, nextLayoutMode, parseLayoutMode } from '../../src/app/views/graph/graph-edits'; +import type { GraphSnapshot } from '../../src/app/views/graph/graph-edits'; +import { assert } from '../assert'; + +function snap(x: number, hidden: string[] = []): GraphSnapshot { + return { positions: { A: { x, y: 0 } }, hidden, mode: 'hierarchical' }; +} + +// 1. undo then redo. +{ + const history = createEditHistory(); + history.push(snap(0)); // before dragging A to 10 + const undone = history.undo(snap(10)); + assert(undone?.positions['A']?.x === 0, `FAIL 1a: undo should return x=0, got ${JSON.stringify(undone)}`); + assert(history.canRedo(), 'FAIL 1b: redo available after undo'); + const redone = history.redo(snap(0)); + assert(redone?.positions['A']?.x === 10, `FAIL 1c: redo should return x=10, got ${JSON.stringify(redone)}`); + assert(history.canUndo() && !history.canRedo(), 'FAIL 1d: after redo, undo available and redo empty'); +} + +// 2. a new change clears redo. +{ + const history = createEditHistory(); + history.push(snap(0)); + history.undo(snap(10)); + history.push(snap(0, ['A'])); // hide A instead + assert(!history.canRedo(), 'FAIL 2: a new change after undo should drop the redo branch'); +} + +// 3. empty sides. +{ + const history = createEditHistory(); + assert(history.undo(snap(0)) === null, 'FAIL 3a: undo on empty history should be null'); + assert(history.redo(snap(0)) === null, 'FAIL 3b: redo on empty history should be null'); + assert(!history.canUndo() && !history.canRedo(), 'FAIL 3c: nothing to undo or redo'); +} + +// 4. limit. +{ + const history = createEditHistory(3); + for (let i = 0; i < 5; i++) history.push(snap(i)); + let steps = 0; + let current = snap(5); + for (let back = history.undo(current); back; back = history.undo(current)) { + current = back; + steps++; + } + assert(steps === 3, `FAIL 4a: expected 3 undo steps, got ${steps}`); + assert(current.positions['A']?.x === 2, `FAIL 4b: oldest kept state should be x=2, got ${current.positions['A']?.x}`); +} + +// 5. discardLast: a relayout cancelled before it landed leaves no undo step. +{ + const history = createEditHistory(); + history.push(snap(0)); // a real drag + history.push(snap(1)); // the relayout's pre-state + history.discardLast(); + const undone = history.undo(snap(1)); + assert(undone?.positions['A']?.x === 0, `FAIL 5a: undo should reach the drag's pre-state, got ${JSON.stringify(undone)}`); + assert(!history.canUndo(), 'FAIL 5b: the discarded step is gone'); + assert(!createEditHistory().canRedo(), 'FAIL 5c: fresh history has nothing to redo'); +} + +// 6. Layout modes: L steps organic → hierarchical → tree → organic; an unknown +// stored value falls back to organic. +{ + assert(nextLayoutMode('organic') === 'hierarchical', 'FAIL 6a: organic → hierarchical'); + assert(nextLayoutMode('hierarchical') === 'tree', 'FAIL 6b: hierarchical → tree'); + assert(nextLayoutMode('tree') === 'organic', 'FAIL 6c: tree → organic'); + assert(parseLayoutMode('tree') === 'tree', 'FAIL 6d: stored tree is kept'); + assert(parseLayoutMode('sideways') === 'organic' && parseLayoutMode(null) === 'organic', 'FAIL 6e: unknown → organic'); +} + +if (process.exitCode !== 1) console.log('PASS: test-graph-edits'); diff --git a/test/checks/test-grouped-layered.ts b/test/checks/test-grouped-layered.ts new file mode 100644 index 0000000..ec0b759 --- /dev/null +++ b/test/checks/test-grouped-layered.ts @@ -0,0 +1,133 @@ +/** + * test-grouped-layered.ts — the hierarchical layout keeps each colour family + * in one block, so the family order and the block packing decide where every + * family lands. + * + * Coverage: + * 1. A family other families reference comes before them (its tables are + * the parents the cross-family FKs point at). + * 2. A reference cycle still yields every family once, in the same order on + * every run. + * 3. Packed blocks never overlap and keep the gap between them. + * 4. A short block fills the hole beside a tall one instead of opening a new + * row under it. + * 5. On a headless graph, layoutByGroup places every node and no two + * groups' blocks overlap, with a subtype joiner kept in its group. + */ + +import cytoscape from 'cytoscape'; +import { buildScratchCore } from '../../src/app/views/graph/organic-layout'; +import { layoutByGroup, orderRegions, packRegions } from '../../src/app/views/graph/grouped-layered'; +import type { RegionBox, RegionEdge } from '../../src/app/views/graph/grouped-layered'; +import { assert } from '../assert'; + +// 1. referenced family first: sales and finance both point at core. +{ + const edges: RegionEdge[] = [ + { source: 'core', target: 'sales' }, + { source: 'core', target: 'finance' }, + { source: 'sales', target: 'finance' }, + ]; + const order = orderRegions(['finance', 'sales', 'core'], edges); + assert(order.join(',') === 'core,sales,finance', `FAIL 1: expected core,sales,finance, got ${order.join(',')}`); +} + +// 2. cycle: a → b → c → a, plus a second a → b edge so a has the most outgoing weight. +{ + const edges: RegionEdge[] = [ + { source: 'a', target: 'b' }, + { source: 'a', target: 'b' }, + { source: 'b', target: 'c' }, + { source: 'c', target: 'a' }, + ]; + const first = orderRegions(['c', 'b', 'a'], edges); + const second = orderRegions(['a', 'c', 'b'], edges); + assert(first.length === 3 && new Set(first).size === 3, `FAIL 2a: every family once, got ${first.join(',')}`); + assert(first.join(',') === second.join(','), `FAIL 2b: order depends on input order: ${first} vs ${second}`); +} + +// 3 + 4. packing. +{ + const gap = 20; + const boxes: RegionBox[] = [ + { id: 'tall', width: 400, height: 900 }, + { id: 'wide', width: 500, height: 200 }, + { id: 'short', width: 300, height: 150 }, + { id: 'small', width: 120, height: 100 }, + ]; + const corners = packRegions(boxes, 16 / 10, gap); + assert(corners.size === boxes.length, 'FAIL 3a: every box placed'); + + const rects = boxes.map((b) => { + const c = corners.get(b.id) ?? { x: NaN, y: NaN }; + return { id: b.id, x1: c.x, y1: c.y, x2: c.x + b.width, y2: c.y + b.height }; + }); + for (const a of rects) { + for (const b of rects) { + if (a.id >= b.id) continue; + const apart = a.x2 + gap <= b.x1 + 1e-6 || b.x2 + gap <= a.x1 + 1e-6 + || a.y2 + gap <= b.y1 + 1e-6 || b.y2 + gap <= a.y1 + 1e-6; + assert(apart, `FAIL 3b: ${a.id} and ${b.id} overlap or sit closer than the gap`); + } + } + + const tall = rects.find(r => r.id === 'tall'); + const short = rects.find(r => r.id === 'short'); + assert(tall !== undefined && short !== undefined, 'FAIL 4a: boxes present'); + if (tall && short) { + assert(short.y1 < tall.y2, `FAIL 4b: short block opened a row under the tall one (y=${short.y1}, tall ends ${tall.y2})`); + } +} + +// 5. End to end on a headless graph: each group lands in one block, blocks +// apart, and a subtype joiner sits inside its cluster's block. +{ + const node = (id: string, group: string, parent?: string) => + ({ group: 'nodes' as const, data: parent ? { id, group, parent } : { id, group } }); + const edge = (source: string, target: string) => + ({ group: 'edges' as const, data: { id: `${source}-${target}`, source, target } }); + const live = cytoscape({ + headless: true, + styleEnabled: true, + style: [{ selector: 'node', style: { width: 80, height: 30 } }], + elements: [ + node('Party', 'core'), node('Address', 'core'), node('Country', 'core'), + { group: 'nodes', data: { id: '_cluster_Party_x', cluster: 'true' } }, + { group: 'nodes', data: { id: '_joiner_Party_x', joiner: 'true' } }, + node('Person', 'core', '_cluster_Party_x'), node('Business', 'core', '_cluster_Party_x'), + node('Order', 'sales'), node('OrderLine', 'sales'), node('Invoice', 'sales'), + node('Product', 'catalog'), node('Price', 'catalog'), + edge('Party', '_joiner_Party_x'), edge('_joiner_Party_x', 'Person'), edge('_joiner_Party_x', 'Business'), + edge('Country', 'Address'), edge('Party', 'Address'), + edge('Party', 'Order'), edge('Order', 'OrderLine'), edge('Order', 'Invoice'), + edge('Product', 'Price'), edge('Product', 'OrderLine'), + ], + }); + // elk.bundled.js takes Bun (a `self` but no `document`) for a web worker and + // skips exporting its in-process worker; without `self` it runs in-process. + Reflect.deleteProperty(globalThis, 'self'); + const layoutOptions = { name: 'elk', elk: { algorithm: 'layered', 'elk.direction': 'DOWN' } }; + const positions = await layoutByGroup(buildScratchCore(live, true), layoutOptions, 60); + + const groupOf = (id: string): string => (id === '_joiner_Party_x' ? 'core' : String(live.$id(id).data('group'))); + const boxes = new Map(); + for (const [id, p] of Object.entries(positions)) { + const g = groupOf(id); + const b = boxes.get(g) ?? { x1: Infinity, y1: Infinity, x2: -Infinity, y2: -Infinity }; + boxes.set(g, { x1: Math.min(b.x1, p.x - 40), y1: Math.min(b.y1, p.y - 15), x2: Math.max(b.x2, p.x + 40), y2: Math.max(b.y2, p.y + 15) }); + } + assert(Object.keys(positions).length === 11, `FAIL 5a: every childless node placed, got ${Object.keys(positions).length}`); + const groups = [...boxes.entries()]; + for (const [ga, a] of groups) { + for (const [gb, b] of groups) { + if (ga >= gb) continue; + const apart = a.x2 <= b.x1 || b.x2 <= a.x1 || a.y2 <= b.y1 || b.y2 <= a.y1; + assert(apart, `FAIL 5b: blocks ${ga} and ${gb} overlap`); + } + } +} + +if (process.exitCode !== 1) console.log('PASS: test-grouped-layered'); + +// The in-process ELK worker keeps the Bun event loop alive. +process.exit(); diff --git a/test/checks/test-keyboard-shortcuts.ts b/test/checks/test-keyboard-shortcuts.ts index 2da918c..76de897 100644 --- a/test/checks/test-keyboard-shortcuts.ts +++ b/test/checks/test-keyboard-shortcuts.ts @@ -20,6 +20,7 @@ import { chromium } from 'playwright'; import { resolve, join } from 'path'; import { existsSync } from 'fs'; import { serveCommand } from '../../src/server/server'; +import { nextLayoutMode, parseLayoutMode } from '../../src/app/views/graph/graph-edits'; const ROOT = resolve(import.meta.dir, '../..'); const MODEL = join(ROOT, 'models/key-inherited'); @@ -106,10 +107,10 @@ try { ); const layoutAfter = await page.evaluate(() => localStorage.getItem('ignatius-layout-mode') ?? 'organic'); - const expectedLayout = layoutBefore === 'organic' ? 'hierarchical' : 'organic'; + const expectedLayout = nextLayoutMode(parseLayoutMode(layoutBefore)); assert( layoutAfter === expectedLayout, - `layout toggle flipped from ${layoutBefore} to ${expectedLayout} (got: ${layoutAfter})`, + `L stepped the layout from ${layoutBefore} to ${expectedLayout} (got: ${layoutAfter})`, ); // --------------------------------------------------------------------------- diff --git a/test/checks/test-layout-store.ts b/test/checks/test-layout-store.ts index 51c436c..026c452 100644 --- a/test/checks/test-layout-store.ts +++ b/test/checks/test-layout-store.ts @@ -12,6 +12,10 @@ * 5. Clear removes one key's entry without touching others. * 6. lastPosition finds a node in the newest other entry that saved it * alongside one of its neighbours; another model's same-named node is ignored. + * 7. previous returns the newest other entry whose shared nodes are at + * least half of each side, with its hidden list — an edited model keeps the user's + * arrangement and hidden nodes; another model's layout is never borrowed. + * 8. hidden round-trips through save and is dropped when saved empty. */ import { createLayoutStore } from '../../src/app/views/graph/layout-store'; @@ -175,4 +179,56 @@ function makeStorage(): StorageLike { console.log('PASS: lastPosition skips the current key and entries without a neighbour, returns the newest other saved position'); } +// --------------------------------------------------------------------------- +// 7. previous: the same model's layout before its last edit +// --------------------------------------------------------------------------- + +{ + let tick = 0; + const store = createLayoutStore(makeStorage(), () => ++tick); + store.save('key-before-edit', { A: { x: 1, y: 1 }, B: { x: 2, y: 2 }, C: { x: 3, y: 3 } }, ['C']); + store.save('key-other-model', { A: { x: 9, y: 9 }, X: { x: 0, y: 0 }, Y: { x: 0, y: 0 }, Z: { x: 0, y: 0 } }); + + // The edit added D: A, B, C survive, so 3 of 4 current nodes are in key-before-edit. + const prev = store.previous('key-after-edit', ['A', 'B', 'C', 'D']); + assert(prev?.positions['A']?.x === 1, `FAIL: previous — expected key-before-edit, got ${JSON.stringify(prev)}`); + assert(prev?.hidden.join(',') === 'C', `FAIL: previous — hidden should carry over, got ${JSON.stringify(prev?.hidden)}`); + + // key-other-model is newer but shares only A (1 of 4): never borrowed. + const unrelated = store.previous('key-after-edit', ['A', 'P', 'Q', 'R']); + assert(unrelated === null, `FAIL: previous — under half overlap should be null, got ${JSON.stringify(unrelated)}`); + assert(store.previous('key-before-edit', ['A', 'B', 'C']) === null, 'FAIL: previous — must skip exceptKey'); + assert(store.previous('', ['A', 'B', 'C', 'D']) === null, 'FAIL: previous — an empty key can never save, so it borrows nothing'); + + // A newer, larger model that contains A–D is not this model's predecessor. + const big: PositionMap = { A: { x: 7, y: 7 }, B: { x: 7, y: 7 }, C: { x: 7, y: 7 }, D: { x: 7, y: 7 } }; + for (const id of ['E', 'F', 'G', 'H', 'I', 'J']) big[id] = { x: 0, y: 0 }; + store.save('key-bigger-model', big); + const stillPrev = store.previous('key-after-edit', ['A', 'B', 'C', 'D']); + assert(stillPrev?.positions['A']?.x === 1, `FAIL: previous — a model with mostly other tables must not win, got ${JSON.stringify(stillPrev?.positions['A'])}`); + + // Newest wins among qualifying entries: an edit that re-adds a table an + // older arrangement still holds must not bring that older arrangement back. + store.save('key-v1', { A: { x: 1, y: 1 }, B: { x: 1, y: 1 }, C: { x: 1, y: 1 }, D: { x: 1, y: 1 } }); + store.save('key-v2-rearranged', { A: { x: 9, y: 9 }, B: { x: 9, y: 9 }, C: { x: 9, y: 9 } }); + const newest = store.previous('key-v3', ['A', 'B', 'C', 'D']); + assert(newest?.positions['A']?.x === 9, `FAIL: previous — the newest qualifying entry should win, got ${JSON.stringify(newest?.positions['A'])}`); + console.log('PASS: previous returns the newest entry sharing half of each side, with its hidden list'); +} + +// --------------------------------------------------------------------------- +// 8. hidden round-trip +// --------------------------------------------------------------------------- + +{ + const storage = makeStorage(); + const store = createLayoutStore(storage); + store.save('k', { A: { x: 0, y: 0 } }, ['A']); + assert(store.hidden('k').join(',') === 'A', `FAIL: hidden — expected [A], got ${JSON.stringify(store.hidden('k'))}`); + store.save('k', { A: { x: 0, y: 0 } }); + assert(store.hidden('k').length === 0, 'FAIL: hidden — saving without hidden should clear it'); + assert(!(storage.getItem('ignatius-layout-positions') ?? '').includes('hidden'), 'FAIL: hidden — empty list should not be stored'); + console.log('PASS: hidden round-trips and an empty list is not stored'); +} + console.log('\nAll layout-store tests passed.'); diff --git a/test/checks/test-shortcuts.ts b/test/checks/test-shortcuts.ts index 0e85fe6..86b86ad 100644 --- a/test/checks/test-shortcuts.ts +++ b/test/checks/test-shortcuts.ts @@ -520,4 +520,39 @@ for (const key of ['g', 'd', 'f', 'l', 'b']) { console.log("PASS T30: i → flowIndex on the flow view only"); } +// --------------------------------------------------------------------------- +// T31: Cmd/Ctrl+Z undoes, Cmd/Ctrl+Shift+Z and Ctrl+Y redo a graph arrangement +// change — only on the graph, and never while typing, where the chord +// must keep undoing the text. +// --------------------------------------------------------------------------- +{ + for (const mod of ['ctrlKey', 'metaKey'] as const) { + assert(resolveShortcut(ev('z', { [mod]: true }), 'graph', false)?.type === 'undo', `T31: ${mod}+z on graph → undo`); + assert(resolveShortcut(ev('Z', { [mod]: true, shiftKey: true }), 'graph', false)?.type === 'redo', `T31: ${mod}+shift+z on graph → redo`); + assert(resolveShortcut(ev('z', { [mod]: true }), 'graph', true) === null, `T31: ${mod}+z while typing → null`); + assert(resolveShortcut(ev('z', { [mod]: true }), 'dict', false) === null, `T31: ${mod}+z on dict → null`); + assert(resolveShortcut(ev('z', { [mod]: true, altKey: true }), 'graph', false) === null, `T31: ${mod}+alt+z → null`); + } + assert(resolveShortcut(ev('y', { ctrlKey: true }), 'graph', false)?.type === 'redo', 'T31: ctrl+y on graph → redo'); + assert(resolveShortcut(ev('y', { metaKey: true }), 'graph', false) === null, 'T31: meta+y → null (browser History)'); + assert(resolveShortcut(ev('z'), 'graph', false) === null, 'T31: bare z → null'); + console.log('PASS T31: undo/redo chords on the graph only, never while typing'); +} + +// --------------------------------------------------------------------------- +// T32: s toggles select mode, h hides the selected nodes — graph only, bare +// keys, suppressed while typing like every bare key. +// --------------------------------------------------------------------------- +{ + assert(resolveShortcut(ev('s'), 'graph', false)?.type === 'toggleSelectMode', "T32: s on graph → toggleSelectMode"); + assert(resolveShortcut(ev('h'), 'graph', false)?.type === 'hideSelection', "T32: h on graph → hideSelection"); + for (const key of ['s', 'h']) { + assert(resolveShortcut(ev(key), 'dict', false) === null, `T32: ${key} on dict → null`); + assert(resolveShortcut(ev(key), 'flow', false) === null, `T32: ${key} on flow → null`); + assert(resolveShortcut(ev(key), 'graph', true) === null, `T32: ${key} while typing → null`); + assert(resolveShortcut(ev(key, { metaKey: true }), 'graph', false) === null, `T32: meta+${key} → null`); + } + console.log('PASS T32: s and h on the graph only'); +} + console.log('\nAll tests passed.'); diff --git a/test/checks/test-tree-layout.ts b/test/checks/test-tree-layout.ts new file mode 100644 index 0000000..5e4fad6 --- /dev/null +++ b/test/checks/test-tree-layout.ts @@ -0,0 +1,520 @@ +/** + * test-tree-layout.ts — the Tree layout reads like an outline: each table sits + * below and to the right of the table whose key it inherits, and every line + * drops down its own lane before turning right into its child. + * + * Coverage: + * 1. A table hangs under its identifying parent; among several, the deepest + * wins, then the id. + * 2. Without an identifying parent, a non-classifier FK parent is used; a + * classifier is never chosen as a parent while another exists. + * 3. A root with no children of its own that a table references (a lookup) + * is never hung under that table; it sits above its tree, and a lookup + * used only by another lookup stays a root. + * 4. Children sit right of their parent and below it, smaller subtrees + * first; no two boxes overlap, across separate trees too. + * 5. Lanes nest: the lower a child's row, the further left its lane, so no + * horizontal run crosses a sibling's lane; the run is long enough for the label. + * 6. A wide key parent's children wrap into columns to its right, each + * starting a row below the parent's bottom edge, only where a fresh column + * has room for two rows; lookup lines into wrapped tables are cross links. + * 7. A subtype joiner never leaves its basetype, and goes first when its + * siblings wrap. + * 8. Every drawn line leaves its parent's bottom edge and enters its child's + * left side, right-angled, including a tree line redrawn as a cross link. + * 9. Lines into one child never cross on its left side; cross links from one + * parent each get their own lane and street. + * 10. After a hide, lines re-route on the boxes as they stand, keep their + * kind, and still do not cross. + */ + +import { buildForest, layoutTree, routeGeometry, routeTree } from '../../src/app/views/graph/tree-layout'; +import type { TreeEdgeInput, TreeLine, TreeNodeInput, TreeRoute } from '../../src/app/views/graph/tree-layout'; +import { assert } from '../assert'; + +function node(id: string, opts: { group?: string; classifier?: boolean; width?: number } = {}): TreeNodeInput { + return { id, width: opts.width ?? 100, height: 30, group: opts.group ?? 'g', classifier: opts.classifier ?? false }; +} +function fk(source: string, target: string, identifying = true, label = 'has'): TreeEdgeInput { + return { id: `${source}-${target}`, source, target, identifying, label }; +} +// Pairs of placed boxes that overlap, each box at its node's own size. +function overlapping(nodes: TreeNodeInput[], positions: Record): string[] { + const boxOf = (n: TreeNodeInput) => { + const p = positions[n.id] ?? { x: NaN, y: NaN }; + return { x1: p.x - n.width / 2, x2: p.x + n.width / 2, y1: p.y - n.height / 2, y2: p.y + n.height / 2 }; + }; + const pairs: string[] = []; + nodes.forEach((a, i) => { + for (const b of nodes.slice(i + 1)) { + const A = boxOf(a); + const B = boxOf(b); + if (!(A.x2 <= B.x1 || B.x2 <= A.x1 || A.y2 <= B.y1 || B.y2 <= A.y1)) pairs.push(`${a.id}/${b.id}`); + } + }); + return pairs; +} + +// 1. identifying parents, deepest wins. +{ + const nodes = ['Community', 'Building', 'Floor', 'Room', 'RoomType', 'UnitType', 'Unit'].map(id => node(id)); + const edges = [ + fk('Community', 'Building'), fk('Building', 'Floor'), fk('Floor', 'Room'), + fk('Community', 'RoomType'), fk('RoomType', 'Room'), + fk('Community', 'UnitType'), fk('Building', 'Unit'), fk('UnitType', 'Unit'), + ]; + const { parent } = buildForest(nodes, edges); + assert(parent.get('Room') === 'Floor', `FAIL 1a: Room under its deeper parent Floor, got ${parent.get('Room')}`); + assert(parent.get('Unit') === 'Building', `FAIL 1b: Unit under Building (tie broken by id), got ${parent.get('Unit')}`); + assert(!parent.has('Community'), 'FAIL 1c: Community is a root'); +} + +// 2. non-identifying fallback skips classifiers. +{ + const nodes = [node('Customer'), node('OrderStatus', { classifier: true }), node('Order')]; + const edges = [fk('OrderStatus', 'Order', false), fk('Customer', 'Order', false)]; + const { parent } = buildForest(nodes, edges); + assert(parent.get('Order') === 'Customer', `FAIL 2: Order under Customer, not the classifier, got ${parent.get('Order')}`); +} + +// 3. A lookup is never hung under the table that uses it (its line would run +// parent back to child); it is assigned a consumer in its own group. +{ + const nodes = [ + node('Party', { group: 'core' }), node('Lease', { group: 'core' }), node('Invoice', { group: 'billing' }), + node('Status', { group: 'core', classifier: true }), + ]; + const edges = [fk('Party', 'Lease'), fk('Party', 'Invoice'), fk('Status', 'Invoice', false), fk('Status', 'Lease', false)]; + const { parent, lookups, roots } = buildForest(nodes, edges); + assert(!parent.has('Status'), `FAIL 3: Status has no tree parent, got ${parent.get('Status')}`); + assert(lookups.get('Status') === 'Lease', `FAIL 3: Status serves Lease (same group), got ${lookups.get('Status')}`); + assert(!roots.includes('Status'), 'FAIL 3c: a lookup is not a tree root of its own'); +} + +// 3c. A lookup sits above the tree it serves; its line drops from its bottom +// and turns right into the consumer's left side, with room for the label. +{ + const nodes = [node('Party'), node('Lease'), node('Unit'), node('LeaseType', { classifier: true }), node('UnitKind', { classifier: true })]; + const edges = [ + fk('Party', 'Lease'), fk('Party', 'Unit'), + fk('LeaseType', 'Lease', false, 'classifies'), fk('UnitKind', 'Unit', false, 'kinds'), + ]; + const { positions, routes } = layoutTree(nodes, edges); + const at = (id: string) => { + const p = positions[id]; + if (!p) throw new Error(`no position for ${id}`); + return p; + }; + for (const [lookup, consumer] of [['LeaseType', 'Lease'], ['UnitKind', 'Unit']] as const) { + const route = routes.get(`${lookup}-${consumer}`); + assert(route?.kind === 'down' && route.parent === lookup, `FAIL 3d: ${lookup} → ${consumer} drops from the lookup (${JSON.stringify(route)})`); + if (!route) continue; + const laneX = at(lookup).x - 50 + route.laneDx; + const consumerLeft = at(consumer).x - 50; + assert(laneX < consumerLeft - 'classifies'.length * 6, `FAIL 3e: ${lookup}'s run into ${consumer} fits its label`); + assert(at(lookup).y + 15 < at(consumer).y + route.entryDy, `FAIL 3f: ${lookup} sits above the row it enters`); + } + assert(at('LeaseType').y < at('Party').y && at('UnitKind').y < at('Party').y, 'FAIL 3h: lookups sit above the tree'); +} + +// 3i. A lookup used by two trees sits above its assigned consumer only; its +// line into the other tree is a cross link. +{ + const nodes = [node('A'), node('A1'), node('B'), node('B1'), node('Kind', { classifier: true })]; + const edges = [fk('A', 'A1'), fk('B', 'B1'), fk('Kind', 'A1', false), fk('Kind', 'B1', false)]; + const { positions, routes } = layoutTree(nodes, edges); + assert(routes.get('Kind-A1')?.kind === 'down', 'FAIL 3i: Kind feeds A1, its assigned consumer'); + const cross = routes.get('Kind-B1'); + assert(cross?.kind === 'cross' && cross.slot.streetDy > 0 && cross.slot.approachDx < 0, + `FAIL 3j: Kind → B1 is a cross link under Kind into B1's left side (${JSON.stringify(cross)})`); + const kind = positions['Kind']; + const a1 = positions['A1']; + assert(kind !== undefined && a1 !== undefined && kind.y < a1.y, 'FAIL 3k: Kind sits above A1'); +} + +// 3l. A lookup used only by another lookup has no tree to sit above: it stays +// a root, placed, and its line is a cross link. +{ + const nodes = [node('Line'), node('LineType', { classifier: true }), node('LineCategory', { classifier: true })]; + const edges = [fk('LineType', 'Line', false), fk('LineCategory', 'LineType', false)]; + const { lookups, roots } = buildForest(nodes, edges); + assert(lookups.get('LineType') === 'Line', 'FAIL 3l: LineType serves Line'); + assert(!lookups.has('LineCategory') && roots.includes('LineCategory'), 'FAIL 3m: LineCategory stays a root'); + const { positions, routes } = layoutTree(nodes, edges); + assert(positions['LineCategory'] !== undefined, 'FAIL 3n: LineCategory is placed'); + assert(routes.get('LineCategory-LineType')?.kind === 'cross', 'FAIL 3o: LineCategory → LineType is a cross link'); +} + +// 3p. A cycle is cut at a plain FK link, never a key link: a table whose +// fallback parent sits in its own subtree stays the root. +{ + const nodes = [node('product'), node('_joiner_product_x'), node('variant'), node('variant_price')]; + const edges = [ + fk('product', '_joiner_product_x'), fk('_joiner_product_x', 'variant'), fk('variant', 'variant_price'), + fk('variant_price', 'product', false), + ]; + const { parent } = buildForest(nodes, edges); + assert(!parent.has('product'), `FAIL 3p: product stays the root, got parent ${parent.get('product')}`); + assert(parent.get('_joiner_product_x') === 'product', 'FAIL 3q: the joiner keeps its key parent'); +} + +// 3r. An audit hub (`created_by` → users everywhere) is never a fallback +// parent: tables with no key parent stay roots. +{ + const nodes = [node('users')]; + const edges: TreeEdgeInput[] = []; + for (let i = 0; i < 10; i++) { + nodes.push(node(`t${i}`)); + edges.push(fk('users', `t${i}`, false, 'created')); + } + const { parent } = buildForest(nodes, edges); + assert([...parent.keys()].length === 0, `FAIL 3r: no table hangs under the hub, got ${JSON.stringify([...parent])}`); +} + +// 4 + 5. geometry. +{ + const nodes = [ + node('Root'), node('Big'), node('Small'), node('Leaf1'), node('Leaf2'), + node('Other'), node('OtherChild'), + ]; + const edges = [ + fk('Root', 'Big', true, 'contains many things'), fk('Root', 'Small', true, 'has'), + fk('Big', 'Leaf1'), fk('Big', 'Leaf2'), + fk('Other', 'OtherChild'), + ]; + const { positions, routes } = layoutTree(nodes, edges); + const box = (id: string) => { + const p = positions[id]; + if (!p) throw new Error(`no position for ${id}`); + return { x1: p.x - 50, x2: p.x + 50, y1: p.y - 15, y2: p.y + 15 }; + }; + assert(Object.keys(positions).length === nodes.length, 'FAIL 4a: every node placed'); + assert(box('Big').x1 > box('Root').x1 && box('Big').y1 > box('Root').y2, 'FAIL 4b: child right of and below its parent'); + assert(box('Small').y1 < box('Big').y1, 'FAIL 4c: the smaller subtree comes first'); + const overlaps4 = overlapping(nodes, positions); + assert(overlaps4.length === 0, `FAIL 4d: no two boxes overlap (${overlaps4.join(', ')})`); + + const toSmall = routes.get('Root-Small'); + const toBig = routes.get('Root-Big'); + assert(toSmall !== undefined && toBig !== undefined, 'FAIL 5a: both root edges are tree-routed'); + if (toSmall && toBig) { + assert(toBig.laneDx < toSmall.laneDx, `FAIL 5b: the lower child's lane is further left (${toBig.laneDx} vs ${toSmall.laneDx})`); + const labelWidth = 'contains many things'.length * 6; + const run = box('Big').x1 - (box('Root').x1 + toBig.laneDx); + assert(run > labelWidth, `FAIL 5c: the run (${run}) fits the label (${labelWidth})`); + assert(toBig.laneDx < 50, 'FAIL 5d: tree lanes use the left half of the parent\'s bottom edge'); + } +} + +// 6. A wide key parent's children that do not fit wrap into columns to its +// right, each starting a row below it, so every line still runs down and right. +function wideParent(extra: TreeEdgeInput[] = []): { nodes: TreeNodeInput[]; edges: TreeEdgeInput[] } { + const nodes = [node('Tenant')]; + const edges: TreeEdgeInput[] = []; + for (let i = 0; i < 12; i++) { + const kid = `T${String(i).padStart(2, '0')}`; + nodes.push(node(kid)); + edges.push(fk('Tenant', kid)); + for (let j = 0; j < 4; j++) { + nodes.push(node(`${kid}_${j}`)); + edges.push(fk(kid, `${kid}_${j}`)); + } + } + return { nodes, edges: [...edges, ...extra] }; +} +function wrappedTooHigh(positions: Record, routes: Map): string[] { + const tenantBottom = (positions['Tenant']?.y ?? 0) + 15; + return [...routes].filter(([id, r]) => id.startsWith('Tenant-') && r.kind !== 'down') + .map(([, r]) => r.child).filter(child => (positions[child]?.y ?? 0) - 15 - tenantBottom < 20); +} +// Width over height of the placed boxes (100 × 30 in these fixtures). +function aspectOf(positions: Record): number { + const xs = Object.values(positions).map(p => p.x); + const ys = Object.values(positions).map(p => p.y); + return (Math.max(...xs) - Math.min(...xs) + 100) / (Math.max(...ys) - Math.min(...ys) + 30); +} +// A chain of single children `levels` deep whose last table has `leaves` children. +function chainTo(levels: number, leaves: number): { nodes: TreeNodeInput[]; edges: TreeEdgeInput[] } { + const nodes: TreeNodeInput[] = []; + const edges: TreeEdgeInput[] = []; + for (let level = 0; level <= levels; level++) { + nodes.push(node(`A${level}`)); + if (level > 0) edges.push(fk(`A${level - 1}`, `A${level}`)); + } + for (let j = 0; j < leaves; j++) { + nodes.push(node(`A${levels}_${j}`)); + edges.push(fk(`A${levels}`, `A${levels}_${j}`)); + } + return { nodes, edges }; +} +{ + const { nodes, edges } = wideParent(); + const { positions, routes } = layoutTree(nodes, edges); + const aspect = aspectOf(positions); + assert(aspect > 1 && aspect < 2.5, `FAIL 6a: the wide parent wraps into a picture near 16:10 (${aspect.toFixed(2)})`); + assert(routes.get('Tenant-T11')?.kind === 'cross' && routes.get('T11-T11_0')?.kind === 'down', + 'FAIL 6b: the last child wraps, and keeps its own tree lines'); + const tenantEdges = [...routes].filter(([id, r]) => id.startsWith('Tenant-') && r.kind === 'down').length; + assert(tenantEdges > 0 && tenantEdges < 12, `FAIL 6c: some, not all, of the parent's lines stay tree lines (${tenantEdges})`); + const high = wrappedTooHigh(positions, routes); + assert(high.length === 0, `FAIL 6d: wrapped children start a row below their parent's bottom edge (${high.join(', ')})`); + const overlaps6 = overlapping(nodes, positions); + assert(overlaps6.length === 0, `FAIL 6e: wrapped columns overlap no box (${overlaps6.join(', ')})`); +} +// 6f. Links from a wrapped subtree back into kept tables change nothing: the +// wrapped children are part of the parent's tree, not trees ordered on their own. +{ + const { nodes, edges } = wideParent([fk('T05_0', 'T00_1', false), fk('T05_1', 'T01_1', false)]); + const { positions, routes } = layoutTree(nodes, edges); + const high = wrappedTooHigh(positions, routes); + assert(high.length === 0, `FAIL 6f: with links back into kept tables, wrapped children still start below the parent (${high.join(', ')})`); + assert(overlapping(nodes, positions).length === 0, 'FAIL 6f2: with links back into kept tables, no boxes overlap'); +} +// 6g. A lookup's line into a wrapped table is a cross link: a straight run from +// the staircase would pass behind the boxes sharing that row. +{ + const { nodes, edges } = wideParent([fk('Kind', 'T00', false), fk('Kind', 'T11', false)]); + nodes.push(node('Kind', { classifier: true })); + const { routes } = layoutTree(nodes, edges); + assert(routes.get('Kind-T00')?.kind === 'down', 'FAIL 6g: a lookup line into a kept table drops from the staircase'); + assert(routes.get('Kind-T11')?.kind === 'cross', 'FAIL 6g2: a lookup line into a wrapped table is a cross link'); +} +// 6h. A lookup that feeds only wrapped tables has no lanes; its tree still places. +{ + const { nodes, edges } = wideParent([fk('Kind', 'T11', false)]); + nodes.push(node('Kind', { classifier: true })); + const { positions, routes } = layoutTree(nodes, edges); + const placed = Object.values(positions).every(p => Number.isFinite(p.x) && Number.isFinite(p.y)); + assert(placed && Object.keys(positions).length === nodes.length, 'FAIL 6h: every box gets a finite position'); + assert(routes.get('Kind-T11')?.kind === 'cross', 'FAIL 6h2: its line is a cross link'); +} +// 6i. A deep chain whose last child wraps at every level steps right, one +// column per level; the picture stays under 3:1. +{ + const nodes: TreeNodeInput[] = []; + const edges: TreeEdgeInput[] = []; + for (let level = 0; level < 9; level++) { + nodes.push(node(`C${level}`)); + if (level > 0) edges.push(fk(`C${level - 1}`, `C${level}`)); + for (let j = 0; j < 3; j++) { + nodes.push(node(`C${level}_${j}`)); + edges.push(fk(`C${level}`, `C${level}_${j}`)); + } + } + const { positions } = layoutTree(nodes, edges); + const aspect = aspectOf(positions); + assert(aspect < 3, `FAIL 6i: a deep chain stays under 3:1 (${aspect.toFixed(2)})`); + assert(overlapping(nodes, positions).length === 0, 'FAIL 6i2: the chain\'s wrapped columns overlap no box'); +} +// 6j. A parent with room for one and a half rows below it keeps its children in +// one column instead of a strip of one-table columns; with room for two, it wraps. +{ + const { nodes, edges } = chainTo(10, 4); + const { routes } = layoutTree(nodes, edges); + const leafLines = Array.from({ length: 4 }, (_, j) => routes.get(`A10-A10_${j}`)?.kind); + assert(leafLines.every(kind => kind === 'down'), `FAIL 6j: without room for two rows the children stay under it (${leafLines.join(', ')})`); +} +{ + const { nodes, edges } = chainTo(5, 4); + const { routes } = layoutTree(nodes, edges); + const leafLines = Array.from({ length: 4 }, (_, j) => routes.get(`A5-A5_${j}`)?.kind); + assert(leafLines.some(kind => kind === 'cross'), `FAIL 6j2: with room for two rows the children wrap (${leafLines.join(', ')})`); +} +// 6k. A wide parent partway down, with room for a few rows, still wraps. +{ + const { nodes, edges } = chainTo(8, 20); + const { positions, routes } = layoutTree(nodes, edges); + const wrapped = Array.from({ length: 20 }, (_, j) => routes.get(`A8-A8_${j}`)?.kind).filter(kind => kind === 'cross').length; + assert(wrapped > 0, 'FAIL 6k: children of a parent with room for two rows below it wrap'); + assert(aspectOf(positions) > 0.8, `FAIL 6k2: the picture stays near 16:10 (${aspectOf(positions).toFixed(2)})`); + assert(overlapping(nodes, positions).length === 0, 'FAIL 6k3: no boxes overlap'); +} + +// 7. Wrapping never moves a subtype joiner off its basetype: it stays under it +// and only members that do not fit wrap. +{ + const nodes = [node('base'), { ...node('_joiner_base_x', { width: 20 }), joiner: true }, node('small')]; + const edges = [fk('base', '_joiner_base_x'), fk('base', 'small')]; + for (let i = 0; i < 8; i++) { + const member = `m${i}`; + nodes.push(node(member)); + edges.push(fk('_joiner_base_x', member)); + for (let j = 0; j < 4; j++) { + nodes.push(node(`${member}_${j}`)); + edges.push(fk(member, `${member}_${j}`)); + } + } + const { positions, routes } = layoutTree(nodes, edges); + assert(routes.get('base-_joiner_base_x')?.kind === 'down', 'FAIL 7a: the joiner stays under its basetype'); + const joiner = positions['_joiner_base_x']; + const base = positions['base']; + assert(joiner !== undefined && base !== undefined && joiner.y > base.y, 'FAIL 7b: the joiner sits below the basetype'); + const keptMembers = [...routes].filter(([id, r]) => id.startsWith('_joiner_base_x-') && r.kind === 'down').length; + assert(keptMembers > 0 && keptMembers < 8, `FAIL 7c: some members stay with the joiner, the rest wrap (${keptMembers})`); + assert(overlapping(nodes, positions).length === 0, 'FAIL 7d: the cluster\'s wrapped columns overlap no box'); +} +// A basetype with 14 leaf children and one subtype cluster per kind ('x' exclusive, +// 'i' inclusive), each of 6 members with 3 children. +function wideBasetype(kinds: string[]): { nodes: TreeNodeInput[]; edges: TreeEdgeInput[] } { + const nodes: TreeNodeInput[] = [node('base')]; + const edges: TreeEdgeInput[] = []; + for (const kind of kinds) { + const joiner = `_joiner_base_${kind}`; + nodes.push({ ...node(joiner, { width: 20 }), joiner: true }); + edges.push(fk('base', joiner)); + for (let i = 0; i < 6; i++) { + const member = `${kind}${i}`; + nodes.push(node(member)); + edges.push(fk(joiner, member)); + for (let j = 0; j < 3; j++) { + nodes.push(node(`${member}_${j}`)); + edges.push(fk(member, `${member}_${j}`)); + } + } + } + for (let i = 0; i < 14; i++) { + nodes.push(node(`leaf${i}`)); + edges.push(fk('base', `leaf${i}`)); + } + return { nodes, edges }; +} +// 7e. Under a basetype whose children wrap, the joiner goes first and stays under +// it, so its cluster gets the column's room instead of running past it. +{ + const { nodes, edges } = wideBasetype(['x']); + const { positions, routes } = layoutTree(nodes, edges); + assert(routes.get('base-_joiner_base_x')?.kind === 'down', 'FAIL 7e: the joiner stays under its basetype'); + const joinerY = positions['_joiner_base_x']?.y ?? Infinity; + const leafYs = Array.from({ length: 14 }, (_, i) => positions[`leaf${i}`]?.y ?? -Infinity); + assert(leafYs.every(y => joinerY <= y), 'FAIL 7f: the joiner is on its basetype\'s first child row'); + const aspect = aspectOf(positions); + assert(aspect > 0.8, `FAIL 7g: the cluster wraps instead of running down past the column (${aspect.toFixed(2)})`); + assert(overlapping(nodes, positions).length === 0, 'FAIL 7h: no boxes overlap'); +} +// 7i. When nothing wraps, the joiner keeps its smallest-subtree-first place. +{ + const nodes: TreeNodeInput[] = [node('base'), node('a'), { ...node('_joiner_base_x', { width: 20 }), joiner: true }, node('m0'), node('m1')]; + const edges = [fk('base', 'a'), fk('base', '_joiner_base_x'), fk('_joiner_base_x', 'm0'), fk('_joiner_base_x', 'm1')]; + // Standalone tables widen the picture, so the base tree fits its column. + for (let i = 0; i < 30; i++) nodes.push(node(`solo${i}`)); + const { positions } = layoutTree(nodes, edges); + assert((positions['a']?.y ?? Infinity) < (positions['_joiner_base_x']?.y ?? -Infinity), 'FAIL 7i: the smaller subtree stays above the joiner'); +} +// 7i2. The same under a basetype too deep to wrap, whose subtree runs past the column. +{ + const { nodes, edges } = chainTo(10, 0); + nodes.push(node('a'), { ...node('_joiner_A10_x', { width: 20 }), joiner: true }, node('m0'), node('m1')); + edges.push(fk('A10', 'a'), fk('A10', '_joiner_A10_x'), fk('_joiner_A10_x', 'm0'), fk('_joiner_A10_x', 'm1')); + const { positions } = layoutTree(nodes, edges); + assert((positions['a']?.y ?? Infinity) < (positions['_joiner_A10_x']?.y ?? -Infinity), 'FAIL 7i2: without room to wrap, the joiner keeps its place'); +} +// 7j. A basetype with two clusters keeps both joiners under it, even when the +// second is reached after the column fills. +{ + const { nodes, edges } = wideBasetype(['x', 'i']); + const { positions, routes } = layoutTree(nodes, edges); + assert(routes.get('base-_joiner_base_x')?.kind === 'down' && routes.get('base-_joiner_base_i')?.kind === 'down', + 'FAIL 7j: both joiners stay under their basetype'); + assert(overlapping(nodes, positions).length === 0, 'FAIL 7j2: no boxes overlap'); +} + +// 8. Every drawn line leaves its parent's bottom edge and enters its child's +// left side, and each segment is horizontal or vertical. +{ + const parent = { x: 100, y: 100, width: 100, height: 30 }; + const square = (pts: { x: number; y: number }[]) => + pts.every((p, i) => i === 0 || Math.abs(p.x - (pts[i - 1]?.x ?? p.x)) < 0.01 || Math.abs(p.y - (pts[i - 1]?.y ?? p.y)) < 0.01); + const check = (name: string, route: TreeRoute, child: { x: number; y: number; width: number; height: number }, kind: string) => { + const g = routeGeometry(route, parent, child); + assert(g.kind === kind, `FAIL 8 ${name}: drawn as ${kind}, got ${g.kind}`); + assert(Math.abs(g.parentEnd.y - (parent.y + 15)) < 0.01 && Math.abs(g.parentEnd.x - parent.x) <= 50, + `FAIL 8 ${name}: leaves the parent's bottom edge (${JSON.stringify(g.parentEnd)})`); + assert(Math.abs(g.childEnd.x - (child.x - child.width / 2)) < 0.01, `FAIL 8 ${name}: enters the child's left side`); + assert(square([g.parentEnd, ...g.bends, g.childEnd]), `FAIL 8 ${name}: right-angled (${JSON.stringify(g)})`); + }; + const slot = { laneDx: -10, streetDy: 10, approachDx: -12 }; + const down: TreeRoute = { kind: 'down', parent: 'P', child: 'C', laneDx: 10, slot, entryDy: 0 }; + check('below-right', down, { x: 250, y: 200, width: 100, height: 30 }, 'down'); + // After a hide or reveal the child can sit level with or above the parent, or under its lane. + check('above', down, { x: 250, y: 40, width: 100, height: 30 }, 'cross'); + check('under the lane', down, { x: 90, y: 200, width: 100, height: 30 }, 'cross'); + const cross: TreeRoute = { kind: 'cross', parent: 'P', child: 'C', laneDx: 0, slot, entryDy: 3 }; + check('cross left', cross, { x: -200, y: 300, width: 100, height: 30 }, 'cross'); +} + +// Last turns into a child that cross: line b's run (from its last vertical to +// the child's left side) crosses line a's last vertical. Boxes are 100 × 30. +function lastTurnCrossings(routes: Map, positions: Record): string[] { + const at = (id: string) => positions[id] ?? { x: 0, y: 0 }; + const crossings: string[] = []; + const byChild = new Map(); + for (const r of routes.values()) byChild.set(r.child, [...(byChild.get(r.child) ?? []), r]); + for (const [child, list] of byChild) { + const turns = list.map((r) => { + const parent = at(r.parent); + const vx = r.kind === 'down' ? parent.x - 50 + r.laneDx : at(child).x - 50 + r.slot.approachDx; + const fromY = r.kind === 'down' ? parent.y + 15 : parent.y + 15 + r.slot.streetDy; + return { id: r.parent, vx, fromY, entry: at(child).y + r.entryDy }; + }); + for (const t of turns) { + for (const u of turns) { + if (t === u || !(t.vx > u.vx)) continue; + const lo = Math.min(t.fromY, t.entry); + const hi = Math.max(t.fromY, t.entry); + if (u.entry > lo + 0.01 && u.entry < hi - 0.01) crossings.push(`${u.id}→${child} crosses ${t.id}→${child}`); + } + } + } + return crossings; +} + +// 9. Cross links from above and below plus a tree line into one child: no +// last turn crosses another; cross links from one parent get distinct slots. +{ + const nodes = [ + node('Root'), node('Child'), node('Sibling'), + node('Low1'), node('Low1a'), node('Low2'), node('Low2a'), node('Low2b'), + node('High'), + ]; + const edges = [ + fk('Root', 'Child'), fk('Root', 'Sibling'), + fk('Low1', 'Low1a'), fk('Low2', 'Low2a'), fk('Low2', 'Low2b'), + fk('High', 'Child', false), fk('Low1a', 'Child', false), fk('Low2b', 'Child', false), fk('Low2a', 'Child', false), + fk('Root', 'Low1a', false), fk('Root', 'Low2b', false), + ]; + const { positions, routes } = layoutTree(nodes, edges); + const crossings = lastTurnCrossings(routes, positions); + assert(crossings.length === 0, `FAIL 9a: last turns cross (${crossings.join('; ')})`); + const crossFromRoot = [...routes.values()].filter(r => r.parent === 'Root' && r.kind === 'cross'); + const lanes = new Set(crossFromRoot.map(r => r.slot.laneDx.toFixed(2))); + const streets = new Set(crossFromRoot.map(r => r.slot.streetDy.toFixed(2))); + assert(crossFromRoot.length >= 2 && lanes.size === crossFromRoot.length && streets.size === crossFromRoot.length, + `FAIL 9b: Root's cross links each have their own lane and street (${crossFromRoot.length})`); +} + +// 10. Hiding a table re-routes on the boxes as they stand: every other line +// keeps its kind, and still no last turns cross. +{ + const nodes = [ + node('Community'), node('Building'), node('Unit'), node('UnitType'), node('Amenity'), + node('Lease'), node('LeaseType', { classifier: true }), node('Owner'), + ]; + const edges = [ + fk('Community', 'Building'), fk('Building', 'Unit'), fk('Community', 'UnitType'), fk('UnitType', 'Unit'), + fk('Unit', 'Amenity'), fk('Unit', 'Lease'), fk('LeaseType', 'Lease', false), fk('Owner', 'Building', false), + fk('Owner', 'Lease', false), + ]; + const { positions, routes } = layoutTree(nodes, edges); + const remaining = nodes.filter(n => n.id !== 'UnitType'); + const lines: TreeLine[] = [...routes] + .filter(([, r]) => r.parent !== 'UnitType' && r.child !== 'UnitType') + .map(([id, r]) => ({ id, parent: r.parent, child: r.child, kind: r.kind })); + const rerouted = routeTree(remaining, positions, lines); + for (const line of lines) { + assert(rerouted.get(line.id)?.kind === line.kind, `FAIL 10a: ${line.id} keeps its kind after the hide`); + } + const crossings = lastTurnCrossings(rerouted, positions); + assert(crossings.length === 0, `FAIL 10b: last turns cross after the hide (${crossings.join('; ')})`); +} + +if (process.exitCode !== 1) console.log('PASS: test-tree-layout'); diff --git a/test/checks/test-viewer-search-branding-overlap.ts b/test/checks/test-viewer-search-branding-overlap.ts new file mode 100644 index 0000000..bde8729 --- /dev/null +++ b/test/checks/test-viewer-search-branding-overlap.ts @@ -0,0 +1,198 @@ +// The branding block, the Graph search bar and its count never sit under each other, the +// toggles or the side panels, at any width (SC13, docs/spec/graph-flow-search.md). + +import { chromium } from 'playwright'; +import type { Page } from 'playwright'; +import { resolve, join } from 'path'; +import { existsSync, mkdirSync } from 'fs'; +import { serveCommand } from '../../src/server/server'; +import { box, LONG_SUBTITLE, onTop, overlaps, settleAt } from '../chrome-geometry'; + +const ROOT = resolve(import.meta.dir, '../..'); +const BUNDLE = join(ROOT, 'dist/static/index.js'); +const TMP = join(ROOT, 'tmp', 'viewer-search-branding-overlap'); + +if (!existsSync(BUNDLE)) { + console.log('SKIP: dist/static/index.js not built (run `bun run build:bundle`). CI builds it before checks.'); + process.exit(0); +} +mkdirSync(TMP, { recursive: true }); + +const WATCHDOG_MS = 150_000; +const watchdog = setTimeout(() => { + console.error(`FAIL: test-viewer-search-branding-overlap.ts exceeded its ${WATCHDOG_MS}ms watchdog`); + process.exit(1); +}, WATCHDOG_MS); + +let failures = 0; +function check(cond: boolean, label: string): void { + if (cond) console.log(` PASS ${label}`); + else { console.error(` FAIL ${label}`); failures++; } +} + +// The count sits outside the bar; "123 of 456" is the widest form SC13 covers. +async function widenCount(page: Page): Promise { + await page.evaluate(() => { + const count = document.querySelector('.viewer-search-count'); + if (count !== null) count.textContent = '123 of 456'; + }); +} + +async function assertGraphClear(page: Page, label: string, width: number): Promise { + await widenCount(page); + const brand = await box(page, '.branding-block'); + const pill = await box(page, '.viewer-search-bar--graph .viewer-search-bar-inner'); + const count = await box(page, '.viewer-search-count'); + const help = await box(page, '.help-toggle'); + const theme = await box(page, '.theme-toggle'); + if (!brand || !pill || !count || !help || !theme) { + check(false, `${label} ${width}px: chrome present`); + return; + } + const clear = !overlaps(brand, pill) && !overlaps(pill, help) && !overlaps(pill, theme) + && !overlaps(count, help) && !overlaps(count, theme) && !overlaps(count, brand) + && !overlaps(brand, help) && !overlaps(brand, theme); + const inside = pill.left >= 0 && pill.right <= width && count.left >= 0 && count.right <= width; + if (!clear || !inside) await page.screenshot({ path: join(TMP, `${label.replace(/\W+/g, '-')}-${width}.png`) }); + check(clear && inside, `${label} ${width}px: bar ${Math.round(pill.left)}..${Math.round(pill.right)}, count ..${Math.round(count.right)} clear of brand (..${Math.round(brand.right)}) and toggles (${Math.round(help.left)}..)`); +} + +async function assertOnTop(page: Page, label: string, selectors: string[]): Promise { + for (const selector of selectors) { + const visible = await onTop(page, selector); + if (!visible) await page.screenshot({ path: join(TMP, `covered-${label.replace(/\W+/g, '-')}.png`) }); + check(visible, `${label}: ${selector} is not covered`); + } +} + +const WIDTHS = [1920, 1440, 1101, 1100, 900, 800, 769, 768, 700, 600, 480, 375]; +const servers = [ + serveCommand(join(ROOT, 'models/key-inherited'), { port: 7466 }), + serveCommand(join(ROOT, 'models/broken-demo'), { port: 7467 }), + serveCommand(join(ROOT, 'test/fixtures/broken-flows-model'), { port: 7468 }), + serveCommand(join(ROOT, 'test/fixtures/flows-leveling'), { port: 7469 }), +]; +const browser = await chromium.launch(); + +try { + const page = await browser.newPage({ viewport: { width: 1440, height: 800 } }); + page.setDefaultTimeout(20_000); + await page.goto('http://localhost:7466/#view=graph', { waitUntil: 'load' }); + await page.waitForSelector('.viewer-search-bar--graph .viewer-search-input'); + await page.waitForSelector('.branding-block'); + await page.fill('.viewer-search-bar--graph .viewer-search-input', 'a'); + await page.waitForSelector('.viewer-search-count'); + + for (const width of WIDTHS) { + await settleAt(page, width); + await assertGraphClear(page, 'default branding', width); + } + + await settleAt(page, 1440); + const centred = await box(page, '.viewer-search-bar--graph .viewer-search-bar-inner'); + check(centred !== null && Math.abs((centred.left + centred.right) / 2 - 720) <= 1, '1440px: the bar stays centred when there is room'); + + await page.evaluate((text) => { + const subtitle = document.querySelector('.branding-subtitle'); + if (subtitle !== null) subtitle.textContent = text; + }, LONG_SUBTITLE); + for (const width of WIDTHS) { + await settleAt(page, width); + await assertGraphClear(page, '50-char subtitle', width); + } + const brandHeight = await page.evaluate(() => document.querySelector('.branding-block')?.getBoundingClientRect().height ?? 0); + check(brandHeight <= 50, `the branding block stays one row tall with a 50-char subtitle (${Math.round(brandHeight)}px)`); + + // The Flows bar shares the compact styles; the breadcrumbs start right of a wide branding block. + await page.goto('http://localhost:7466/#view=flow', { waitUntil: 'load' }); + await page.waitForSelector('.viewer-search-bar--flow .viewer-search-bar-inner'); + await page.waitForSelector('.flow-crumbs'); + await page.evaluate((text) => { + const subtitle = document.querySelector('.branding-subtitle'); + if (subtitle !== null) subtitle.textContent = text; + }, LONG_SUBTITLE); + for (const width of [1440, 1100, 800, 480, 375]) { + await settleAt(page, width); + const pill = await box(page, '.viewer-search-bar--flow .viewer-search-bar-inner'); + const help = await box(page, '.help-toggle'); + const crumbs = await box(page, '.flow-crumbs'); + const brand = await box(page, '.branding-block'); + check(pill !== null && help !== null && pill.left >= 0 && pill.right <= width && !overlaps(pill, help), + `flows ${width}px: bar ${Math.round(pill?.left ?? 0)}..${Math.round(pill?.right ?? 0)} inside the viewport and clear of the toggles`); + check(crumbs !== null && brand !== null && !overlaps(crumbs, brand), + `flows ${width}px: breadcrumbs (${Math.round(crumbs?.left ?? 0)}..) clear of a 50-char-subtitle branding block (..${Math.round(brand?.right ?? 0)})`); + } + await page.close(); + + // Deep breadcrumbs beside a wide branding block wrap short of the toggles, and the bar stays below them. + const deep = await browser.newPage({ viewport: { width: 1440, height: 800 } }); + deep.setDefaultTimeout(20_000); + await deep.goto('http://localhost:7469/#view=flow&dfd=Login', { waitUntil: 'load' }); + await deep.waitForSelector('.flow-crumbs'); + await deep.evaluate((text) => { + const subtitle = document.querySelector('.branding-subtitle'); + if (subtitle !== null) subtitle.textContent = text; + }, LONG_SUBTITLE); + for (const width of [1440, 1100, 1000, 940, 800]) { + await settleAt(deep, width); + await deep.waitForFunction(() => { + const crumbs = document.querySelector('.flow-crumbs')?.getBoundingClientRect().bottom ?? 0; + const top = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--flow-search-bar-top')); + return Math.abs(top - crumbs - 12) < 1; + }, undefined, { timeout: 5000 }); + const crumbs = await box(deep, '.flow-crumbs'); + const help = await box(deep, '.help-toggle'); + const theme = await box(deep, '.theme-toggle'); + const brand = await box(deep, '.branding-block'); + const bar = await box(deep, '.viewer-search-bar--flow .viewer-search-bar-inner'); + const clear = crumbs !== null && help !== null && theme !== null && brand !== null && bar !== null + && !overlaps(crumbs, help) && !overlaps(crumbs, theme) && !overlaps(crumbs, brand) && !overlaps(crumbs, bar); + if (!clear) await deep.screenshot({ path: join(TMP, `deep-crumbs-${width}.png`) }); + check(clear, `deep breadcrumbs ${width}px: crumbs ${Math.round(crumbs?.left ?? 0)}..${Math.round(crumbs?.right ?? 0)} clear of the branding, the toggles and the bar`); + } + await deep.close(); + + // Models with findings open the side panels; they must not cover the bars or the count. + const demo = await browser.newPage({ viewport: { width: 1440, height: 800 } }); + demo.setDefaultTimeout(20_000); + await demo.goto('http://localhost:7467/#view=graph', { waitUntil: 'load' }); + await demo.waitForSelector('.side-panel-stack'); + await demo.waitForSelector('.graph-global-banner-close'); + await demo.fill('.viewer-search-bar--graph .viewer-search-input', 'a'); + await demo.waitForSelector('.viewer-search-count'); + for (const width of [1100, 900, 800]) { + await settleAt(demo, width); + await widenCount(demo); + await assertOnTop(demo, `broken-demo graph with banner ${width}px`, ['.viewer-search-bar--graph .viewer-search-input', '.viewer-search-count']); + } + await demo.click('.graph-global-banner-close'); + for (const width of [768, 480, 375]) { + await settleAt(demo, width); + await widenCount(demo); + await assertOnTop(demo, `broken-demo graph ${width}px`, ['.viewer-search-bar--graph .viewer-search-input', '.viewer-search-count']); + } + await demo.goto('http://localhost:7467/#view=dict', { waitUntil: 'load' }); + await demo.waitForSelector('.dict-search-input'); + for (const width of [768, 480, 375]) { + await settleAt(demo, width); + await assertOnTop(demo, `broken-demo dict ${width}px`, ['.dict-search-input']); + } + await demo.goto('http://localhost:7468/#view=flow', { waitUntil: 'load' }); + await demo.waitForSelector('.viewer-search-bar--flow .viewer-search-input'); + await demo.waitForSelector('.side-panel-stack'); + for (const width of [1100, 800, 375]) { + await settleAt(demo, width); + await assertOnTop(demo, `broken-flows flows ${width}px`, ['.viewer-search-bar--flow .viewer-search-input', '.viewer-search-switch-track']); + } +} finally { + clearTimeout(watchdog); + await browser.close(); + for (const server of servers) server.stop(true); +} + +if (failures > 0) { + console.error(`\ntest-viewer-search-branding-overlap: ${failures} failure(s)`); + process.exit(1); +} +console.log('\ntest-viewer-search-branding-overlap: all assertions passed.'); +process.exit(0); diff --git a/test/chrome-geometry.ts b/test/chrome-geometry.ts new file mode 100644 index 0000000..bc3e45a --- /dev/null +++ b/test/chrome-geometry.ts @@ -0,0 +1,40 @@ +import type { Page } from 'playwright'; + +/** A subtitle at the parser's 50-character cap: the widest branding block. */ +export const LONG_SUBTITLE = 'Order-to-cash reference model for noorm.dev guides'; + +export type Box = { left: number; right: number; top: number; bottom: number }; + +/** True when the two boxes cover each other on both axes. */ +export const overlaps = (a: Box, b: Box): boolean => + a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom; + +/** The viewport box of the first element matching `selector`, or null. */ +export async function box(page: Page, selector: string): Promise { + return page.evaluate((sel) => { + const r = document.querySelector(sel)?.getBoundingClientRect(); + return r ? { left: r.left, right: r.right, top: r.top, bottom: r.bottom } : null; + }, selector); +} + +/** Resizes, then waits until the CSS sees the width and `--branding-gutter` matches the + * branding block: the top-row bars read both, so measuring sooner sees the old layout. */ +export async function settleAt(page: Page, width: number, height = 800): Promise { + await page.setViewportSize({ width, height }); + await page.waitForFunction((w) => { + const brand = document.querySelector('.branding-block')?.getBoundingClientRect().width ?? 0; + const gutter = getComputedStyle(document.documentElement).getPropertyValue('--branding-gutter').trim(); + return window.innerWidth === w && gutter === `${Math.ceil(brand)}px`; + }, width, { timeout: 5000 }); + await page.evaluate(() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))); +} + +/** True when the element at the centre of `selector` is that element or inside it. */ +export async function onTop(page: Page, selector: string): Promise { + return page.evaluate((sel) => { + const el = document.querySelector(sel); + const r = el?.getBoundingClientRect(); + if (!el || !r) return false; + return el.contains(document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2)); + }, selector); +} diff --git a/test/visual/test-graph-edits.ts b/test/visual/test-graph-edits.ts new file mode 100644 index 0000000..f95b654 --- /dev/null +++ b/test/visual/test-graph-edits.ts @@ -0,0 +1,360 @@ +/** + * Playwright check: graph arrangement edits, driven by real pointer and + * keyboard gestures. + * + * 1. Dragging a node, then Ctrl+Z, puts it back; Ctrl+Shift+Z moves it again. + * 2. Select mode (S): a background drag box-selects; dragging one selected + * node moves the others with it; S again leaves select mode. + * 3. Right-click hides a node; undo/redo cover it; the "N hidden" list + * shows it again; a hidden node stays hidden across a reload; hiding every + * member of a subtype cluster hides its joiner too. + * 4. A model edit keeps the user's arrangement: after a new entity file + * appears, a node the user moved stays where it was and the new entity + * sits next to the table it references. + * 5. Reset layout runs without a hidden node; showing it after a view switch + * seats it clear of the others, near its neighbour. + * 6. Undoing a layout-mode switch restores the mode as well as the positions. + * In the Tree layout a parent→child line is right-angled, and stays so + * after a drag; leaving Tree drops the routing. A click during a pending + * switch does not cancel it. + * 7. Searching for a hidden entity shows it before selecting it. + * + * Serves a copy of models/key-inherited (step 4 writes into it) on port 7441. + * Screenshots land in tmp/graph-edits/. NOT run by `bun run test`. + */ + +import { chromium } from 'playwright'; +import type { Page } from 'playwright'; +import { resolve, join } from 'path'; +import { cpSync, mkdirSync, rmSync, writeFileSync } from 'fs'; +import { HOVER_INTENT_MS } from '../../src/app/logic/motion'; + +const ROOT = resolve(import.meta.dir, '../..'); +const TMP = join(ROOT, 'tmp', 'graph-edits'); +const MODEL = join(TMP, 'model'); +rmSync(TMP, { recursive: true, force: true }); +mkdirSync(TMP, { recursive: true }); +cpSync(join(ROOT, 'models', 'key-inherited'), MODEL, { recursive: true }); + +const PORT = 7441; +const BASE = `http://localhost:${PORT}`; +const note = (m: string) => console.log(m); +let failures = 0; +const check = (ok: boolean, m: string) => { + if (ok) note(`PASS: ${m}`); + else { console.error(`FAIL: ${m}`); failures++; } +}; + +const proc = Bun.spawn( + ['bun', 'src/cli/cli.ts', 'serve', MODEL, '--port', String(PORT)], + { cwd: ROOT, stdout: 'pipe', stderr: 'pipe' }, +); + +async function waitForServer(url: string, timeout = 12_000): Promise { + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + try { if ((await fetch(url)).ok) return true; } catch {} + await Bun.sleep(200); + } + return false; +} + +type Point = { x: number; y: number }; + +// Page coordinates of a node's centre. +async function screenPoint(page: Page, id: string): Promise { + return page.evaluate((nodeId) => { + const cy = (() => { const c = window.__IGNATIUS_CY__; if (!c) throw new Error('no live cytoscape core'); return c; })(); + const rp = cy.$id(nodeId).renderedPosition(); + const rect = cy.container().getBoundingClientRect(); + return { x: rect.left + rp.x, y: rect.top + rp.y }; + }, id); +} + +async function modelPosition(page: Page, id: string): Promise { + return page.evaluate((nodeId) => { + const p = (() => { const c = window.__IGNATIUS_CY__; if (!c) throw new Error('no live cytoscape core'); return c; })().$id(nodeId).position(); + return { x: p.x, y: p.y }; + }, id); +} + +async function hasClass(page: Page, id: string, cls: string): Promise { + return page.evaluate(([nodeId, cls]) => (() => { const c = window.__IGNATIUS_CY__; if (!c) throw new Error('no live cytoscape core'); return c; })().$id(nodeId).hasClass(cls), + [id, cls] as const); +} + +async function selectedIds(page: Page): Promise { + return page.evaluate(() => { + const ids: string[] = []; + (() => { const c = window.__IGNATIUS_CY__; if (!c) throw new Error('no live cytoscape core'); return c; })().nodes(':selected').forEach((n: { id(): string }) => { ids.push(n.id()); }); + return ids; + }); +} + +async function drag(page: Page, from: Point, to: Point): Promise { + await page.mouse.move(from.x, from.y); + await page.mouse.down(); + await page.mouse.move((from.x + to.x) / 2, (from.y + to.y) / 2, { steps: 5 }); + await page.mouse.move(to.x, to.y, { steps: 5 }); + await page.mouse.up(); + await page.waitForTimeout(150); +} + +async function rightClick(page: Page, id: string): Promise { + const p = await screenPoint(page, id); + await page.mouse.click(p.x, p.y, { button: 'right' }); + await page.waitForTimeout(HOVER_INTENT_MS + 150); // past the hover delay, so a stuck fade would show +} + +const near = (a: Point, b: Point, tolerance = 1) => Math.hypot(a.x - b.x, a.y - b.y) <= tolerance; + +async function waitForLayout(page: Page): Promise { + await page.waitForFunction(() => window.__IGNATIUS_PERF__?.layoutStopAt, null, { timeout: 60_000 }); + await page.waitForTimeout(300); +} + +async function showAll(page: Page): Promise { + await page.locator('.graph-edit-hidden').click(); + await page.getByText('Show all').click(); + await page.waitForTimeout(300); +} + +try { + if (!(await waitForServer(BASE))) throw new Error('server did not start'); + const browser = await chromium.launch(); + const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); + await page.addInitScript(() => { + if (!sessionStorage.getItem('graph-edits-seeded')) { + localStorage.setItem('ignatius-layout-mode', 'hierarchical'); + sessionStorage.setItem('graph-edits-seeded', '1'); + } + }); + await page.goto(BASE); + await waitForLayout(page); + + // 1. drag + undo/redo + const partyStart = await modelPosition(page, 'Party'); + const partyScreen = await screenPoint(page, 'Party'); + await drag(page, partyScreen, { x: partyScreen.x + 160, y: partyScreen.y + 40 }); + const partyMoved = await modelPosition(page, 'Party'); + check(!near(partyStart, partyMoved, 20), 'drag moves Party'); + await page.keyboard.press('Control+z'); + check(near(await modelPosition(page, 'Party'), partyStart), 'Ctrl+Z puts Party back'); + await page.keyboard.press('Control+Shift+Z'); + check(near(await modelPosition(page, 'Party'), partyMoved), 'Ctrl+Shift+Z moves Party again'); + await page.keyboard.press('Control+z'); + + // 2. select mode, box select, group drag + await page.keyboard.press('s'); + check(await page.locator('[aria-label="Select mode"][aria-pressed="true"]').count() === 1, 'S turns select mode on'); + const business = await screenPoint(page, 'Business'); + const person = await screenPoint(page, 'Person'); + const boxFrom = { x: Math.min(business.x, person.x) - 60, y: Math.min(business.y, person.y) - 30 }; + const boxTo = { x: Math.max(business.x, person.x) + 60, y: Math.max(business.y, person.y) + 30 }; + await drag(page, boxFrom, boxTo); + const selected = await selectedIds(page); + check(selected.includes('Business') && selected.includes('Person'), `box selects Business and Person (got ${selected.join(', ')})`); + await page.screenshot({ path: join(TMP, '01-box-selected.png') }); + const personBefore = await modelPosition(page, 'Person'); + const businessBefore = await modelPosition(page, 'Business'); + await drag(page, business, { x: business.x, y: business.y + 120 }); + const personAfter = await modelPosition(page, 'Person'); + const businessAfter = await modelPosition(page, 'Business'); + const dy = (p: Point, q: Point) => q.y - p.y; + check(dy(businessBefore, businessAfter) > 30 && Math.abs(dy(personBefore, personAfter) - dy(businessBefore, businessAfter)) < 1, + 'dragging Business moves the selected Person by the same amount'); + await page.keyboard.press('Control+z'); + check(near(await modelPosition(page, 'Person'), personBefore) && near(await modelPosition(page, 'Business'), businessBefore), + 'one Ctrl+Z undoes the whole group drag'); + await page.keyboard.press('s'); + check(await page.locator('[aria-label="Select mode"][aria-pressed="false"]').count() === 1, 'S turns select mode off'); + + // 3. hide / show + await page.mouse.click(5, 450); // background: clear the selection + await rightClick(page, 'Identity'); + check(await hasClass(page, 'Identity', 'user-hidden'), 'right-click hides Identity'); + check(await page.locator('.graph-edit-hidden[aria-label="1 hidden"]').count() === 1, 'edit bar counts 1 hidden'); + const fadedAfterHide = await page.evaluate(() => (() => { const c = window.__IGNATIUS_CY__; if (!c) throw new Error('no live cytoscape core'); return c; })().nodes('.faded').length); + check(fadedAfterHide === 0, `hiding the hovered node clears its hover fade (${fadedAfterHide} nodes still faded)`); + await page.screenshot({ path: join(TMP, '02-identity-hidden.png') }); + await page.keyboard.press('Control+z'); + check(!(await hasClass(page, 'Identity', 'user-hidden')), 'Ctrl+Z shows Identity again'); + await page.keyboard.press('Control+Shift+Z'); + check(await hasClass(page, 'Identity', 'user-hidden'), 'Ctrl+Shift+Z hides it again'); + await page.locator('.graph-edit-hidden').click(); + await page.screenshot({ path: join(TMP, '03-hidden-list.png') }); + await page.getByText('Show all').click(); + check(!(await hasClass(page, 'Identity', 'user-hidden')), '"Show all" brings Identity back'); + await rightClick(page, 'Identity'); + await page.waitForTimeout(300); + await page.reload(); + await waitForLayout(page); + check(await hasClass(page, 'Identity', 'user-hidden'), 'a hidden node stays hidden across a reload'); + await showAll(page); + await rightClick(page, 'Person'); + await rightClick(page, 'Business'); + check(await hasClass(page, '_joiner_Party_x', 'user-hidden'), 'hiding every member of a subtype cluster hides its joiner'); + await showAll(page); + check(!(await hasClass(page, '_joiner_Party_x', 'user-hidden')), 'showing a member brings the joiner back'); + + // 4. carry-over across a model edit + const ssnScreen = await screenPoint(page, 'SSN'); + await drag(page, ssnScreen, { x: ssnScreen.x + 200, y: ssnScreen.y - 120 }); + await page.waitForTimeout(600); // debounced save + const ssnMoved = await modelPosition(page, 'SSN'); + const gen = await page.evaluate(() => window.__IGNATIUS_CY_GEN__ ?? 0); + writeFileSync(join(MODEL, 'data', 'transactional', 'Refund.md'), [ + '---', + 'entity: Refund', + 'group: transactional', + 'pk: [party_id, payment_method_id, payment_id, refund_no]', + 'columns:', + ' party_id: { type: integer, desc: "Party." }', + ' payment_method_id: { type: integer, desc: "Method." }', + ' payment_id: { type: integer, desc: "Payment refunded." }', + ' refund_no: { type: integer, desc: "Refund within the payment." }', + 'relationships:', + ' - target: Payment', + ' on: { party_id: party_id, payment_method_id: payment_method_id, payment_id: payment_id }', + ' predicate: { fwd: is returned by, rev: returns }', + '---', + '', + '# Refund', + '', + 'Money returned against a [[Payment]].', + '', + ].join('\n')); + await page.waitForFunction((g) => (window.__IGNATIUS_CY_GEN__ ?? 0) > g + && (window.__IGNATIUS_CY__?.$id('Refund').nonempty() ?? false), gen, { timeout: 30_000 }); + await waitForLayout(page); + await page.waitForTimeout(500); + check(near(await modelPosition(page, 'SSN'), ssnMoved), 'SSN keeps the spot the user dragged it to after the model edit'); + const refund = await modelPosition(page, 'Refund'); + const payment = await modelPosition(page, 'Payment'); + const gap = Math.hypot(refund.x - payment.x, refund.y - payment.y); + check(gap < 400, `the new Refund sits next to Payment (${Math.round(gap)} units away)`); + await page.screenshot({ path: join(TMP, '04-after-model-edit.png') }); + + // 5. Reset layout without a hidden node, then a view switch, then show it. + await rightClick(page, 'Identity'); + await page.locator('button.fab').click(); + await page.getByRole('menuitem', { name: 'Reset layout' }).click(); + await page.waitForTimeout(2000); + await page.screenshot({ path: join(TMP, '05-reset-without-identity.png') }); + check(await hasClass(page, 'Identity', 'user-hidden'), 'Identity stays hidden through Reset layout'); + await page.keyboard.press('d'); + await page.waitForTimeout(500); + await page.keyboard.press('g'); + await waitForLayout(page); + await showAll(page); + const overlaps = await page.evaluate(() => { + const cy = (() => { const c = window.__IGNATIUS_CY__; if (!c) throw new Error('no live cytoscape core'); return c; })(); + const me = cy.$id('Identity').boundingBox(); + const hits: string[] = []; + cy.nodes(':childless:visible').not(cy.$id('Identity')).forEach((n: { id(): string; boundingBox(): { x1: number; x2: number; y1: number; y2: number } }) => { + const b = n.boundingBox(); + if (b.x1 < me.x2 && me.x1 < b.x2 && b.y1 < me.y2 && me.y1 < b.y2) hits.push(n.id()); + }); + return hits; + }); + check(overlaps.length === 0, `Identity shown after Reset and a view switch overlaps nothing (overlaps: ${overlaps.join(', ') || 'none'})`); + const identityBack = await modelPosition(page, 'Identity'); + const party = await modelPosition(page, 'Party'); + const toParty = Math.hypot(identityBack.x - party.x, identityBack.y - party.y); + check(toParty < 600, `shown Identity sits near Party (${Math.round(toParty)} units away)`); + await page.screenshot({ path: join(TMP, '06-identity-shown.png') }); + + // 6. Undo a layout-mode switch. L steps hierarchical → tree. + const stored = () => page.evaluate(() => localStorage.getItem('ignatius-layout-mode')); + const partyHier = await modelPosition(page, 'Party'); + await page.keyboard.press('l'); + await page.waitForFunction(() => localStorage.getItem('ignatius-layout-mode') === 'tree', null, { timeout: 5_000 }); + await page.waitForTimeout(500); + await page.keyboard.press('Control+z'); + await page.waitForTimeout(300); + const modeAfterUndo = await stored(); + check(modeAfterUndo === 'hierarchical', `undoing the mode switch restores the stored mode (got ${modeAfterUndo})`); + check(near(await modelPosition(page, 'Party'), partyHier), 'undoing the mode switch restores the hierarchical positions'); + + // 6a. Tree layout: a parent→child line drops down then turns right into + // the child, and stays right-angled after the child is dragged. + await page.keyboard.press('l'); + await page.waitForFunction(() => localStorage.getItem('ignatius-layout-mode') === 'tree', null, { timeout: 5_000 }); + await page.waitForTimeout(500); + await page.screenshot({ path: join(TMP, '07-tree.png') }); + // Any parent→child tree line into a table (not a subtype diamond). + const picked = await page.evaluate(() => { + const cy = window.__IGNATIUS_CY__; + const edge = cy?.edges('.tree-edge').not('.tree-label-source').filter(e => e.target().data('joiner') !== 'true')[0]; + return edge ? { id: edge.id(), parent: edge.source().id(), child: edge.target().id() } : null; + }); + check(picked !== null, `the tree layout routes parent→child lines (${JSON.stringify(picked)})`); + const treeLine = () => page.evaluate((line) => { + const cy = (() => { const c = window.__IGNATIUS_CY__; if (!c) throw new Error('no live cytoscape core'); return c; })(); + const edge = cy.$id(line?.id ?? ''); + if (edge.empty() || !line) return null; + const pts = [edge.sourceEndpoint(), ...(edge.segmentPoints() ?? []), edge.targetEndpoint()]; + const square = pts.every((p, i) => i === 0 || Math.abs(p.x - (pts[i - 1]?.x ?? p.x)) < 0.5 || Math.abs(p.y - (pts[i - 1]?.y ?? p.y)) < 0.5); + return { + tree: edge.hasClass('tree-edge'), + square, + bends: pts.length - 2, + childRight: cy.$id(line.child).position('x') > cy.$id(line.parent).position('x'), + }; + }, picked); + const before = await treeLine(); + check(before?.tree === true && before.square && before.bends >= 1 && before.childRight, + `${picked?.parent} → ${picked?.child} is a right-angled tree line into a child to its right (${JSON.stringify(before)})`); + const childScreen = await screenPoint(page, picked?.child ?? ''); + await drag(page, childScreen, { x: childScreen.x + 90, y: childScreen.y + 60 }); + const after = await treeLine(); + check(after?.square === true, `the tree line stays right-angled after a drag (${JSON.stringify(after)})`); + await page.keyboard.press('Control+z'); + + // 6a2. Undo while a switch away from Tree computes keeps Tree and its routing. + await page.keyboard.press('l'); + await page.keyboard.press('Control+z'); + await page.waitForTimeout(500); + const afterCancel = await page.evaluate(() => ({ + mode: localStorage.getItem('ignatius-layout-mode'), + routed: window.__IGNATIUS_CY__?.edges('.tree-edge, .tree-cross').length ?? 0, + })); + check(afterCancel.mode === 'tree' && afterCancel.routed > 0, + `undo during a pending switch keeps Tree routed (${JSON.stringify(afterCancel)})`); + + // 6b. A plain click while a mode switch computes must not cancel it. + // L steps tree → organic, whose search takes a moment. + const partyBeforeSwitch = await modelPosition(page, 'Party'); + await page.keyboard.press('l'); + await page.waitForTimeout(150); + const paymentScreen = await screenPoint(page, 'Payment'); + await page.mouse.click(paymentScreen.x, paymentScreen.y); + await page.waitForTimeout(5000); + await page.keyboard.press('Escape'); + const modeAfterClick = await stored(); + check(modeAfterClick === 'organic', `the switch still lands after a click (mode ${modeAfterClick})`); + check(!near(await modelPosition(page, 'Party'), partyBeforeSwitch, 5), 'the organic positions landed under the organic label'); + const lingering = await page.evaluate(() => window.__IGNATIUS_CY__?.edges('.tree-edge, .tree-cross').length ?? -1); + check(lingering === 0, `leaving the tree layout drops its line routing (${lingering} edges still routed)`); + await page.keyboard.press('Control+z'); + await page.waitForTimeout(300); + + // 7. Search for a hidden entity. + await rightClick(page, 'SalesOrder'); + await page.keyboard.press('/'); + await page.keyboard.type('SalesOrder'); + await page.waitForTimeout(600); // the search term is debounced + await page.keyboard.press('Enter'); + await page.waitForTimeout(500); + check(!(await hasClass(page, 'SalesOrder', 'user-hidden')), 'searching for a hidden entity shows it'); + + await browser.close(); +} finally { + proc.kill(); +} + +if (failures > 0) { + console.error(`\n${failures} check(s) failed`); + process.exit(1); +} +note('\nAll graph-edit checks passed.'); From 2f130e481b0c793b5bdc76d0b5838e972533258a Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 21 Oct 2026 04:38:55 -0400 Subject: [PATCH 2/2] feat(graph): share trunks and drops in tree layout A parent's lines share one trunk; lines into a wrapped column share one drop with labels on their runs; lines move or jog around boxes. A first visit opens the Graph in Tree; a browser with a saved arrangement and no stored mode keeps Organic. Runs fit the reverse predicate shown on hover, and a fit keeps tables out from under the header and footer. --- docs/design/graph-tree-layout.md | 62 ++- docs/guides/commands.md | 2 +- docs/spec/graph-tree-layout.md | 113 +++-- docs/spec/help-overlay.md | 8 +- docs/spec/viewer-ux-polish.md | 10 +- docs/wiki/feature-map.md | 4 +- docs/wiki/frontend.md | 8 +- scripts/perf-harness.ts | 2 +- src/app/App.tsx | 9 +- src/app/components/ui/HelpModal.tsx | 2 +- src/app/views/graph/GraphView.tsx | 49 ++- src/app/views/graph/graph-edits.ts | 11 +- src/app/views/graph/layout-store.ts | 6 + src/app/views/graph/tree-layout.ts | 447 +++++++++++++++----- test/checks/test-graph-edits.ts | 13 +- test/checks/test-graph-fit-clear.ts | 91 ++++ test/checks/test-graph-search.ts | 10 +- test/checks/test-keyboard-shortcuts.ts | 11 +- test/checks/test-layout-store.ts | 13 + test/checks/test-tree-layout.ts | 378 +++++++++++++++-- test/checks/test-tree-predicate-hover.ts | 101 +++++ test/visual/screenshot-lineage-highlight.ts | 2 +- 22 files changed, 1139 insertions(+), 213 deletions(-) create mode 100644 test/checks/test-graph-fit-clear.ts create mode 100644 test/checks/test-tree-predicate-hover.ts diff --git a/docs/design/graph-tree-layout.md b/docs/design/graph-tree-layout.md index 8a675de..f3683a0 100644 --- a/docs/design/graph-tree-layout.md +++ b/docs/design/graph-tree-layout.md @@ -10,7 +10,7 @@ The Organic and Hierarchical layouts place tables in two dimensions and draw str ## Goals / Non-goals -- Goals: a third layout mode that reads as an indented outline, left to right and top to bottom; a tree or lookup parent always above and left of its child; every line leaves its parent's bottom edge and enters its child's left side; each parent→child line keeps its own lane; the relationship name on the run into the child. +- Goals: a third layout mode that reads as an indented outline, left to right and top to bottom; a tree or lookup parent always above and left of its child; every line leaves its parent's bottom edge and enters its child's left side; a parent's lines share one trunk and branch into each child, as IDEF1X tools draw them; the relationship name on the run into the child; Tree is the layout a first visit opens in. - Non-goals: column lists or icons inside the boxes; free-floating notes on the canvas; hand-tuned per-child offsets. @@ -28,9 +28,9 @@ A root with nothing under it that some table references (a classifier, a table w 1. Each tree is laid out depth first: a node takes the next row, its children follow in rows below it, indented to its right. -2. Children are ordered smallest subtree first, so the lanes that run furthest down pass the fewest rows. -3. A child's indent is the parent's lane fan plus the widest relationship label among its siblings, so every label fits on its run. -4. A tree's lookups form a staircase above it. Each drops its lanes from its bottom-left, past the lookups below it (each starts right of the lanes above), and turns right into its consumer. The lookup feeding the deepest table is top-left, so a lookup's run into its deepest table crosses no other lookup's lane; a lookup that also feeds a shallower table crosses the lanes of the lookups after it on that run, which a staircase cannot avoid. The tree starts right of the last lane, under the staircase. +2. Children are ordered smallest subtree first, so the largest subtrees come last on the trunk. +3. A parent's tree lines share one trunk a quarter of its width in from its left edge, so a parent with many children draws one vertical, not a band of them. A child's indent is the trunk plus the widest relationship label among its siblings, so every label fits on its branch. +4. A tree's lookups form a staircase above it. Each drops one trunk from its bottom-left, past the lookups below it (each starts right of the trunk above), and branches right into its consumers. The lookup feeding the deepest table is top-left, so a lookup's run into its deepest table crosses no other lookup's trunk; a lookup that also feeds a shallower table crosses the trunks of the lookups after it on that run, which a staircase cannot avoid. The tree starts right of the last trunk, under the staircase. 5. Trees are stacked into columns about as tall as makes the picture 16:10. A tree that other trees reference goes first, colour groups first and then the trees inside each group, so links between trees also run right and down. 6. A parent whose children would run past the bottom of the column keeps those that fit under it. The rest wrap into columns to its right, the way an outline wraps. Each wrapped column starts one row below the parent, so its cross links still run down and right. Wrapped children stay part of the parent's tree, so the tree order never moves them above or left of it. 7. Children wrap only when a fresh column has room for two of the parent's child rows. With less room, wrapping would make a strip of one-table columns, so the tree runs past the column instead. @@ -54,11 +54,12 @@ A wide parent wraps: the children that fit stay under it, the rest continue in c ``` Party - │└──────────────────────┐ Party's street - ├─► Business ├─► SalesOrder kept column: tree lines - ├─► Person │ └─► SO Line wrapped column: cross links - ├─► PaymentMethod └─► Identity from Party's street, - └─► SalesInvoice └─► ITIN entering from above + │ └───────────────────────────────┐ Party's street feeds one drop + ├── is a ───────► Business ├── is placed by ─► SalesOrder + ├── is a ───────► Person │ └─► SO Line + ├── is held by ─► PaymentMethod └── identifies ────► Identity + └── is owed by ─► SalesInvoice └─► ITIN + one trunk, a branch per child one drop, a branch per child ``` @@ -68,12 +69,47 @@ Party | # | Approach | Pros | Cons | |---|----------|------|------| | A | cytoscape `taxi` edges | Built in, adapts to drags | Leaves and enters on whichever side the shape picks; no per-edge lanes | -| B | `segments` edges with computed bends (chosen) | Exact control of where a line leaves and enters, its own lane | Bends are relative to the endpoints, so they are recomputed when a node moves | +| B | `segments` edges with computed bends (chosen) | Exact control of where a line leaves and enters, and of the trunk or lane it runs down | Bends are relative to the endpoints, so they are recomputed when a node moves | -**B** for every line, with both ends pinned (`source-endpoint`/`target-endpoint`) so a line leaves exactly on its lane and enters exactly on its row. A `down` line (tree lines and lookup lines) bends once, at (lane, entry row): straight down its lane, right into the child's left edge. If the child no longer sits below the parent and right of the lane (after a hide, a reveal, a reload or a drag), the line is drawn with cross geometry instead. Tree lanes use the left half of the parent's bottom edge. A `cross` line (any other link) leaves on a lane in the right half of the parent's bottom edge, runs along a short street just under the parent, down or up an approach column just left of the child, and into the child's left side. +**B** for every line, with both ends pinned (`source-endpoint`/`target-endpoint`) so a line leaves exactly on its trunk or lane and enters exactly on its row. A `down` line (tree lines and lookup lines) bends once, at (trunk, entry row): straight down the parent's trunk, right into the child's left edge. If the child no longer sits below the parent and right of the trunk, with room between them for the child's approach columns from above (after a hide, a reveal, a reload or a drag), the line is drawn with cross geometry instead: those columns would otherwise cross the trunk. A `cross` line (any other link) leaves on a lane in the right half of the parent's bottom edge, runs along a short street just under the parent, down or up an approach column just left of the child, and into the child's left side. -Lines share the child's left side: cross links from above enter highest, then `down` lines from the rightmost lane to the leftmost, then cross links from below. Approach columns follow the same order (from above, the further-left column enters lower; from below, higher), so no two last turns into one child cross. +Lines from one parent into a wrapped column share one lane, one street and one drop in the gap before the column, like a second trunk; each branches into its child with its label on the branch. A parent's streets sit at most 2 px apart, so labels on separate streets print over each other; on the branches they each have their own row. The gap before a wrapped column is widened to fit those labels. Two parents' drops into one column would sit at the same x and draw as one line, so one parent owns the column's drop: the one with the most lines into it. The other parents' lines keep their own slots, and the drop sits left of their columns into the same children as well as a label run left of the column. + +The owner's lines share a drop unless there is no room for it, or a single line does better on its own: + +```mermaid +flowchart TD + R{Room right of the parent?} -->|no| O[Own slot per line] + R -->|yes| C{clearDrop finds an x?} + C -->|yes| X{One line, crossed?} + X -->|no| D[Shared drop] + X -->|yes| O + C -->|no| S{Several lines?} + S -->|yes| D + S -->|no| O +``` + +The drop moves left until it, its street and its runs miss every box. With nothing clear, several lines keep the drop, behind a box, rather than print their labels over each other. A single line's label has no sibling to collide with on its own street, so it takes its own slot, as it does when another line's street into its child would cross its drop. With no room right of the parent (a drag moved the parent next to the column), the column is not shared: a drop there would land among the other columns or squeeze its labels. + +Lines share the child's left side: cross links from above enter highest, then `down` lines from the rightmost trunk to the leftmost, then cross links from below. Approach columns follow the same order (from above, the further-left column enters lower, shared drops leftmost; from below, higher), so no two last turns into one child cross. + +A child's own approach columns can still pass through a box, on a fresh layout as well as after a drag (orm-pure jogs two lines). Lines from above and lines from below are two groups, each moved left together, which keeps its order. + +A blocked line jogs only when it comes from above and no shifted column is clear: + +```mermaid +flowchart TD + A{straightClear} -->|yes| K[Keep it] + A -->|no| B{shiftClear} + B -->|yes| S[Use the shift] + B -->|no, from below| D[Leave as routed] + B -->|no, from above| C{jogAround} + C -->|found| J[Jog above the child] + C -->|none| D +``` + +Lines from above stop at the child's trunks and shared drops, so a moved column never crosses the child's own tree line. A jog column keeps to the same side, stays 6 px clear of the child's other columns, and is rejected when its row would cross another column from above or its drop another line's street. So only the outermost lines from above can jog, to either side, and their rows never meet. A drop lies no further right than 12 px past both the child and the line's own lane. Right of both, the street overshoots and the jog row hooks back across other lines; on a 400-table model such hooks ran 3000 px past their child. A drop between the child and a lane far to the right stays allowed: that path only ever runs left. Jogs into different children never drop down one column, where they would draw as one line; a line whose only jog would do that stays behind its box. Rejecting jogs whose street crosses another line's lane was tried and dropped: across 20,000 fuzzed scenes it removed 35 such crossings but added 179 elsewhere, because the lines it stopped from jogging crossed more. Lines from below enter under the trunks and need no such stop. A jog costs two bends, so it is used only when every straight drop would run behind a box. Routing is separate from placement. `routeTree` takes the positions on screen and the lines the last Tree layout drew as tree lines, and derives every lane, street, entry row and approach column from them. After a hide, a reveal, an undo or the end of a drag, the graph routes again on the boxes as they stand, so nothing reorders; a tree line whose child no longer sits below and right of its parent becomes a cross link with a slot of its own. Across parents and children there is no global channel routing: lines into stacked siblings can share an approach column, and a long street can run along another line. Bends are stored relative to the parent's edges and the child's left edge and centre, and recomputed on every node move, so a dragged node keeps right-angled lines. -A `down` line's predicate sits on the run into the child (`target-label`, or `source-label` when the edge runs child→parent). A `cross` line's predicate sits centred on the street: cytoscape puts a segments edge's mid label between the two central bends when their count is even, so the first bend is repeated. Rows are 20 px apart to leave the street and its label room under the parent. Crow's-foot markers take their angle from the bend next to each end. +A `down` line's predicate, and a shared-drop line's, sits on the run into the child (`target-label`, or `source-label` when the edge runs child→parent). Any other `cross` line's predicate sits centred on the street: cytoscape puts a segments edge's mid label between the two central bends when their count is even, so the first bend is repeated. Runs are sized for the longer of a line's two predicates, because hovering a table swaps each line into it to its reverse predicate on the same run. Rows are 20 px apart to leave the street and its label room under the parent. Crow's-foot markers take their angle from the bend next to each end. diff --git a/docs/guides/commands.md b/docs/guides/commands.md index 080ad92..7eb5022 100644 --- a/docs/guides/commands.md +++ b/docs/guides/commands.md @@ -163,7 +163,7 @@ The app responds to single-key shortcuts while no text field is focused and no m | `g` | Switch to the Data Graph | | `d` | Switch to the Dictionary | | `f` | Switch to the Data Flows | -| `l` | Step through graph layouts (organic → hierarchical → tree) — Graph view | +| `l` | Step through graph layouts (organic → hierarchical → tree; a first visit opens in tree) — Graph view | | `s` | Toggle select mode: a plain drag draws a selection box, a click adds or removes — Graph view | | `h` | Hide the selected entities — Graph view | | `Cmd`/`Ctrl` + `Z` | Undo the last move, hide, or layout change — Graph view | diff --git a/docs/spec/graph-tree-layout.md b/docs/spec/graph-tree-layout.md index f1c34ab..af6bb3f 100644 --- a/docs/spec/graph-tree-layout.md +++ b/docs/spec/graph-tree-layout.md @@ -18,19 +18,19 @@ The Graph view gains a third layout mode, Tree: an indented outline where each t ## Success criteria -- [x] `LayoutMode` is `'organic' | 'hierarchical' | 'tree'`. `L` and the FAB item step organic → hierarchical → tree → organic (`nextLayoutMode`); the FAB item names the next mode. A stored `ignatius-layout-mode` that is not one of the three reads as organic (`parseLayoutMode`). +- [x] `LayoutMode` is `'organic' | 'hierarchical' | 'tree'`. `L` and the FAB item step organic → hierarchical → tree → organic (`nextLayoutMode`); the FAB item names the next mode. A stored `ignatius-layout-mode` that is not one of the three reads as tree (`parseLayoutMode`). On load the mode is the stored one; with none stored it is Organic when `ignatius-layout-positions` already holds an arrangement (`LayoutStoreHandle.hasEntries`; laid out in Organic, the default before the mode was always stored) and Tree otherwise (`loadLayoutMode`); the result is stored at once. - [x] `buildForest(nodes, edges)` in `tree-layout.ts`: a node's parent is its deepest key-inheritance parent (ties by id); without one, a plain FK parent that is neither a classifier nor a hub (referenced without key inheritance by at least max(8, 10% of the nodes) tables). A cycle is cut at a non-key link, smallest id first. A childless, non-hub root referenced by some table is a lookup (`Forest.lookups`): it gets no tree parent and is assigned to one consumer that is not itself a lookup, same colour group first, then id; a lookup used only by other lookups stays a root. -- [x] `layoutTree(nodes, edges)` returns positions and a route per edge. Depth first; children smallest subtree first; a child's indent = lane fan + widest sibling label (6 px per character + 28 px), at least 28 px. Tree lanes fan across the left half of the parent's bottom edge from 10 px in, at most 6 px apart (every lane fan uses this gap) and squeezed to fit the half; a node whose half is 10 px or narrower sends them all down one trunk from its centre. The first child's lane is rightmost. -- [x] A tree's lookups form a staircase above it: each lookup's lanes drop from its bottom-left, fanned like tree lanes (at most 6 px apart, squeezed to fit the left half), and the next lookup starts right of them. The lookup feeding the deepest table is top-left, so a lookup's run into its deepest table crosses no other lookup's lane (a run into a shallower table may). The tree starts right of the last lane plus the widest lookup label run, below the staircase. A lookup joins only its assigned consumer's tree; its links into other trees, and into tables placed in a wrapped column (a run there would pass behind the boxes on that row), are cross links. -- [x] `routeTree(nodes, positions, lines)` routes `lines` (`TreeLine`: which lines are tree lines) at the given positions; `layoutTree` calls it on its own result. A tree line stays `down` only while its child sits below its parent and right of its lane (otherwise `cross`). `down` lanes (`laneDx` from the parent's left edge) fan across the left half, the topmost child on the rightmost lane, at most 6 px apart. Every `cross` route has a `slot` (lane from the parent's right edge, street below its bottom, approach column left of the child): per parent, the farthest child takes the outer lane and top street (so lines to children right of and below the parent nest; lines to children left or above can cross inside the street band), each line its own lane fanned across the right half and its own street spread 10–16 px below the bottom (at most 2 px apart). Rows are 20 px apart. Lanes on both halves stop 2 px short of the centre. Ties break by edge id. -- [x] Into each child: cross links from above enter highest, then `down` lines from the rightmost lane to the leftmost, then cross links from below; `entryDy` at most 6 px apart and within the child's height. Approach columns follow that order (12 px left of the child, 6 px apart): from above, the further-left column enters lower; from below (numbered after those from above), higher. No two last turns into one child cross. +- [x] `layoutTree(nodes, edges)` returns positions and a route per edge. Depth first; children smallest subtree first; a child's indent = trunk + widest sibling label (6 px per character + 28 px), at least 28 px. A line's label (`TreeEdgeInput.label`) is the longer of its forward and reverse predicates, because hovering the child swaps the reverse one onto the same run. A parent's tree lines share one trunk (`trunkOffset`) a quarter of its width in from its left edge (at least 10 px, at most 2 px short of the centre): each drops down the trunk and branches right into its child. +- [x] A tree's lookups form a staircase above it: each lookup's lines share one trunk placed like a tree trunk, and the next lookup starts 10 px right of it. The lookup feeding the deepest table is top-left, so a lookup's run into its deepest table crosses no other lookup's trunk (a run into a shallower table may). The tree starts right of the last trunk plus the widest lookup label run, below the staircase. A lookup joins only its assigned consumer's tree; its links into other trees, and into tables placed in a wrapped column (a run there would pass behind the boxes on that row), are cross links. +- [x] `routeTree(nodes, positions, lines)` routes `lines` (`TreeLine`: which lines are tree lines) at the given positions; `layoutTree` calls it on its own result. A tree line stays `down` (`trunkDx`: its trunk) only while its child sits below its parent and right of its trunk by more than 2 px plus room for the child's other lines from above, of either kind (12 px for the first, 6 px for each further one), so none of their columns crosses the trunk; otherwise it is `cross`. Every `cross` route has a `slot` (lane from the parent's right edge, street below its bottom, approach column left of the child). Cross links from one parent into children sharing a left edge, below its street, right of it and with no tree line into them, share one lane, one street and one drop, and carry `runLabel`. The drop starts left of the column by the wider of a label run (the widest of their labels) and room for the other parents' columns into its children (12 px plus 6 px per line). When that start is not right of the parent's right edge, the column is not shared and its lines take their own lanes and streets. Otherwise the drop sits at the nearest x from that start leftwards, right of the parent, where the drop, its street and its runs miss every box. With no such x, several lines keep the drop at its start, since on separate streets their labels would print over each other; a single line takes its own lane and street. A single line's drop also gives way to its own lane and street when another parent's street into the same child would cross it. One parent's lines share a column's drop: the parent with the most lines into it, the farthest reaching on a tie, then by id. Every other cross link gets its own lane and street. Per parent, the farthest unit takes the outer lane and top street (so lines to children right of and below the parent nest; lines to children left or above can cross inside the street band); lanes fan across the right half at most 6 px apart, stopping 2 px short of the centre, and streets spread 10–16 px below the bottom (at most 2 px apart). Rows are 20 px apart. Ties break by edge id. +- [x] Into each child: cross links from above enter highest, then `down` lines from the rightmost trunk to the leftmost, then cross links from below; `entryDy` at most 6 px apart and within the child's height. Approach columns follow that order (12 px left of the child, 6 px apart): from above, the further-left column enters lower, with shared drops (`runLabel`) leftmost; from below (numbered after those from above), higher. No two last turns into one child cross. When a child's own approach columns (cross links without `runLabel`) or their runs would pass through a box, those from above move left together, and those from below move left together, to the nearest position clear of boxes (6 px steps up to 360 px, or just right of a box's edge). Columns from above also stay right of the other lines' last verticals into the child (its tree trunks and shared drops), so no last turns cross. If no position is clear for the lines from above, each of them still blocked jogs (`slot.jog`): it drops through the column nearest its approach column that lies right of those verticals and no more than 12 px right of whichever lies further right, the child's right edge or its own lane, at least 6 px from the child's other columns and from any other jog's drop over the same rows, with no other column from above between it and the approach column and no other from-above line's street across its drop, clear down to a row 10 px above the child; it crosses to the approach column and drops into the child. So only the outermost lines from above can jog, to either side; with no clear column a line stays as routed. Lines from below never jog; with no clear position they stay as routed. - [x] Trees are stacked into columns of height √(area / 1.6), counting lookup staircases. Order: `orderRegions` over colour groups by cross-tree links, then over the trees in each group, so a referenced tree comes first. -- [x] Wrapping measures from the top of the tree, below its lookup staircase, with the column height as the limit; a child "fits" when its unwrapped subtree height (`stackHeight`) does. A parent whose subtree would run past the limit keeps its first child and those that fit under it; the rest wrap into columns to its right (80 px apart), each starting at the parent's first child row, 20 px below its bottom edge, and their lines are cross links. Children wrap only when a fresh column has room below that row for two of the parent's child rows (tallest child box + 20 px each); otherwise they stay under it and the tree runs past the column. A subtype joiner (`joiner`) never wraps: it stays under its basetype and goes first among its siblings when they wrap, and its members wrap instead. -- [x] `routeGeometry(route, parentBox, childBox)` gives the line drawn now: its parent end on the parent's bottom edge, its child end on the child's left edge, and the bends between. `down`: one bend at (lane, entry row). While a drag moves a `down` route's child out of place, the line is drawn with cross geometry; when the drag ends the graph re-routes. `cross`: bends at (lane, street) twice, (approach, street), (approach, entry row). -- [x] In Tree mode every routed edge is a `segments` edge (`edge-distances: node-position`) with its ends pinned through `source-endpoint`/`target-endpoint` offsets. A line drawn `down` carries its label as the `target-label` (`source-label` when the edge runs child→parent), horizontal, 9 px above the run, halfway along it. A line drawn `cross` carries its mid label centred on the street. Bends are recomputed whenever a node moves. After a hide, a reveal, an undo or a drag the graph calls `routeTree` on the boxes as they stand, keeping which lines the last Tree layout drew as tree lines, so no line reorders. +- [x] Wrapping measures from the top of the tree, below its lookup staircase, with the column height as the limit; a child "fits" when its unwrapped subtree height (`stackHeight`) does. A parent whose subtree would run past the limit keeps its first child and those that fit under it; the rest wrap into columns to its right, each starting at the parent's first child row, 20 px below its bottom edge, and their lines are cross links. The gap before a wrapped column is 80 px or, if wider, 12 px plus the label run of every line into the wrapped children, so each label fits its run. Children wrap only when a fresh column has room below that row for two of the parent's child rows (tallest child box + 20 px each); otherwise they stay under it and the tree runs past the column. A subtype joiner (`joiner`) never wraps: it stays under its basetype and goes first among its siblings when they wrap, and its members wrap instead. +- [x] `routeGeometry(route, parentBox, childBox)` gives the line drawn now: its parent end on the parent's bottom edge, its child end on the child's left edge, and the bends between. `down`: one bend at (trunk, entry row). While a drag moves a `down` route's child out of place, the line is drawn with cross geometry; when the drag ends the graph re-routes. `cross`: bends at (lane, street) twice, (approach, street), (approach, entry row); with a jog, (lane, street) four times, (drop, street), (drop, jog row), (approach, jog row), (approach, entry row), so the mid label stays on the street. +- [x] In Tree mode every routed edge is a `segments` edge (`edge-distances: node-position`) with its ends pinned through `source-endpoint`/`target-endpoint` offsets. A line drawn `down`, or `cross` with `runLabel`, carries its label as the `target-label` (`source-label` when the edge runs child→parent), horizontal, 9 px above the run into the child, halfway along it. Any other line drawn `cross` carries its mid label centred on the street. Bends are recomputed whenever a node moves. After a hide, a reveal, an undo or a drag the graph calls `routeTree` on the boxes as they stand, keeping which lines the last Tree layout drew as tree lines, so no line reorders. - [x] Leaving Tree mode removes the routing classes (`tree-edge`, `tree-label-source`, `tree-cross`) and only the routing style bypasses (`segment-weights`, `segment-distances`, `source-endpoint`, `target-endpoint`, `source-text-offset`, `target-text-offset`). Bypasses survive a stylesheet swap, so a retheme does not re-route. Undoing a pending switch restores the mode before the routing is re-applied. Routing follows the mode the on-screen positions belong to, so a switch still computing keeps the old routing. - [x] Crow's-foot markers take each end's angle from the adjacent bend point on segmented edges; with pinned ends that is vertical at the parent and horizontal at the child. -- [x] Checks: `test-tree-layout.ts` (including chained lookups, wrapped children starting a row below their parent and overlapping no box, lookup lines into wrapped tables as cross links, a lookup feeding only wrapped tables, a deep chain under 3:1, a parent with room for under two rows keeping its children and one with room for two wrapping, a joiner going first under a wrapping basetype and keeping its place when nothing wraps, two clusters on one basetype, `routeGeometry` fallback, no crossing last turns, distinct lanes and streets), `test-graph-edits.ts` case 6; Playwright `test/visual/test-graph-edits.ts` steps 6a (right-angled within 0.5 px before and after a drag) and 6a2 (undo during a pending switch keeps the routing). +- [x] Checks: `test-tree-layout.ts` (including chained lookups, wrapped children starting a row below their parent and overlapping no box, lookup lines into wrapped tables as cross links, a lookup feeding only wrapped tables, a deep chain under 3:1, a parent with room for under two rows keeping its children and one with room for two wrapping, a joiner going first under a wrapping basetype and keeping its place when nothing wraps, two clusters on one basetype, one shared trunk per parent, shared lane, street and drop into a wrapped column with labels on the runs, an approach column moving past a box, a jog when no straight drop is clear, a line from below moving past a box, a jog never crossing the child's own trunk nor dropping down another line's column, the drop owned by the parent with the most lines, several lines keeping a blocked drop and one line giving it up, a cross link clear of a trunk into the same child, two blocked lines into one child neither crossing nor overlapping, both outer lines jogging to either side, a jog drop never right of both its child and its lane, a jog from a parent far to the right dropping short of its lane, two jogs never sharing a drop, a several-line drop moving left past a box and sitting left of the other columns, a parent too close to its column giving up the drop, a demoted second tree line counting toward the trunk's room, a line from below taking none of it, a crossed drop of several lines kept, a one-line drop giving way to a street that would cross it, `routeGeometry` fallback, no crossing last turns, distinct lanes and streets), `test-graph-edits.ts` case 6, `test-layout-store.ts` case 9, `test-tree-predicate-hover.ts` (every reverse label shown on hover fits its run), `test-graph-fit-clear.ts` (contract in viewer-ux-polish #3); Playwright `test/visual/test-graph-edits.ts` steps 6a (right-angled within 0.5 px before and after a drag) and 6a2 (undo during a pending switch keeps the routing). ## Approach @@ -44,22 +44,29 @@ Depth-first outline; lookups in a staircase above each tree; every line routed b ``` src/app/ -├── App.tsx ................................ M (parseLayoutMode, nextLayoutMode) +├── App.tsx ................................ M (loadLayoutMode, nextLayoutMode) ├── components/ui/ │ ├── FabMenu.tsx ........................ M (next mode label) │ └── HelpModal.tsx ...................... M (Tree in Layouts and L rows) └── views/graph/ - ├── GraphView.tsx ...................... M (tree branch in runLayout, edge routing) - ├── graph-edits.ts ..................... M (tree mode, nextLayoutMode, parseLayoutMode) + ├── GraphView.tsx ...................... M (tree branch in runLayout, edge routing, longer predicate as label, fitGraph) + ├── graph-edits.ts ..................... M (tree mode, nextLayoutMode, parseLayoutMode, loadLayoutMode) + ├── layout-store.ts .................... M (hasEntries) ├── markers.ts ......................... M (angle from adjacent bend) ├── styles.ts .......................... M (tree-edge, tree-cross classes) └── tree-layout.ts ..................... A scripts/perf-harness.ts .................... M (--mode tree) test/checks/ ├── test-graph-edits.ts .................... M -├── test-keyboard-shortcuts.ts ............. M (L steps with nextLayoutMode) -└── test-tree-layout.ts .................... A -test/visual/test-graph-edits.ts ............ M +├── test-graph-fit-clear.ts ................ A +├── test-keyboard-shortcuts.ts ............. M (L steps with nextLayoutMode; first visit stores tree) +├── test-graph-search.ts ................... M (first visit stores tree; hover evaluates return nothing) +├── test-layout-store.ts ................... M (hasEntries) +├── test-tree-layout.ts .................... A +└── test-tree-predicate-hover.ts ........... A +test/visual/ +├── screenshot-lineage-highlight.ts ........ M (fit evaluate returns nothing) +└── test-graph-edits.ts .................... M docs/ ├── design/graph-tree-layout.md ............ A ├── guides/commands.md ..................... M @@ -67,7 +74,8 @@ docs/ │ ├── graph-arrangement.md ............... M (LayoutMode includes tree) │ ├── graph-tree-layout.md ............... A │ ├── help-overlay.md .................... M -│ └── keyboard-nav-shortcuts.md .......... M +│ ├── keyboard-nav-shortcuts.md .......... M +│ └── viewer-ux-polish.md ................ M (Graph fit clears the header and footer) └── wiki/ ├── feature-map.md ..................... M ├── frontend.md ........................ M @@ -81,24 +89,35 @@ docs/ ``` tree-layout.ts - TreeNodeInput / TreeEdgeInput — size, group, classifier, joiner; parent→child edges - CrossSlot — lane, street and approach of the cross geometry - TreeRoute — down or cross; lane, street, approach and entry offsets + TreeNodeInput / TreeEdgeInput — size, group, classifier, joiner; parent→child edges and the label runs fit + CrossSlot — lane, street and approach of the cross geometry, and a jog's drop and row + TreeRoute — down or cross; trunk, slot, entry offset, and runLabel for a shared drop Box / RouteGeometry — a node's box; the ends, bends and label of a line drawn now routeGeometry — a route's line between two boxes, falling back to cross geometry buildForest — tree parents, hubs, cycle cuts, lookups and their consumers - layoutTree — depth-first rows, lanes, wrapping wide parents, lookup staircases, ordering, column packing - TreeLine — an edge to route and whether the last layout drew it as a tree line - routeTree — lanes, slots, entries and approach columns at given positions - crossSlots — a lane and street per cross link from each parent + trunkOffset — where a parent's shared trunk drops + layoutTree — depth-first rows, trunks, wrapping wide parents, lookup staircases, ordering, column packing + TreeLine — an edge to route, its label, and whether the last layout drew it as a tree line + routeTree — trunks kept only with room for the child's columns from above; slots, entries and approach columns + crossSlots — one owner per wrapped column; gives a crossed one-line drop its own slot and spreads again + parentSlots — one parent's units on lanes and streets, a column's drop kept for several lines + clearDrop — the nearest drop x whose street, drop and runs miss every box + crossedDrops — one-line drops another parent's street into the same child crosses assignEntries — entry rows and approach columns on each child's left side + clearApproaches — splits a child's approach columns into from above and from below; jogs blocked lines from above + shiftClear — moves a group of approach columns left together to the nearest position clear of boxes + JogDrop / jogAround — a drop through a clear column and across above the child when no straight drop clears; jogs never share a drop graph-edits.ts LayoutMode — organic, hierarchical, tree nextLayoutMode / parseLayoutMode / LAYOUT_MODE_NAME — L cycle and stored mode + loadLayoutMode — the mode on load: stored, else Organic for a saved arrangement, else Tree +layout-store.ts + hasEntries — whether any arrangement is saved GraphView.tsx - treeInput — visible nodes and edges for the layout + treeInput — visible nodes and edges for the layout, each labelled with its longer predicate applyEdgeRouting — tree-edge / tree-label-source / tree-cross classes for the positions' mode routeTreeEdge — down and cross bends as segment weights and distances + fitGraph — fits the graph between the header chrome and the footer (contract: viewer-ux-polish #3) markers.ts updateMarkers — angle from the adjacent bend ``` @@ -107,6 +126,12 @@ markers.ts ## Flows +**First load** + +1. App reads `ignatius-layout-mode`; with a mode stored, that mode opens. +2. With none stored, `loadLayoutMode` picks Organic when `hasEntries` finds a saved arrangement, else Tree. +3. App stores the result, so later loads read it directly. + **Switch to Tree** 1. User presses `L` on Hierarchical; the mode is stored as `tree`. @@ -118,19 +143,31 @@ markers.ts 1. User drags a table. 2. Each `position` event re-routes the dragged node's connected edges from their stored offsets. +**Hover in Tree** + +1. User rests the pointer on a table. +2. Each line into it swaps its label to the reverse predicate, on the same run; the run was sized for the longer predicate, so the label fits. +3. Leaving the table restores the forward predicates. + ## Risks | Risk | Likelihood | Mitigation | |------|-----------|------------| -| Cross-link labels pile up where several streets pass the same area | med | They are de-emphasised by span grading and hidden at overview zoom like every label | -| Cross links run behind unrelated boxes (no obstacle avoidance; most cross links on a real model pass at least one) | high | Lines are drawn under boxes, so a line never covers a table; the crow's feet mark the true ends | -| Cross links into stacked siblings can share an approach column, and long streets can run along other lines | med | Lanes, streets and columns are distinct per parent and per child; across them there is no global channel routing | -| A busy parent's lines (a hub such as `users`) fan into lanes and streets under 4 px apart, which the 3 px edge casing draws as one band | med | Lanes and streets stay distinct in position; hiding the hub removes the band | +| Labels of cross links that keep their own streets pile up where several streets pass the same area | med | Lines into a wrapped column carry their labels on their own runs; the rest are de-emphasised by span grading and hidden at overview zoom like every label | +| Streets and lookup trunks run behind unrelated boxes (only approach columns, shared drops and jogs avoid boxes) | med | Lines are drawn under boxes, so a line never covers a table; the crow's feet mark the true ends | +| Cross links into stacked siblings can share an approach column, and long streets can run along other lines | med | Lanes, streets and columns are distinct per parent and per child, except lines into one wrapped column, which share one; across them there is no global channel routing | +| A busy parent's cross links (a hub such as `users`) fan into lanes and streets under 4 px apart, which the 3 px edge casing draws as one band | med | Lanes and streets stay distinct in position; hiding the hub removes the band | | The hub threshold misses a hub or catches a real parent | low | Key-inheritance parents are never excluded; the user can hide a hub | | A deep chain whose last child wraps at every level steps right one column per level, wider than 16:10 (the 120-entity synthetic model lays out at w/h 2.25) | med | Every line still runs down and right; check 6i pins a 9-level chain under 3:1 | | A second subtype cluster on the same basetype, or a cluster under a basetype without room for two rows below it, runs past the column | med | Under a basetype whose children wrap, the joiner goes first, so one cluster gets the column's room and its members wrap; check 7g pins that case above 0.8 w/h | +| One trunk carries identifying (solid) and non-identifying (dashed) lines; along the shared part the later line's casing hides the other, and their parent-end markers sit on one point | low | Each line's own branch and child-end marker still show its kind; the shared part is the parent's trunk | +| A jog (drop, cross above the child, drop) can cross other parents' trunks, streets and lanes on its way across, and its drop can run along another child's approach column; fresh layouts jog (orm-pure, llm-memory-db-mssql and the synthetic models) | med | Jogs are used only when every straight drop would run behind a box, stay right of the child's own trunks and shared drops, and never share a drop with another jog; a line whose only jog would is left behind its box | +| A parent dragged so close to its wrapped column that the drop's start (a label run, or room for the other parents' columns into its children) is not right of it gives up the shared drop, and its lines' street labels, at most 2 px apart, overlap | low | Only after such a drag; moving the parent back restores the drop | +| A blocked line from above whose column sits between two others into the same child cannot jog (its row would cross one of them) and still runs behind a box | low | The outermost lines jog to either side, and their rows never meet | +| With no clear place for a column's drop (a drag or hide puts a box in the gap), several lines keep the drop and can run behind that box | low | Their labels stay on separate runs; a single line takes its own slot instead | +| Another parent's street into the same child can cross a drop of several lines | low | A one-line drop gives way to its own slot; on the demo models no drop of several lines is crossed | ## Checkpoints @@ -138,16 +175,32 @@ markers.ts | # | Checkpoint | Files/areas | Agent | Est. files | Verifies | |---|------------|-------------|-------|------------|----------| -| 1 | Forest and placement: tree parents, hubs, cycle cuts, lookups and their staircase, depth-first rows and lanes, column packing | `src/app/views/graph/tree-layout.ts`, `test/checks/test-tree-layout.ts` | main | 2 | `test-tree-layout.ts` cases 1–5 | +| 1 | Forest and placement: tree parents, hubs, cycle cuts, lookups and their staircase, depth-first rows and trunks, column packing | `src/app/views/graph/tree-layout.ts`, `test/checks/test-tree-layout.ts` | main | 2 | `test-tree-layout.ts` cases 1–5 | | 2 | Wrapping wide parents into columns one row below them, the two-row room rule, joiners first, lookup lines into wrapped tables as cross links | `src/app/views/graph/tree-layout.ts`, `test/checks/test-tree-layout.ts` | main | 2 | `test-tree-layout.ts` cases 6a–7j | | 3 | Routing: tree and cross lines, lanes, streets, entry rows and approach columns, re-routing on the boxes as they stand | `src/app/views/graph/tree-layout.ts`, `test/checks/test-tree-layout.ts` | main | 2 | `test-tree-layout.ts` cases 8–10 | -| 4 | Tree mode in the Graph view: `L` cycle, segments edges with pinned ends, re-route after hide, reveal, undo and drag, marker angles | `GraphView.tsx`, `graph-edits.ts`, `styles.ts`, `markers.ts`, `App.tsx`, `FabMenu.tsx`, `HelpModal.tsx`, `scripts/perf-harness.ts` | main | 8 | `test-graph-edits.ts` case 6, `test-keyboard-shortcuts.ts`, Playwright `test/visual/test-graph-edits.ts` steps 6a, 6a2, 6b | +| 4 | Tree mode in the Graph view: `L` cycle, Tree on a first visit, segments edges with pinned ends, re-route after hide, reveal, undo and drag, marker angles, fit between the header and footer | `GraphView.tsx`, `graph-edits.ts`, `layout-store.ts`, `styles.ts`, `markers.ts`, `App.tsx`, `FabMenu.tsx`, `HelpModal.tsx`, `scripts/perf-harness.ts` | main | 9 | `test-graph-edits.ts` case 6, `test-layout-store.ts` case 9, `test-keyboard-shortcuts.ts`, `test-tree-predicate-hover.ts`, `test-graph-fit-clear.ts`, Playwright `test/visual/test-graph-edits.ts` steps 6a, 6a2, 6b | | 5 | Surfaces: design, spec, guide, help overlay, keymap, wiki | `docs/design/graph-tree-layout.md`, `docs/spec/*.md`, `docs/guides/commands.md`, `docs/wiki/*.md` | main | 10 | Feature-map row and every surface describe the same behaviour | ## Change log +### 2026-10-21 — Correction: runs fit the reverse predicate + +**What changed:** a line's label width is the longer of its forward and reverse predicates. + +**Why:** hovering a table swaps each line into it to the reverse predicate on the same run; runs sized for the forward one let "← is placed by" run 8 px past its run on key-inherited, into the trunk and the child's box (`test-tree-predicate-hover.ts`). + +**Superseded:** the label was the forward predicate only. + +### 2026-10-21 — One trunk per parent, shared drops, Tree by default + +**What changed:** a parent's tree lines (and a lookup's lines) share one trunk a quarter in from its left edge instead of a lane each. Cross links from one parent into one wrapped column share a lane, a street and a drop placed a label run left of the column, with each label on its run into its child; the parent with the most lines into a column owns its drop, which starts left of the other parents' columns too and moves left past boxes; with no clear place several lines keep it while a single line, or a single line whose drop another street would cross, takes its own slot, and with no room right of the parent the column is not shared. Wrapped columns leave room for those labels. A tree line stays a tree line only with room right of its trunk for the child's columns from above. A child's approach columns from above and from below each move into the nearest gap clear of boxes, those from above staying right of the child's trunks and shared drops; when none is clear, a line from above jogs through a clear column, no further right than 12 px past both the child and its own lane, and across above the child, never down another jog's drop. A first visit opens in Tree; a browser that already saved an arrangement without a stored mode keeps Organic, the layout it was saved in. + +**Why:** user feedback on the drawn diagram: labels of lines into a wrapped column printed over each other on adjacent streets, a parent's lanes packed into tight parallel verticals, a dragged child pulled its approach column behind other boxes, and the user wanted the fewer, shared lines of IDEF1X tools and Tree as the first view. + +**Superseded:** tree lanes fanned across the left half of the parent's bottom edge, one per line, 6 px apart; every cross link had its own lane, street and approach column 12 px left of its child; a missing stored mode read as Organic. + ### 2026-10-20 — Correction: checkpoints table **What changed:** added the `## Checkpoints` table; the contract it lists was already in the success criteria. diff --git a/docs/spec/help-overlay.md b/docs/spec/help-overlay.md index 4ddd58f..84fcd95 100644 --- a/docs/spec/help-overlay.md +++ b/docs/spec/help-overlay.md @@ -22,7 +22,7 @@ primitive, view-switched on `ViewName`, with static term→description content; ## Success criteria - [x] `HelpModal` (`src/app/components/ui/HelpModal.tsx`) renders on the shared `Modal` primitive with `className="help-modal"`, switches body content on `view: ViewName`, and titles per view ("About the Graph/Dictionary/Flows"). -- [x] Graph body covers: an ER-diagram intro, the five entity types (Independent, Dependent, Subtype, Associative, Classifier), how-to-explore (layouts — Hierarchical as one block per colour group, Tree as an outline with right-angled lines, Shift+hover lineage, click/drag/zoom with positions kept across model edits, moving several via box select and select mode, undo/redo, hiding entities and the hidden list, search — term matching with a body-text toggle, Enter cycling through matches), and the key-inherited vs surrogate distinction. +- [x] Graph body covers: an ER-diagram intro, the five entity types (Independent, Dependent, Subtype, Associative, Classifier), how-to-explore (layouts — Hierarchical as one block per colour group, Tree as an outline with right-angled lines and the layout a first visit opens in, `L` stepping through them with the choice kept, Shift+hover lineage, click/drag/zoom with positions kept across model edits, moving several via box select and select mode, undo/redo, hiding entities and the hidden list, search — term matching with a body-text toggle, Enter cycling through matches), and the key-inherited vs surrogate distinction. - [x] Dictionary body covers: Read/Browse lenses, spotlight, Shift+hover lineage, search/focus. - [x] Flows body covers: a DFD intro, symbols (process/store/external), drill-down + inspect, the flow index (Process Flows chip or `I`), breadcrumb level switching (▾), cross-diagram search (results grouped by diagram, non-matches dimmed in the rendered diagram). - [x] A Keyboard section is present on every view and tailored to it (only the keys active there), including `/` to focus that view's search input and, on the Graph, `S`, `H`, and `⌘/Ctrl Z` · `⌘/Ctrl ⇧ Z`; Graph and Flows footnote a pointer to the Legend. @@ -80,3 +80,9 @@ primitive, view-switched on `ViewName`, with static term→description content; **What changed:** the Graph Layouts row describes the Tree layout and says `L` steps through the three layouts; the Keyboard row for `L` reads "Step through the graph layouts (Organic → Hierarchical → Tree)". **Why:** the Tree layout shipped (`docs/spec/graph-tree-layout.md`). + +### 2026-10-21 — Layouts row names the default + +**What changed:** the Graph Layouts row says a first visit opens in Tree and that the layout `L` picks is kept. + +**Why:** Tree became the default layout (`docs/spec/graph-tree-layout.md`). diff --git a/docs/spec/viewer-ux-polish.md b/docs/spec/viewer-ux-polish.md index ba4d0f3..ec674d2 100644 --- a/docs/spec/viewer-ux-polish.md +++ b/docs/spec/viewer-ux-polish.md @@ -25,7 +25,7 @@ See `docs/design/viewer-ux-polish.md`. - [x] #1 — A served/exported model named `Foo` produces `document.title` `Foo` (live tab and static-export HTML ``); a nameless model falls back to `Ignatius`. A check asserts the static-export `<title>`. - [x] #2 — When a spotlit DD card has a bidirectional (`both`) or multi-edge connection to another card, the overlay draws ≥2 distinct `<path>` elements with distinct connection points (no shared endpoint) — not one path with arrowheads at both ends. A unit test on the splitting/geometry helper proves separation; a visual screenshot confirms. -- [x] #3 — The readout shows true scale: at Cytoscape `zoom===1` / SVG `scale===1` it reads `100%`, regardless of model size. The initial view fits and reports its real (non-100%) percentage on a large model. Home/reset fits-to-screen. `setPercent(100)` yields 1:1. +- [x] #3 — The readout shows true scale: at Cytoscape `zoom===1` / SVG `scale===1` it reads `100%`, regardless of model size. The initial view fits and reports its real (non-100%) percentage on a large model. Home/reset fits-to-screen. `setPercent(100)` yields 1:1. On the Graph, every fit (initial view, relayout, Home) uses the band between the bottom of the branding block, search bar and toggles and the top of the footer, 30 px in from each side, so no table sits under them (`test-graph-fit-clear.ts`). A graph too big for that band at the smallest zoom shows its top-left corner there instead of its middle, and a viewport with no room left under the chrome fits the whole canvas. - [x] #4 — Trackpad pinch (`ctrl`+wheel) and Cmd/Ctrl `+`/`-`/`0` zoom the canvas and never the browser page, on both DG and DFD. A unit test covers the resolver's new zoom actions; the page does not zoom (`preventDefault`). - [x] #5 — A process named `Confirm OTP And Create Individual` renders fully inside its box (no overflow); the box grows to fit and ELK spacing reflects the measured size. A unit test pins the sizing helper; a visual screenshot confirms. - [x] #6/#8 — Opening an entity modal pushes a history entry carrying `entity=<id>`; browser Back returns to the previous modal/state; closing the modal removes `entity=` from the URL. @@ -67,6 +67,14 @@ Docs per CP: update the CLAUDE.md feature↔doc map and any touched guide ## Change log +### 2026-10-21 — Graph fit clears the header and footer + +**What changed:** the Graph fits into the band between the header chrome and the footer instead of the whole canvas. + +**Why:** the branding block and footer overlay the canvas, and with Tree as the first layout the root table always sits top-left; on orm-pure at 1600×1000, 1440×900 and 1280×720 the fit put `Subscription` under the branding and `SIL_Subscription` under the footer. + +**Superseded:** the Graph fit padded 30 px on every side of the whole canvas (`cy.fit(undefined, 30)`). + ### 2026-06-19 — CP7 added (#9 inherited 1:1 key-inheritance connections) diff --git a/docs/wiki/feature-map.md b/docs/wiki/feature-map.md index fc60c30..284b80d 100644 --- a/docs/wiki/feature-map.md +++ b/docs/wiki/feature-map.md @@ -34,7 +34,7 @@ Paths are relative to [`docs/design/`](../design), [`docs/spec/`](../spec), [`do | Graph and Flows search (dim-don't-filter title-first matching with a per-bar body-text toggle; Graph: `search-match`/`search-dim` cytoscape classes, `n of N` count readout, Enter cycles ascending-id matches wrapping via `navigateToEntity`, survives hover/lineage/relayout/SSE refresh; Flows: `searchFlowDiagrams` walks every non-synthetic diagram incl. sub-DFDs, results dropdown grouped by diagram navigates via `selectDiagramById`, in-diagram dim keys off suffix-stripped base token; `/` focuses the active view's search bar (Dictionary via `DictionaryViewHandle.focusSearch()`, unchanged otherwise); bundle-only, no model/layout-store/hash/export-payload writes) | graph-flow-search | graph-flow-search | commands | — | | Graph node position persistence (drag-to-save, reset; a structural change carries the arrangement over — see Graph arrangement) | graph-position-persistence, graph-arrangement | graph-position-persistence, graph-arrangement | — | — | | Graph arrangement (hierarchical layout one block per colour group — per-group ELK `layered`, `orderRegions` + skyline `packRegions` in [`grouped-layered.ts`](../../src/app/views/graph/grouped-layered.ts); arrangement + hidden set carry over to a new layout key via `layout-store` `previous`, new nodes seated beside neighbours; undo/redo history of position + hidden snapshots (`graph-edits.ts`, `Cmd`/`Ctrl`+`Z`, `Cmd`/`Ctrl`+`Shift`+`Z`, `Ctrl`+`Y`); select mode `s` + Shift+drag box select, group drag; hide by right-click or `h`, `GraphEditBar` "N hidden" list, layouts exclude hidden nodes) | graph-arrangement | graph-arrangement, keyboard-nav-shortcuts, help-overlay | commands | — | -| Graph tree layout (third layout mode, `L` steps organic → hierarchical → tree; outline where each table sits below and right of its key-inheritance parent, deepest parent wins, hubs and classifiers never fallback parents; lookups in a staircase above the tree they serve; every line leaves its parent's bottom edge and enters its child's left side — lane-separated `down` lines, `cross` links via a street under the parent and an approach column; referenced trees placed first; re-routed on drag; a wide parent's children wrap into columns to its right; [`tree-layout.ts`](../../src/app/views/graph/tree-layout.ts)) | graph-tree-layout | graph-tree-layout, keyboard-nav-shortcuts, help-overlay | commands | — | +| Graph tree layout (third layout mode, `L` steps organic → hierarchical → tree; outline where each table sits below and right of its key-inheritance parent, deepest parent wins, hubs and classifiers never fallback parents; lookups in a staircase above the tree they serve; every line leaves its parent's bottom edge and enters its child's left side — one trunk per parent with a branch into each child, `cross` links via a street under the parent and an approach column that moves past boxes, lines into a wrapped column sharing one drop with labels on their branches, runs sized for the longer predicate so the reverse one shown on hover fits; a first visit opens in Tree, a browser with a saved arrangement and no stored mode keeps Organic; referenced trees placed first; re-routed on drag; a wide parent's children wrap into columns to its right; [`tree-layout.ts`](../../src/app/views/graph/tree-layout.ts)) | graph-tree-layout | graph-tree-layout, keyboard-nav-shortcuts, help-overlay | commands | — | | Business-narrative body + existence/cascade rules | markdown-driven-erd | ignatius-modeling-skill | modeling-skill | entity-flow E9, templates (body sections) | | Entity body wiki-links `[[Entity]]` | wiki-entity-links | wiki-entity-links, schema-lint-and-error-ux | folder-format | entity-flow E9 (body authoring) | | The modeling skill itself | ignatius-modeling-skill | ignatius-modeling-skill | modeling-skill | SKILL + all references | @@ -49,7 +49,7 @@ Paths are relative to [`docs/design/`](../design), [`docs/spec/`](../spec), [`do | Arbitrary DFD nesting depth (dotted process numbers preserve the full ancestor chain at any depth — renumberLeaf recurses + prefixes the full relative number; skill F8 authors decompositions recursively, no depth cap) | dfd-nesting-depth | dfd-nesting-depth | flows | dfd-authoring (F8) | | HTML title from model name (#1; `document.title` = `model._meta.name` else `Ignatius`; SPA runtime effect in `App.tsx` for live + static; `generateApp` rewrites `<title>` with HTML-escaped name on export) | viewer-ux-polish | viewer-ux-polish | — | — | | Entity-modal history + URL sync (#6/#8; `entity=<id>` in the hash is the single source of truth for "which modal is open"; `useHashRoute` owns the history lifecycle — `openEntity` pushState/dedup, `closeEntity` replaceState-drop, `onEntityChange` popstate reconcile; shell `App.tsx` is the single writer — graph tap / dict click / FK hop / flow `db:` store / findings-panel row all route through it; GraphView no longer writes `entity=`, only viewport zoom/pan with flush-time entity re-merge; Back steps the modal stack, close clears the URL) | viewer-ux-polish | viewer-ux-polish | — | — | -| Zoom 100% = native 1:1 (#3; `100%` = 1 diagram unit → 1 CSS px, model-size-independent; initial view + Home still fit-to-screen but the readout shows the true percent; pure [`src/flow-view/zoom-scale.ts`](../../src/flow-view/zoom-scale.ts) helper maps `internalScale × fitScale`; DFD viewBox stays = world box so drag/minimap/pan untouched; graph readout = `cy.zoom()*100`) | viewer-ux-polish | viewer-ux-polish | — | — | +| Zoom 100% = native 1:1 (#3; `100%` = 1 diagram unit → 1 CSS px, model-size-independent; initial view + Home still fit-to-screen but the readout shows the true percent; the Graph fits between the header chrome and the footer (`fitGraph`); pure [`src/flow-view/zoom-scale.ts`](../../src/flow-view/zoom-scale.ts) helper maps `internalScale × fitScale`; DFD viewBox stays = world box so drag/minimap/pan untouched; graph readout = `cy.zoom()*100`) | viewer-ux-polish | viewer-ux-polish | — | — | | Pinch + keyboard zoom → canvas (#4; trackpad pinch = `ctrl`/`meta`+wheel and `Cmd`/`Ctrl` +/-/0 zoom the active canvas, never the browser page, on DG + DFD; native non-passive `wheel` listener on each canvas `preventDefault`s page-zoom while the canvas's own handler still zooms — React `onWheel` is passive so its preventDefault is a no-op; resolver `shortcuts.ts` adds `zoomIn`/`zoomOut`/`zoomReset` actions resolved before the bare-key guards, gated on ctrl/meta only, bypassing the editable guard; `useKeyboardShortcuts` routes to the active view handle, dict no-op) | viewer-ux-polish | viewer-ux-polish, keyboard-nav-shortcuts | commands | — | | DFD process node sizes to text (#5; pure `processNodeSize(label)` in [`src/flow-view/flow-layout.ts`](../../src/flow-view/flow-layout.ts) word-wraps the label and returns `{lines,width,height}` with a `120×68` min floor — short names look unchanged, long names grow; same helper feeds ELK `nodeSize` AND the `FlowDiagramSvg` renderer via a shared `sizingLabel(node)` so the rect drawn equals the box ELK laid out — removed the old 130/120 + 64/68 mismatch; `ProcessNode` renders all wrapped lines, badge stays top-left; `test-cp4c-single-row-bands` C16 rephrased to a vertical-overlap strip check since process heights are now label-derived) | viewer-ux-polish | viewer-ux-polish | — | — | | DD spotlight separate lines (#2; a spotlit DD browse-lens `both`/multi-edge bundle fans into SEPARATE `<path>` elements — one per edge/direction with offset connection points — never one path with arrowheads at both ends; "always separate" at rest; pure [`src/app/logic/spotlight-lines.ts`](../../src/app/logic/spotlight-lines.ts) `separateSpotlightLines(base, directions)` offsets perpendicular to the line axis — HORIZONTAL anchor spreads y, VERTICAL spreads x, symmetric `(i−(K−1)/2)·14px` about the base midpoint; K=1 → unchanged base line so the common single-FK look is bit-identical; `SpotlightOverlay` keeps `computeAnchor` DOM measurement then calls the helper and draws one path per spec, single arrowhead each; same separation on the dashed flow lines; `buildSpotlightConnections`/`buildFlowSpotlightConnections` bundling contract + pill second-pass + scrollport-skip + off-screen chips untouched) | viewer-ux-polish | viewer-ux-polish | — | — | diff --git a/docs/wiki/frontend.md b/docs/wiki/frontend.md index a020078..1f1ec77 100644 --- a/docs/wiki/frontend.md +++ b/docs/wiki/frontend.md @@ -92,7 +92,7 @@ flowchart LR state --> remount["FlowsView renderer effect re-mounts (flowView/collapseLevel are effect deps)"] ``` -On load, `App.tsx` resolves each setting hash first, then `localStorage`, then a hardcoded default (`per-process` / `clusters`), a 3-step hash-then-localStorage-then-default precedence that makes both deep-linkable. `layoutMode` does not share it: its initializer reads only `localStorage.getItem('ignatius-layout-mode')`, with no hash step, so the graph's layout mode is not deep-linkable. `nextCollapseLevel` ([`src/app/hash-router.ts`](../../src/app/hash-router.ts)) is a `switch` over the closed `FlowCollapseLevel` union implementing the cycle `stores → clusters → groups → stores`; `FabMenu`'s `collapseActionLabel` names the action by its *target* level ("Collapse to clusters", "Collapse to groups", "Expand to stores") since the last leg of the cycle un-collapses. +On load, `App.tsx` resolves each setting hash first, then `localStorage`, then a hardcoded default (`per-process` / `clusters`), a 3-step hash-then-localStorage-then-default precedence that makes both deep-linkable. `layoutMode` does not share it: its initializer reads `localStorage` only, with no hash step, so the graph's layout mode is not deep-linkable. It passes `ignatius-layout-mode` and `createLayoutStore().hasEntries()` to `loadLayoutMode` (stored mode, else Organic when an arrangement is saved, else Tree) and writes the result back at once. `nextCollapseLevel` ([`src/app/hash-router.ts`](../../src/app/hash-router.ts)) is a `switch` over the closed `FlowCollapseLevel` union implementing the cycle `stores → clusters → groups → stores`; `FabMenu`'s `collapseActionLabel` names the action by its *target* level ("Collapse to clusters", "Collapse to groups", "Expand to stores") since the last leg of the cycle un-collapses. A `flowview=`/`collapse=` value can also arrive via **Back/Forward**, independent of a fresh FAB toggle: a history entry pushed earlier (e.g. opening an entity) snapshotted whatever `flowview`/`collapse` was current at push time, since the FAB toggle only `replaceState`s the top entry rather than pushing a new one. `useHashRoute`'s `popstate` handler fires `onRestoreFlowView`/`onRestoreCollapseLevel` unconditionally whenever the reconciled hash carries either param, mirroring how it already restores `dfd=`. @@ -299,10 +299,10 @@ Change-review checks: [`test/checks/test-change-review-graph.ts`](../../test/che | `views/graph/styles.ts` | `buildStyles(groups, theme, mode)` → cytoscape stylesheet array. `.faded` (0.2), `.inherited-dim` (0.5), `edge.inherited` dotted at 0.5 opacity. Length-graded de-emphasis on `edge[span]`. Graph search: `SEARCH_MATCH_BORDER` gold/yellow border on `.search-match`, `.search-dim` at 0.2 opacity, both pushed last in the stylesheet array to win the cascade. Change review: `node[change=...]`/`edge[change=...]` rules use `glowColors(theme, mode)` from `change-glow.ts`, edge glow underlays at `GLOW_STEADY * EDGE_GLOW_SCALE` (node glows live in `change-glow.ts`), the removed-ghost dashed border, and are pushed before the focus and search classes so those still dim changed elements; `[unexplained = "true"]` blends the underlay toward the unexplained color (`UNEXPLAINED_STEADY_MIX`). | | `views/graph/markers.ts` | Exports `drawWarningBadges(cy, svg, entityIds: Set<string>)`, `createMarkerOverlay(container)`, `updateMarkers(cy, svg, theme, mode?)`. On segmented and taxi edges each crow's-foot takes its angle from the adjacent bend point. | | `views/graph/wrap-label.ts` | `wrapEntityLabel`: underscores → spaces; names longer than ~13 chars break at PascalCase/acronym/digit boundaries. | -| `views/graph/layout-store.ts` | Exports `PositionMap`, `StorageLike`, `LayoutStoreHandle`, `createLayoutStore(storage?, now?)`. Single localStorage key `ignatius-layout-positions`; newest-10 pruning on save. `PositionMap` is also imported directly by [`src/flow-view/FlowDiagramSvg.tsx`](../../src/flow-view/FlowDiagramSvg.tsx). `LayoutStoreHandle.lastPosition(nodeId, exceptKey, neighbourIds)` returns the newest saved position of `nodeId` from any entry other than `exceptKey` that also saved at least one of `neighbourIds`; the store is shared by every model, and the neighbour test keeps same-named nodes of other models out. Only `GraphView`'s ghost placement calls it. Entries carry an optional `hidden` list: `save(key, positions, hidden)`, `hidden(key)`; `previous(exceptKey, nodeIds)` finds the same model before its last edit. | +| `views/graph/layout-store.ts` | Exports `PositionMap`, `StorageLike`, `LayoutStoreHandle`, `createLayoutStore(storage?, now?)`. Single localStorage key `ignatius-layout-positions`; newest-10 pruning on save. `PositionMap` is also imported directly by [`src/flow-view/FlowDiagramSvg.tsx`](../../src/flow-view/FlowDiagramSvg.tsx). `LayoutStoreHandle.lastPosition(nodeId, exceptKey, neighbourIds)` returns the newest saved position of `nodeId` from any entry other than `exceptKey` that also saved at least one of `neighbourIds`; the store is shared by every model, and the neighbour test keeps same-named nodes of other models out. Only `GraphView`'s ghost placement calls it. Entries carry an optional `hidden` list: `save(key, positions, hidden)`, `hidden(key)`; `previous(exceptKey, nodeIds)` finds the same model before its last edit. `hasEntries()` says whether any arrangement is saved; `App.tsx` uses it to keep Organic for a returning browser with no stored mode. | | `views/graph/grouped-layered.ts` | Hierarchical layout by colour group: `layoutByGroup` runs ELK `layered` per group region of a `buildScratchCore(cy, true)` mirror, `orderRegions` puts referenced groups first, `packRegions` skyline-packs the blocks. | -| `views/graph/tree-layout.ts` | Tree layout ([`docs/spec/graph-tree-layout.md`](../spec/graph-tree-layout.md)): `buildForest` picks each table's tree parent (deepest key-inheritance parent; hubs and classifiers never fallback parents) and assigns each lookup to one consumer; `layoutTree` places trees depth first with lookups in a staircase above each tree, orders trees so referenced ones come first, packs them into columns, and returns a route per edge (`TreeRoute`: `down` lane or `cross` lane/street/approach, plus `entryDy`). Every line leaves its parent's bottom and enters the child's left side. `routeGeometry` gives a line's pinned ends and bends for the current boxes, drawing a `down` route as a cross link when its child no longer sits below and right of its lane. GraphView applies it as `segments` bypasses and re-routes on node moves. | -| `views/graph/graph-edits.ts` | `createEditHistory` (undo/redo over `GraphSnapshot` = positions + hidden + `LayoutMode`), `GraphEditState`, `USER_HIDDEN_CLASS`, and the `LayoutMode` type `GraphView` re-exports. `nextLayoutMode` / `parseLayoutMode` / `LAYOUT_MODE_NAME` drive the `L` cycle and the stored mode. | +| `views/graph/tree-layout.ts` | Tree layout ([`docs/spec/graph-tree-layout.md`](../spec/graph-tree-layout.md)): `buildForest` picks each table's tree parent (deepest key-inheritance parent; hubs and classifiers never fallback parents) and assigns each lookup to one consumer; `layoutTree` places trees depth first with lookups in a staircase above each tree, orders trees so referenced ones come first, packs them into columns, and returns a route per edge (`TreeRoute`: `down` on the parent's shared trunk (`trunkDx`), or `cross` lane/street/approach with `runLabel` when lines into a wrapped column share a drop, or `slot.jog` when a line from above drops through a clear column and crosses above its child to get past boxes, plus `entryDy`). Every line leaves its parent's bottom and enters the child's left side. `routeGeometry` gives a line's pinned ends and bends for the current boxes, drawing a `down` route as a cross link when its child no longer sits below and right of its trunk. GraphView applies it as `segments` bypasses and re-routes on node moves. | +| `views/graph/graph-edits.ts` | `createEditHistory` (undo/redo over `GraphSnapshot` = positions + hidden + `LayoutMode`), `GraphEditState`, `USER_HIDDEN_CLASS`, and the `LayoutMode` type `GraphView` re-exports. `nextLayoutMode` / `parseLayoutMode` / `LAYOUT_MODE_NAME` drive the `L` cycle and the stored mode; `loadLayoutMode(stored, hasSavedArrangement)` picks the mode on load. | | `views/dict/DictionaryView.tsx` | `DictionaryView` is a `forwardRef` exposing `DictionaryViewHandle { toggleLens(); focusSearch(); }`. Keep-mounted via CSS `display:none`. Imports `SYNTHETIC_DIAGRAM_IDS` from [`src/flows/flow-derive-levels.ts`](../../src/flows/flow-derive-levels.ts) to exclude synthesized context/L1 diagrams from the DD sidebar. Owns DD CSS Custom Highlight search, `beforeprint`/`afterprint` print handling, DD sidebar process nesting. **Browse lens** (`'read'\|'browse'`, persisted to localStorage): entity groups + Processes/External Entities/Data Stores sections; spotlight state `hoverId`, `pinnedId`, `labelHoverCardId`, `focusId`. A card's hover goes through `cardHover`, a `createHoverIntent` instance whose `apply` callback sets `hoverId` and, when the settled card is a spotlit-but-not-active card, `labelHoverCardId`; switching lens calls `cardHover.applyNow(null)` to clear at once. The root `.dict-view` gets `data-motion="off"` when `animationsAllowed(totalVisible + totalFlowVisible)` is `false` (see How it works), and every `scrollIntoView` call in the file (`scrollToEntity`, `scrollToSection`, `scrollToMissing`, `scrollToProcess`) uses `scrollBehaviorWithin(el)` instead of a hardcoded `'smooth'`. FK connections from `buildSpotlightConnections`; flow connections from `buildFlowSpotlightConnections`; inherited (lineage) connections from `buildInheritedConnections`, gated behind a document-level `shiftHeld` boolean. **Change review:** props `changeReview` and `entityChanges`; passes `change={entityChanges?.get(n.id)?.mark}` to each `GridCard` and `changes=` to each `EntityCard`. `removedNodes` (from `changeReview.removedNodes`, filtered by the active search) feeds a "N removed since approval" link in the browse lens, whose `showRemoved` switches to the read lens inside `flushSync` and scrolls to `#dict-removed`, and a read-lens "Removed since approval" section of `EntityCard`s built from `changeReview.removedEdges`. | | `views/flow/FlowsView.tsx` | Exports `FlowsView` (forwardRef), `FlowsViewHandle`, `FlowSurface` (the stateful dialog-hosting wrapper around `FlowDiagramSvg`, see How it works), `FlowChromeCallbacks`, and `initFlowGraphCore` (the imperative renderer lifecycle both static and live modes call). `FlowsViewProps` carries `flowView: FlowViewMode` and `collapseLevel: FlowCollapseLevel`, both owned by the shell; a change to either is an effect dependency of the renderer-mount effect, so toggling either re-mounts `initFlowGraphCore` and every diagram in the session picks up the new opts. `renderDiagram` calls `computeElkLayout(diagram, flowDataOpts)` from [`src/flow-view/elk-flow-layout.ts`](../../src/flow-view/elk-flow-layout.ts) and passes `elkPositions`/`elkEdgeRoutes` into `FlowDiagramSvg`; falls back to a banded `computeFlowLayout` only on ELK failure. `FlowsViewHandle` exposes `selectDiagramById(ref)` (resolves a `dfd=`-style diagram reference — a bare id or a path such as `invoicing/Submit-PCI` — via `findDiagramByRef` from [`src/flow-view/flow-nav.ts`](../../src/flow-view/flow-nav.ts)), `toggleIndex()` (delegates to `flowChromeRef.current?.toggleIndex()`, the `i`-key entry point), `resetLayout`, `zoomIn`, `zoomOut`, `setPercent`, `resetZoom`, `panBy`, `openFlowToken`. `initFlowGraphCore` exposes both `selectDiagramById(ref)` and `selectDiagramPath(ids)` (walks `resolveDiagramPath`, exact — so two flows sharing a process file name resolve to the right one); both funnel into an internal `showPath(path)` that replaces the breadcrumb stack via `stackFor(path)` (each step's label is the owning process's dotted number + label, or the diagram's own title at the root) and renders the path's last diagram (see How it works). On mount, the starting diagram resolves the seeded `dfd=` reference the same way, falling back to `defaultDiagramPath(allDiagrams)` (flow-view domain — opens the System overview) when it is absent or unresolvable. **Change review:** `FlowsViewProps.changeReview` seeds `initFlowGraphCore`'s `initialChangeReview` parameter; later changes arrive through `FlowChromeCallbacks.onRegisterChangeReview` (the same register-a-setter pattern as `onRegisterSearchTokens`), so a toggle re-renders the current diagram without re-mounting. Each render computes `diagramPath` from every id in the breadcrumb stack (synthetic levels included, matching how change items key diagrams) and passes `changeMarks: flowNodeMarks(review, diagram, diagramPath)` to `FlowDiagramSvg`. `onRegisterHandlers(drillUp, selectDiagram, selectPath)` hands all three selectors up to `FlowChrome` (flow-view domain) for the breadcrumb level menus and the flow index. `FlowChrome` receives `modelName`/`modelDescription` from `getEntityModel()?._meta` for the index's whole-system description fallback. `window.__IGNATIUS_ACTIVE_FLOW_DFD__` is set to the bare `diagram.id` on every render; `onActiveDiagramChange` fires separately with the path reference (`diagramRef(stack...)`) that `App.tsx` writes into the `dfd=` hash. **`disposed` teardown guard:** `initFlowGraphCore` declares `let disposed = false`; the async `renderDiagram` checks it before and after every `await computeElkLayout(...)` to guard against an orphaned continuation resuming after React StrictMode's dev-mode mount→cleanup→mount. | | `views/flow/LegendModal.tsx` | `LegendModal` component; imports `DARK_PALETTE`/`LIGHT_PALETTE`/`FlowPalette` from [`src/flow-view/FlowDiagramSvg.tsx`](../../src/flow-view/FlowDiagramSvg.tsx). | diff --git a/scripts/perf-harness.ts b/scripts/perf-harness.ts index fd52616..4050df3 100644 --- a/scripts/perf-harness.ts +++ b/scripts/perf-harness.ts @@ -39,7 +39,7 @@ const PORT = parseInt(argValue('--port', '7799'), 10); const RUNS = parseInt(argValue('--runs', '1'), 10); const MODEL_ARG = argValue('--model', ''); // --mode organic|hierarchical|tree — forces layout mode before measuring; default keeps -// whatever the browser has stored (usually 'organic' on a cold profile). +// whatever the browser has stored (tree on a cold profile). const MODE_ARG = argValue('--mode', ''); // ── Helpers ─────────────────────────────────────────────────────────────────── diff --git a/src/app/App.tsx b/src/app/App.tsx index ecbbf48..2dda406 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -20,7 +20,8 @@ import { Modal } from './components/ui/Modal'; import { HelpModal } from './components/ui/HelpModal'; import { ZoomControl } from './components/ui/ZoomControl'; import { GraphEditBar } from './components/ui/GraphEditBar'; -import { nextLayoutMode, parseLayoutMode } from './views/graph/graph-edits'; +import { loadLayoutMode, nextLayoutMode } from './views/graph/graph-edits'; +import { createLayoutStore } from './views/graph/layout-store'; import type { GraphEditState } from './views/graph/graph-edits'; import { SearchBar } from './components/ui/SearchBar'; import type { SearchBarHandle } from './components/ui/SearchBar'; @@ -199,8 +200,10 @@ export function App() { const [graphEdits, setGraphEdits] = useState<GraphEditState>({ canUndo: false, canRedo: false, selectMode: false, hidden: [] }); // Layout algorithm mode — shell owns it for FAB display; GraphView reads it via prop. const [layoutMode, setLayoutMode] = useState<LayoutMode>(() => { - const stored = localStorage.getItem('ignatius-layout-mode'); - return parseLayoutMode(stored); + const mode = loadLayoutMode(localStorage.getItem('ignatius-layout-mode'), createLayoutStore().hasEntries()); + // Stored at once, so a later load never has to work it out from the saved arrangements again. + localStorage.setItem('ignatius-layout-mode', mode); + return mode; }); // Flow view + collapse level (docs/spec/dfd-store-clusters.md) — global, diff --git a/src/app/components/ui/HelpModal.tsx b/src/app/components/ui/HelpModal.tsx index 97d4cc1..038451b 100644 --- a/src/app/components/ui/HelpModal.tsx +++ b/src/app/components/ui/HelpModal.tsx @@ -36,7 +36,7 @@ const GRAPH_TYPES: Row[] = [ ]; const GRAPH_EXPLORE: Row[] = [ - { term: 'Layouts', desc: 'Organic clusters related entities by force; Hierarchical gives each colour group its own block, stacked in dependency layers; Tree lays the model out as an outline, each table below and right of the table whose key it inherits, with right-angled lines. L steps through them.' }, + { term: 'Layouts', desc: 'Organic clusters related entities by force; Hierarchical gives each colour group its own block, stacked in dependency layers; Tree lays the model out as an outline, each table below and right of the table whose key it inherits, with right-angled lines. A first visit opens in Tree; L steps through them and the choice is kept.' }, { term: 'Shift + hover', desc: 'Reveals dotted lines to an entity’s whole key-inheritance family — every relative that shares its primary key, however far apart.' }, { term: 'Click · drag · zoom', desc: 'Click an entity for full detail; drag to rearrange (positions are saved, and survive edits to the model); scroll or ⌘/Ctrl +/−/0 to zoom.' }, { term: 'Move several', desc: 'Shift+drag on the background draws a selection box; press S for select mode, where a plain drag boxes and a click adds or removes. Drag any selected entity to move them all.' }, diff --git a/src/app/views/graph/GraphView.tsx b/src/app/views/graph/GraphView.tsx index f499f8c..a3f5460 100644 --- a/src/app/views/graph/GraphView.tsx +++ b/src/app/views/graph/GraphView.tsx @@ -997,12 +997,15 @@ export const GraphView = forwardRef<GraphViewHandle, GraphViewProps>( const edges: TreeEdgeInput[] = []; cy.edges().not(`.${CHANGE_GHOST_CLASS}, .${INHERITED_EDGE_CLASS}`).forEach((e) => { if (!present.has(e.source().id()) || !present.has(e.target().id())) return; + const fwd = String(e.data('predicateFwd') ?? ''); + const rev = String(e.data('predicateRev') ?? ''); edges.push({ id: e.id(), source: e.source().id(), target: e.target().id(), identifying: e.data('identifying') === 'true', - label: String(e.data('predicateFwd') ?? ''), + // Hovering the child swaps in the reverse predicate on the same run. + label: rev.length > fwd.length ? rev : fwd, }); }); return { nodes, edges }; @@ -1027,7 +1030,10 @@ export const GraphView = forwardRef<GraphViewHandle, GraphViewProps>( // Positions restored from a save or an undo: take the lines a layout of this graph would draw. if (treeLines === null) treeLines = linesOf(layoutTree(nodes, links).routes); const known = treeLines; - const lines: TreeLine[] = links.map(e => known.get(e.id) ?? { id: e.id, parent: e.source, child: e.target, kind: 'cross' }); + const lines: TreeLine[] = links.map(e => ({ + ...(known.get(e.id) ?? { id: e.id, parent: e.source, child: e.target, kind: 'cross' }), + label: e.label, + })); const positions: PositionMap = {}; for (const n of nodes) positions[n.id] = cy.$id(n.id).position(); treeRoutes = routeTree(nodes, positions, lines); @@ -1072,10 +1078,10 @@ export const GraphView = forwardRef<GraphViewHandle, GraphViewProps>( 'source-endpoint': `${sourceEnd.x - s.x}px ${sourceEnd.y - s.y}px`, 'target-endpoint': `${targetEnd.x - t.x}px ${targetEnd.y - t.y}px`, }; - // The geometry, not the stored kind, picks the label: a `down` route - // drawn as a cross link labels its street. + // The geometry, not the stored kind, picks the label: a cross link labels its + // street unless its drop leaves room on the run into the child. edge.removeClass(TREE_EDGE_CLASSES); - if (g.kind === 'cross') { + if (g.kind === 'cross' && g.labelOffset <= 0) { edge.addClass('tree-cross'); } else { edge.addClass(reversed ? 'tree-edge tree-label-source' : 'tree-edge'); @@ -1418,7 +1424,7 @@ export const GraphView = forwardRef<GraphViewHandle, GraphViewProps>( settleLayout(); gradeSpans(); - cy.fit(undefined, 30); + fitGraph(); cy.style(buildStyles(modelNonNull.groups, modelNonNull.theme, themeModeRef.current)); cy.forceRender(); requestAnimationFrame(redrawMarkers); @@ -1493,10 +1499,37 @@ export const GraphView = forwardRef<GraphViewHandle, GraphViewProps>( cy.zoom({ level: clamped, renderedPosition: getViewportCenter() }); }; + // The branding, search bar and toggles sit over the canvas top and the footer over its bottom, + // so a fit uses the band between them. + function fitGraph(): void { + const panel = containerRef.current?.getBoundingClientRect(); + const bb = cy.elements().boundingBox(); + if (!panel || bb.w <= 0 || bb.h <= 0) return; + const bottomOf = (selector: string) => + Math.max(0, (document.querySelector(selector)?.getBoundingClientRect().bottom ?? panel.top) - panel.top); + const top = Math.max(...['.branding-block', '.viewer-search-bar--graph .viewer-search-bar-inner', '.help-toggle', '.theme-toggle'].map(bottomOf)); + const footer = document.querySelector('.branding-footer')?.getBoundingClientRect(); + const bottom = footer ? Math.max(0, panel.bottom - footer.top) : 0; + const pad = 30; + const width = cy.width() - 2 * pad; + const height = cy.height() - top - bottom - 2 * pad; + if (width <= 0 || height <= 0) { + cy.fit(undefined, pad); + return; + } + const zoom = Math.max(cy.minZoom(), Math.min(cy.maxZoom(), width / bb.w, height / bb.h)); + // Too big even at the smallest zoom: show the top-left corner, where a Tree starts, not the middle. + const offset = (room: number, size: number) => (size > room ? 0 : (room - size) / 2); + cy.viewport({ + zoom, + pan: { x: pad + offset(width, bb.w * zoom) - bb.x1 * zoom, y: top + pad + offset(height, bb.h * zoom) - bb.y1 * zoom }, + }); + } + cyZoomResetRef.current = () => { // Home still fits-to-screen; the readout shows the real fit percent // (driven by the 'zoom' event), not a forced 100. - cy.fit(undefined, 30); + fitGraph(); }; cy.on('position', redrawMarkers); @@ -1772,7 +1805,7 @@ export const GraphView = forwardRef<GraphViewHandle, GraphViewProps>( pendingRelayout = null; settleLayout(); gradeSpans(); - cy.fit(undefined, 30); + fitGraph(); redrawMarkers(); persist(); reportEdits(); diff --git a/src/app/views/graph/graph-edits.ts b/src/app/views/graph/graph-edits.ts index cb98fe9..5f479ef 100644 --- a/src/app/views/graph/graph-edits.ts +++ b/src/app/views/graph/graph-edits.ts @@ -18,9 +18,16 @@ export function nextLayoutMode(mode: LayoutMode): LayoutMode { return LAYOUT_CYCLE[(LAYOUT_CYCLE.indexOf(mode) + 1) % LAYOUT_CYCLE.length] ?? 'organic'; } -/** A stored mode string, or organic when it is missing or unknown. */ +/** A stored mode string, or tree when it is missing or unknown. */ export function parseLayoutMode(value: string | null): LayoutMode { - return LAYOUT_CYCLE.find(mode => mode === value) ?? 'organic'; + return LAYOUT_CYCLE.find(mode => mode === value) ?? 'tree'; +} + +/** The mode a load opens in: the stored one; else organic when an arrangement is already saved + * (Organic was the default before the mode was always stored); else tree. */ +export function loadLayoutMode(storedMode: string | null, hasSavedArrangement: boolean): LayoutMode { + if (storedMode !== null) return parseLayoutMode(storedMode); + return hasSavedArrangement ? 'organic' : 'tree'; } /** Where every node sits, which nodes the user removed from view, and the layout mode. */ diff --git a/src/app/views/graph/layout-store.ts b/src/app/views/graph/layout-store.ts index 3174ecd..a27c8e3 100644 --- a/src/app/views/graph/layout-store.ts +++ b/src/app/views/graph/layout-store.ts @@ -33,6 +33,8 @@ export interface LayoutStoreHandle { hidden(layoutKey: string): string[]; save(layoutKey: string, positions: PositionMap, hidden?: string[]): void; clear(layoutKey: string): void; + /** True when any arrangement is saved, for any model. */ + hasEntries(): boolean; /** Newest saved position of `nodeId` outside `exceptKey`, from an entry that also saved one of * `neighbourIds`; the store is shared by every model, so this keeps out same-named nodes. */ lastPosition(nodeId: string, exceptKey: string, neighbourIds: string[]): { x: number; y: number } | null; @@ -121,6 +123,10 @@ export function createLayoutStore( writeStore(storage, store, storageKey); }, + hasEntries(): boolean { + return Object.keys(readStore(storage, storageKey)).length > 0; + }, + lastPosition(nodeId: string, exceptKey: string, neighbourIds: string[]): { x: number; y: number } | null { const entries = Object.entries(readStore(storage, storageKey)) .filter(([key, entry]) => key !== exceptKey diff --git a/src/app/views/graph/tree-layout.ts b/src/app/views/graph/tree-layout.ts index 9b8d31a..cbdeb09 100644 --- a/src/app/views/graph/tree-layout.ts +++ b/src/app/views/graph/tree-layout.ts @@ -1,7 +1,7 @@ import type { PositionMap } from './layout-store'; import { orderRegions } from './grouped-layered'; -// Outline layout with lane-separated right-angled lines: see +// Outline layout with right-angled lines, one trunk per parent: see // docs/design/graph-tree-layout.md. export type TreeNodeInput = { @@ -13,7 +13,7 @@ export type TreeNodeInput = { /** A subtype joiner diamond: never wraps away from its basetype. */ joiner?: boolean; }; -/** `source` is the referenced (parent) table, `target` the table holding the FK. */ +/** `source` is the referenced (parent) table, `target` the FK holder; runs are sized to fit `label`, its longest label. */ export type TreeEdgeInput = { id: string; source: string; target: string; identifying: boolean; label: string }; export type Forest = { @@ -34,27 +34,35 @@ export type RouteGeometry = { parentEnd: Point; bends: Point[]; childEnd: Point; - /** `down` only: label distance from the child end, halfway along the run into it. */ + /** `down`, or `cross` with `runLabel`: label distance from the child end, halfway along the run into it. */ labelOffset: number; }; /** Lane (from the parent's right edge, negative), street (below the parent's bottom) and approach column (from the child's left edge, negative). */ -export type CrossSlot = { laneDx: number; streetDy: number; approachDx: number }; +export type CrossSlot = { + laneDx: number; + streetDy: number; + approachDx: number; + /** When no straight drop clears the boxes: drop at `dropDx` from the child's left edge to `jogDy` from its centre, then across to the approach column. */ + jog?: { dropDx: number; jogDy: number }; +}; /** A line to route: tree lines (`down`) as the last tree layout drew them, everything else `cross`. */ -export type TreeLine = { id: string; parent: string; child: string; kind: 'down' | 'cross' }; +export type TreeLine = { id: string; parent: string; child: string; kind: 'down' | 'cross'; label?: string }; /** Where a line runs from its parent's bottom edge into its child's left side, relative to both so it survives a drag. */ export type TreeRoute = { kind: 'down' | 'cross'; parent: string; child: string; - /** `down` only: lane x from the parent's left edge. */ - laneDx: number; + /** `down` only: the trunk's x from the parent's left edge. */ + trunkDx: number; /** The cross geometry: a `cross` route's own; for a `down` route, the fallback while a drag moves its child out of place. */ slot: CrossSlot; /** Row the line enters the child at, from the child's centre. */ entryDy: number; + /** `cross` only: the approach column sits far enough left that the label goes on the run into the child. */ + runLabel?: boolean; }; // Rows leave room for a cross link's street and its centred label under a parent. @@ -185,12 +193,22 @@ function treeRoots(roots: string[], children: Map<string, string[]>, lookups: Ma return rootOf; } -// Lanes fan across half a bottom edge, stopping 2 px short of the centre so the -// halves never meet; a half with no room past the inset is one trunk at the centre. -function fan(halfWidth: number, count: number, gap: number): { base: number; gap: number } { +// Cross-link lanes fan across the right half of a bottom edge, stopping 2 px short of the centre; +// a half with no room past the inset sends them all down from the centre. +function fan(halfWidth: number, count: number): { base: number; gap: number } { const roomy = halfWidth - LANE_INSET - 2; if (roomy <= 0) return { base: halfWidth, gap: 0 }; - return { base: LANE_INSET, gap: count <= 1 ? 0 : Math.min(gap, roomy / (count - 1)) }; + return { base: LANE_INSET, gap: count <= 1 ? 0 : Math.min(LANE_GAP, roomy / (count - 1)) }; +} + +// A parent's tree lines share one trunk a quarter of the way in from its left edge. +function trunkOffset(width: number): number { + return Math.max(Math.min(LANE_INSET, width / 2), Math.min(width / 4, width / 2 - 2)); +} + +/** The horizontal run a set of labels needs: the widest label plus padding. */ +function labelRunOf(labels: string[]): number { + return Math.max(MIN_RUN, Math.max(0, ...labels.map(l => l.length * LABEL_CHAR_WIDTH)) + 2 * LABEL_PAD); } const NO_SLOT: CrossSlot = { laneDx: -LANE_INSET, streetDy: STREET_INSET, approachDx: -APPROACH_INSET }; @@ -208,7 +226,7 @@ export function layoutTree(nodes: TreeNodeInput[], edges: TreeEdgeInput[]): { po sizeMemo.set(id, size); return size; }; - // Smaller subtrees first: the lanes that run furthest down pass fewer rows. + // Smaller subtrees first, so the largest hangs last, at the foot of the trunk. const orderedChildren = (id: string) => [...(children.get(id) ?? [])].sort((a, b) => subtreeSize(a) - subtreeSize(b) || byId(a, b)); const heightMemo = new Map<string, number>(); @@ -228,8 +246,12 @@ export function layoutTree(nodes: TreeNodeInput[], edges: TreeEdgeInput[]): { po byPair.set(key, [...(byPair.get(key) ?? []), e]); } const edgesBetween = (p: string, c: string) => byPair.get(pairKey(p, c)) ?? []; - const labelRun = (list: TreeEdgeInput[]) => - Math.max(MIN_RUN, Math.max(0, ...list.map(e => e.label.length * LABEL_CHAR_WIDTH)) + 2 * LABEL_PAD); + const labelRun = (list: TreeEdgeInput[]) => labelRunOf(list.map(e => e.label)); + const incoming = new Map<string, TreeEdgeInput[]>(); + for (const e of edges) incoming.set(e.target, [...(incoming.get(e.target) ?? []), e]); + // A wrapped column sits far enough right of what it wraps past that every label into it fits its run. + const wrapGap = (kids: string[]) => + Math.max(COLUMN_GAP, APPROACH_INSET + labelRun(kids.flatMap(k => incoming.get(k) ?? []))); let positions: PositionMap = {}; let routes = new Map<string, TreeRoute>(); @@ -271,21 +293,14 @@ export function layoutTree(nodes: TreeNodeInput[], edges: TreeEdgeInput[]): { po if (kept.length === 0) return { bottom, right }; const kidEdges = kept.map(k => edgesBetween(id, k)); - const laneCount = kidEdges.reduce((sum, list) => sum + list.length, 0); - // Tree lanes use the left half of the parent's bottom edge; cross links the right. - const { base: laneBase, gap: laneGap } = fan(self.width / 2, laneCount, LANE_GAP); - const childLeft = left + laneBase + (laneCount - 1) * laneGap + labelRun(kidEdges.flat()); - - // The first child's lanes are rightmost, so each lower child's lane runs - // left of every earlier child's horizontal and never crosses it. - let lane = laneCount - 1; + const trunk = trunkOffset(self.width); + const childLeft = left + trunk + labelRun(kidEdges.flat()); + let rowTop = firstRow; kept.forEach((kid, i) => { const placed = place(kid, childLeft, rowTop, limit); for (const edge of kidEdges[i] ?? []) { - const laneDx = laneBase + lane * laneGap; - routes.set(edge.id, { kind: 'down', parent: id, child: kid, laneDx, slot: NO_SLOT, entryDy: 0 }); - lane--; + routes.set(edge.id, { kind: 'down', parent: id, child: kid, trunkDx: trunk, slot: NO_SLOT, entryDy: 0 }); } rowTop = placed.bottom + ROW_GAP; bottom = placed.bottom; @@ -293,13 +308,14 @@ export function layoutTree(nodes: TreeNodeInput[], edges: TreeEdgeInput[]): { po }); // Wrapped columns start at the first child row, so their cross links still run down and right. - let columnLeft = right + COLUMN_GAP; + const gap = wrapGap(wrapped); + let columnLeft = right + gap; let columnRight = columnLeft; let inColumn = 0; rowTop = firstRow; for (const kid of wrapped) { if (inColumn > 0 && rowTop + stackHeight(kid) > limit) { - columnLeft = columnRight + COLUMN_GAP; + columnLeft = columnRight + gap; rowTop = firstRow; inColumn = 0; } @@ -323,7 +339,7 @@ export function layoutTree(nodes: TreeNodeInput[], edges: TreeEdgeInput[]): { po } // A tree's lookups sit in a staircase above it, the one feeding the deepest - // table top-left, so a lookup's run into its deepest table crosses no other lookup's lane. + // table top-left, so a lookup's run into its deepest table crosses no other lookup's trunk. const placeBlock = (root: string): { members: string[]; width: number; height: number } => { const before = new Set(Object.keys(positions)); const tree = place(root, 0, 0, columnHeight); @@ -342,33 +358,29 @@ export function layoutTree(nodes: TreeNodeInput[], edges: TreeEdgeInput[]): { po let stepLeft = 0; let stepTop = 0; let bandRight = 0; - const lanes: { edge: TreeEdgeInput; lookup: string; laneDx: number; x: number }[] = []; + const trunks: { edge: TreeEdgeInput; lookup: string; trunkDx: number; x: number }[] = []; for (const l of stair) { const self = info.get(l); const width = self?.width ?? 0; const height = self?.height ?? 0; positions[l] = { x: stepLeft + width / 2, y: stepTop + height / 2 }; - const own = feedsFor(l); - const spread = fan(width / 2, own.length, LANE_GAP); - own.forEach((edge, k) => { - const laneDx = spread.base + k * spread.gap; - lanes.push({ edge, lookup: l, laneDx, x: stepLeft + laneDx }); - }); + const trunk = trunkOffset(width); + for (const edge of feedsFor(l)) trunks.push({ edge, lookup: l, trunkDx: trunk, x: stepLeft + trunk }); bandRight = Math.max(bandRight, stepLeft + width); - // The next lookup starts right of these lanes, so they drop past it. - stepLeft += spread.base + Math.max(0, own.length - 1) * spread.gap + LANE_INSET; + // The next lookup starts right of this trunk, so it drops past it. + stepLeft += trunk + LANE_INSET; stepTop += height + ROW_GAP; } - const lastLane = Math.max(0, ...lanes.map(l => l.x)); - const treeLeft = lastLane + labelRun(lanes.map(l => l.edge)); + const lastTrunk = Math.max(0, ...trunks.map(l => l.x)); + const treeLeft = lastTrunk + labelRun(trunks.map(l => l.edge)); const treeTop = stepTop; for (const id of members) { const p = positions[id]; if (p) positions[id] = { x: p.x + treeLeft, y: p.y + treeTop }; } - for (const { edge, lookup, laneDx } of lanes) { - routes.set(edge.id, { kind: 'down', parent: lookup, child: edge.target, laneDx, slot: NO_SLOT, entryDy: 0 }); + for (const { edge, lookup, trunkDx } of trunks) { + routes.set(edge.id, { kind: 'down', parent: lookup, child: edge.target, trunkDx, slot: NO_SLOT, entryDy: 0 }); } return { members: [...members, ...stair], @@ -436,93 +448,309 @@ export function layoutTree(nodes: TreeNodeInput[], edges: TreeEdgeInput[]): { po columnWidth = Math.max(columnWidth, tree.width); } - const lines: TreeLine[] = [...routes].map(([id, r]) => ({ id, parent: r.parent, child: r.child, kind: 'down' })); + const labelOf = new Map(edges.map(e => [e.id, e.label])); + const lines: TreeLine[] = [...routes].map(([id, r]) => ({ id, parent: r.parent, child: r.child, kind: 'down', label: labelOf.get(id) })); for (const e of edges) { if (routes.has(e.id) || e.source === e.target || !positions[e.source] || !positions[e.target]) continue; - lines.push({ id: e.id, parent: e.source, child: e.target, kind: 'cross' }); + lines.push({ id: e.id, parent: e.source, child: e.target, kind: 'cross', label: e.label }); } return { positions, routes: routeTree(nodes, positions, lines) }; } -/** Lanes, slots, entry rows and approach columns for `lines` at `positions`; a tree line whose - * child is no longer below its parent and right of its lane is routed as a cross link. */ +/** Trunks, slots, entry rows and approach columns for `lines` at `positions`; a tree line whose + * child is no longer below its parent and right of its trunk is routed as a cross link. */ export function routeTree(nodes: TreeNodeInput[], positions: PositionMap, lines: TreeLine[]): Map<string, TreeRoute> { const info = new Map(nodes.map(n => [n.id, n])); - const left = (id: string) => (positions[id]?.x ?? 0) - (info.get(id)?.width ?? 0) / 2; - const top = (id: string) => (positions[id]?.y ?? 0) - (info.get(id)?.height ?? 0) / 2; - const bottom = (id: string) => (positions[id]?.y ?? 0) + (info.get(id)?.height ?? 0) / 2; + const frame = frameOf(info, positions); const placed = lines .filter(l => l.parent !== l.child && positions[l.parent] && positions[l.child] && info.has(l.parent) && info.has(l.child)) .sort((a, b) => byId(a.id, b.id)); const routes = new Map<string, TreeRoute>(); - const downsByParent = new Map<string, TreeLine[]>(); - for (const l of placed) { - if (l.kind === 'down' && top(l.child) > bottom(l.parent)) { - downsByParent.set(l.parent, [...(downsByParent.get(l.parent) ?? []), l]); - } - } - for (const [parent, all] of downsByParent) { - // The topmost child takes the rightmost lane; a child left of its lane leaves the fan. - let list = [...all].sort((a, b) => (positions[a.child]?.y ?? 0) - (positions[b.child]?.y ?? 0) || byId(a.id, b.id)); - const laneDx = (i: number, count: number) => { - const spread = fan((info.get(parent)?.width ?? 0) / 2, count, LANE_GAP); - return spread.base + (count - 1 - i) * spread.gap; - }; - for (;;) { - const fits = list.filter((l, i) => left(l.child) > left(parent) + laneDx(i, list.length) + 2); - if (fits.length === list.length) break; - list = fits; - } - list.forEach((l, i) => { - routes.set(l.id, { kind: 'down', parent, child: l.child, laneDx: laneDx(i, list.length), slot: NO_SLOT, entryDy: 0 }); - }); - } + // A tree line keeps room right of its trunk for the columns of the child's other lines from above (any of + // them may be routed as a cross link), which would cross it. + const roomFor = (line: TreeLine) => { + const c = frame.rect(line.child); + const fromAbove = placed.filter(o => o.child === line.child && o.id !== line.id && frame.rect(o.parent).y2 < midY(c)).length; + return fromAbove === 0 ? 0 : APPROACH_INSET + (fromAbove - 1) * LANE_GAP; + }; for (const l of placed) { - if (!routes.has(l.id)) routes.set(l.id, { kind: 'cross', parent: l.parent, child: l.child, laneDx: 0, slot: NO_SLOT, entryDy: 0 }); + const p = frame.rect(l.parent); + const c = frame.rect(l.child); + const trunk = trunkOffset(p.x2 - p.x1); + const stays = l.kind === 'down' && c.y1 > p.y2 && c.x1 > p.x1 + trunk + 2 + roomFor(l); + routes.set(l.id, stays + ? { kind: 'down', parent: l.parent, child: l.child, trunkDx: trunk, slot: NO_SLOT, entryDy: 0 } + : { kind: 'cross', parent: l.parent, child: l.child, trunkDx: 0, slot: NO_SLOT, entryDy: 0 }); } - crossSlots(info, positions, routes); - assignEntries(info, positions, routes); + const labelOf = new Map(placed.map(l => [l.id, l.label ?? ''])); + crossSlots(frame, routes, labelOf); + assignEntries(frame, routes); + clearApproaches(frame, routes); return routes; } -// Each cross link from a parent gets its own lane and street, the farthest child -// the outer lane and top street: nearer children right and below nest inside. -function crossSlots(info: Map<string, TreeNodeInput>, positions: PositionMap, routes: Map<string, TreeRoute>): void { - const x = (id: string) => positions[id]?.x ?? 0; +type Rect = { id: string; x1: number; y1: number; x2: number; y2: number }; + +/** One routing pass's boxes, by id and as a list, and each route's street row. */ +type Frame = { rects: Rect[]; rect: (id: string) => Rect; streetY: (route: TreeRoute) => number }; + +function frameOf(info: Map<string, TreeNodeInput>, positions: PositionMap): Frame { + const boxes = new Map<string, Rect>(); + for (const n of info.values()) { + const p = positions[n.id]; + if (p) boxes.set(n.id, { id: n.id, x1: p.x - n.width / 2, y1: p.y - n.height / 2, x2: p.x + n.width / 2, y2: p.y + n.height / 2 }); + } + const none: Rect = { id: '', x1: 0, y1: 0, x2: 0, y2: 0 }; + return { + rects: [...boxes.values()], + rect: id => boxes.get(id) ?? none, + streetY: route => (boxes.get(route.parent)?.y2 ?? 0) + route.slot.streetDy, + }; +} + +const midY = (r: Rect) => (r.y1 + r.y2) / 2; +const centreX = (r: Rect) => (r.x1 + r.x2) / 2; + +/** The boxes that reach into the band between two rows. */ +function within(rects: Rect[], y1: number, y2: number): Rect[] { + const [ly, hy] = [Math.min(y1, y2), Math.max(y1, y2)]; + return rects.filter(r => r.y1 < hy && ly < r.y2); +} + +// True when the segment (x1, y1)–(x2, y2), horizontal or vertical, passes through a box other than `skip`. +function blocked(rects: Rect[], skip: string[], x1: number, y1: number, x2: number, y2: number): boolean { + const [lx, hx, ly, hy] = [Math.min(x1, x2) - 2, Math.max(x1, x2) + 2, Math.min(y1, y2), Math.max(y1, y2)]; + return rects.some(r => !skip.includes(r.id) && r.x1 < hx && lx < r.x2 && r.y1 < hy && ly < r.y2); +} + +// Cross links from a parent into one column (below its street, right of it, no tree line in) share a lane, +// street and a drop left of the column; any other gets its own, the farthest on the outer lane. +function crossSlots(frame: Frame, routes: Map<string, TreeRoute>, labelOf: Map<string, string>): void { + const withTreeLine = new Set([...routes.values()].filter(r => r.kind === 'down').map(r => r.child)); + const columnOf = (route: TreeRoute): string | null => { + const p = frame.rect(route.parent); + const c = frame.rect(route.child); + return !withTreeLine.has(route.child) && c.y1 > p.y2 + STREET_INSET + STREET_BAND && c.x1 > p.x2 ? `column ${Math.round(c.x1)}` : null; + }; + const dropKey = (route: TreeRoute) => `${route.parent} column ${Math.round(frame.rect(route.child).x1)}`; + // One drop per column: the parent with the most lines into it owns it, the farthest reaching on a tie; + // other parents' lines keep their own slots. + const claims = new Map<string, Map<string, { lines: number; reach: number }>>(); + for (const route of routes.values()) { + const key = route.kind === 'cross' ? columnOf(route) : null; + if (key === null) continue; + const reach = reachOf(frame, route); + const parents = claims.get(key) ?? new Map<string, { lines: number; reach: number }>(); + const held = parents.get(route.parent); + parents.set(route.parent, { lines: (held?.lines ?? 0) + 1, reach: Math.max(held?.reach ?? 0, reach) }); + claims.set(key, parents); + } + const owner = new Map<string, string>(); + for (const [key, parents] of claims) { + const [best] = [...parents].sort(([pa, a], [pb, b]) => b.lines - a.lines || b.reach - a.reach || byId(pa, pb)); + if (best) owner.set(key, best[0]); + } const byParent = new Map<string, [string, TreeRoute][]>(); for (const [id, route] of routes) { if (route.kind === 'cross') byParent.set(route.parent, [...(byParent.get(route.parent) ?? []), [id, route]]); } - for (const [parent, list] of byParent) { - list.sort(([ia, a], [ib, b]) => Math.abs(x(b.child) - x(parent)) - Math.abs(x(a.child) - x(parent)) || byId(ia, ib)); - const spread = fan((info.get(parent)?.width ?? 0) / 2, list.length, LANE_GAP); - const streetGap = list.length <= 1 ? 0 : Math.min(STREET_GAP, STREET_BAND / (list.length - 1)); - list.forEach(([, route], k) => { - route.slot = { laneDx: -(spread.base + k * spread.gap), streetDy: STREET_INSET + k * streetGap, approachDx: -APPROACH_INSET }; - }); + // A drop sits a label run left of its column, and left of every other parent's column into its children. + const all = [...routes.values()]; + const dropStart = (parent: string, unit: [string, TreeRoute][]) => { + const others = Math.max(0, ...unit.map(([, r]) => all.filter(o => o.kind === 'cross' && o.child === r.child && o.parent !== parent).length)); + const run = labelRunOf(unit.map(([id]) => labelOf.get(id) ?? '')); + return frame.rect(unit[0]?.[1].child ?? '').x1 - Math.max(run, APPROACH_INSET + others * LANE_GAP); + }; + // A one-line drop that another line's street into its child crosses gives way to the line's own slot, + // and the slots are spread again until none changes. + const split = new Set<string>(); + for (;;) { + for (const [parent, list] of byParent) { + const shared = (route: TreeRoute) => { + const column = columnOf(route); + return column !== null && owner.get(column) === parent && !split.has(dropKey(route)) ? column : null; + }; + parentSlots(frame, parent, list, shared, unit => dropStart(parent, unit)); + } + const crossed = crossedDrops(frame, routes, dropKey).filter(key => !split.has(key)); + if (crossed.length === 0) return; + for (const key of crossed) split.add(key); + } +} + +const reachOf = (frame: Frame, route: TreeRoute) => Math.abs(centreX(frame.rect(route.child)) - centreX(frame.rect(route.parent))); + +// One parent's units, the farthest on the outer lane and top street. Several lines keep a drop with nothing clear: +// on their own streets, at most 2 px apart, their labels would print as one. +function parentSlots(frame: Frame, parent: string, list: [string, TreeRoute][], shared: (route: TreeRoute) => string | null, + dropStart: (unit: [string, TreeRoute][]) => number): void { + const p = frame.rect(parent); + const units = new Map<string, [string, TreeRoute][]>(); + for (const [id, route] of [...list].sort(([a], [b]) => byId(a, b))) { + const column = shared(route); + const key = column !== null && dropStart(list.filter(([, r]) => shared(r) === column)) > p.x2 ? column : `line ${id}`; + units.set(key, [...(units.get(key) ?? []), [id, route]]); } + const reach = (unit: [string, TreeRoute][]) => Math.max(...unit.map(([, r]) => reachOf(frame, r))); + const ordered = [...units].sort(([ka, a], [kb, b]) => reach(b) - reach(a) || byId(ka, kb)); + const spread = fan((p.x2 - p.x1) / 2, ordered.length); + const streetGap = ordered.length <= 1 ? 0 : Math.min(STREET_GAP, STREET_BAND / (ordered.length - 1)); + ordered.forEach(([key, unit], k) => { + const slot: CrossSlot = { laneDx: -(spread.base + k * spread.gap), streetDy: STREET_INSET + k * streetGap, approachDx: -APPROACH_INSET }; + let runLabel = false; + if (key.startsWith('column')) { + const columnLeft = frame.rect(unit[0]?.[1].child ?? '').x1; + const start = dropStart(unit); + const dropX = clearDrop(frame, p, slot, unit, columnLeft, start) ?? (unit.length > 1 ? start : null); + if (dropX !== null) { + slot.approachDx = dropX - columnLeft; + runLabel = true; + } + } + for (const [, route] of unit) { + route.slot = { ...slot }; + route.runLabel = runLabel; + } + }); +} + +// A column's drop: the nearest x from `start` leftwards, right of the parent, whose street, drop and runs +// miss every box, or null. +function clearDrop(frame: Frame, p: Rect, slot: CrossSlot, unit: [string, TreeRoute][], columnLeft: number, start: number): number | null { + const streetY = p.y2 + slot.streetDy; + const rows = unit.map(([, r]) => midY(frame.rect(r.child))); + const bottom = Math.max(...rows) + ENTRY_GAP; + const skip = [p.id, ...unit.map(([, r]) => r.child)]; + const near = within(frame.rects, streetY, bottom); + const candidates = [start, ...near.map(r => r.x1 - 4)].filter(x => x <= start && x > p.x2).sort((a, b) => b - a); + return candidates.find(x => !blocked(near, skip, x, streetY, x, bottom) + && !blocked(frame.rects, skip, p.x2 + slot.laneDx, streetY, x, streetY) + && rows.every(y => !blocked(near, skip, x, y, columnLeft, y))) ?? null; +} + +// The keys of one-line shared drops that another line's street into their child crosses. +function crossedDrops(frame: Frame, routes: Map<string, TreeRoute>, keyOf: (route: TreeRoute) => string): string[] { + const all = [...routes.values()]; + const lines = new Map<string, number>(); + for (const r of all) if (r.runLabel) lines.set(keyOf(r), (lines.get(keyOf(r)) ?? 0) + 1); + const crossed = new Set<string>(); + for (const drop of all) { + if (!drop.runLabel || lines.get(keyOf(drop)) !== 1) continue; + const dropX = frame.rect(drop.child).x1 + drop.slot.approachDx; + const top = frame.streetY(drop); + const bottom = midY(frame.rect(drop.child)) + ENTRY_GAP; + const hit = all.some(r => r.kind === 'cross' && r.child === drop.child && r.parent !== drop.parent + && top < frame.streetY(r) && frame.streetY(r) < bottom && frame.rect(r.parent).x2 + r.slot.laneDx < dropX); + if (hit) crossed.add(keyOf(drop)); + } + return [...crossed]; +} + +// A child's own approach columns move left, those from above and those from below each together, into the +// nearest gap clear of boxes; from above they stay right of the child's trunks and shared drops, or jog. +function clearApproaches(frame: Frame, routes: Map<string, TreeRoute>): void { + const into = new Map<string, TreeRoute[]>(); + for (const route of routes.values()) into.set(route.child, [...(into.get(route.child) ?? []), route]); + const jogDrops: JogDrop[] = []; + for (const [child, all] of into) { + const own = all.filter(r => r.kind === 'cross' && !r.runLabel); + if (own.length === 0) continue; + const c = frame.rect(child); + const others = all.filter(r => !own.includes(r)) + .map(r => (r.kind === 'down' ? frame.rect(r.parent).x1 + r.trunkDx : c.x1 + r.slot.approachDx)); + // A column from above left of a trunk or shared drop into this child would cross that line. + const floor = Math.max(-Infinity, ...others); + const above = own.filter(r => frame.streetY(r) < midY(c)); + const below = own.filter(r => frame.streetY(r) >= midY(c)); + if (!shiftClear(frame, c, above, floor)) { + const taken = [...others, ...own.map(r => c.x1 + r.slot.approachDx)]; + for (const r of above) if (!straightClear(frame, c, r, 0)) jogAround(frame, r, floor, taken, above.filter(o => o !== r), jogDrops); + } + shiftClear(frame, c, below, -Infinity); + } +} + +// The line's last vertical (from its street to its entry row) and its run into the child, `shift` left, miss every box. +function straightClear(frame: Frame, c: Rect, r: TreeRoute, shift: number, near: Rect[] = frame.rects): boolean { + const x = c.x1 + r.slot.approachDx - shift; + const entryY = midY(c) + r.entryDy; + return !blocked(near, [r.parent, c.id], x, frame.streetY(r), x, entryY) && !blocked(near, [r.parent, c.id], x, entryY, c.x1, entryY); +} + +// Moves `group`'s columns left together to the nearest shift that clears every box and keeps them right of `floor`. +function shiftClear(frame: Frame, c: Rect, group: TreeRoute[], floor: number): boolean { + if (group.length === 0) return true; + const rows = group.flatMap(r => [frame.streetY(r), midY(c) + r.entryDy]); + const near = within(frame.rects, Math.min(...rows), Math.max(...rows)); + const clearAt = (shift: number) => group.every(r => + (shift === 0 || c.x1 + r.slot.approachDx - shift > floor + 2) && straightClear(frame, c, r, shift, near)); + if (clearAt(0)) return true; + const leftmost = Math.min(...group.map(r => c.x1 + r.slot.approachDx)); + const shifts = new Set<number>(); + for (let step = 1; step <= 60; step++) shifts.add(step * LANE_GAP); + for (const r of near) if (leftmost - r.x2 - 4 > 0) shifts.add(leftmost - r.x2 - 4); + const best = [...shifts].sort((a, b) => a - b).find(clearAt); + if (best === undefined) return false; + for (const r of group) r.slot = { ...r.slot, approachDx: r.slot.approachDx - best }; + return true; +} + +type JogDrop = { x: number; top: number; bottom: number }; + +// No straight drop clears the boxes: drop through the nearest clear column right of `floor` to a row just +// above the child, cross to the approach column, and drop in; else as routed. Two jogs never share a drop. +function jogAround(frame: Frame, route: TreeRoute, floor: number, taken: number[], rest: TreeRoute[], jogDrops: JogDrop[]): void { + const c = frame.rect(route.child); + const skip = [route.parent, route.child]; + const streetY = frame.streetY(route); + const entryY = midY(c) + route.entryDy; + const approachX = c.x1 + route.slot.approachDx; + const jogY = c.y1 - ROW_GAP / 2; + const near = within(frame.rects, streetY, entryY); + if (jogY <= streetY || blocked(near, skip, approachX, jogY, approachX, entryY) || blocked(near, skip, approachX, entryY, c.x1, entryY)) return; + // The other lines from above: the jog's row must not cross their columns, nor its drop their streets. + const between = (x: number, a: number, b: number) => Math.min(a, b) < x && x < Math.max(a, b); + const crossesRest = (x: number) => rest.some((r) => { + const column = c.x1 + r.slot.approachDx; + const street = frame.streetY(r); + return between(column, x, approachX) || (between(street, streetY, jogY) && between(x, frame.rect(r.parent).x2 + r.slot.laneDx, column)); + }); + // A drop never lies right of both the child and the line's own lane: there the street overshoots and the + // jog row hooks back. + const rightmost = Math.max(c.x2, frame.rect(route.parent).x2 + route.slot.laneDx) + 2 * LANE_GAP; + const candidates = new Set<number>(); + for (let k = 1; k <= 100; k++) candidates.add(approachX + k * LANE_GAP).add(approachX - k * LANE_GAP); + for (const r of near) candidates.add(r.x2 + 6).add(r.x1 - 6); + const dropX = [...candidates] + .filter(x => x > floor + 2 && x <= rightmost && taken.every(t => Math.abs(x - t) >= LANE_GAP) && !crossesRest(x) + && !jogDrops.some(d => Math.abs(d.x - x) < LANE_GAP && d.top < jogY && streetY < d.bottom)) + .sort((a, b) => Math.abs(a - approachX) - Math.abs(b - approachX) || a - b) + .find(x => !blocked(near, skip, x, streetY, x, jogY) && !blocked(near, skip, x, jogY, approachX, jogY)); + if (dropX === undefined) return; + route.slot = { ...route.slot, jog: { dropDx: dropX - c.x1, jogDy: jogY - midY(c) } }; + jogDrops.push({ x: dropX, top: streetY, bottom: jogY }); } // Entry rows and approach columns share one order so no last turns cross: // see docs/design/graph-tree-layout.md, Drawing the lines. -function assignEntries(info: Map<string, TreeNodeInput>, positions: PositionMap, routes: Map<string, TreeRoute>): void { +function assignEntries(frame: Frame, routes: Map<string, TreeRoute>): void { const into = new Map<string, [string, TreeRoute][]>(); for (const [id, route] of routes) into.set(route.child, [...(into.get(route.child) ?? []), [id, route]]); for (const [child, entries] of into) { - const childY = positions[child]?.y ?? 0; - const height = info.get(child)?.height ?? 0; - const parentLeft = (route: TreeRoute) => (positions[route.parent]?.x ?? 0) - (info.get(route.parent)?.width ?? 0) / 2; - const streetY = (route: TreeRoute) => - (positions[route.parent]?.y ?? 0) + (info.get(route.parent)?.height ?? 0) / 2 + route.slot.streetDy; - const group = (route: TreeRoute) => (route.kind === 'down' ? 1 : streetY(route) < childY ? 0 : 2); - const key = (route: TreeRoute) => (route.kind === 'down' ? -(parentLeft(route) + route.laneDx) : streetY(route)); + const c = frame.rect(child); + const childY = midY(c); + const height = c.y2 - c.y1; + const group = (route: TreeRoute) => (route.kind === 'down' ? 1 : frame.streetY(route) < childY ? 0 : 2); + // Shared drops sit left of every own column, so among lines from above they enter lowest, rightmost drop first. + const key = (route: TreeRoute) => (route.kind === 'down' ? -(frame.rect(route.parent).x1 + route.trunkDx) + : route.runLabel ? 1e6 - route.slot.approachDx : frame.streetY(route)); entries.sort(([ia, a], [ib, b]) => group(a) - group(b) || key(a) - key(b) || byId(ia, ib)); const list = entries.map(([, route]) => route); const gap = list.length <= 1 ? 0 : Math.min(ENTRY_GAP, Math.max(0, height - 8) / (list.length - 1)); list.forEach((route, k) => { route.entryDy = (k - (list.length - 1) / 2) * gap; }); - const fromAbove = list.filter(r => group(r) === 0); + const fromAbove = list.filter(r => group(r) === 0 && !r.runLabel); const fromBelow = list.filter(r => group(r) === 2); const column = (i: number) => -(APPROACH_INSET + i * LANE_GAP); fromAbove.forEach((route, i) => { route.slot = { ...route.slot, approachDx: column(i) }; }); @@ -531,7 +759,7 @@ function assignEntries(info: Map<string, TreeNodeInput>, positions: PositionMap, } } -// A `down` route whose child no longer sits below and right of its lane (after +// A `down` route whose child no longer sits below and right of its trunk (after // a hide, reveal, reload or drag) is drawn with cross geometry instead. export function routeGeometry(route: TreeRoute, parent: Box, child: Box): RouteGeometry { const parentBottom = parent.y + parent.height / 2; @@ -539,27 +767,40 @@ export function routeGeometry(route: TreeRoute, parent: Box, child: Box): RouteG const entryY = child.y + route.entryDy; const childEnd = { x: childLeft, y: entryY }; if (route.kind === 'down') { - const laneX = parent.x - parent.width / 2 + route.laneDx; - if (entryY > parentBottom + 2 && childLeft > laneX + 2) { + const trunkX = parent.x - parent.width / 2 + route.trunkDx; + if (entryY > parentBottom + 2 && childLeft > trunkX + 2) { return { kind: 'down', - parentEnd: { x: laneX, y: parentBottom }, - bends: [{ x: laneX, y: entryY }], + parentEnd: { x: trunkX, y: parentBottom }, + bends: [{ x: trunkX, y: entryY }], childEnd, - labelOffset: (childLeft - laneX) / 2, + labelOffset: (childLeft - trunkX) / 2, }; } } const laneX = parent.x + parent.width / 2 + route.slot.laneDx; const streetY = parentBottom + route.slot.streetDy; const approachX = childLeft + route.slot.approachDx; + const jog = route.slot.jog; + const lane = { x: laneX, y: streetY }; + // cytoscape puts a segments edge's mid label between its two central bends, so the first bend repeats + // until those two are the street's ends: twice for a plain cross link, four times for a jog. + if (jog !== undefined) { + const dropX = childLeft + jog.dropDx; + const jogY = child.y + jog.jogDy; + return { + kind: 'cross', + parentEnd: { x: laneX, y: parentBottom }, + bends: [lane, lane, lane, lane, { x: dropX, y: streetY }, { x: dropX, y: jogY }, { x: approachX, y: jogY }, { x: approachX, y: entryY }], + childEnd, + labelOffset: 0, + }; + } return { kind: 'cross', parentEnd: { x: laneX, y: parentBottom }, - // The first bend twice: an even bend count puts cytoscape's mid label on - // the street, between the two central bends. - bends: [{ x: laneX, y: streetY }, { x: laneX, y: streetY }, { x: approachX, y: streetY }, { x: approachX, y: entryY }], + bends: [lane, lane, { x: approachX, y: streetY }, { x: approachX, y: entryY }], childEnd, - labelOffset: 0, + labelOffset: route.runLabel ? (childLeft - approachX) / 2 : 0, }; } diff --git a/test/checks/test-graph-edits.ts b/test/checks/test-graph-edits.ts index de3ecf1..597ff3a 100644 --- a/test/checks/test-graph-edits.ts +++ b/test/checks/test-graph-edits.ts @@ -11,10 +11,10 @@ * 3. Undo and redo on an empty side return null. * 4. The history keeps only the newest `limit` changes. * 5. discardLast drops the newest undo step without making it redoable. - * 6. L cycles organic → hierarchical → tree; unknown stored modes read as organic. + * 6. L cycles organic → hierarchical → tree; a first load opens in tree, an older saved arrangement in organic. */ -import { createEditHistory, nextLayoutMode, parseLayoutMode } from '../../src/app/views/graph/graph-edits'; +import { createEditHistory, loadLayoutMode, nextLayoutMode, parseLayoutMode } from '../../src/app/views/graph/graph-edits'; import type { GraphSnapshot } from '../../src/app/views/graph/graph-edits'; import { assert } from '../assert'; @@ -77,14 +77,17 @@ function snap(x: number, hidden: string[] = []): GraphSnapshot { assert(!createEditHistory().canRedo(), 'FAIL 5c: fresh history has nothing to redo'); } -// 6. Layout modes: L steps organic → hierarchical → tree → organic; an unknown -// stored value falls back to organic. +// 6. Layout modes: L steps organic → hierarchical → tree → organic; a first load +// opens in tree, but an arrangement saved before modes were stored stays organic. { assert(nextLayoutMode('organic') === 'hierarchical', 'FAIL 6a: organic → hierarchical'); assert(nextLayoutMode('hierarchical') === 'tree', 'FAIL 6b: hierarchical → tree'); assert(nextLayoutMode('tree') === 'organic', 'FAIL 6c: tree → organic'); assert(parseLayoutMode('tree') === 'tree', 'FAIL 6d: stored tree is kept'); - assert(parseLayoutMode('sideways') === 'organic' && parseLayoutMode(null) === 'organic', 'FAIL 6e: unknown → organic'); + assert(parseLayoutMode('sideways') === 'tree' && parseLayoutMode(null) === 'tree', 'FAIL 6e: a missing or unknown mode opens in Tree'); + assert(loadLayoutMode(null, false) === 'tree', 'FAIL 6f: a first load opens in Tree'); + assert(loadLayoutMode(null, true) === 'organic', 'FAIL 6g: a saved arrangement with no stored mode stays Organic'); + assert(loadLayoutMode('hierarchical', true) === 'hierarchical', 'FAIL 6h: a stored mode wins'); } if (process.exitCode !== 1) console.log('PASS: test-graph-edits'); diff --git a/test/checks/test-graph-fit-clear.ts b/test/checks/test-graph-fit-clear.ts new file mode 100644 index 0000000..554fd3c --- /dev/null +++ b/test/checks/test-graph-fit-clear.ts @@ -0,0 +1,91 @@ +// The branding, search bar and toggles sit over the canvas top and the footer over its bottom; a fit, on +// load or from the Fit button, leaves every table clear of them (a Tree's root always sits top-left). + +import { chromium } from 'playwright'; +import type { Page } from 'playwright'; +import { resolve, join } from 'path'; +import { existsSync } from 'fs'; +import { serveCommand } from '../../src/server/server'; +import { box, overlaps } from '../chrome-geometry'; +import type { Box } from '../chrome-geometry'; + +const ROOT = resolve(import.meta.dir, '../..'); + +if (!existsSync(join(ROOT, 'dist/static/index.js'))) { + console.log('SKIP: dist/static/index.js not built (run `bun run build:bundle`). CI builds it before checks.'); + process.exit(0); +} + +const WATCHDOG_MS = 90_000; +const watchdog = setTimeout(() => { + console.error(`FAIL: test-graph-fit-clear.ts exceeded its ${WATCHDOG_MS}ms watchdog`); + process.exit(1); +}, WATCHDOG_MS); + +const CHROME = ['.branding-block', '.viewer-search-bar--graph .viewer-search-bar-inner', '.help-toggle', '.theme-toggle', '.branding-footer']; + +let failures = 0; +function check(cond: boolean, label: string): void { + if (cond) console.log(` PASS ${label}`); + else { console.error(` FAIL ${label}`); failures++; } +} + +// Tables drawn under a piece of chrome, as "table under selector". +async function covered(page: Page): Promise<string[]> { + const tables: { id: string; box: Box }[] = await page.evaluate(() => { + const cy = window.__IGNATIUS_CY__; + const panel = document.querySelector('.graph-panel')?.getBoundingClientRect(); + if (!cy || !panel) return []; + return cy.nodes('[classification]').map((n) => { + const b = n.renderedBoundingBox({ includeLabels: false }); + return { id: n.id(), box: { left: panel.left + b.x1, right: panel.left + b.x2, top: panel.top + b.y1, bottom: panel.top + b.y2 } }; + }); + }); + if (tables.length === 0) return ['no tables rendered']; + const hits: string[] = []; + for (const selector of CHROME) { + const chrome = await box(page, selector); + // A selector GraphView's fit also reads that stops matching would let the fit, and this check, ignore it. + if (chrome === null) { + hits.push(`${selector} not found`); + continue; + } + for (const t of tables) if (overlaps(t.box, chrome)) hits.push(`${t.id} under ${selector}`); + } + return hits; +} + +const PORT = 3335; +const handle = serveCommand(join(ROOT, 'models/orm-pure'), { port: PORT }); +await new Promise<void>(r => setTimeout(r, 400)); +const browser = await chromium.launch(); + +try { + const VIEWPORTS: [number, number][] = [[1600, 1000], [1440, 900], [1280, 720]]; + for (const [width, height] of VIEWPORTS) { + const page = await browser.newPage({ viewport: { width, height } }); + page.setDefaultTimeout(30_000); + await page.goto(`http://localhost:${PORT}/#view=graph`, { waitUntil: 'load' }); + await page.waitForSelector('.graph-panel canvas', { timeout: 20_000 }); + await page.waitForTimeout(2000); + const onLoad = await covered(page); + check(onLoad.length === 0, `${width}×${height} on load: no table under the chrome (${onLoad.join(', ') || 'none'})`); + await page.getByRole('button', { name: 'Fit to screen' }).click(); + await page.waitForTimeout(300); + const onFit = await covered(page); + check(onFit.length === 0, `${width}×${height} after Fit: no table under the chrome (${onFit.join(', ') || 'none'})`); + await page.close(); + } +} catch (err) { + check(false, err instanceof Error ? err.message : String(err)); +} finally { + clearTimeout(watchdog); + await browser.close(); + handle.stop(); +} + +if (failures > 0) { + console.error(`\nFAIL: test-graph-fit-clear (${failures})`); + process.exit(1); +} +console.log('PASS: test-graph-fit-clear'); diff --git a/test/checks/test-graph-search.ts b/test/checks/test-graph-search.ts index c6b3e70..2c06bed 100644 --- a/test/checks/test-graph-search.ts +++ b/test/checks/test-graph-search.ts @@ -165,9 +165,10 @@ try { // --------------------------------------------------------------------- // SC4 — hover in/out does not erase active search dimming. // --------------------------------------------------------------------- - await page.evaluate(() => window.__IGNATIUS_CY__!.$id('Person').emit('mouseover')); + // Braced so nothing comes back: emit returns the element, and Playwright serialising it drags in the whole graph. + await page.evaluate(() => { window.__IGNATIUS_CY__?.$id('Person').emit('mouseover'); }); await page.waitForTimeout(200); - await page.evaluate(() => window.__IGNATIUS_CY__!.$id('Person').emit('mouseout')); + await page.evaluate(() => { window.__IGNATIUS_CY__?.$id('Person').emit('mouseout'); }); await page.waitForTimeout(200); const afterHover = await page.evaluate(() => { @@ -192,10 +193,11 @@ try { const active = document.activeElement; if (active instanceof HTMLElement) active.blur(); }); - const layoutModeBefore = await page.evaluate(() => localStorage.getItem('ignatius-layout-mode') ?? 'organic'); + const layoutModeBefore = await page.evaluate(() => localStorage.getItem('ignatius-layout-mode')); + assert(layoutModeBefore === 'tree', 'a first visit opens in Tree and stores it', String(layoutModeBefore)); await page.keyboard.press('l'); await page.waitForFunction( - (before: string) => (localStorage.getItem('ignatius-layout-mode') ?? 'organic') !== before, + (before: string | null) => localStorage.getItem('ignatius-layout-mode') !== before, layoutModeBefore, { timeout: 5000 }, ); diff --git a/test/checks/test-keyboard-shortcuts.ts b/test/checks/test-keyboard-shortcuts.ts index 76de897..330f551 100644 --- a/test/checks/test-keyboard-shortcuts.ts +++ b/test/checks/test-keyboard-shortcuts.ts @@ -93,20 +93,17 @@ try { 'on graph view before layout toggle test', ); - // Read current layout mode from localStorage (may be null = defaults to organic). - const layoutBefore = await page.evaluate(() => localStorage.getItem('ignatius-layout-mode') ?? 'organic'); + const layoutBefore = await page.evaluate(() => localStorage.getItem('ignatius-layout-mode')); + assert(layoutBefore === 'tree', 'a first visit opens in Tree and stores it', String(layoutBefore)); await page.keyboard.press('l'); await page.waitForFunction( - (before: string) => { - const current = localStorage.getItem('ignatius-layout-mode') ?? 'organic'; - return current !== before; - }, + (before: string | null) => localStorage.getItem('ignatius-layout-mode') !== before, layoutBefore, { timeout: 5000 }, ); - const layoutAfter = await page.evaluate(() => localStorage.getItem('ignatius-layout-mode') ?? 'organic'); + const layoutAfter = await page.evaluate(() => localStorage.getItem('ignatius-layout-mode')); const expectedLayout = nextLayoutMode(parseLayoutMode(layoutBefore)); assert( layoutAfter === expectedLayout, diff --git a/test/checks/test-layout-store.ts b/test/checks/test-layout-store.ts index 026c452..f52c26e 100644 --- a/test/checks/test-layout-store.ts +++ b/test/checks/test-layout-store.ts @@ -231,4 +231,17 @@ function makeStorage(): StorageLike { console.log('PASS: hidden round-trips and an empty list is not stored'); } +// 9. hasEntries: whether any arrangement is saved, which keeps a returning user's layout mode + +{ + const storage = makeStorage(); + const store = createLayoutStore(storage); + assert(!store.hasEntries(), 'FAIL: hasEntries — an empty store holds no arrangement'); + storage.setItem('ignatius-layout-positions', 'not json'); + assert(!store.hasEntries(), 'FAIL: hasEntries — an unreadable store holds no arrangement'); + store.save('k', { A: { x: 0, y: 0 } }); + assert(store.hasEntries(), 'FAIL: hasEntries — a saved arrangement is seen'); + console.log('PASS: hasEntries reports whether any arrangement is saved'); +} + console.log('\nAll layout-store tests passed.'); diff --git a/test/checks/test-tree-layout.ts b/test/checks/test-tree-layout.ts index 5e4fad6..631324a 100644 --- a/test/checks/test-tree-layout.ts +++ b/test/checks/test-tree-layout.ts @@ -1,7 +1,7 @@ /** * test-tree-layout.ts — the Tree layout reads like an outline: each table sits - * below and to the right of the table whose key it inherits, and every line - * drops down its own lane before turning right into its child. + * below and to the right of the table whose key it inherits, and a parent's lines + * share one trunk, each branching right into its child. * * Coverage: * 1. A table hangs under its identifying parent; among several, the deepest @@ -13,17 +13,21 @@ * used only by another lookup stays a root. * 4. Children sit right of their parent and below it, smaller subtrees * first; no two boxes overlap, across separate trees too. - * 5. Lanes nest: the lower a child's row, the further left its lane, so no - * horizontal run crosses a sibling's lane; the run is long enough for the label. + * 5. A parent's tree lines share one trunk a quarter in from its left edge; + * each branch is long enough for its label. * 6. A wide key parent's children wrap into columns to its right, each * starting a row below the parent's bottom edge, only where a fresh column - * has room for two rows; lookup lines into wrapped tables are cross links. + * has room for two rows; lookup lines into wrapped tables are cross links; lines + * into a wrapped column share one drop with labels on their runs, owned by + * the parent with the most lines into it; approach columns move past boxes, + * from below as well as above, jogging when no straight drop is clear, + * never across the child's own trunk or down another line's column. * 7. A subtype joiner never leaves its basetype, and goes first when its * siblings wrap. * 8. Every drawn line leaves its parent's bottom edge and enters its child's * left side, right-angled, including a tree line redrawn as a cross link. * 9. Lines into one child never cross on its left side; cross links from one - * parent each get their own lane and street. + * parent outside a shared drop each get their own lane and street. * 10. After a hide, lines re-route on the boxes as they stand, keep their * kind, and still do not cross. */ @@ -109,9 +113,9 @@ function overlapping(nodes: TreeNodeInput[], positions: Record<string, { x: numb const route = routes.get(`${lookup}-${consumer}`); assert(route?.kind === 'down' && route.parent === lookup, `FAIL 3d: ${lookup} → ${consumer} drops from the lookup (${JSON.stringify(route)})`); if (!route) continue; - const laneX = at(lookup).x - 50 + route.laneDx; + const trunkX = at(lookup).x - 50 + route.trunkDx; const consumerLeft = at(consumer).x - 50; - assert(laneX < consumerLeft - 'classifies'.length * 6, `FAIL 3e: ${lookup}'s run into ${consumer} fits its label`); + assert(trunkX < consumerLeft - 'classifies'.length * 6, `FAIL 3e: ${lookup}'s run into ${consumer} fits its label`); assert(at(lookup).y + 15 < at(consumer).y + route.entryDy, `FAIL 3f: ${lookup} sits above the row it enters`); } assert(at('LeaseType').y < at('Party').y && at('UnitKind').y < at('Party').y, 'FAIL 3h: lookups sit above the tree'); @@ -198,11 +202,12 @@ function overlapping(nodes: TreeNodeInput[], positions: Record<string, { x: numb const toBig = routes.get('Root-Big'); assert(toSmall !== undefined && toBig !== undefined, 'FAIL 5a: both root edges are tree-routed'); if (toSmall && toBig) { - assert(toBig.laneDx < toSmall.laneDx, `FAIL 5b: the lower child's lane is further left (${toBig.laneDx} vs ${toSmall.laneDx})`); + assert(toBig.trunkDx === toSmall.trunkDx && toBig.trunkDx === 25, + `FAIL 5b: a parent's tree lines share one trunk a quarter in from its left edge (${toBig.trunkDx}, ${toSmall.trunkDx})`); const labelWidth = 'contains many things'.length * 6; - const run = box('Big').x1 - (box('Root').x1 + toBig.laneDx); + const run = box('Big').x1 - (box('Root').x1 + toBig.trunkDx); assert(run > labelWidth, `FAIL 5c: the run (${run}) fits the label (${labelWidth})`); - assert(toBig.laneDx < 50, 'FAIL 5d: tree lanes use the left half of the parent\'s bottom edge'); + assert(toBig.trunkDx < 50, 'FAIL 5d: the trunk sits in the left half of the parent\'s bottom edge'); } } @@ -279,7 +284,7 @@ function chainTo(levels: number, leaves: number): { nodes: TreeNodeInput[]; edge assert(routes.get('Kind-T00')?.kind === 'down', 'FAIL 6g: a lookup line into a kept table drops from the staircase'); assert(routes.get('Kind-T11')?.kind === 'cross', 'FAIL 6g2: a lookup line into a wrapped table is a cross link'); } -// 6h. A lookup that feeds only wrapped tables has no lanes; its tree still places. +// 6h. A lookup that feeds only wrapped tables has no trunk; its tree still places. { const { nodes, edges } = wideParent([fk('Kind', 'T11', false)]); nodes.push(node('Kind', { classifier: true })); @@ -306,18 +311,18 @@ function chainTo(levels: number, leaves: number): { nodes: TreeNodeInput[]; edge assert(aspect < 3, `FAIL 6i: a deep chain stays under 3:1 (${aspect.toFixed(2)})`); assert(overlapping(nodes, positions).length === 0, 'FAIL 6i2: the chain\'s wrapped columns overlap no box'); } -// 6j. A parent with room for one and a half rows below it keeps its children in +// 6j. A parent with room for one row but not two below it keeps its children in // one column instead of a strip of one-table columns; with room for two, it wraps. { - const { nodes, edges } = chainTo(10, 4); + const { nodes, edges } = chainTo(24, 4); const { routes } = layoutTree(nodes, edges); - const leafLines = Array.from({ length: 4 }, (_, j) => routes.get(`A10-A10_${j}`)?.kind); + const leafLines = Array.from({ length: 4 }, (_, j) => routes.get(`A24-A24_${j}`)?.kind); assert(leafLines.every(kind => kind === 'down'), `FAIL 6j: without room for two rows the children stay under it (${leafLines.join(', ')})`); } { - const { nodes, edges } = chainTo(5, 4); + const { nodes, edges } = chainTo(10, 4); const { routes } = layoutTree(nodes, edges); - const leafLines = Array.from({ length: 4 }, (_, j) => routes.get(`A5-A5_${j}`)?.kind); + const leafLines = Array.from({ length: 4 }, (_, j) => routes.get(`A10-A10_${j}`)?.kind); assert(leafLines.some(kind => kind === 'cross'), `FAIL 6j2: with room for two rows the children wrap (${leafLines.join(', ')})`); } // 6k. A wide parent partway down, with room for a few rows, still wraps. @@ -329,6 +334,327 @@ function chainTo(levels: number, leaves: number): { nodes: TreeNodeInput[]; edge assert(aspectOf(positions) > 0.8, `FAIL 6k2: the picture stays near 16:10 (${aspectOf(positions).toFixed(2)})`); assert(overlapping(nodes, positions).length === 0, 'FAIL 6k3: no boxes overlap'); } +// 6l. A parent's lines into one wrapped column share a lane, a street and a drop, +// and each label sits on its own run into its child, so no two labels stack. +{ + const { nodes, edges } = wideParent(); + const { positions, routes } = layoutTree(nodes, edges); + const wrapped = [...routes.values()].filter(r => r.parent === 'Tenant' && r.kind === 'cross'); + const columns = new Map<number, TreeRoute[]>(); + for (const r of wrapped) { + const left = Math.round((positions[r.child]?.x ?? 0) - 50); + columns.set(left, [...(columns.get(left) ?? []), r]); + } + const shared = [...columns.values()].every(list => new Set(list.map(r => JSON.stringify(r.slot))).size === 1); + assert(wrapped.length > 0 && shared, 'FAIL 6l: lines into one wrapped column share one lane, street and drop'); + assert(wrapped.every(r => r.runLabel === true && -r.slot.approachDx >= 'has'.length * 6 + 28), + 'FAIL 6l2: each wrapped line carries its label on the run into its child'); +} +// 6m. A drop that would pass through a box moves left of it. +{ + const nodes = [node('P'), node('C'), node('B')]; + const positions = { P: { x: 0, y: 0 }, C: { x: 400, y: 300 }, B: { x: 338, y: 150 } }; + const routes = routeTree(nodes, positions, [{ id: 'P-C', parent: 'P', child: 'C', kind: 'cross', label: 'has' }]); + const route = routes.get('P-C'); + const dropX = 350 + (route?.slot.approachDx ?? 0); + assert(route !== undefined && dropX < 286, `FAIL 6m: the drop moves left of the box in its way (x ${dropX})`); +} + +// 6n. With no straight drop clear of the boxes, the line drops through a clear column, +// crosses above the child, and drops into it, so no segment runs behind a box. +{ + const nodes = [node('P'), node('C'), node('B1'), node('B2'), node('B3')]; + const positions: Record<string, { x: number; y: number }> = { + P: { x: 0, y: 0 }, C: { x: 400, y: 300 }, B1: { x: 380, y: 215 }, B2: { x: 285, y: 165 }, B3: { x: 250, y: 300 }, + }; + const routes = routeTree(nodes, positions, [{ id: 'P-C', parent: 'P', child: 'C', kind: 'cross', label: 'has' }]); + const route = routes.get('P-C'); + assert(route?.slot.jog !== undefined, 'FAIL 6n: the route jogs around the boxes'); + if (route) { + const pts = drawn(route, positions); + const behind = ['B1', 'B2', 'B3'].filter(id => through(pts, positions[id] ?? { x: 0, y: 0 })); + assert(behind.length === 0, `FAIL 6n2: no segment runs behind a box (${behind.join(', ')})`); + } +} + +// 6o. Moving an approach column past a box never takes it across the child's own trunk. +{ + const nodes = [node('Q'), node('P'), node('C')]; + const positions = { Q: { x: 0, y: 0 }, P: { x: 300, y: 100 }, C: { x: 390, y: 170 } }; + const routes = routeTree(nodes, positions, [ + { id: 'P-C', parent: 'P', child: 'C', kind: 'down', label: 'has' }, + { id: 'Q-C', parent: 'Q', child: 'C', kind: 'cross', label: 'has' }, + ]); + const crossings = lastTurnCrossings(routes, positions); + assert(crossings.length === 0, `FAIL 6o: no last turns into C cross (${crossings.join('; ')})`); +} + +// The drawn points of a route between 100 × 30 boxes. +function drawn(route: TreeRoute, positions: Record<string, { x: number; y: number }>): { x: number; y: number }[] { + const box = (id: string) => ({ ...(positions[id] ?? { x: 0, y: 0 }), width: 100, height: 30 }); + const g = routeGeometry(route, box(route.parent), box(route.child)); + return [g.parentEnd, ...g.bends, g.childEnd]; +} +// True when a polyline passes through the 100 × 30 box centred at `at`. +function through(pts: { x: number; y: number }[], at: { x: number; y: number }): boolean { + return pts.some((a, i) => { + const b = pts[i + 1]; + return b !== undefined && at.x - 50 < Math.max(a.x, b.x) && Math.min(a.x, b.x) < at.x + 50 + && at.y - 15 < Math.max(a.y, b.y) && Math.min(a.y, b.y) < at.y + 15; + }); +} +// 6p. A line from below clears a box under its child, even beside a line from above into the same child. +{ + const positions = { A: { x: 380, y: 0 }, B: { x: 0, y: 600 }, C: { x: 400, y: 300 }, X: { x: 332, y: 450 } }; + const routes = routeTree(['A', 'B', 'C', 'X'].map(id => node(id)), positions, [ + { id: 'A-C', parent: 'A', child: 'C', kind: 'cross', label: 'has' }, + { id: 'B-C', parent: 'B', child: 'C', kind: 'cross', label: 'has' }, + ]); + const route = routes.get('B-C'); + assert(route !== undefined && !through(drawn(route, positions), positions.X), 'FAIL 6p: the line from below misses the box under its child'); +} +// 6q. A jog never crosses the child's own trunk on its way across. +{ + const positions = { Q: { x: 0, y: 0 }, P: { x: 330, y: 200 }, S: { x: 400, y: 250 }, C: { x: 400, y: 300 } }; + const routes = routeTree(['Q', 'P', 'S', 'C'].map(id => node(id)), positions, [ + { id: 'P-S', parent: 'P', child: 'S', kind: 'down', label: 'has' }, + { id: 'P-C', parent: 'P', child: 'C', kind: 'down', label: 'has' }, + { id: 'Q-C', parent: 'Q', child: 'C', kind: 'cross', label: 'has' }, + ]); + const qc = routes.get('Q-C'); + const pc = routes.get('P-C'); + const count = qc && pc ? clashes(drawn(qc, positions), drawn(pc, positions)) : -1; + assert(count === 0, `FAIL 6q: the line from Q never crosses P's trunk into C (${count})`); +} +// 6r. A jog never drops down another line's column into the same child, where the two would draw as one. +{ + const positions = { C: { x: 400, y: 300 }, A: { x: 268, y: 195 }, B: { x: -112, y: 185 }, D: { x: 316, y: 163 }, X: { x: 286, y: 222 } }; + const routes = routeTree(['C', 'A', 'B', 'D', 'X'].map(id => node(id)), positions, [ + { id: 'A-C', parent: 'A', child: 'C', kind: 'cross', label: 'has' }, + { id: 'B-C', parent: 'B', child: 'C', kind: 'cross', label: 'has' }, + { id: 'D-C', parent: 'D', child: 'C', kind: 'down', label: 'has' }, + ]); + const columnOf = (r: TreeRoute | undefined) => r?.slot?.jog?.dropDx ?? r?.slot?.approachDx; + const jog = routes.get('A-C')?.slot?.jog; + const other = columnOf(routes.get('B-C')); + assert(other !== undefined && (jog === undefined || Math.abs(jog.dropDx - other) >= 6), + `FAIL 6r: A's jog, if any, drops 6 px or more from B's column (${jog?.dropDx} vs ${other})`); +} +// 6s. A wrapped column fed by two parents gets one shared drop, owned by the parent with the most lines into it. +{ + const { nodes, edges } = wideParent([fk('Kind', 'T11', false)]); + nodes.push(node('Kind', { classifier: true })); + edges.push(fk('Kind', 'T00', false)); + const { routes } = layoutTree(nodes, edges); + const shared = [...routes.values()].filter(r => r.runLabel === true && r.child === 'T11'); + assert(shared.length === 1 && shared[0]?.parent === 'Tenant', `FAIL 6s: one shared drop into T11, Tenant's (${shared.map(r => r.parent).join(', ')})`); +} +// A box spanning x1..x2 and y1..y2, to stand in a line's way. +function blocker(id: string, x1: number, x2: number, y1: number, y2: number): { node: TreeNodeInput; at: { x: number; y: number } } { + return { node: { id, width: x2 - x1, height: y2 - y1, group: 'g', classifier: false }, at: { x: (x1 + x2) / 2, y: (y1 + y2) / 2 } }; +} +// Places where one polyline runs across the other, or along it. +function clashes(a: { x: number; y: number }[], b: { x: number; y: number }[]): number { + let count = 0; + for (let i = 0; i + 1 < a.length; i++) { + for (let j = 0; j + 1 < b.length; j++) { + const [s0, s1, t0, t1] = [a[i], a[i + 1], b[j], b[j + 1]]; + if (!s0 || !s1 || !t0 || !t1) continue; + const sH = s0.y === s1.y; + const tH = t0.y === t1.y; + const span = (p: number, q: number): [number, number] => [Math.min(p, q), Math.max(p, q)]; + if (sH !== tH) { + const [h0, h1, v0, v1] = sH ? [s0, s1, t0, t1] : [t0, t1, s0, s1]; + const [hx1, hx2] = span(h0.x, h1.x); + const [vy1, vy2] = span(v0.y, v1.y); + if (hx1 < v0.x && v0.x < hx2 && vy1 < h0.y && h0.y < vy2) count++; + } else if (sH ? s0.y === t0.y : s0.x === t0.x) { + const [a1, a2] = sH ? span(s0.x, s1.x) : span(s0.y, s1.y); + const [b1, b2] = sH ? span(t0.x, t1.x) : span(t0.y, t1.y); + if (Math.min(a2, b2) - Math.max(a1, b1) > 0) count++; + } + } + } + return count; +} +// 6t. With no clear place for a column's drop, several lines keep the drop and their labels on their runs +// (on one street the labels would print as one), and a single line takes its own slot. +{ + const box = blocker('B', 135, 165, 140, 160); + const positions = { P: { x: 0, y: 0 }, C1: { x: 250, y: 100 }, C2: { x: 250, y: 150 }, C3: { x: 250, y: 200 }, B: box.at }; + const lines = (children: string[]): TreeLine[] => children.map(c => ({ id: `P-${c}`, parent: 'P', child: c, kind: 'cross', label: 'is placed by' })); + const several = routeTree([node('P'), node('C1'), node('C2'), node('C3'), box.node], positions, lines(['C1', 'C2', 'C3'])); + assert([...several.values()].every(r => r.runLabel === true), + `FAIL 6t: three lines keep their labels on their runs (${[...several.values()].map(r => r.runLabel).join(', ')})`); + const one = routeTree([node('P'), node('C2'), box.node], positions, lines(['C2'])).get('P-C2'); + assert(one !== undefined && !one.runLabel && one.slot.approachDx === -12, `FAIL 6t: one line takes its own slot (${JSON.stringify(one?.slot)})`); +} +// 6u. A cross link never crosses the trunk of a tree line into the same child, even with no box in its way. +{ + const positions = { P: { x: 300, y: 100 }, C: { x: 330, y: 250 }, Q: { x: 0, y: 150 } }; + const routes = routeTree([node('P'), node('C'), node('Q')], positions, [ + { id: 'P-C', parent: 'P', child: 'C', kind: 'down', label: 'has' }, + { id: 'Q-C', parent: 'Q', child: 'C', kind: 'cross', label: 'has' }, + ]); + const pc = routes.get('P-C'); + const qc = routes.get('Q-C'); + const count = pc && qc ? clashes(drawn(pc, positions), drawn(qc, positions)) : -1; + assert(count === 0, `FAIL 6u: Q's line into C stays clear of P's trunk into C (${count})`); +} +// Lines into `child` that cross or overlap one another. +function clashesInto(routes: Map<string, TreeRoute>, positions: Record<string, { x: number; y: number }>, child: string): number { + const into = [...routes.values()].filter(r => r.child === child).map(r => drawn(r, positions)); + return into.reduce((n, a, i) => n + into.slice(i + 1).reduce((m, b) => m + clashes(a, b), 0), 0); +} +// 6u2. A second tree line into the child, itself routed as a cross link, counts toward the room the trunk keeps. +{ + const positions = { C: { x: 400, y: 300 }, T: { x: 359, y: 150 }, L: { x: 500, y: 50 }, A: { x: 0, y: 100 } }; + const routes = routeTree(['C', 'T', 'L', 'A'].map(id => node(id)), positions, [ + { id: 'T-C', parent: 'T', child: 'C', kind: 'down', label: 'has' }, + { id: 'L-C', parent: 'L', child: 'C', kind: 'down', label: 'has' }, + { id: 'A-C', parent: 'A', child: 'C', kind: 'cross', label: 'has' }, + ]); + const count = clashesInto(routes, positions, 'C'); + assert(count === 0, `FAIL 6u2: no two lines into C cross (${count})`); +} +// 6u3. A line from below enters under the trunk, so it takes none of the room the trunk keeps. +{ + const positions = { P: { x: 300, y: 100 }, C: { x: 330, y: 250 }, B: { x: 0, y: 500 } }; + const routes = routeTree(['P', 'C', 'B'].map(id => node(id)), positions, [ + { id: 'P-C', parent: 'P', child: 'C', kind: 'down', label: 'has' }, + { id: 'B-C', parent: 'B', child: 'C', kind: 'cross', label: 'has' }, + ]); + assert(routes.get('P-C')?.kind === 'down', `FAIL 6u3: P's tree line stays a tree line (${routes.get('P-C')?.kind})`); +} +// 6v. Lines from above that both need a jog into one child neither cross nor overlap, +// and a jog's run across above the child never crosses another line's column. +{ + const scenes = [ + { boxes: [blocker('X', -100, 345, 200, 230)], name: 'one wide box' }, + { boxes: [blocker('R', 337, 500, 30, 60), blocker('W1', -100, 316, 40, 90), blocker('W2', 324, 335, 40, 90), blocker('Z', 310, 316, 150, 180)], name: 'a box maze' }, + ]; + for (const scene of scenes) { + const positions: Record<string, { x: number; y: number }> = { A: { x: 0, y: 0 }, B: { x: 0, y: 100 }, C: { x: 400, y: 300 } }; + for (const b of scene.boxes) positions[b.node.id] = b.at; + const routes = routeTree([node('A'), node('B'), node('C'), ...scene.boxes.map(b => b.node)], positions, [ + { id: 'A-C', parent: 'A', child: 'C', kind: 'cross', label: 'has' }, + { id: 'B-C', parent: 'B', child: 'C', kind: 'cross', label: 'has' }, + ]); + const ac = routes.get('A-C'); + const bc = routes.get('B-C'); + const count = ac && bc ? clashes(drawn(ac, positions), drawn(bc, positions)) : -1; + assert(count === 0, `FAIL 6v: with ${scene.name}, the two lines into C neither cross nor overlap (${count})`); + } +} +// 6v2. A jog's drop stays beside its child: the only clear column, 550 px past it, is not taken. +{ + const wall = blocker('X', 200, 1000, 150, 250); + const positions: Record<string, { x: number; y: number }> = { P: { x: 0, y: 0 }, T: { x: 325, y: 100 }, C: { x: 400, y: 300 }, X: wall.at }; + const routes = routeTree([node('P'), node('T'), node('C'), wall.node], positions, [ + { id: 'T-C', parent: 'T', child: 'C', kind: 'down', label: 'has' }, + { id: 'P-C', parent: 'P', child: 'C', kind: 'cross', label: 'has' }, + ]); + const jog = routes.get('P-C')?.slot.jog; + assert(jog === undefined || jog.dropDx <= 112, `FAIL 6v2: a jog drops no further than 12 px past its child (${jog?.dropDx})`); +} +// 6v2b. A line from a parent far to the right may drop anywhere short of its own lane: its path only runs left. +{ + const boxes = [blocker('X', 300, 470, 150, 250), blocker('W', -300, 299, 150, 250)]; + const positions: Record<string, { x: number; y: number }> = { P: { x: 900, y: 0 }, C: { x: 400, y: 300 } }; + for (const b of boxes) positions[b.node.id] = b.at; + const routes = routeTree([node('P'), node('C'), ...boxes.map(b => b.node)], positions, + [{ id: 'P-C', parent: 'P', child: 'C', kind: 'cross', label: 'has' }]); + const jog = routes.get('P-C')?.slot.jog; + assert(jog !== undefined && jog.dropDx > 112, `FAIL 6v2b: the line jogs right of the box, past its child (${jog?.dropDx})`); +} +// 6v3. Jogs into different children never drop down one column, where they would draw as one line. +{ + const wall = { node: { id: 'X', width: 387, height: 63, group: 'g', classifier: false }, at: { x: 257, y: 199 } }; + const positions: Record<string, { x: number; y: number }> = { + C1: { x: 400, y: 284 }, C2: { x: 490, y: 447 }, P1: { x: 266, y: 76 }, P2: { x: 22, y: 113 }, X: wall.at, + }; + const routes = routeTree([node('C1'), node('C2'), node('P1'), node('P2'), wall.node], positions, [ + { id: 'P1-C1', parent: 'P1', child: 'C1', kind: 'cross', label: 'has' }, + { id: 'P2-C2', parent: 'P2', child: 'C2', kind: 'cross', label: 'has' }, + ]); + const a = routes.get('P1-C1')?.slot.jog; + const b = routes.get('P2-C2')?.slot.jog; + const apart = a === undefined || b === undefined || Math.abs(350 + a.dropDx - (440 + b.dropDx)) >= 6; + assert(apart, `FAIL 6v3: two jogs drop at least 6 px apart (${a?.dropDx}, ${b?.dropDx})`); +} +// 6w. Each blocked line from above jogs when it has a clear column, the outer ones to either side. +{ + const boxes = [blocker('K', 328, 342, 150, 250), blocker('W', -100, 327, 150, 250)]; + const positions: Record<string, { x: number; y: number }> = { A: { x: 0, y: 0 }, B: { x: 0, y: 100 }, C: { x: 400, y: 300 } }; + for (const b of boxes) positions[b.node.id] = b.at; + const routes = routeTree([node('A'), node('B'), node('C'), ...boxes.map(b => b.node)], positions, [ + { id: 'A-C', parent: 'A', child: 'C', kind: 'cross', label: 'has' }, + { id: 'B-C', parent: 'B', child: 'C', kind: 'cross', label: 'has' }, + ]); + const ac = routes.get('A-C'); + const bc = routes.get('B-C'); + const count = ac && bc ? clashes(drawn(ac, positions), drawn(bc, positions)) : -1; + assert(ac?.slot.jog !== undefined && bc?.slot.jog !== undefined && count === 0, + `FAIL 6w: both lines jog and neither crosses the other (${ac?.slot.jog?.dropDx}, ${bc?.slot.jog?.dropDx}, ${count})`); +} +// 6x. A column's drop on a box moves left of it and keeps the labels on the runs. +{ + const box = blocker('B', 85, 115, 50, 70); + const positions = { P: { x: 0, y: 0 }, C1: { x: 250, y: 100 }, C2: { x: 250, y: 150 }, C3: { x: 250, y: 200 }, B: box.at }; + const routes = routeTree([node('P'), node('C1'), node('C2'), node('C3'), box.node], positions, + ['C1', 'C2', 'C3'].map(c => ({ id: `P-${c}`, parent: 'P', child: c, kind: 'cross', label: 'is placed by' }))); + const drops = [...routes.values()].map(r => 200 + r.slot.approachDx); + assert([...routes.values()].every(r => r.runLabel === true) && drops.every(x => x < 83), + `FAIL 6x: the drop moves left of the box at 85 (${drops.join(', ')})`); +} +// 6x2. A drop sits left of the other parents' columns into its children and a full label run from them; +// with no room for that right of the parent, its lines take their own slots. +{ + const positions = { C: { x: 400, y: 300 }, C2: { x: 400, y: 400 }, O: { x: 290, y: 0 }, A1: { x: -300, y: 50 }, A2: { x: -300, y: 100 } }; + const lines = (o: string): TreeLine[] => [ + { id: `${o}-C`, parent: o, child: 'C', kind: 'cross', label: 'has' }, + { id: `${o}-C2`, parent: o, child: 'C2', kind: 'cross', label: 'has' }, + { id: 'A1-C', parent: 'A1', child: 'C', kind: 'cross', label: 'has' }, + { id: 'A2-C', parent: 'A2', child: 'C', kind: 'cross', label: 'has' }, + ]; + const near = routeTree(Object.keys(positions).map(id => node(id)), positions, lines('O')); + const count = clashesInto(near, positions, 'C'); + assert(count === 0 && !near.get('O-C')?.runLabel, `FAIL 6x2: a parent too close to the column gives up its drop (${count})`); + // Seven other lines into C need 12 + 7 × 6 = 54 px of columns, more than the 46 px label run. + const more = ['A3', 'A4', 'A5', 'A6', 'A7']; + const far: Record<string, { x: number; y: number }> = { ...positions, F: { x: 0, y: 0 } }; + more.forEach((id, i) => { far[id] = { x: -450 - 150 * Math.floor(i / 3), y: 50 + 50 * (i % 3) }; }); + const routes = routeTree(Object.keys(far).map(id => node(id)), far, + [...lines('F'), ...more.map((id): TreeLine => ({ id: `${id}-C`, parent: id, child: 'C', kind: 'cross', label: 'has' }))]); + const drop = routes.get('F-C'); + const run = drop ? -drop.slot.approachDx : 0; + const others = ['A1', 'A2', ...more].map(id => -(routes.get(`${id}-C`)?.slot.approachDx ?? 0)); + assert(drop?.runLabel === true && run >= 46 && others.every(o => run > o), + `FAIL 6x2: a far parent's drop sits a label run left of the column, left of the other columns (${run} vs ${others.join(', ')})`); +} +// 6y. A drop of several lines stays shared when another street crosses it: on their own streets, +// at most 2 px apart, their labels would print as one. +{ + const positions = { O: { x: 0, y: 0 }, M1: { x: 400, y: 200 }, M2: { x: 400, y: 250 }, R: { x: -100, y: 150 } }; + const routes = routeTree(['O', 'M1', 'M2', 'R'].map(id => node(id)), positions, [ + { id: 'O-M1', parent: 'O', child: 'M1', kind: 'cross', label: 'has' }, + { id: 'O-M2', parent: 'O', child: 'M2', kind: 'cross', label: 'has' }, + { id: 'R-M2', parent: 'R', child: 'M2', kind: 'cross', label: 'has' }, + ]); + assert(routes.get('O-M1')?.runLabel === true && routes.get('O-M2')?.runLabel === true, 'FAIL 6y: O\'s two lines keep their shared drop'); +} +// 6z. A one-line drop gives way when another line's street into its child would cross it. +{ + const positions = { O: { x: -100, y: 0 }, M: { x: 400, y: 250 }, R: { x: 0, y: 150 } }; + const routes = routeTree(['O', 'M', 'R'].map(id => node(id)), positions, [ + { id: 'O-M', parent: 'O', child: 'M', kind: 'cross', label: 'has' }, + { id: 'R-M', parent: 'R', child: 'M', kind: 'cross', label: 'has' }, + ]); + const om = routes.get('O-M'); + const rm = routes.get('R-M'); + const count = om && rm ? clashes(drawn(om, positions), drawn(rm, positions)) : -1; + assert(count === 0, `FAIL 6z: R's line into M crosses none of O's (${count})`); +} // 7. Wrapping never moves a subtype joiner off its basetype: it stays under it // and only members that do not fit wrap. @@ -402,11 +728,11 @@ function wideBasetype(kinds: string[]): { nodes: TreeNodeInput[]; edges: TreeEdg } // 7i2. The same under a basetype too deep to wrap, whose subtree runs past the column. { - const { nodes, edges } = chainTo(10, 0); - nodes.push(node('a'), { ...node('_joiner_A10_x', { width: 20 }), joiner: true }, node('m0'), node('m1')); - edges.push(fk('A10', 'a'), fk('A10', '_joiner_A10_x'), fk('_joiner_A10_x', 'm0'), fk('_joiner_A10_x', 'm1')); + const { nodes, edges } = chainTo(32, 0); + nodes.push(node('a'), { ...node('_joiner_A32_x', { width: 20 }), joiner: true }, node('m0'), node('m1')); + edges.push(fk('A32', 'a'), fk('A32', '_joiner_A32_x'), fk('_joiner_A32_x', 'm0'), fk('_joiner_A32_x', 'm1')); const { positions } = layoutTree(nodes, edges); - assert((positions['a']?.y ?? Infinity) < (positions['_joiner_A10_x']?.y ?? -Infinity), 'FAIL 7i2: without room to wrap, the joiner keeps its place'); + assert((positions['a']?.y ?? Infinity) < (positions['_joiner_A32_x']?.y ?? -Infinity), 'FAIL 7i2: without room to wrap, the joiner keeps its place'); } // 7j. A basetype with two clusters keeps both joiners under it, even when the // second is reached after the column fills. @@ -433,12 +759,12 @@ function wideBasetype(kinds: string[]): { nodes: TreeNodeInput[]; edges: TreeEdg assert(square([g.parentEnd, ...g.bends, g.childEnd]), `FAIL 8 ${name}: right-angled (${JSON.stringify(g)})`); }; const slot = { laneDx: -10, streetDy: 10, approachDx: -12 }; - const down: TreeRoute = { kind: 'down', parent: 'P', child: 'C', laneDx: 10, slot, entryDy: 0 }; + const down: TreeRoute = { kind: 'down', parent: 'P', child: 'C', trunkDx: 10, slot, entryDy: 0 }; check('below-right', down, { x: 250, y: 200, width: 100, height: 30 }, 'down'); - // After a hide or reveal the child can sit level with or above the parent, or under its lane. + // After a hide or reveal the child can sit level with or above the parent, or under its trunk. check('above', down, { x: 250, y: 40, width: 100, height: 30 }, 'cross'); - check('under the lane', down, { x: 90, y: 200, width: 100, height: 30 }, 'cross'); - const cross: TreeRoute = { kind: 'cross', parent: 'P', child: 'C', laneDx: 0, slot, entryDy: 3 }; + check('under the trunk', down, { x: 90, y: 200, width: 100, height: 30 }, 'cross'); + const cross: TreeRoute = { kind: 'cross', parent: 'P', child: 'C', trunkDx: 0, slot, entryDy: 3 }; check('cross left', cross, { x: -200, y: 300, width: 100, height: 30 }, 'cross'); } @@ -452,7 +778,7 @@ function lastTurnCrossings(routes: Map<string, TreeRoute>, positions: Record<str for (const [child, list] of byChild) { const turns = list.map((r) => { const parent = at(r.parent); - const vx = r.kind === 'down' ? parent.x - 50 + r.laneDx : at(child).x - 50 + r.slot.approachDx; + const vx = r.kind === 'down' ? parent.x - 50 + r.trunkDx : at(child).x - 50 + r.slot.approachDx; const fromY = r.kind === 'down' ? parent.y + 15 : parent.y + 15 + r.slot.streetDy; return { id: r.parent, vx, fromY, entry: at(child).y + r.entryDy }; }); diff --git a/test/checks/test-tree-predicate-hover.ts b/test/checks/test-tree-predicate-hover.ts new file mode 100644 index 0000000..434a3e0 --- /dev/null +++ b/test/checks/test-tree-predicate-hover.ts @@ -0,0 +1,101 @@ +// Runs are sized before any hover, yet hovering a table swaps each line into it to the reverse predicate on +// the same run; on key-inherited, where many reverse predicates are longer, every one must still fit. + +import { chromium } from 'playwright'; +import { resolve, join } from 'path'; +import { existsSync } from 'fs'; +import { serveCommand } from '../../src/server/server'; +import { HOVER_INTENT_MS } from '../../src/app/logic/motion'; + +const ROOT = resolve(import.meta.dir, '../..'); +const MODEL = join(ROOT, 'models/key-inherited'); + +if (!existsSync(join(ROOT, 'dist/static/index.js'))) { + console.log('SKIP: dist/static/index.js not built (run `bun run build:bundle`). CI builds it before checks.'); + process.exit(0); +} + +const WATCHDOG_MS = 55_000; +const watchdog = setTimeout(() => { + console.error(`FAIL: test-tree-predicate-hover.ts exceeded its ${WATCHDOG_MS}ms watchdog`); + process.exit(1); +}, WATCHDOG_MS); + +const PORT = 3334; +const handle = serveCommand(MODEL, { port: PORT }); +await new Promise<void>(r => setTimeout(r, 400)); + +const browser = await chromium.launch(); +const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); +page.setDefaultTimeout(30_000); + +type RunLabel = { id: string; label: string; spill: number }; + +let failures = 0; +try { + await page.goto(`http://localhost:${PORT}/#view=graph`, { waitUntil: 'load' }); + await page.waitForSelector('.graph-panel canvas', { timeout: 20_000 }); + await page.waitForTimeout(1500); + + const mode = await page.evaluate(() => localStorage.getItem('ignatius-layout-mode')); + if (mode !== 'tree') throw new Error(`a first visit should open in Tree, got ${mode}`); + + // Entities with a labelled line coming in: hovering one of them shows its reverse predicates. + const children = await page.evaluate(() => { + const cy = window.__IGNATIUS_CY__; + if (!cy) return []; + return [...new Set(cy.edges('.tree-edge').filter(e => e.data('predicateRev') !== undefined).map(e => e.target().id()))]; + }); + if (children.length === 0) throw new Error('no labelled tree lines to hover'); + + let checked = 0; + for (const id of children) { + await page.evaluate((nid: string) => { window.__IGNATIUS_CY__?.$id(nid).emit('mouseover'); }, id); + await page.waitForTimeout(HOVER_INTENT_MS + 60); + // How far each reverse label, background pad included, runs past either end of its run. + const labels: RunLabel[] = await page.evaluate((nid: string) => { + const cy = window.__IGNATIUS_CY__; + if (!cy) return []; + const out: RunLabel[] = []; + cy.$id(nid).connectedEdges('.tree-edge').forEach((e) => { + if (e.data('predicateMode') !== 'rev') return; + const atSource = e.hasClass('tree-label-source'); + const bends = e.segmentPoints() ?? []; + const corner = atSource ? bends[0] : bends[bends.length - 1]; + const end = atSource ? e.sourceEndpoint() : e.targetEndpoint(); + if (corner === undefined) return; + const box = e.boundingBox({ + includeNodes: false, includeEdges: false, includeLabels: true, includeMainLabels: false, + includeSourceLabels: atSource, includeTargetLabels: !atSource, includeOverlays: false, + }); + const spill = Math.max(Math.min(corner.x, end.x) - box.x1, box.x2 - Math.max(corner.x, end.x)); + out.push({ id: e.id(), label: String(e.data('edgeLabel')), spill: Math.round(spill) }); + }); + return out; + }, id); + for (const l of labels) { + checked++; + if (l.spill > 0) { + console.error(` FAIL ${l.id}: "${l.label}" runs ${l.spill}px past its run`); + failures++; + } + } + await page.evaluate((nid: string) => { window.__IGNATIUS_CY__?.$id(nid).emit('mouseout'); }, id); + await page.waitForTimeout(HOVER_INTENT_MS + 60); + } + if (checked === 0) throw new Error('no reverse label landed on a run'); + console.log(` checked ${checked} reverse labels on runs`); +} catch (err) { + console.error('FAIL:', err instanceof Error ? err.message : String(err)); + failures++; +} finally { + clearTimeout(watchdog); + await browser.close(); + handle.stop(); +} + +if (failures > 0) { + console.error(`\nFAIL: test-tree-predicate-hover (${failures})`); + process.exit(1); +} +console.log('PASS: test-tree-predicate-hover'); diff --git a/test/visual/screenshot-lineage-highlight.ts b/test/visual/screenshot-lineage-highlight.ts index 4ca568f..2a43e53 100644 --- a/test/visual/screenshot-lineage-highlight.ts +++ b/test/visual/screenshot-lineage-highlight.ts @@ -48,7 +48,7 @@ try { await page.waitForSelector('.graph-panel canvas', { timeout: 20_000 }); await page.waitForTimeout(2500); - await page.evaluate(() => window.__IGNATIUS_CY__?.fit(undefined, 50)); + await page.evaluate(() => { window.__IGNATIUS_CY__?.fit(undefined, 50); }); await page.waitForTimeout(300); await page.screenshot({ path: join(TMP, 'lineage-before.png') }); note('Saved tmp/lineage-before.png');