Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions NOTICE
Original file line number Diff line number Diff line change
@@ -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.
75 changes: 75 additions & 0 deletions docs/design/graph-arrangement.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions docs/design/graph-position-persistence.md
Original file line number Diff line number Diff line change
@@ -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


Expand Down
115 changes: 115 additions & 0 deletions docs/design/graph-tree-layout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# 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; 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.


## 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 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.
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 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
```


## 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, 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 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 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, 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.
Loading
Loading