diff --git a/.changeset/grid-layout.md b/.changeset/grid-layout.md new file mode 100644 index 00000000000..ecbd31d1188 --- /dev/null +++ b/.changeset/grid-layout.md @@ -0,0 +1,5 @@ +--- +'mermaid': minor +--- + +feat: add a grid layout with configurable row and column placement, recursive subgraphs, alignment, and orthogonal edge routing diff --git a/.esbuild/dev-explorer/diagram-viewer.ts b/.esbuild/dev-explorer/diagram-viewer.ts index a49fe34bc34..cb55038f1a9 100644 --- a/.esbuild/dev-explorer/diagram-viewer.ts +++ b/.esbuild/dev-explorer/diagram-viewer.ts @@ -121,11 +121,11 @@ type MermaidTheme = | 'redux-dark' | 'redux-color' | 'redux-dark-color'; -type MermaidLayout = 'dagre' | 'elk' | 'domus' | 'hola' | 'swimlane'; +type MermaidLayout = 'dagre' | 'elk' | 'domus' | 'hola' | 'swimlane' | 'grid'; type MermaidLook = 'classic' | 'handDrawn' | 'neo'; type MermaidLogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal'; -const ALL_LAYOUTS: MermaidLayout[] = ['dagre', 'elk', 'domus', 'hola', 'swimlane']; +const ALL_LAYOUTS: MermaidLayout[] = ['dagre', 'elk', 'domus', 'hola', 'swimlane', 'grid']; // Phases emitted by the profiler tree, in display order. "total" is taken from // the root `render` span. See packages/mermaid/src/profiler.ts. @@ -324,7 +324,14 @@ function isTheme(v: unknown): v is MermaidTheme { } function isLayout(v: unknown): v is MermaidLayout { - return v === 'dagre' || v === 'elk' || v === 'domus' || v === 'hola' || v === 'swimlane'; + return ( + v === 'dagre' || + v === 'elk' || + v === 'domus' || + v === 'hola' || + v === 'swimlane' || + v === 'grid' + ); } function isLook(v: unknown): v is MermaidLook { @@ -341,7 +348,16 @@ function normalizeLayout(v: unknown): MermaidLayout | null { // Back-compat: // - older UI used `renderer=dagre-d3|dagre-wrapper|elk` // - new UI uses `layout=dagre|elk|domus` - if (v === 'dagre' || v === 'elk' || v === 'domus' || v === 'hola' || v === 'swimlane') return v; + if ( + v === 'dagre' || + v === 'elk' || + v === 'domus' || + v === 'hola' || + v === 'swimlane' || + v === 'grid' + ) { + return v; + } if (v === 'dagre-d3' || v === 'dagre-wrapper') return 'dagre'; return null; } diff --git a/demos/flowchart-grid.html b/demos/flowchart-grid.html new file mode 100644 index 00000000000..dab2143dc5b --- /dev/null +++ b/demos/flowchart-grid.html @@ -0,0 +1,317 @@ + + + + + + Mermaid Flowchart GRID Test Page + + + + +

Flowchart GRID

+

+ These examples exercise grid placement and the visible edge-routing behavior. Resize the + browser or edit the diagrams to inspect different route choices. +

+ +
+
+

Explicit and automatic placement

+

+ Explicit cells are reserved first. Row-only, column-only, and unplaced nodes fill the + remaining cells deterministically. +

+
+---
+config:
+  layout: grid
+  grid:
+    columns: 3
+    rowGap: 45
+    columnGap: 55
+---
+flowchart TB
+  A["Explicit (1, 1)"]@{ row: 1, column: 1 }
+  B["Row 1"]@{ row: 1 }
+  C["Column 1"]@{ column: 1 }
+  D["Automatic"]
+  E["Explicit (3, 3)"]@{ row: 3, column: 3 }
+
+  A --> B
+  A --> C
+  B --> D
+  C --> D
+  D --> E
+        
+
+ +
+

Shared cells and alignment

+

+ Nodes assigned to the same explicit cell form a vertical stack. Horizontal alignment + applies to each item; vertical alignment applies to the whole stack. +

+
+---
+config:
+  layout: grid
+  grid:
+    cellGap: 18
+    rowGap: 55
+    columnGap: 80
+---
+flowchart TB
+  A["Wide top item"]@{ row: 1, column: 1, horizontalAlign: left, verticalAlign: center }
+  B["Middle"]@{ row: 1, column: 1, horizontalAlign: center, verticalAlign: center }
+  C["Right"]@{ row: 1, column: 1, horizontalAlign: right, verticalAlign: center }
+  D["Peer cell"]@{ row: 1, column: 2 }
+  E["Lower row"]@{ row: 2, column: 1 }
+
+  A --> B
+  B --> C
+  A --> D
+  C --> E
+  D --> E
+        
+
+ +
+

Nested groups and hierarchy routing

+

+ Child coordinates are local to their immediate group. Boundary-crossing edges avoid group + titles and use explicit entry and exit points. +

+
+---
+config:
+  layout: grid
+  grid:
+    placements:
+      Input: { row: 1, column: 1 }
+      Work: { row: 1, column: 2 }
+      Output: { row: 1, column: 3 }
+      A: { row: 1, column: 1 }
+      B: { row: 2, column: 1 }
+      C: { row: 1, column: 1 }
+      Nested: { row: 2, column: 1 }
+      D: { row: 1, column: 1 }
+      E: { row: 2, column: 1 }
+      F: { row: 1, column: 1 }
+    rowGap: 45
+    columnGap: 70
+---
+flowchart LR
+  subgraph Input["Input group"]
+    A["Receive"]
+    B["Validate"]
+  end
+
+  subgraph Work["Processing group"]
+    C["Dispatch"]
+    subgraph Nested["Nested group"]
+      D["Transform"]
+      E["Enrich"]
+    end
+  end
+
+  subgraph Output["Output group"]
+    F["Publish"]
+  end
+
+  A --> C
+  B --> D
+  C --> D
+  C --> E
+  D --> F
+  E --> F
+        
+
+ +
+

Parallel, reverse, and self-loop edges

+

+ Related edges use deterministic ports and lanes. Self-loops use separate ports on the same + node. +

+
+---
+config:
+  layout: grid
+  grid:
+    rowGap: 85
+    columnGap: 130
+---
+flowchart LR
+  A["Producer"]@{ row: 1, column: 1 }
+  B["Broker"]@{ row: 1, column: 2 }
+  C["Consumer"]@{ row: 1, column: 3 }
+  D["Retry queue"]@{ row: 2, column: 2 }
+
+  A -->|normal| B
+  A -->|priority| B
+  B -->|deliver| C
+  C -->|reject| B
+  B -->|retry| D
+  D -->|ready| B
+  D -->|wait| D
+        
+
+ +
+

Label-aware routing

+

+ Labels are placed after the first routing pass. Affected edges are rerouted around the + frozen label positions where space permits. +

+
+---
+config:
+  layout: grid
+  grid:
+    rowGap: 70
+    columnGap: 90
+---
+flowchart TB
+  A["Request"]@{ row: 1, column: 1 }
+  B{"Authorized?"}@{ row: 2, column: 2 }
+  C["Process"]@{ row: 3, column: 1 }
+  D["Reject"]@{ row: 3, column: 3 }
+  E["Audit"]@{ row: 4, column: 2 }
+
+  A -->|validate credentials| B
+  B -->|yes: continue processing| C
+  B -->|no: return an error| D
+  C -->|record successful request| E
+  D -->|record rejected request| E
+  A -->|capture request metadata| E
+        
+
+ +
+

Rounded orthogonal routes

+

+ The default rounded curve preserves the calculated orthogonal corridor while rounding its + corners. +

+
+---
+config:
+  layout: grid
+  grid:
+    curve: rounded
+    edgeCornerRadius: 12
+    rowGap: 65
+    columnGap: 85
+---
+flowchart TB
+  TL["Top left"]@{ row: 1, column: 1 }
+  TR["Top right"]@{ row: 1, column: 3 }
+  C["Center obstacle"]@{ row: 2, column: 2 }
+  BL["Bottom left"]@{ row: 3, column: 1 }
+  BR["Bottom right"]@{ row: 3, column: 3 }
+
+  TL -->|TL-BR| BR
+  TR -->|TR-BL| BL
+  TL -->|TL-C| C
+  C -->|C-BR| BR
+        
+
+ +
+

Linear orthogonal routes

+

+ The linear curve draws the same calculated route with square corners, which makes the + individual routing segments easy to inspect. +

+
+---
+config:
+  layout: grid
+  grid:
+    curve: linear
+    rowGap: 65
+    columnGap: 85
+---
+flowchart TB
+  TL["Top left"]@{ row: 1, column: 1 }
+  TR["Top right"]@{ row: 1, column: 3 }
+  C["Center obstacle"]@{ row: 2, column: 2 }
+  BL["Bottom left"]@{ row: 3, column: 1 }
+  BR["Bottom right"]@{ row: 3, column: 3 }
+
+  TL -->|TL-BR| BR
+  TR -->|TR-BL| BL
+  TL -->|TL-C| C
+  C -->|C-BR| BR
+        
+
+ +
+

Interpolated routes

+

+ Interpolating curves use the calculated route points but can move outside the orthogonal + corridor. Compare this example with the rounded and linear versions. +

+
+---
+config:
+  layout: grid
+  grid:
+    curve: basis
+    rowGap: 65
+    columnGap: 85
+---
+flowchart TB
+  TL["Top left"]@{ row: 1, column: 1 }
+  TR["Top right"]@{ row: 1, column: 3 }
+  C["Center obstacle"]@{ row: 2, column: 2 }
+  BL["Bottom left"]@{ row: 3, column: 1 }
+  BR["Bottom right"]@{ row: 3, column: 3 }
+
+  TL -->|TL-BR| BR
+  TR -->|TR-BL| BL
+  TL -->|TL-C| C
+  C -->|C-BR| BR
+        
+
+
+ + + + diff --git a/demos/index.html b/demos/index.html index 9b5af00455d..c5e606b630d 100644 --- a/demos/index.html +++ b/demos/index.html @@ -43,6 +43,9 @@

Flow charts

  • Flow charts ELK

  • +
  • +

    Flow charts GRID

    +
  • Flowchart - expanded node shapes diff --git a/docs/community/layout-makers-guide.md b/docs/community/layout-makers-guide.md index 3eee48e0f48..7ea35087703 100644 --- a/docs/community/layout-makers-guide.md +++ b/docs/community/layout-makers-guide.md @@ -103,18 +103,18 @@ So write the core as one exported function, and have both the browser and your t ## A minimal layout -The examples from here on build a layout called `grid`. No such layout ships with Mermaid. It stands in for whatever you are writing, and the code below is what you would write to create it. +The examples from here on build a layout called `simple-grid`. Mermaid ships a production `grid` layout under `rendering-util/layout-algorithms/grid/`; the snippets here stay intentionally minimal and pedagogical so you can focus on the layout-maker contract rather than production complexity. Put the algorithm in `packages/mermaid/src/rendering-util/layout-algorithms//`. This one arranges leaf nodes in a grid and connects them with straight lines: ```ts -// layout-algorithms/grid/layoutCore.ts +// layout-algorithms/simple-grid/layoutCore.ts import type { LayoutData } from '../../types.js'; const GAP = 60; /** DOM-free: positions come from measured sizes only. */ -export function runGridLayoutCore(data4Layout: LayoutData): void { +export function runSimpleGridLayoutCore(data4Layout: LayoutData): void { const leaves = data4Layout.nodes.filter((node) => !node.isGroup); const columns = Math.ceil(Math.sqrt(leaves.length)); const cell = Math.max(...leaves.map((n) => Math.max(n.width ?? 0, n.height ?? 0))) + GAP; @@ -140,11 +140,11 @@ export function runGridLayoutCore(data4Layout: LayoutData): void { ``` ```ts -// layout-algorithms/grid/index.ts +// layout-algorithms/simple-grid/index.ts import { createCommonLayoutRenderer } from '../common/index.js'; -import { runGridLayoutCore } from './layoutCore.js'; +import { runSimpleGridLayoutCore } from './layoutCore.js'; -export const render = createCommonLayoutRenderer({ runLayoutCore: runGridLayoutCore }); +export const render = createCommonLayoutRenderer({ runLayoutCore: runSimpleGridLayoutCore }); ``` That renders. It is also wrong in most of the ways a layout can be wrong. @@ -158,7 +158,10 @@ registerLayoutLoaders([ { name: 'dagre', loader: async () => await import('./layout-algorithms/dagre/index.js') }, { name: 'swimlane', loader: async () => await import('./layout-algorithms/swimlanes/index.js') }, // your new layout - { name: 'grid', loader: async () => await import('./layout-algorithms/grid/index.js') }, + { + name: 'simple-grid', + loader: async () => await import('./layout-algorithms/simple-grid/index.js'), + }, ]); ``` @@ -167,7 +170,7 @@ The loader is lazy, so the code only downloads when a diagram asks for it. `cose ```text --- config: - layout: grid + layout: simple-grid --- flowchart TB A --> B @@ -472,16 +475,16 @@ Open `/dev/` and you get the explorer: the fixture tree on one side, the diagram For a diagram that is not in the fixture tree, copy the standalone page template instead: ```bash -cp demos/dev/example.html demos/dev/grid.html +cp demos/dev/example.html demos/dev/simple-grid.html ``` -That lands at `/dev/grid.html` on the same server. Put the diagram in the page and name your layout in the frontmatter: +That lands at `/dev/simple-grid.html` on the same server. Put the diagram in the page and name your layout in the frontmatter: ```html
     ---
     config:
    -  layout: grid
    +  layout: simple-grid
     ---
     flowchart TB
       A --> B
    @@ -575,7 +578,9 @@ import type { LayoutLoaderDefinition } from 'mermaid';
     
     const loader = async () => await import('./render.js');
     
    -const layouts: LayoutLoaderDefinition[] = [{ name: 'grid', loader, algorithm: 'grid.compact' }];
    +const layouts: LayoutLoaderDefinition[] = [
    +  { name: 'simple-grid', loader, algorithm: 'simple-grid.compact' },
    +];
     
     export default layouts;
     ```
    diff --git a/docs/config/layouts.md b/docs/config/layouts.md
    index 7c6ea4dfca0..fdfe5c69ee3 100644
    --- a/docs/config/layouts.md
    +++ b/docs/config/layouts.md
    @@ -36,6 +36,7 @@ build can opt back in.
     
     - **elk** (default): [ELK (Eclipse Layout Kernel)](https://www.eclipse.org/elk/). Bundled with Mermaid; no setup required. Specific ELK algorithms can be selected as `elk.stress`, `elk.force`, `elk.mrtree`, `elk.sporeOverlap`, `elk.box`, and `elk.rectpacking`.
     - **dagre**: Dagre layout for layered graphs
    +- **grid**: Built-in deterministic row/column layout for unified renderers. See [Grid Layout](/syntax/grid-layout.html).
     - **cose-bilkent**: Cose Bilkent layout for force-directed graphs
     - **tidy-tree**: Tidy tree layout for hierarchical diagrams, from the [`@mermaid-js/layout-tidy-tree`](https://www.npmjs.com/package/@mermaid-js/layout-tidy-tree) package [Tidy Tree Configuration](/config/tidy-tree)
     
    diff --git a/docs/config/schema-docs/config-defs-grid-layout-config.md b/docs/config/schema-docs/config-defs-grid-layout-config.md
    new file mode 100644
    index 00000000000..5b8b754c547
    --- /dev/null
    +++ b/docs/config/schema-docs/config-defs-grid-layout-config.md
    @@ -0,0 +1,34 @@
    +> **Warning**
    +>
    +> ## THIS IS AN AUTOGENERATED FILE. DO NOT EDIT.
    +>
    +> ## Please edit the corresponding file in [/packages/mermaid/src/docs/config/schema-docs/config-defs-grid-layout-config.md](../../../packages/mermaid/src/docs/config/schema-docs/config-defs-grid-layout-config.md).
    +
    +# Grid Layout Config
    +
    +Configuration for Mermaid's built-in `grid` layout.
    +
    +## Properties
    +
    +| Property           | Type                                                                                                                                             | Default   | Description                                                                  |
    +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | --------- | ---------------------------------------------------------------------------- |
    +| `placements`       | `Record`                                                                                                                  | `{}`      | Placement map keyed by an authored placement id or emitted node id.          |
    +| `columns`          | `integer`                                                                                                                                        | `0`       | Non-negative candidate column count for auto-placement. `0` means automatic. |
    +| `rowGap`           | `number`                                                                                                                                         | `50`      | Pixel gap between occupied rows.                                             |
    +| `columnGap`        | `number`                                                                                                                                         | `50`      | Pixel gap between occupied columns.                                          |
    +| `cellGap`          | `number`                                                                                                                                         | `20`      | Pixel gap between items that explicitly share one cell.                      |
    +| `containerPadding` | `number`                                                                                                                                         | `20`      | Minimum padding inside groups.                                               |
    +| `titleGap`         | `number`                                                                                                                                         | `8`       | Clearance between a measured group title and the child grid.                 |
    +| `horizontalAlign`  | `left \| center \| right`                                                                                                                        | `center`  | Default horizontal item alignment.                                           |
    +| `verticalAlign`    | `top \| center \| bottom`                                                                                                                        | `center`  | Default vertical stack alignment.                                            |
    +| `curve`            | `basis \| bumpX \| bumpY \| cardinal \| catmullRom \| linear \| monotoneX \| monotoneY \| natural \| step \| stepAfter \| stepBefore \| rounded` | `rounded` | Curve style used to render grid edges.                                       |
    +| `edgeCornerRadius` | `number`                                                                                                                                         | `5`       | Pixel radius used for grid edges when `curve` is `rounded`.                  |
    +
    +## `GridPlacement`
    +
    +| Property          | Type                      | Description                                              |
    +| ----------------- | ------------------------- | -------------------------------------------------------- |
    +| `row`             | `integer`                 | Positive row number local to the direct parent group.    |
    +| `column`          | `integer`                 | Positive column number local to the direct parent group. |
    +| `horizontalAlign` | `left \| center \| right` | Item alignment within the resolved cell.                 |
    +| `verticalAlign`   | `top \| center \| bottom` | Stack alignment within the resolved cell.                |
    diff --git a/docs/config/setup/mermaid/interfaces/ExternalDiagramDefinition.md b/docs/config/setup/mermaid/interfaces/ExternalDiagramDefinition.md
    index 3943bbc858a..ad308f288fd 100644
    --- a/docs/config/setup/mermaid/interfaces/ExternalDiagramDefinition.md
    +++ b/docs/config/setup/mermaid/interfaces/ExternalDiagramDefinition.md
    @@ -10,7 +10,7 @@
     
     # Interface: ExternalDiagramDefinition
     
    -Defined in: [packages/mermaid/src/diagram-api/types.ts:141](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/diagram-api/types.ts#L141)
    +Defined in: [packages/mermaid/src/diagram-api/types.ts:142](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/diagram-api/types.ts#L142)
     
     ## Properties
     
    @@ -18,7 +18,7 @@ Defined in: [packages/mermaid/src/diagram-api/types.ts:141](https://github.com/m
     
     > **detector**: `DiagramDetector`
     
    -Defined in: [packages/mermaid/src/diagram-api/types.ts:143](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/diagram-api/types.ts#L143)
    +Defined in: [packages/mermaid/src/diagram-api/types.ts:144](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/diagram-api/types.ts#L144)
     
     ---
     
    @@ -26,7 +26,7 @@ Defined in: [packages/mermaid/src/diagram-api/types.ts:143](https://github.com/m
     
     > **id**: `string`
     
    -Defined in: [packages/mermaid/src/diagram-api/types.ts:142](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/diagram-api/types.ts#L142)
    +Defined in: [packages/mermaid/src/diagram-api/types.ts:143](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/diagram-api/types.ts#L143)
     
     ---
     
    @@ -34,4 +34,4 @@ Defined in: [packages/mermaid/src/diagram-api/types.ts:142](https://github.com/m
     
     > **loader**: `DiagramLoader`
     
    -Defined in: [packages/mermaid/src/diagram-api/types.ts:144](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/diagram-api/types.ts#L144)
    +Defined in: [packages/mermaid/src/diagram-api/types.ts:145](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/diagram-api/types.ts#L145)
    diff --git a/docs/config/setup/mermaid/interfaces/LayoutData.md b/docs/config/setup/mermaid/interfaces/LayoutData.md
    index c15f83278e2..e81c7795d3f 100644
    --- a/docs/config/setup/mermaid/interfaces/LayoutData.md
    +++ b/docs/config/setup/mermaid/interfaces/LayoutData.md
    @@ -10,7 +10,7 @@
     
     # Interface: LayoutData
     
    -Defined in: [packages/mermaid/src/rendering-util/types.ts:236](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/types.ts#L236)
    +Defined in: [packages/mermaid/src/rendering-util/types.ts:247](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/types.ts#L247)
     
     ## Indexable
     
    @@ -22,7 +22,7 @@ Defined in: [packages/mermaid/src/rendering-util/types.ts:236](https://github.co
     
     > **config**: [`MermaidConfig`](MermaidConfig.md)
     
    -Defined in: [packages/mermaid/src/rendering-util/types.ts:239](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/types.ts#L239)
    +Defined in: [packages/mermaid/src/rendering-util/types.ts:250](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/types.ts#L250)
     
     ---
     
    @@ -30,7 +30,7 @@ Defined in: [packages/mermaid/src/rendering-util/types.ts:239](https://github.co
     
     > `optional` **diagramId**: `string`
     
    -Defined in: [packages/mermaid/src/rendering-util/types.ts:240](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/types.ts#L240)
    +Defined in: [packages/mermaid/src/rendering-util/types.ts:251](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/types.ts#L251)
     
     ---
     
    @@ -38,7 +38,7 @@ Defined in: [packages/mermaid/src/rendering-util/types.ts:240](https://github.co
     
     > **edges**: `Edge`\[]
     
    -Defined in: [packages/mermaid/src/rendering-util/types.ts:238](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/types.ts#L238)
    +Defined in: [packages/mermaid/src/rendering-util/types.ts:249](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/types.ts#L249)
     
     ---
     
    @@ -46,4 +46,4 @@ Defined in: [packages/mermaid/src/rendering-util/types.ts:238](https://github.co
     
     > **nodes**: `Node`\[]
     
    -Defined in: [packages/mermaid/src/rendering-util/types.ts:237](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/types.ts#L237)
    +Defined in: [packages/mermaid/src/rendering-util/types.ts:248](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/types.ts#L248)
    diff --git a/docs/config/setup/mermaid/interfaces/MermaidConfig.md b/docs/config/setup/mermaid/interfaces/MermaidConfig.md
    index 62cc9251979..dc5f6cb2e87 100644
    --- a/docs/config/setup/mermaid/interfaces/MermaidConfig.md
    +++ b/docs/config/setup/mermaid/interfaces/MermaidConfig.md
    @@ -10,7 +10,7 @@
     
     # Interface: MermaidConfig
     
    -Defined in: [packages/mermaid/src/config.type.ts:66](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L66)
    +Defined in: [packages/mermaid/src/config.type.ts:80](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L80)
     
     ## Properties
     
    @@ -18,7 +18,7 @@ Defined in: [packages/mermaid/src/config.type.ts:66](https://github.com/mermaid-
     
     > `optional` **agentflow**: `AgentflowDiagramConfig`
     
    -Defined in: [packages/mermaid/src/config.type.ts:335](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L335)
    +Defined in: [packages/mermaid/src/config.type.ts:350](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L350)
     
     ---
     
    @@ -26,7 +26,7 @@ Defined in: [packages/mermaid/src/config.type.ts:335](https://github.com/mermaid
     
     > `optional` **altFontFamily**: `string`
     
    -Defined in: [packages/mermaid/src/config.type.ts:273](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L273)
    +Defined in: [packages/mermaid/src/config.type.ts:288](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L288)
     
     ---
     
    @@ -34,7 +34,7 @@ Defined in: [packages/mermaid/src/config.type.ts:273](https://github.com/mermaid
     
     > `optional` **architecture**: `ArchitectureDiagramConfig`
     
    -Defined in: [packages/mermaid/src/config.type.ts:347](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L347)
    +Defined in: [packages/mermaid/src/config.type.ts:362](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L362)
     
     ---
     
    @@ -42,7 +42,7 @@ Defined in: [packages/mermaid/src/config.type.ts:347](https://github.com/mermaid
     
     > `optional` **arrowMarkerAbsolute**: `boolean`
     
    -Defined in: [packages/mermaid/src/config.type.ts:292](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L292)
    +Defined in: [packages/mermaid/src/config.type.ts:307](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L307)
     
     Controls whether or arrow markers in html code are absolute paths or anchors.
     This matters if you are using base tag settings.
    @@ -53,7 +53,7 @@ This matters if you are using base tag settings.
     
     > `optional` **block**: `BlockDiagramConfig`
     
    -Defined in: [packages/mermaid/src/config.type.ts:355](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L355)
    +Defined in: [packages/mermaid/src/config.type.ts:370](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L370)
     
     ---
     
    @@ -61,7 +61,7 @@ Defined in: [packages/mermaid/src/config.type.ts:355](https://github.com/mermaid
     
     > `optional` **c4**: `C4DiagramConfig`
     
    -Defined in: [packages/mermaid/src/config.type.ts:352](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L352)
    +Defined in: [packages/mermaid/src/config.type.ts:367](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L367)
     
     ---
     
    @@ -69,7 +69,7 @@ Defined in: [packages/mermaid/src/config.type.ts:352](https://github.com/mermaid
     
     > `optional` **class**: `ClassDiagramConfig`
     
    -Defined in: [packages/mermaid/src/config.type.ts:340](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L340)
    +Defined in: [packages/mermaid/src/config.type.ts:355](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L355)
     
     ---
     
    @@ -77,7 +77,7 @@ Defined in: [packages/mermaid/src/config.type.ts:340](https://github.com/mermaid
     
     > `optional` **cynefin**: `CynefinDiagramConfig`
     
    -Defined in: [packages/mermaid/src/config.type.ts:362](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L362)
    +Defined in: [packages/mermaid/src/config.type.ts:377](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L377)
     
     ---
     
    @@ -85,7 +85,7 @@ Defined in: [packages/mermaid/src/config.type.ts:362](https://github.com/mermaid
     
     > `optional` **darkMode**: `boolean`
     
    -Defined in: [packages/mermaid/src/config.type.ts:257](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L257)
    +Defined in: [packages/mermaid/src/config.type.ts:272](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L272)
     
     ---
     
    @@ -93,7 +93,7 @@ Defined in: [packages/mermaid/src/config.type.ts:257](https://github.com/mermaid
     
     > `optional` **deterministicIds**: `boolean`
     
    -Defined in: [packages/mermaid/src/config.type.ts:325](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L325)
    +Defined in: [packages/mermaid/src/config.type.ts:340](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L340)
     
     This option controls if the generated ids of nodes in the SVG are
     generated randomly or based on a seed.
    @@ -109,7 +109,7 @@ should not change unless content is changed.
     
     > `optional` **deterministicIDSeed**: `string`
     
    -Defined in: [packages/mermaid/src/config.type.ts:332](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L332)
    +Defined in: [packages/mermaid/src/config.type.ts:347](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L347)
     
     This option is the optional seed for deterministic ids.
     If set to `undefined` but deterministicIds is `true`, a simple number iterator is used.
    @@ -121,7 +121,7 @@ You can set this attribute to base the seed on a static string.
     
     > `optional` **dompurifyConfig**: `Config`
     
    -Defined in: [packages/mermaid/src/config.type.ts:364](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L364)
    +Defined in: [packages/mermaid/src/config.type.ts:379](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L379)
     
     ---
     
    @@ -129,7 +129,7 @@ Defined in: [packages/mermaid/src/config.type.ts:364](https://github.com/mermaid
     
     > `optional` **elk**: `object`
     
    -Defined in: [packages/mermaid/src/config.type.ts:114](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L114)
    +Defined in: [packages/mermaid/src/config.type.ts:128](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L128)
     
     #### considerModelOrder?
     
    @@ -279,7 +279,7 @@ port, or would introduce a crossing.
     
     > `optional` **er**: `ErDiagramConfig`
     
    -Defined in: [packages/mermaid/src/config.type.ts:342](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L342)
    +Defined in: [packages/mermaid/src/config.type.ts:357](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L357)
     
     ---
     
    @@ -287,7 +287,7 @@ Defined in: [packages/mermaid/src/config.type.ts:342](https://github.com/mermaid
     
     > `optional` **eventmodeling**: `EventModelingDiagramConfig`
     
    -Defined in: [packages/mermaid/src/config.type.ts:356](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L356)
    +Defined in: [packages/mermaid/src/config.type.ts:371](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L371)
     
     ---
     
    @@ -295,7 +295,7 @@ Defined in: [packages/mermaid/src/config.type.ts:356](https://github.com/mermaid
     
     > `optional` **flowchart**: `FlowchartDiagramConfig`
     
    -Defined in: [packages/mermaid/src/config.type.ts:333](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L333)
    +Defined in: [packages/mermaid/src/config.type.ts:348](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L348)
     
     ---
     
    @@ -303,7 +303,7 @@ Defined in: [packages/mermaid/src/config.type.ts:333](https://github.com/mermaid
     
     > `optional` **fontFamily**: `string`
     
    -Defined in: [packages/mermaid/src/config.type.ts:272](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L272)
    +Defined in: [packages/mermaid/src/config.type.ts:287](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L287)
     
     Specifies the font to be used in the rendered diagrams.
     Can be any possible CSS `font-family`.
    @@ -315,7 +315,7 @@ See 
     
     > `optional` **fontSize**: `number`
     
    -Defined in: [packages/mermaid/src/config.type.ts:366](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L366)
    +Defined in: [packages/mermaid/src/config.type.ts:381](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L381)
     
     ---
     
    @@ -323,7 +323,7 @@ Defined in: [packages/mermaid/src/config.type.ts:366](https://github.com/mermaid
     
     > `optional` **forceLegacyMathML**: `boolean`
     
    -Defined in: [packages/mermaid/src/config.type.ts:314](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L314)
    +Defined in: [packages/mermaid/src/config.type.ts:329](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L329)
     
     This option forces Mermaid to rely on KaTeX's own stylesheet for rendering MathML. Due to differences between OS
     fonts and browser's MathML implementation, this option is recommended if consistent rendering is important.
    @@ -335,7 +335,7 @@ If set to true, ignores legacyMathML.
     
     > `optional` **gantt**: `GanttDiagramConfig`
     
    -Defined in: [packages/mermaid/src/config.type.ts:337](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L337)
    +Defined in: [packages/mermaid/src/config.type.ts:352](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L352)
     
     ---
     
    @@ -343,7 +343,15 @@ Defined in: [packages/mermaid/src/config.type.ts:337](https://github.com/mermaid
     
     > `optional` **gitGraph**: `GitGraphDiagramConfig`
     
    -Defined in: [packages/mermaid/src/config.type.ts:351](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L351)
    +Defined in: [packages/mermaid/src/config.type.ts:366](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L366)
    +
    +---
    +
    +### grid?
    +
    +> `optional` **grid**: `GridLayoutConfig`
    +
    +Defined in: [packages/mermaid/src/config.type.ts:271](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L271)
     
     ---
     
    @@ -351,7 +359,7 @@ Defined in: [packages/mermaid/src/config.type.ts:351](https://github.com/mermaid
     
     > `optional` **handDrawnSeed**: `number`
     
    -Defined in: [packages/mermaid/src/config.type.ts:96](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L96)
    +Defined in: [packages/mermaid/src/config.type.ts:110](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L110)
     
     Defines the seed to be used when using handDrawn look. This is important for the automated tests as they will always find differences without the seed. The default value is 0 which gives a random seed.
     
    @@ -361,7 +369,7 @@ Defines the seed to be used when using handDrawn look. This is important for the
     
     > `optional` **htmlLabels**: `boolean`
     
    -Defined in: [packages/mermaid/src/config.type.ts:265](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L265)
    +Defined in: [packages/mermaid/src/config.type.ts:280](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L280)
     
     Flag for setting whether or not a html tag should be used for rendering labels on nodes and edges.
     **Note:** Diagram-specific `htmlLabels` settings (e.g., `flowchart.htmlLabels`) are deprecated.
    @@ -374,7 +382,7 @@ over any diagram-specific settings.
     
     > `optional` **ishikawa**: `IshikawaDiagramConfig`
     
    -Defined in: [packages/mermaid/src/config.type.ts:349](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L349)
    +Defined in: [packages/mermaid/src/config.type.ts:364](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L364)
     
     ---
     
    @@ -382,7 +390,7 @@ Defined in: [packages/mermaid/src/config.type.ts:349](https://github.com/mermaid
     
     > `optional` **journey**: `JourneyDiagramConfig`
     
    -Defined in: [packages/mermaid/src/config.type.ts:338](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L338)
    +Defined in: [packages/mermaid/src/config.type.ts:353](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L353)
     
     ---
     
    @@ -390,7 +398,7 @@ Defined in: [packages/mermaid/src/config.type.ts:338](https://github.com/mermaid
     
     > `optional` **kanban**: `KanbanDiagramConfig`
     
    -Defined in: [packages/mermaid/src/config.type.ts:350](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L350)
    +Defined in: [packages/mermaid/src/config.type.ts:365](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L365)
     
     ---
     
    @@ -398,7 +406,7 @@ Defined in: [packages/mermaid/src/config.type.ts:350](https://github.com/mermaid
     
     > `optional` **layout**: `string`
     
    -Defined in: [packages/mermaid/src/config.type.ts:104](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L104)
    +Defined in: [packages/mermaid/src/config.type.ts:118](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L118)
     
     Defines which layout algorithm to use for rendering the diagram.
     
    @@ -411,7 +419,7 @@ ELK to stay small and falls back to `dagre`.
     
     > `optional` **legacyMathML**: `boolean`
     
    -Defined in: [packages/mermaid/src/config.type.ts:307](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L307)
    +Defined in: [packages/mermaid/src/config.type.ts:322](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L322)
     
     This option specifies if Mermaid can expect the dependent to include KaTeX stylesheets for browsers
     without their own MathML implementation. If this option is disabled and MathML is not supported, the math
    @@ -424,7 +432,7 @@ fall back to legacy rendering for KaTeX.
     
     > `optional` **logLevel**: `0` | `2` | `1` | `"trace"` | `"debug"` | `"info"` | `"warn"` | `"error"` | `"fatal"` | `3` | `4` | `5`
     
    -Defined in: [packages/mermaid/src/config.type.ts:278](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L278)
    +Defined in: [packages/mermaid/src/config.type.ts:293](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L293)
     
     This option decides the amount of logging to be used by mermaid.
     
    @@ -434,7 +442,7 @@ This option decides the amount of logging to be used by mermaid.
     
     > `optional` **look**: `"neo"` | `"classic"` | `"handDrawn"`
     
    -Defined in: [packages/mermaid/src/config.type.ts:91](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L91)
    +Defined in: [packages/mermaid/src/config.type.ts:105](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L105)
     
     Defines which main look to use for the diagram.
     
    @@ -444,7 +452,7 @@ Defines which main look to use for the diagram.
     
     > `optional` **markdownAutoWrap**: `boolean`
     
    -Defined in: [packages/mermaid/src/config.type.ts:367](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L367)
    +Defined in: [packages/mermaid/src/config.type.ts:382](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L382)
     
     ---
     
    @@ -452,7 +460,7 @@ Defined in: [packages/mermaid/src/config.type.ts:367](https://github.com/mermaid
     
     > `optional` **maxEdges**: `number`
     
    -Defined in: [packages/mermaid/src/config.type.ts:113](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L113)
    +Defined in: [packages/mermaid/src/config.type.ts:127](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L127)
     
     Defines the maximum number of edges that can be drawn in a graph.
     
    @@ -462,7 +470,7 @@ Defines the maximum number of edges that can be drawn in a graph.
     
     > `optional` **maxTextSize**: `number`
     
    -Defined in: [packages/mermaid/src/config.type.ts:108](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L108)
    +Defined in: [packages/mermaid/src/config.type.ts:122](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L122)
     
     The maximum allowed size of the users text diagram
     
    @@ -472,7 +480,7 @@ The maximum allowed size of the users text diagram
     
     > `optional` **mindmap**: `MindmapDiagramConfig`
     
    -Defined in: [packages/mermaid/src/config.type.ts:348](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L348)
    +Defined in: [packages/mermaid/src/config.type.ts:363](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L363)
     
     ---
     
    @@ -480,7 +488,7 @@ Defined in: [packages/mermaid/src/config.type.ts:348](https://github.com/mermaid
     
     > `optional` **packet**: `PacketDiagramConfig`
     
    -Defined in: [packages/mermaid/src/config.type.ts:354](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L354)
    +Defined in: [packages/mermaid/src/config.type.ts:369](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L369)
     
     ---
     
    @@ -488,7 +496,7 @@ Defined in: [packages/mermaid/src/config.type.ts:354](https://github.com/mermaid
     
     > `optional` **pie**: `PieDiagramConfig`
     
    -Defined in: [packages/mermaid/src/config.type.ts:343](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L343)
    +Defined in: [packages/mermaid/src/config.type.ts:358](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L358)
     
     ---
     
    @@ -496,7 +504,7 @@ Defined in: [packages/mermaid/src/config.type.ts:343](https://github.com/mermaid
     
     > `optional` **quadrantChart**: `QuadrantChartConfig`
     
    -Defined in: [packages/mermaid/src/config.type.ts:344](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L344)
    +Defined in: [packages/mermaid/src/config.type.ts:359](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L359)
     
     ---
     
    @@ -504,7 +512,7 @@ Defined in: [packages/mermaid/src/config.type.ts:344](https://github.com/mermaid
     
     > `optional` **radar**: `RadarDiagramConfig`
     
    -Defined in: [packages/mermaid/src/config.type.ts:358](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L358)
    +Defined in: [packages/mermaid/src/config.type.ts:373](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L373)
     
     ---
     
    @@ -512,7 +520,7 @@ Defined in: [packages/mermaid/src/config.type.ts:358](https://github.com/mermaid
     
     > `optional` **railroad**: `RailroadDiagramConfig`
     
    -Defined in: [packages/mermaid/src/config.type.ts:363](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L363)
    +Defined in: [packages/mermaid/src/config.type.ts:378](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L378)
     
     ---
     
    @@ -520,7 +528,7 @@ Defined in: [packages/mermaid/src/config.type.ts:363](https://github.com/mermaid
     
     > `optional` **requirement**: `RequirementDiagramConfig`
     
    -Defined in: [packages/mermaid/src/config.type.ts:346](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L346)
    +Defined in: [packages/mermaid/src/config.type.ts:361](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L361)
     
     ---
     
    @@ -528,7 +536,7 @@ Defined in: [packages/mermaid/src/config.type.ts:346](https://github.com/mermaid
     
     > `optional` **sankey**: `SankeyDiagramConfig`
     
    -Defined in: [packages/mermaid/src/config.type.ts:353](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L353)
    +Defined in: [packages/mermaid/src/config.type.ts:368](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L368)
     
     ---
     
    @@ -536,7 +544,7 @@ Defined in: [packages/mermaid/src/config.type.ts:353](https://github.com/mermaid
     
     > `optional` **secure**: `string`\[]
     
    -Defined in: [packages/mermaid/src/config.type.ts:299](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L299)
    +Defined in: [packages/mermaid/src/config.type.ts:314](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L314)
     
     This option controls which `currentConfig` keys are considered secure and
     can only be changed via call to `mermaid.initialize`.
    @@ -548,7 +556,7 @@ This prevents malicious graph directives from overriding a site's default securi
     
     > `optional` **securityLevel**: `"strict"` | `"loose"` | `"antiscript"` | `"sandbox"`
     
    -Defined in: [packages/mermaid/src/config.type.ts:282](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L282)
    +Defined in: [packages/mermaid/src/config.type.ts:297](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L297)
     
     Level of trust for parsed diagram
     
    @@ -558,7 +566,7 @@ Level of trust for parsed diagram
     
     > `optional` **sequence**: `SequenceDiagramConfig`
     
    -Defined in: [packages/mermaid/src/config.type.ts:336](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L336)
    +Defined in: [packages/mermaid/src/config.type.ts:351](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L351)
     
     ---
     
    @@ -566,7 +574,7 @@ Defined in: [packages/mermaid/src/config.type.ts:336](https://github.com/mermaid
     
     > `optional` **startOnLoad**: `boolean`
     
    -Defined in: [packages/mermaid/src/config.type.ts:286](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L286)
    +Defined in: [packages/mermaid/src/config.type.ts:301](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L301)
     
     Dictates whether mermaid starts on Page load
     
    @@ -576,7 +584,7 @@ Dictates whether mermaid starts on Page load
     
     > `optional` **state**: `StateDiagramConfig`
     
    -Defined in: [packages/mermaid/src/config.type.ts:341](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L341)
    +Defined in: [packages/mermaid/src/config.type.ts:356](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L356)
     
     ---
     
    @@ -584,7 +592,7 @@ Defined in: [packages/mermaid/src/config.type.ts:341](https://github.com/mermaid
     
     > `optional` **suppressErrorRendering**: `boolean`
     
    -Defined in: [packages/mermaid/src/config.type.ts:373](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L373)
    +Defined in: [packages/mermaid/src/config.type.ts:388](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L388)
     
     Suppresses inserting 'Syntax error' diagram in the DOM.
     This is useful when you want to control how to handle syntax errors in your application.
    @@ -595,7 +603,7 @@ This is useful when you want to control how to handle syntax errors in your appl
     
     > `optional` **swimlane**: `SwimlaneDiagramConfig`
     
    -Defined in: [packages/mermaid/src/config.type.ts:334](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L334)
    +Defined in: [packages/mermaid/src/config.type.ts:349](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L349)
     
     ---
     
    @@ -603,7 +611,7 @@ Defined in: [packages/mermaid/src/config.type.ts:334](https://github.com/mermaid
     
     > `optional` **theme**: `"default"` | `"base"` | `"dark"` | `"forest"` | `"neutral"` | `"neo"` | `"neo-dark"` | `"redux"` | `"redux-dark"` | `"redux-color"` | `"redux-dark-color"` | `"null"`
     
    -Defined in: [packages/mermaid/src/config.type.ts:72](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L72)
    +Defined in: [packages/mermaid/src/config.type.ts:86](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L86)
     
     Theme, the CSS style sheet.
     You may also use `themeCSS` to override this value.
    @@ -614,7 +622,7 @@ You may also use `themeCSS` to override this value.
     
     > `optional` **themeCSS**: `string`
     
    -Defined in: [packages/mermaid/src/config.type.ts:86](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L86)
    +Defined in: [packages/mermaid/src/config.type.ts:100](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L100)
     
     ---
     
    @@ -622,7 +630,7 @@ Defined in: [packages/mermaid/src/config.type.ts:86](https://github.com/mermaid-
     
     > `optional` **themeVariables**: `any`
     
    -Defined in: [packages/mermaid/src/config.type.ts:85](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L85)
    +Defined in: [packages/mermaid/src/config.type.ts:99](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L99)
     
     ---
     
    @@ -630,7 +638,7 @@ Defined in: [packages/mermaid/src/config.type.ts:85](https://github.com/mermaid-
     
     > `optional` **timeline**: `TimelineDiagramConfig`
     
    -Defined in: [packages/mermaid/src/config.type.ts:339](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L339)
    +Defined in: [packages/mermaid/src/config.type.ts:354](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L354)
     
     ---
     
    @@ -638,7 +646,7 @@ Defined in: [packages/mermaid/src/config.type.ts:339](https://github.com/mermaid
     
     > `optional` **treeView**: `TreeViewDiagramConfig`
     
    -Defined in: [packages/mermaid/src/config.type.ts:357](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L357)
    +Defined in: [packages/mermaid/src/config.type.ts:372](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L372)
     
     ---
     
    @@ -646,7 +654,7 @@ Defined in: [packages/mermaid/src/config.type.ts:357](https://github.com/mermaid
     
     > `optional` **usecase**: `UsecaseDiagramConfig`
     
    -Defined in: [packages/mermaid/src/config.type.ts:359](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L359)
    +Defined in: [packages/mermaid/src/config.type.ts:374](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L374)
     
     ---
     
    @@ -654,7 +662,7 @@ Defined in: [packages/mermaid/src/config.type.ts:359](https://github.com/mermaid
     
     > `optional` **venn**: `VennDiagramConfig`
     
    -Defined in: [packages/mermaid/src/config.type.ts:360](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L360)
    +Defined in: [packages/mermaid/src/config.type.ts:375](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L375)
     
     ---
     
    @@ -662,7 +670,7 @@ Defined in: [packages/mermaid/src/config.type.ts:360](https://github.com/mermaid
     
     > `optional` **wardley-beta**: `WardleyDiagramConfig`
     
    -Defined in: [packages/mermaid/src/config.type.ts:361](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L361)
    +Defined in: [packages/mermaid/src/config.type.ts:376](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L376)
     
     ---
     
    @@ -670,7 +678,7 @@ Defined in: [packages/mermaid/src/config.type.ts:361](https://github.com/mermaid
     
     > `optional` **wrap**: `boolean`
     
    -Defined in: [packages/mermaid/src/config.type.ts:365](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L365)
    +Defined in: [packages/mermaid/src/config.type.ts:380](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L380)
     
     ---
     
    @@ -678,4 +686,4 @@ Defined in: [packages/mermaid/src/config.type.ts:365](https://github.com/mermaid
     
     > `optional` **xyChart**: `XYChartConfig`
     
    -Defined in: [packages/mermaid/src/config.type.ts:345](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L345)
    +Defined in: [packages/mermaid/src/config.type.ts:360](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L360)
    diff --git a/docs/config/setup/mermaid/interfaces/ParseOptions.md b/docs/config/setup/mermaid/interfaces/ParseOptions.md
    index bb4d73b8886..cb781a1bc94 100644
    --- a/docs/config/setup/mermaid/interfaces/ParseOptions.md
    +++ b/docs/config/setup/mermaid/interfaces/ParseOptions.md
    @@ -10,7 +10,7 @@
     
     # Interface: ParseOptions
     
    -Defined in: [packages/mermaid/src/types.ts:96](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L96)
    +Defined in: [packages/mermaid/src/types.ts:115](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L115)
     
     ## Properties
     
    @@ -18,7 +18,7 @@ Defined in: [packages/mermaid/src/types.ts:96](https://github.com/mermaid-js/mer
     
     > `optional` **suppressErrors**: `boolean`
     
    -Defined in: [packages/mermaid/src/types.ts:101](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L101)
    +Defined in: [packages/mermaid/src/types.ts:120](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L120)
     
     If `true`, parse will return `false` instead of throwing error when the diagram is invalid.
     The `parseError` function will not be called.
    diff --git a/docs/config/setup/mermaid/interfaces/ParseResult.md b/docs/config/setup/mermaid/interfaces/ParseResult.md
    index f99981e73a3..b22586f10de 100644
    --- a/docs/config/setup/mermaid/interfaces/ParseResult.md
    +++ b/docs/config/setup/mermaid/interfaces/ParseResult.md
    @@ -10,7 +10,7 @@
     
     # Interface: ParseResult
     
    -Defined in: [packages/mermaid/src/types.ts:104](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L104)
    +Defined in: [packages/mermaid/src/types.ts:123](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L123)
     
     ## Properties
     
    @@ -18,7 +18,7 @@ Defined in: [packages/mermaid/src/types.ts:104](https://github.com/mermaid-js/me
     
     > **config**: [`MermaidConfig`](MermaidConfig.md)
     
    -Defined in: [packages/mermaid/src/types.ts:112](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L112)
    +Defined in: [packages/mermaid/src/types.ts:131](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L131)
     
     The config passed as YAML frontmatter or directives
     
    @@ -28,6 +28,6 @@ The config passed as YAML frontmatter or directives
     
     > **diagramType**: `string`
     
    -Defined in: [packages/mermaid/src/types.ts:108](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L108)
    +Defined in: [packages/mermaid/src/types.ts:127](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L127)
     
     The diagram type, e.g. 'flowchart', 'sequence', etc.
    diff --git a/docs/config/setup/mermaid/interfaces/RenderResult.md b/docs/config/setup/mermaid/interfaces/RenderResult.md
    index 5829fd09abe..f5407a09bb7 100644
    --- a/docs/config/setup/mermaid/interfaces/RenderResult.md
    +++ b/docs/config/setup/mermaid/interfaces/RenderResult.md
    @@ -10,7 +10,7 @@
     
     # Interface: RenderResult
     
    -Defined in: [packages/mermaid/src/types.ts:135](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L135)
    +Defined in: [packages/mermaid/src/types.ts:154](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L154)
     
     ## Properties
     
    @@ -18,7 +18,7 @@ Defined in: [packages/mermaid/src/types.ts:135](https://github.com/mermaid-js/me
     
     > `optional` **bindFunctions**: (`element`) => `void`
     
    -Defined in: [packages/mermaid/src/types.ts:153](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L153)
    +Defined in: [packages/mermaid/src/types.ts:172](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L172)
     
     Bind function to be called after the svg has been inserted into the DOM.
     This is necessary for adding event listeners to the elements in the svg.
    @@ -45,7 +45,7 @@ bindFunctions?.(div); // To call bindFunctions only if it's present.
     
     > **diagramType**: `string`
     
    -Defined in: [packages/mermaid/src/types.ts:143](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L143)
    +Defined in: [packages/mermaid/src/types.ts:162](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L162)
     
     The diagram type, e.g. 'flowchart', 'sequence', etc.
     
    @@ -55,6 +55,6 @@ The diagram type, e.g. 'flowchart', 'sequence', etc.
     
     > **svg**: `string`
     
    -Defined in: [packages/mermaid/src/types.ts:139](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L139)
    +Defined in: [packages/mermaid/src/types.ts:158](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L158)
     
     The svg code for the rendered graph.
    diff --git a/docs/config/setup/mermaid/type-aliases/ParseErrorFunction.md b/docs/config/setup/mermaid/type-aliases/ParseErrorFunction.md
    index 55c907a8a76..546c528ebd7 100644
    --- a/docs/config/setup/mermaid/type-aliases/ParseErrorFunction.md
    +++ b/docs/config/setup/mermaid/type-aliases/ParseErrorFunction.md
    @@ -12,7 +12,7 @@
     
     > **ParseErrorFunction** = (`err`, `hash?`) => `void`
     
    -Defined in: [packages/mermaid/src/Diagram.ts:12](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/Diagram.ts#L12)
    +Defined in: [packages/mermaid/src/Diagram.ts:13](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/Diagram.ts#L13)
     
     ## Parameters
     
    diff --git a/docs/config/setup/mermaid/type-aliases/SVG.md b/docs/config/setup/mermaid/type-aliases/SVG.md
    index 272a9f3d33e..a515280704a 100644
    --- a/docs/config/setup/mermaid/type-aliases/SVG.md
    +++ b/docs/config/setup/mermaid/type-aliases/SVG.md
    @@ -12,4 +12,4 @@
     
     > **SVG** = `d3.Selection`<`SVGSVGElement`, `unknown`, `Element` | `null`, `unknown`>
     
    -Defined in: [packages/mermaid/src/diagram-api/types.ts:173](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/diagram-api/types.ts#L173)
    +Defined in: [packages/mermaid/src/diagram-api/types.ts:174](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/diagram-api/types.ts#L174)
    diff --git a/docs/config/setup/mermaid/type-aliases/SVGGroup.md b/docs/config/setup/mermaid/type-aliases/SVGGroup.md
    index b1139d26dcf..3feb02e23ee 100644
    --- a/docs/config/setup/mermaid/type-aliases/SVGGroup.md
    +++ b/docs/config/setup/mermaid/type-aliases/SVGGroup.md
    @@ -12,4 +12,4 @@
     
     > **SVGGroup** = `d3.Selection`<`SVGGElement`, `unknown`, `Element` | `null`, `unknown`>
     
    -Defined in: [packages/mermaid/src/diagram-api/types.ts:175](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/diagram-api/types.ts#L175)
    +Defined in: [packages/mermaid/src/diagram-api/types.ts:176](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/diagram-api/types.ts#L176)
    diff --git a/docs/syntax/agentflow.md b/docs/syntax/agentflow.md
    index 843971dedc5..42f4f2671b0 100644
    --- a/docs/syntax/agentflow.md
    +++ b/docs/syntax/agentflow.md
    @@ -52,6 +52,8 @@ Agentflow diagrams use the `redux-color` theme and the `neo` look by default, an
     diagram type does — see [Per-diagram defaults](../config/theming.md#per-diagram-defaults) for
     the list and for the order in which Mermaid decides.
     
    +For deterministic row and column placement, use the [grid layout](/syntax/grid-layout.html).
    +
     The same diagram, drawn both ways:
     
     ### With the defaults
    diff --git a/docs/syntax/classDiagram.md b/docs/syntax/classDiagram.md
    index 546aa64f9f9..0ec75983b0f 100644
    --- a/docs/syntax/classDiagram.md
    +++ b/docs/syntax/classDiagram.md
    @@ -78,6 +78,8 @@ Class diagrams use the `redux-color` theme and the `neo` look by default, and ar
     does — see [Per-diagram defaults](../config/theming.md#per-diagram-defaults) for the list and
     for the order in which Mermaid decides.
     
    +For deterministic row and column placement, use the [grid layout](/syntax/grid-layout.html).
    +
     The same diagram, drawn both ways:
     
     ### With the defaults
    diff --git a/docs/syntax/entityRelationshipDiagram.md b/docs/syntax/entityRelationshipDiagram.md
    index beac0cf9182..cad3d68a440 100644
    --- a/docs/syntax/entityRelationshipDiagram.md
    +++ b/docs/syntax/entityRelationshipDiagram.md
    @@ -86,6 +86,8 @@ Entity relationship diagrams use the `redux-color` theme and the `neo` look by d
     does — see [Per-diagram defaults](../config/theming.md#per-diagram-defaults) for the list and
     for the order in which Mermaid decides.
     
    +For deterministic row and column placement, use the [grid layout](/syntax/grid-layout.html).
    +
     The same diagram, drawn both ways:
     
     ### With the defaults
    diff --git a/docs/syntax/flowchart.md b/docs/syntax/flowchart.md
    index 934bd13efcc..0dd331263f6 100644
    --- a/docs/syntax/flowchart.md
    +++ b/docs/syntax/flowchart.md
    @@ -150,6 +150,8 @@ Flowcharts use the `redux-color` theme and the `neo` look by default, and are la
     does — see [Per-diagram defaults](../config/theming.md#per-diagram-defaults) for the list and
     for the order in which Mermaid decides.
     
    +For deterministic row and column placement, use the [grid layout](/syntax/grid-layout.html).
    +
     The same diagram, drawn both ways:
     
     ### With the defaults
    diff --git a/docs/syntax/grid-layout.md b/docs/syntax/grid-layout.md
    new file mode 100644
    index 00000000000..f1887ab43cd
    --- /dev/null
    +++ b/docs/syntax/grid-layout.md
    @@ -0,0 +1,649 @@
    +> **Warning**
    +>
    +> ## THIS IS AN AUTOGENERATED FILE. DO NOT EDIT.
    +>
    +> ## Please edit the corresponding file in [/packages/mermaid/src/docs/syntax/grid-layout.md](../../packages/mermaid/src/docs/syntax/grid-layout.md).
    +
    +# Grid Layout (v\+)
    +
    +## Introduction
    +
    +Use `layout: grid` when you want deterministic row and column placement instead of a layout derived mainly from edge direction. You can place every item explicitly, constrain only a row or column, or let Mermaid fill the remaining cells automatically.
    +
    +Grid layout supports flowchart, agentflow, state, class, entity relationship, requirement, use case, and mindmap diagrams. Flowchart and agentflow support inline `@{ ... }` placement metadata. All supported diagram types can use `config.grid.placements`.
    +
    +## Enable the layout
    +
    +```mermaid-example
    +---
    +config:
    +  layout: grid
    +---
    +flowchart TB
    +  A@{ row: 1, column: 1 } --> B@{ row: 1, column: 2 }
    +  B --> C@{ row: 2, column: 2 }
    +```
    +
    +```mermaid
    +---
    +config:
    +  layout: grid
    +---
    +flowchart TB
    +  A@{ row: 1, column: 1 } --> B@{ row: 1, column: 2 }
    +  B --> C@{ row: 2, column: 2 }
    +```
    +
    +## Examples for other diagram types
    +
    +The flowchart example above uses inline placement metadata. The following examples use automatic grid placement for the other supported diagram types.
    +
    +### Agentflow
    +
    +```mermaid-example
    +---
    +config:
    +  layout: grid
    +---
    +agentflow-beta
    +  request["Receive request"]@{ shape: input, row: 1, column: 1 }
    +  plan["Plan work"]@{ shape: task, row: 1, column: 2  }
    +  run["Run tool"]@{ shape: tool, row: 1, column: 3  }
    +  publish["Publish result"]@{ shape: action, row: 1, column: 4  }
    +
    +  request --> plan --> run --> publish
    +```
    +
    +```mermaid
    +---
    +config:
    +  layout: grid
    +---
    +agentflow-beta
    +  request["Receive request"]@{ shape: input, row: 1, column: 1 }
    +  plan["Plan work"]@{ shape: task, row: 1, column: 2  }
    +  run["Run tool"]@{ shape: tool, row: 1, column: 3  }
    +  publish["Publish result"]@{ shape: action, row: 1, column: 4  }
    +
    +  request --> plan --> run --> publish
    +```
    +
    +### State diagram
    +
    +```mermaid-example
    +---
    +config:
    +  layout: grid
    +  grid:
    +    columnGap: 300
    +    placements:
    +      Draft: { row: 1, column: 1 }
    +      Review: { row: 1, column: 2 }
    +      Published: { row: 2, column: 2 }
    +---
    +stateDiagram-v2
    +
    +  Draft --> Review
    +  Review --> Published
    +  Review --> Draft : changes requested
    +```
    +
    +```mermaid
    +---
    +config:
    +  layout: grid
    +  grid:
    +    columnGap: 300
    +    placements:
    +      Draft: { row: 1, column: 1 }
    +      Review: { row: 1, column: 2 }
    +      Published: { row: 2, column: 2 }
    +---
    +stateDiagram-v2
    +
    +  Draft --> Review
    +  Review --> Published
    +  Review --> Draft : changes requested
    +```
    +
    +### Class diagram
    +
    +```mermaid-example
    +---
    +config:
    +  layout: grid
    +  grid:
    +   columnGap: 100
    +   rowGap: 100
    +   placements:
    +    Customer: { row: 1, column: 1 }
    +    Order: {row: 1, column: 2}
    +    Payment: {row: 2, column: 2}
    +    Shipment: {row: 1, column: 3}
    +---
    +classDiagram
    +  Customer --> Order : places
    +  Customer --> Payment : makes
    +  Order --> Payment : pays with
    +  Order --> Shipment : creates
    +```
    +
    +```mermaid
    +---
    +config:
    +  layout: grid
    +  grid:
    +   columnGap: 100
    +   rowGap: 100
    +   placements:
    +    Customer: { row: 1, column: 1 }
    +    Order: {row: 1, column: 2}
    +    Payment: {row: 2, column: 2}
    +    Shipment: {row: 1, column: 3}
    +---
    +classDiagram
    +  Customer --> Order : places
    +  Customer --> Payment : makes
    +  Order --> Payment : pays with
    +  Order --> Shipment : creates
    +```
    +
    +### Entity relationship diagram
    +
    +```mermaid-example
    +---
    +config:
    +  layout: grid
    +  grid:
    +   columnGap: 150
    +   rowGap: 100
    +   placements:
    +    CUSTOMER: { row: 1, column: 1 }
    +    ORDER: { row: 1, column: 2 }
    +    PAYMENT: { row: 2, column: 2 }
    +    LINE_ITEM: { row: 1, column: 3 }
    +---
    +erDiagram
    +  CUSTOMER ||--o{ ORDER : places
    +  ORDER ||--|{ LINE_ITEM : contains
    +  ORDER ||--o| PAYMENT : has
    +```
    +
    +```mermaid
    +---
    +config:
    +  layout: grid
    +  grid:
    +   columnGap: 150
    +   rowGap: 100
    +   placements:
    +    CUSTOMER: { row: 1, column: 1 }
    +    ORDER: { row: 1, column: 2 }
    +    PAYMENT: { row: 2, column: 2 }
    +    LINE_ITEM: { row: 1, column: 3 }
    +---
    +erDiagram
    +  CUSTOMER ||--o{ ORDER : places
    +  ORDER ||--|{ LINE_ITEM : contains
    +  ORDER ||--o| PAYMENT : has
    +```
    +
    +### Requirement diagram
    +
    +```mermaid-example
    +---
    +config:
    +  layout: grid
    +  grid:
    +   columnGap: 200
    +---
    +requirementDiagram
    +  requirement checkout {
    +    id: 1
    +    text: "Customers can complete an order"
    +    risk: high
    +    verifymethod: test
    +  }
    +
    +  element storefront {
    +    type: service
    +    docref: "checkout"
    +  }
    +
    +  storefront - satisfies -> checkout
    +```
    +
    +```mermaid
    +---
    +config:
    +  layout: grid
    +  grid:
    +   columnGap: 200
    +---
    +requirementDiagram
    +  requirement checkout {
    +    id: 1
    +    text: "Customers can complete an order"
    +    risk: high
    +    verifymethod: test
    +  }
    +
    +  element storefront {
    +    type: service
    +    docref: "checkout"
    +  }
    +
    +  storefront - satisfies -> checkout
    +```
    +
    +### Use case diagram
    +
    +```mermaid-example
    +---
    +config:
    +  layout: grid
    +  grid:
    +    placements:
    +      Customer: {row: 2, column: 1}
    +      Browse: {row: 1, column: 2}
    +      Checkout: {row: 2, column: 2}
    +      Track: {row: 3, column: 2}
    +---
    +usecase-beta
    +  actor Customer
    +  Browse("Browse products")
    +  Checkout("Complete checkout")
    +  Track("Track order")
    +
    +  Customer --> Browse
    +  Customer --> Checkout
    +  Customer --> Track
    +```
    +
    +```mermaid
    +---
    +config:
    +  layout: grid
    +  grid:
    +    placements:
    +      Customer: {row: 2, column: 1}
    +      Browse: {row: 1, column: 2}
    +      Checkout: {row: 2, column: 2}
    +      Track: {row: 3, column: 2}
    +---
    +usecase-beta
    +  actor Customer
    +  Browse("Browse products")
    +  Checkout("Complete checkout")
    +  Track("Track order")
    +
    +  Customer --> Browse
    +  Customer --> Checkout
    +  Customer --> Track
    +```
    +
    +### Mindmap
    +
    +```mermaid-example
    +---
    +config:
    +  layout: grid
    +  grid:
    +    placements:
    +      root: {row: 2, column: 2}
    +      Plan: {row: 1, column: 1}
    +      Build: {row: 1, column: 3}
    +      Test: {row: 1, column: 4}
    +      Deploy: {row: 3, column: 3}
    +---
    +mindmap
    +  root((Release))
    +    Plan
    +    Build
    +      Test
    +    Deploy
    +```
    +
    +```mermaid
    +---
    +config:
    +  layout: grid
    +  grid:
    +    placements:
    +      root: {row: 2, column: 2}
    +      Plan: {row: 1, column: 1}
    +      Build: {row: 1, column: 3}
    +      Test: {row: 1, column: 4}
    +      Deploy: {row: 3, column: 3}
    +---
    +mindmap
    +  root((Release))
    +    Plan
    +    Build
    +      Test
    +    Deploy
    +```
    +
    +## Placement metadata
    +
    +Flowchart and agentflow nodes and expanded subgraphs accept four grid-specific metadata keys:
    +
    +- `row`
    +- `column`
    +- `horizontalAlign`
    +- `verticalAlign`
    +
    +Coordinates are:
    +
    +- positive integers starting at `1`
    +- local to the direct parent group
    +- sparse but collapsed (rows `1` and `100` render as adjacent occupied tracks)
    +
    +Invalid `row` or `column` values are rejected during parsing with an error that identifies the placement and field.
    +
    +When inline metadata and `config.grid.placements` both set the same property, inline metadata takes precedence.
    +
    +```mermaid-example
    +---
    +config:
    +  layout: grid
    +  grid:
    +    rowGap: 32
    +    columnGap: 40
    +    cellGap: 12
    +---
    +flowchart TB
    +  A@{ row: 1, column: 1 }
    +  B@{ row: 2, column: 1 }
    +  C@{ row: 2, column: 2, horizontalAlign: right }
    +  A --> B
    +  B --> C
    +```
    +
    +```mermaid
    +---
    +config:
    +  layout: grid
    +  grid:
    +    rowGap: 32
    +    columnGap: 40
    +    cellGap: 12
    +---
    +flowchart TB
    +  A@{ row: 1, column: 1 }
    +  B@{ row: 2, column: 1 }
    +  C@{ row: 2, column: 2, horizontalAlign: right }
    +  A --> B
    +  B --> C
    +```
    +
    +## Placement maps
    +
    +All supported diagram types can use the global placement map:
    +
    +```mermaid-example
    +---
    +config:
    +  layout: grid
    +  grid:
    +    placements:
    +      A: { row: 1, column: 1 }
    +      B: { row: 2, column: 1 }
    +      C: { row: 2, column: 2 }
    +---
    +flowchart TB
    +  A --> B
    +  B --> C
    +```
    +
    +```mermaid
    +---
    +config:
    +  layout: grid
    +  grid:
    +    placements:
    +      A: { row: 1, column: 1 }
    +      B: { row: 2, column: 1 }
    +      C: { row: 2, column: 2 }
    +---
    +flowchart TB
    +  A --> B
    +  B --> C
    +```
    +
    +Placement-map keys use authored node identifiers when the diagram type provides them. This includes flowchart, agentflow, entity relationship, and mindmap identifiers. Other diagram types use the node ids they emit for layout.
    +
    +When an authored identifier occurs more than once, use an array to place each occurrence in source
    +order. A single placement object is equivalent to an array with one entry. Occurrences without a
    +matching entry are placed automatically, and extra entries are ignored with a console warning.
    +Inline metadata still takes precedence over the matching placement-map entry.
    +
    +```mermaid-example
    +---
    +config:
    +  layout: grid
    +  grid:
    +    placements:
    +      service:
    +        - { row: 1, column: 1 }
    +        - { row: 1, column: 2 }
    +---
    +mindmap
    +  root((Services))
    +    service[API]
    +    service[Worker]
    +    service[Automatically placed]
    +```
    +
    +```mermaid
    +---
    +config:
    +  layout: grid
    +  grid:
    +    placements:
    +      service:
    +        - { row: 1, column: 1 }
    +        - { row: 1, column: 2 }
    +---
    +mindmap
    +  root((Services))
    +    service[API]
    +    service[Worker]
    +    service[Automatically placed]
    +```
    +
    +Placement-map keys beginning with `__` are removed by Mermaid's recursive configuration sanitizer
    +as a prototype-pollution precaution. For flowchart and agentflow nodes with such ids, use inline
    +placement metadata instead.
    +
    +Placements for ids that are not present in the diagram are ignored with a console warning. This
    +allows a shared or generated placement map to contain entries for optional nodes.
    +
    +## Automatic placement
    +
    +If either coordinate is omitted, Mermaid fills it deterministically:
    +
    +- explicit `(row, column)` cells are reserved first
    +- `row` only picks the first free column in that row
    +- `column` only picks the first free row in that column
    +- when both are omitted, Mermaid scans row-major using `grid.columns` when it is greater than `0`; otherwise, it uses `ceil(sqrt(itemCount))`
    +
    +Automatic placement runs independently within each group. Two or more direct siblings with the same explicit `(row, column)` share one cell as a vertical stack in declaration order.
    +
    +## Alignment and stacking
    +
    +- `horizontalAlign`: `left`, `center`, `right`
    +- `verticalAlign`: `top`, `center`, `bottom`
    +
    +Horizontal alignment applies to each item. Vertical alignment applies to the whole stack in an explicitly shared cell. Items in the same cell must resolve to the same vertical alignment; otherwise, Mermaid reports `GRID_CELL_ALIGNMENT_CONFLICT`.
    +
    +```mermaid-example
    +---
    +config:
    +  layout: grid
    +  grid:
    +    cellGap: 25
    +---
    +flowchart TB
    +  A["Alpha"]@{ row: 1, column: 1, horizontalAlign: left, verticalAlign: bottom }
    +  B["Beta"]@{ row: 1, column: 1, horizontalAlign: right, verticalAlign: bottom }
    +  A --> B
    +```
    +
    +```mermaid
    +---
    +config:
    +  layout: grid
    +  grid:
    +    cellGap: 25
    +---
    +flowchart TB
    +  A["Alpha"]@{ row: 1, column: 1, horizontalAlign: left, verticalAlign: bottom }
    +  B["Beta"]@{ row: 1, column: 1, horizontalAlign: right, verticalAlign: bottom }
    +  A --> B
    +```
    +
    +## Nested groups
    +
    +Groups are laid out recursively from the inside out. Child coordinates are always local to the immediate parent group; diagram direction (`TB`, `BT`, `LR`, `RL`) does not rotate or mirror the authored grid.
    +
    +```mermaid-example
    +---
    +config:
    +  layout: grid
    +  grid:
    +    placements:
    +      A: { row: 1, column: 1 }
    +      B: { row: 2, column: 1 }
    +      C: { row: 1, column: 2 }
    +      G: { row: 1, column: 1 }
    +---
    +flowchart TB
    +  subgraph G["Group One"]
    +    A["Alpha"]
    +    B["Beta"]
    +  end
    +  C["Peer"]
    +  A --> C
    +  B --> C
    +```
    +
    +```mermaid
    +---
    +config:
    +  layout: grid
    +  grid:
    +    placements:
    +      A: { row: 1, column: 1 }
    +      B: { row: 2, column: 1 }
    +      C: { row: 1, column: 2 }
    +      G: { row: 1, column: 1 }
    +---
    +flowchart TB
    +  subgraph G["Group One"]
    +    A["Alpha"]
    +    B["Beta"]
    +  end
    +  C["Peer"]
    +  A --> C
    +  B --> C
    +```
    +
    +## Configuration
    +
    +| Setting            | Default   | Purpose                                                             |
    +| ------------------ | --------- | ------------------------------------------------------------------- |
    +| `placements`       | `{}`      | Maps node ids to placement values.                                  |
    +| `columns`          | `0`       | Sets the auto-placement column count. `0` selects it automatically. |
    +| `rowGap`           | `50`      | Sets the gap between occupied rows.                                 |
    +| `columnGap`        | `50`      | Sets the gap between occupied columns.                              |
    +| `cellGap`          | `20`      | Sets the gap between items stacked in one cell.                     |
    +| `containerPadding` | `20`      | Sets the minimum padding inside groups.                             |
    +| `titleGap`         | `8`       | Sets the clearance between a group title and its child grid.        |
    +| `horizontalAlign`  | `center`  | Sets the default horizontal alignment within a cell.                |
    +| `verticalAlign`    | `center`  | Sets the default vertical alignment for a cell stack.               |
    +| `curve`            | `rounded` | Sets the edge rendering curve.                                      |
    +| `edgeCornerRadius` | `5`       | Sets the corner radius for `rounded` edges.                         |
    +
    +For accepted values and validation rules, see the [grid layout configuration reference](/config/schema-docs/config-defs-grid-layout-config.html).
    +
    +## Edge routing
    +
    +Grid layout calculates edge routes after positioning the nodes and groups. The calculated route:
    +
    +- uses horizontal and vertical segments
    +- avoids measured nodes, unrelated groups, and group titles
    +- may pass through unused space within a grid cell
    +- crosses group boundaries when connecting nodes in different groups
    +- attempts to keep parallel and reverse edges on separate ports and lanes
    +
    +In dense nested diagrams, parallel hierarchy edges can share part of an internal corridor while keeping distinct endpoint ports. If no valid route exists around node or group geometry, Mermaid reports `GRID_ROUTE_NOT_FOUND`.
    +
    +Edge labels are placed after the initial routes are calculated. Mermaid attempts to reroute edges around those labels. If it cannot find a safe bounded detour, an edge can pass through another edge's label rather than failing the whole diagram.
    +
    +## Edge curves
    +
    +Grid edges use `rounded` rendering by default. Set `grid.curve` to any Mermaid
    +flowchart curve style, including `basis`, `cardinal`, `catmullRom`, `step`, or
    +`rounded`.
    +
    +`edgeCornerRadius` controls the corner radius for `rounded` edges. It is ignored
    +by other curve styles, and each corner is automatically limited by the adjacent
    +segment lengths.
    +
    +```mermaid-example
    +---
    +config:
    +  layout: grid
    +  grid:
    +    curve: rounded
    +    edgeCornerRadius: 10
    +---
    +flowchart TB
    +  A@{ row: 1, column: 1 } --> B@{ row: 2, column: 2 }
    +```
    +
    +```mermaid
    +---
    +config:
    +  layout: grid
    +  grid:
    +    curve: rounded
    +    edgeCornerRadius: 10
    +---
    +flowchart TB
    +  A@{ row: 1, column: 1 } --> B@{ row: 2, column: 2 }
    +```
    +
    +```mermaid-example
    +---
    +config:
    +  layout: grid
    +  grid:
    +    curve: linear
    +---
    +flowchart TB
    +  A@{ row: 1, column: 1 } --> B@{ row: 2, column: 2 }
    +```
    +
    +```mermaid
    +---
    +config:
    +  layout: grid
    +  grid:
    +    curve: linear
    +---
    +flowchart TB
    +  A@{ row: 1, column: 1 } --> B@{ row: 2, column: 2 }
    +```
    +
    +`linear` and `rounded` preserve the calculated route corridor. Other curves interpolate between the obstacle-aware route points and can move outside that corridor. The interpolated curve is not revalidated against obstacles.
    +
    +## Current limits
    +
    +Grid layout does **not** support:
    +
    +- row or column spanning
    +- in-cell layouts other than vertical stacking; use a nested subgraph to create a separate grid within a cell
    +- track-level row/column alignment declarations
    +- manual absolute coordinates or manual edge waypoints
    diff --git a/docs/syntax/mindmap.md b/docs/syntax/mindmap.md
    index 6e6b95eed3a..e90814bbb28 100644
    --- a/docs/syntax/mindmap.md
    +++ b/docs/syntax/mindmap.md
    @@ -426,6 +426,8 @@ cspell:ignore Buzan
     
     ## Layouts
     
    +For deterministic row and column placement, use the [grid layout](/syntax/grid-layout.html).
    +
     Mermaid also supports a Tidy Tree layout for mindmaps.
     
     ```
    diff --git a/docs/syntax/requirementDiagram.md b/docs/syntax/requirementDiagram.md
    index 0c2ba8e1c6d..74b754eae1c 100644
    --- a/docs/syntax/requirementDiagram.md
    +++ b/docs/syntax/requirementDiagram.md
    @@ -50,6 +50,8 @@ Requirement diagrams use the `redux-color` theme and the `neo` look by default,
     does — see [Per-diagram defaults](../config/theming.md#per-diagram-defaults) for the list and
     for the order in which Mermaid decides.
     
    +For deterministic row and column placement, use the [grid layout](/syntax/grid-layout.html).
    +
     The same diagram, drawn both ways:
     
     ### With the defaults
    diff --git a/docs/syntax/stateDiagram.md b/docs/syntax/stateDiagram.md
    index b5ce426f4bc..6f6f307bc6e 100644
    --- a/docs/syntax/stateDiagram.md
    +++ b/docs/syntax/stateDiagram.md
    @@ -76,6 +76,8 @@ State diagrams use the `redux-color` theme and the `neo` look by default, and ar
     does — see [Per-diagram defaults](../config/theming.md#per-diagram-defaults) for the list and
     for the order in which Mermaid decides.
     
    +For deterministic row and column placement, use the [grid layout](/syntax/grid-layout.html).
    +
     The same diagram, drawn both ways:
     
     ### With the defaults
    diff --git a/docs/syntax/usecase.md b/docs/syntax/usecase.md
    index 6817f0daacf..5c1980f3ee8 100644
    --- a/docs/syntax/usecase.md
    +++ b/docs/syntax/usecase.md
    @@ -38,6 +38,8 @@ Use case diagrams use the `redux-color` theme and the `neo` look by default, and
     does — see [Per-diagram defaults](../config/theming.md#per-diagram-defaults) for the list and
     for the order in which Mermaid decides.
     
    +For deterministic row and column placement, use the [grid layout](/syntax/grid-layout.html).
    +
     The same diagram, drawn both ways:
     
     ### With the defaults
    diff --git a/e2e/diagrams/grid/01-agentflow-inline-placement.mmd b/e2e/diagrams/grid/01-agentflow-inline-placement.mmd
    new file mode 100644
    index 00000000000..f60ad4d3bcc
    --- /dev/null
    +++ b/e2e/diagrams/grid/01-agentflow-inline-placement.mmd
    @@ -0,0 +1,11 @@
    +---
    +config:
    +  layout: grid
    +---
    +agentflow-beta
    +  request["Receive request"]@{ shape: input, row: 1, column: 1 }
    +  plan["Plan work"]@{ shape: task, row: 1, column: 2 }
    +  run["Run tool"]@{ shape: tool, row: 1, column: 3 }
    +  publish["Publish result"]@{ shape: action, row: 1, column: 4 }
    +
    +  request --> plan --> run --> publish
    diff --git a/e2e/diagrams/grid/02-bevan-graph.mmd b/e2e/diagrams/grid/02-bevan-graph.mmd
    new file mode 100644
    index 00000000000..4726acbb61b
    --- /dev/null
    +++ b/e2e/diagrams/grid/02-bevan-graph.mmd
    @@ -0,0 +1,14 @@
    +---
    +config:
    +  layout: grid
    +  grid:
    +    placements:
    +      V1: {row: 1, column: 1}
    +      V2p: {row: 2, column: 2}
    +      V2: {row: 1, column: 3}
    +      V3: {row: 1, column: 4}
    +---
    +flowchart TB
    +  V1 --> V2
    +  V2 --> V3
    +  V2p --> V1
    \ No newline at end of file
    diff --git a/e2e/diagrams/grid/03-class-placement-map.mmd b/e2e/diagrams/grid/03-class-placement-map.mmd
    new file mode 100644
    index 00000000000..b4d1d64592d
    --- /dev/null
    +++ b/e2e/diagrams/grid/03-class-placement-map.mmd
    @@ -0,0 +1,17 @@
    +---
    +config:
    +  layout: grid
    +  grid:
    +    columnGap: 100
    +    rowGap: 100
    +    placements:
    +      Customer: { row: 1, column: 1 }
    +      Order: { row: 1, column: 2 }
    +      Payment: { row: 2, column: 2 }
    +      Shipment: { row: 1, column: 3 }
    +---
    +classDiagram
    +  Customer --> Order : places
    +  Customer --> Payment : makes
    +  Order --> Payment : pays with
    +  Order --> Shipment : creates
    diff --git a/e2e/diagrams/grid/04-direct-path.mmd b/e2e/diagrams/grid/04-direct-path.mmd
    new file mode 100644
    index 00000000000..f103051d36f
    --- /dev/null
    +++ b/e2e/diagrams/grid/04-direct-path.mmd
    @@ -0,0 +1,8 @@
    +---
    +config:
    +  layout: grid
    +---
    +flowchart TB
    +  A@{row: 1, column: 1}
    +  B@{row: 1, column: 2}
    +  A --> B
    diff --git a/e2e/diagrams/grid/05-er-authored-placement-ids.mmd b/e2e/diagrams/grid/05-er-authored-placement-ids.mmd
    new file mode 100644
    index 00000000000..c41597f465a
    --- /dev/null
    +++ b/e2e/diagrams/grid/05-er-authored-placement-ids.mmd
    @@ -0,0 +1,16 @@
    +---
    +config:
    +  layout: grid
    +  grid:
    +    columnGap: 150
    +    rowGap: 100
    +    placements:
    +      CUSTOMER: { row: 1, column: 1 }
    +      ORDER: { row: 1, column: 2 }
    +      PAYMENT: { row: 2, column: 2 }
    +      LINE_ITEM: { row: 1, column: 3 }
    +---
    +erDiagram
    +  CUSTOMER ||--o{ ORDER : places
    +  ORDER ||--|{ LINE_ITEM : contains
    +  ORDER ||--o| PAYMENT : has
    diff --git a/e2e/diagrams/grid/06-flowchart-edges-to-and-from-subgraphs.mmd b/e2e/diagrams/grid/06-flowchart-edges-to-and-from-subgraphs.mmd
    new file mode 100644
    index 00000000000..a38dd20061c
    --- /dev/null
    +++ b/e2e/diagrams/grid/06-flowchart-edges-to-and-from-subgraphs.mmd
    @@ -0,0 +1,18 @@
    +---
    +config:
    +  layout: grid
    +  securityLevel: loose
    +  grid:
    +    rowGap: 72
    +    columnGap: 96
    +---
    +flowchart LR
    +  O["Outside"]@{ row: 1, column: 1 }
    +  subgraph G["Group"]
    +    M["Member"]@{ row: 1, column: 1 }
    +  end
    +  G@{ row: 1, column: 2 }
    +
    +  G --> M
    +  O --> G
    +  O --> M
    diff --git a/e2e/diagrams/grid/07-flowchart-inline-placement.mmd b/e2e/diagrams/grid/07-flowchart-inline-placement.mmd
    new file mode 100644
    index 00000000000..f2115183aad
    --- /dev/null
    +++ b/e2e/diagrams/grid/07-flowchart-inline-placement.mmd
    @@ -0,0 +1,18 @@
    +---
    +config:
    +  layout: grid
    +  securityLevel: loose
    +  grid:
    +    rowGap: 32
    +    columnGap: 40
    +---
    +flowchart TB
    +  A["Start"]@{ row: 1, column: 1 }
    +  B["Review"]@{ row: 2, column: 1 }
    +  C["Docs"]@{ row: 2, column: 2 }
    +  D["Callback"]@{ row: 3, column: 1 }
    +  click C href "https://mermaid.ai/" "Open Mermaid Website"
    +  click D call gridCallback() "Run callback"
    +  A --> B
    +  B --> C
    +  B --> D
    diff --git a/e2e/diagrams/grid/08-flowchart-svg-labels.mmd b/e2e/diagrams/grid/08-flowchart-svg-labels.mmd
    new file mode 100644
    index 00000000000..5fcd6dac25a
    --- /dev/null
    +++ b/e2e/diagrams/grid/08-flowchart-svg-labels.mmd
    @@ -0,0 +1,12 @@
    +---
    +config:
    +  layout: grid
    +  htmlLabels: false
    +  grid:
    +    columns: 1
    +---
    +flowchart TB
    +  A -- "`Bold **edge label**`" --> B
    +  B -->|Some
    +  Split
    +  Lines| C
    diff --git a/e2e/diagrams/grid/09-loop.mmd b/e2e/diagrams/grid/09-loop.mmd
    new file mode 100644
    index 00000000000..92940b39cc1
    --- /dev/null
    +++ b/e2e/diagrams/grid/09-loop.mmd
    @@ -0,0 +1,8 @@
    +---
    +config:
    +  layout: grid
    +---
    +flowchart TB
    +  a -->|label| a
    +  a -->|label| a
    +  a -->|label| a
    diff --git a/e2e/diagrams/grid/10-mindmap-authored-placement-ids.mmd b/e2e/diagrams/grid/10-mindmap-authored-placement-ids.mmd
    new file mode 100644
    index 00000000000..f4fd634b3c2
    --- /dev/null
    +++ b/e2e/diagrams/grid/10-mindmap-authored-placement-ids.mmd
    @@ -0,0 +1,17 @@
    +---
    +config:
    +  layout: grid
    +  grid:
    +    placements:
    +      root: { row: 2, column: 2 }
    +      Plan: { row: 1, column: 1 }
    +      Build: { row: 3, column: 1 }
    +      Test: { row: 1, column: 3 }
    +      Deploy: { row: 3, column: 3 }
    +---
    +mindmap
    +  root((Release))
    +    Plan
    +    Build
    +      Test
    +    Deploy
    diff --git a/e2e/diagrams/grid/11-nested-groups-edges-to-outer-node.mmd b/e2e/diagrams/grid/11-nested-groups-edges-to-outer-node.mmd
    new file mode 100644
    index 00000000000..e231a0416c7
    --- /dev/null
    +++ b/e2e/diagrams/grid/11-nested-groups-edges-to-outer-node.mmd
    @@ -0,0 +1,29 @@
    +---
    +config:
    +  layout: grid
    +  grid:
    +    placements:
    +      Work: { row: 1, column: 2 }
    +      Output: { row: 1, column: 3 }
    +      C: { row: 1, column: 1 }
    +      Nested: { row: 2, column: 1 }
    +      D: { row: 1, column: 1 }
    +      E: { row: 2, column: 1 }
    +      F: { row: 1, column: 1 }
    +    rowGap: 45
    +    columnGap: 70
    +---
    +flowchart LR
    +  subgraph Work
    +    subgraph Nested
    +      D
    +      E
    +    end
    +  end
    +
    +  subgraph Output
    +    F
    +  end
    +
    +  D --> F
    +  E --> F
    \ No newline at end of file
    diff --git a/e2e/diagrams/grid/12-edges-from-outer-node-to-nodes-in-group-below.mmd b/e2e/diagrams/grid/12-edges-from-outer-node-to-nodes-in-group-below.mmd
    new file mode 100644
    index 00000000000..1e58e3ce671
    --- /dev/null
    +++ b/e2e/diagrams/grid/12-edges-from-outer-node-to-nodes-in-group-below.mmd
    @@ -0,0 +1,21 @@
    +---
    +config:
    +  layout: grid
    +  grid:
    +    placements:
    +      C: { row: 1, column: 1 }
    +      Nested: { row: 2, column: 1 }
    +      D: { row: 1, column: 1 }
    +      E: { row: 2, column: 1 }
    +    rowGap: 45
    +    columnGap: 70
    +---
    +flowchart LR
    +  C
    +  subgraph Nested
    +    D
    +    E
    +  end
    +
    +  C --> D
    +  C --> E
    diff --git a/e2e/diagrams/grid/13-path-prioritisation-to-minimise-bends-in-line.mmd b/e2e/diagrams/grid/13-path-prioritisation-to-minimise-bends-in-line.mmd
    new file mode 100644
    index 00000000000..36721e8577c
    --- /dev/null
    +++ b/e2e/diagrams/grid/13-path-prioritisation-to-minimise-bends-in-line.mmd
    @@ -0,0 +1,16 @@
    +---
    +config:
    +  layout: grid
    +---
    +flowchart TB
    +  A@{ row: 1, column: 1 }
    +  B@{ row: 1, column 2}
    +  C@{ column: 1, row 2 }
    +  D@{ column: 3, row 1}
    +  E@{ row: 3, column: 3 }
    +
    +  A --> B
    +  A --> C
    +  B --> D
    +  C --> D
    +  D --> E
    diff --git a/e2e/diagrams/grid/14-two-nodes-in-same-cell-edge-outside-cell.mmd b/e2e/diagrams/grid/14-two-nodes-in-same-cell-edge-outside-cell.mmd
    new file mode 100644
    index 00000000000..aa0f128f526
    --- /dev/null
    +++ b/e2e/diagrams/grid/14-two-nodes-in-same-cell-edge-outside-cell.mmd
    @@ -0,0 +1,15 @@
    +---
    +config:
    +  layout: grid
    +  grid:
    +    cellGap: 18
    +    rowGap: 55
    +    columnGap: 80
    +---
    +flowchart TB
    +  A@{ row: 1, column: 1, horizontalAlign: left, verticalAlign: center }
    +  B@{ row: 1, column: 1, horizontalAlign: center, verticalAlign: center }
    +  D@{ row: 1, column: 2 }
    +
    +  A --> B
    +  A --> D
    diff --git a/e2e/diagrams/grid/15-two-nodes-in-same-column-linking-to-node-in-seperate-column.mmd b/e2e/diagrams/grid/15-two-nodes-in-same-column-linking-to-node-in-seperate-column.mmd
    new file mode 100644
    index 00000000000..9e82a4c9615
    --- /dev/null
    +++ b/e2e/diagrams/grid/15-two-nodes-in-same-column-linking-to-node-in-seperate-column.mmd
    @@ -0,0 +1,15 @@
    +---
    +config:
    +  layout: grid
    +  grid:
    +    columns: 3
    +    rowGap: 45
    +    columnGap: 55
    +---
    +flowchart TB
    +  A@{ row: 1, column: 1 }
    +  C@{ row: 2, column: 1 }
    +  D@{ row: 1, column: 3 }
    +
    +  A --> D
    +  C --> D
    diff --git a/e2e/diagrams/grid/16-path-with-long-label-causing-expansion.mmd b/e2e/diagrams/grid/16-path-with-long-label-causing-expansion.mmd
    new file mode 100644
    index 00000000000..4bfe7b62c80
    --- /dev/null
    +++ b/e2e/diagrams/grid/16-path-with-long-label-causing-expansion.mmd
    @@ -0,0 +1,6 @@
    +---
    +config:
    +  layout: grid
    +---
    +flowchart TB
    +  a -->|a very long label| b
    diff --git a/e2e/diagrams/grid/17-requirement-diagram-using-automatic-placement.mmd b/e2e/diagrams/grid/17-requirement-diagram-using-automatic-placement.mmd
    new file mode 100644
    index 00000000000..26ee7d87edb
    --- /dev/null
    +++ b/e2e/diagrams/grid/17-requirement-diagram-using-automatic-placement.mmd
    @@ -0,0 +1,20 @@
    +---
    +config:
    +  layout: grid
    +  grid:
    +    columnGap: 200
    +---
    +requirementDiagram
    +  requirement checkout {
    +    id: 1
    +    text: "Customers can complete an order"
    +    risk: high
    +    verifymethod: test
    +  }
    +
    +  element storefront {
    +    type: service
    +    docref: "checkout"
    +  }
    +
    +  storefront - satisfies -> checkout
    diff --git a/e2e/diagrams/grid/18-state-diagram-direct-edge-around-label.mmd b/e2e/diagrams/grid/18-state-diagram-direct-edge-around-label.mmd
    new file mode 100644
    index 00000000000..c2a6ff36936
    --- /dev/null
    +++ b/e2e/diagrams/grid/18-state-diagram-direct-edge-around-label.mmd
    @@ -0,0 +1,14 @@
    +---
    +config:
    +  layout: grid
    +  grid:
    +    columnGap: 300
    +    placements:
    +      Draft: { row: 1, column: 1 }
    +      Review: { row: 1, column: 2 }
    +      Published: { row: 2, column: 2 }
    +---
    +stateDiagram-v2
    +  Draft --> Review
    +  Review --> Published
    +  Review --> Draft : changes requested
    diff --git a/e2e/diagrams/grid/19-tl-br-routing-crossing-lines-dont-use-same-path.mmd b/e2e/diagrams/grid/19-tl-br-routing-crossing-lines-dont-use-same-path.mmd
    new file mode 100644
    index 00000000000..aa3f7fd0127
    --- /dev/null
    +++ b/e2e/diagrams/grid/19-tl-br-routing-crossing-lines-dont-use-same-path.mmd
    @@ -0,0 +1,13 @@
    +---
    +config:
    +  layout: grid
    +---
    +flowchart TB
    +  TL["Top left"]@{ row: 1, column: 1 }
    +  TR["Top right"]@{ row: 1, column: 2 }
    +  BL["Bottom left"]@{ row: 2, column: 1 }
    +  BR["Bottom right"]@{ row: 2, column: 2 }
    +
    +  TL --> BR
    +  TL --> BL
    +  BL --> TR
    diff --git a/e2e/diagrams/grid/20-usecase-works.mmd b/e2e/diagrams/grid/20-usecase-works.mmd
    new file mode 100644
    index 00000000000..aa340b5d181
    --- /dev/null
    +++ b/e2e/diagrams/grid/20-usecase-works.mmd
    @@ -0,0 +1,19 @@
    +---
    +config:
    +  layout: grid
    +  grid:
    +    placements:
    +      Customer: { row: 2, column: 1 }
    +      Browse: { row: 1, column: 2 }
    +      Checkout: { row: 2, column: 2 }
    +      Track: { row: 3, column: 2 }
    +---
    +usecase-beta
    +  actor Customer
    +  Browse("Browse products")
    +  Checkout("Complete checkout")
    +  Track("Track order")
    +
    +  Customer --> Browse
    +  Customer --> Checkout
    +  Customer --> Track
    diff --git a/e2e/diagrams/grid/21-multiple-parallel-routes.mmd b/e2e/diagrams/grid/21-multiple-parallel-routes.mmd
    new file mode 100644
    index 00000000000..2b88e5ffb5f
    --- /dev/null
    +++ b/e2e/diagrams/grid/21-multiple-parallel-routes.mmd
    @@ -0,0 +1,16 @@
    +---
    +config:
    +  layout: grid
    +  securityLevel: loose
    +  grid:
    +    rowGap: 72
    +    columnGap: 96
    +---
    +flowchart LR
    +  Source([Source])@{ row: 2, column: 5 }
    +  Target([Target])@{ row: 2, column: 6 }
    +
    +  Source --> Target
    +  Source --> Target
    +  Source --> Target
    +  Target --> Source
    diff --git a/e2e/diagrams/grid/22-html-label.mmd b/e2e/diagrams/grid/22-html-label.mmd
    new file mode 100644
    index 00000000000..969e35c28cc
    --- /dev/null
    +++ b/e2e/diagrams/grid/22-html-label.mmd
    @@ -0,0 +1,10 @@
    +---
    +config:
    +  layout: grid
    +  securityLevel: loose
    +  grid:
    +    rowGap: 72
    +    columnGap: 96
    +---
    +flowchart LR
    +  M -->|HTML
    label
    | O diff --git a/e2e/diagrams/grid/23-nested-groups-edges-from-left-out-node-into-group-nodes.mmd b/e2e/diagrams/grid/23-nested-groups-edges-from-left-out-node-into-group-nodes.mmd new file mode 100644 index 00000000000..0c8c07f0e02 --- /dev/null +++ b/e2e/diagrams/grid/23-nested-groups-edges-from-left-out-node-into-group-nodes.mmd @@ -0,0 +1,25 @@ +--- +config: + layout: grid + grid: + placements: + Work: { row: 1, column: 2 } + C: { row: 1, column: 1 } + Nested: { row: 2, column: 1 } + D: { row: 1, column: 1 } + E: { row: 2, column: 1 } + rowGap: 45 + columnGap: 70 +--- +flowchart LR + C + + subgraph Work + subgraph Nested + D + E + end + end + + C --> D + C --> E diff --git a/e2e/diagrams/grid/24-nested-groups-edges-into-inner-group-and-out.mmd b/e2e/diagrams/grid/24-nested-groups-edges-into-inner-group-and-out.mmd new file mode 100644 index 00000000000..12e0658b393 --- /dev/null +++ b/e2e/diagrams/grid/24-nested-groups-edges-into-inner-group-and-out.mmd @@ -0,0 +1,25 @@ +--- +config: + layout: grid + grid: + placements: + Work: { row: 1, column: 2 } + Output: { row: 1, column: 3 } + C: { row: 1, column: 1 } + Nested: { row: 2, column: 1 } + D: { row: 1, column: 1 } + E: { row: 2, column: 1 } + F: { row: 2, column: 2 } + rowGap: 45 + columnGap: 70 +--- +flowchart LR + C + subgraph Nested + D + E + end + F + + C --> E + D --> F diff --git a/e2e/diagrams/grid/25-off-centre-shape-ports-step-curve.mmd b/e2e/diagrams/grid/25-off-centre-shape-ports-step-curve.mmd new file mode 100644 index 00000000000..6f9ea1951a7 --- /dev/null +++ b/e2e/diagrams/grid/25-off-centre-shape-ports-step-curve.mmd @@ -0,0 +1,33 @@ +--- +config: + layout: grid + grid: + curve: step + rowGap: 48 + columnGap: 120 +--- +flowchart LR + D{"Diamond"}@{ row: 2, column: 1 } + DT["Diamond top"]@{ row: 1, column: 2 } + DM["Diamond middle"]@{ row: 2, column: 2 } + DB["Diamond bottom"]@{ row: 3, column: 2 } + + C(("Circle"))@{ row: 5, column: 1 } + CT["Circle top"]@{ row: 4, column: 2 } + CM["Circle middle"]@{ row: 5, column: 2 } + CB["Circle bottom"]@{ row: 6, column: 2 } + + H{{"Hexagon"}}@{ row: 8, column: 1 } + HT["Hexagon top"]@{ row: 7, column: 2 } + HM["Hexagon middle"]@{ row: 8, column: 2 } + HB["Hexagon bottom"]@{ row: 9, column: 2 } + + D --> DT + D --> DM + D --> DB + C --> CT + C --> CM + C --> CB + H --> HT + H --> HM + H --> HB diff --git a/e2e/diagrams/grid/26-off-centre-shape-ports-corner-radius.mmd b/e2e/diagrams/grid/26-off-centre-shape-ports-corner-radius.mmd new file mode 100644 index 00000000000..dbf48cbeebe --- /dev/null +++ b/e2e/diagrams/grid/26-off-centre-shape-ports-corner-radius.mmd @@ -0,0 +1,34 @@ +--- +config: + layout: grid + grid: + curve: rounded + edgeCornerRadius: 18 + rowGap: 48 + columnGap: 120 +--- +flowchart LR + D{"Diamond"}@{ row: 2, column: 1 } + DT["Diamond top"]@{ row: 1, column: 2 } + DM["Diamond middle"]@{ row: 2, column: 2 } + DB["Diamond bottom"]@{ row: 3, column: 2 } + + C(("Circle"))@{ row: 5, column: 1 } + CT["Circle top"]@{ row: 4, column: 2 } + CM["Circle middle"]@{ row: 5, column: 2 } + CB["Circle bottom"]@{ row: 6, column: 2 } + + H{{"Hexagon"}}@{ row: 8, column: 1 } + HT["Hexagon top"]@{ row: 7, column: 2 } + HM["Hexagon middle"]@{ row: 8, column: 2 } + HB["Hexagon bottom"]@{ row: 9, column: 2 } + + D --> DT + D --> DM + D --> DB + C --> CT + C --> CM + C --> CB + H --> HT + H --> HM + H --> HB diff --git a/e2e/diagrams/grid/27-mindmap-occurrence-indexed-placement.mmd b/e2e/diagrams/grid/27-mindmap-occurrence-indexed-placement.mmd new file mode 100644 index 00000000000..d63b7b1a03a --- /dev/null +++ b/e2e/diagrams/grid/27-mindmap-occurrence-indexed-placement.mmd @@ -0,0 +1,17 @@ +--- +config: + layout: grid + look: handDrawn + theme: forest + grid: + placements: + root: { row: 2, column: 2 } + service: + - { row: 1, column: 1 } + - { row: 3, column: 3 } +--- +mindmap + root((Services)) + service[First occurrence] + service[Second occurrence] + service[Automatically placed] diff --git a/e2e/helpers/util.ts b/e2e/helpers/util.ts index 3a0f2b469e1..a9c91733951 100644 --- a/e2e/helpers/util.ts +++ b/e2e/helpers/util.ts @@ -157,7 +157,8 @@ export const openURLAndVerifyRendering = async ( }); } catch { try { - await diagramSvg(page).first().waitFor({ state: 'visible', timeout: 15_000 }); + const renderedSvg = diagramSvg(page).first(); + await renderedSvg.waitFor({ state: 'visible', timeout: 15_000 }); } catch (error) { const details = [...pageErrors, ...consoleErrors]; if (details.length > 0) { diff --git a/e2e/platform/dev-diagrams/layout-tests/ddlt-manifest.json b/e2e/platform/dev-diagrams/layout-tests/ddlt-manifest.json index 16adf344ed9..4727d623c5b 100644 --- a/e2e/platform/dev-diagrams/layout-tests/ddlt-manifest.json +++ b/e2e/platform/dev-diagrams/layout-tests/ddlt-manifest.json @@ -29,6 +29,54 @@ "id": "swimlanes/13-disagree-exits-b-c-d", "profile": "swimlanes", "allowLevel1Failure": true + }, + { + "id": "grid/simple", + "profile": "grid" + }, + { + "id": "grid/group-stack", + "profile": "grid" + }, + { + "id": "grid/placement-matrix-tb", + "profile": "grid" + }, + { + "id": "grid/placement-matrix-lr", + "profile": "grid" + }, + { + "id": "grid/singleton-alignments", + "profile": "grid" + }, + { + "id": "grid/stack-default", + "profile": "grid" + }, + { + "id": "grid/stack-gap-zero", + "profile": "grid" + }, + { + "id": "grid/routing-group-member", + "profile": "grid" + }, + { + "id": "grid/routing-outside-member", + "profile": "grid" + }, + { + "id": "grid/routing-loops-parallel-lr", + "profile": "grid" + }, + { + "id": "grid/routing-cell-aware-empty-cell", + "profile": "grid" + }, + { + "id": "grid/routing-hierarchy-portals", + "profile": "grid" } ] } diff --git a/e2e/platform/dev-diagrams/layout-tests/grid/group-stack.mmd b/e2e/platform/dev-diagrams/layout-tests/grid/group-stack.mmd new file mode 100644 index 00000000000..a5f5dcd63b6 --- /dev/null +++ b/e2e/platform/dev-diagrams/layout-tests/grid/group-stack.mmd @@ -0,0 +1,17 @@ +--- +config: + layout: grid + grid: + columnGap: 40 + cellGap: 12 +--- +flowchart TB + A["Alpha"]@{ row: 1, column: 1, horizontalAlign: left, verticalAlign: bottom } + + subgraph G["Nested"] + G1["Inner member"]@{ row: 1, column: 1 } + end + G@{ row: 1, column: 1, horizontalAlign: center, verticalAlign: bottom } + + B["Beta wider label"]@{ row: 1, column: 1, horizontalAlign: right, verticalAlign: bottom } + C["Tall peer
    line two
    line three"]@{ row: 1, column: 2 } diff --git a/e2e/platform/dev-diagrams/layout-tests/grid/group-stack.sizes.json b/e2e/platform/dev-diagrams/layout-tests/grid/group-stack.sizes.json new file mode 100644 index 00000000000..2c03de92bfa --- /dev/null +++ b/e2e/platform/dev-diagrams/layout-tests/grid/group-stack.sizes.json @@ -0,0 +1,30 @@ +{ + "metadata": { + "captureVersion": 1, + "capturedAt": "2026-09-21T01:20:20.835Z", + "capturedFrom": "browser-capture layout-tests/grid/group-stack.mmd theme=default look=classic", + "sourceSha256": "60870dd07eb2e245e3aaf942d4000b94087c1c521c7c4ed629006f4243688a21" + }, + "nodes": [ + { + "id": "A", + "width": 152, + "height": 45 + }, + { + "id": "G1", + "width": 152, + "height": 45 + }, + { + "id": "B", + "width": 152, + "height": 45 + }, + { + "id": "C", + "width": 152, + "height": 87 + } + ] +} diff --git a/e2e/platform/dev-diagrams/layout-tests/grid/placement-matrix-lr.mmd b/e2e/platform/dev-diagrams/layout-tests/grid/placement-matrix-lr.mmd new file mode 100644 index 00000000000..aeed8973722 --- /dev/null +++ b/e2e/platform/dev-diagrams/layout-tests/grid/placement-matrix-lr.mmd @@ -0,0 +1,18 @@ +--- +config: + layout: grid + grid: + columns: 3 + rowGap: 32 + columnGap: 36 +--- +flowchart LR + A["Short"]@{ row: 1, column: 1 } + B["Row only wide label"]@{ row: 1 } + C["Column only"]@{ column: 3 } + D["Auto one"] + E["Auto two"] + F["Sparse tall
    node"]@{ row: 9, column: 7 } + G["Disconnected"] + + A --> D diff --git a/e2e/platform/dev-diagrams/layout-tests/grid/placement-matrix-lr.sizes.json b/e2e/platform/dev-diagrams/layout-tests/grid/placement-matrix-lr.sizes.json new file mode 100644 index 00000000000..01b3d110e28 --- /dev/null +++ b/e2e/platform/dev-diagrams/layout-tests/grid/placement-matrix-lr.sizes.json @@ -0,0 +1,45 @@ +{ + "metadata": { + "captureVersion": 1, + "capturedAt": "2026-09-21T01:20:21.059Z", + "capturedFrom": "browser-capture layout-tests/grid/placement-matrix-lr.mmd theme=default look=classic", + "sourceSha256": "b9f60bf5a8529c5d8737a47854e8ff75fd5620194ebee00f86f604d3ae78aa13" + }, + "nodes": [ + { + "id": "A", + "width": 152, + "height": 45 + }, + { + "id": "B", + "width": 152, + "height": 66 + }, + { + "id": "C", + "width": 152, + "height": 45 + }, + { + "id": "D", + "width": 152, + "height": 45 + }, + { + "id": "E", + "width": 152, + "height": 45 + }, + { + "id": "F", + "width": 152, + "height": 66 + }, + { + "id": "G", + "width": 152, + "height": 45 + } + ] +} diff --git a/e2e/platform/dev-diagrams/layout-tests/grid/placement-matrix-tb.mmd b/e2e/platform/dev-diagrams/layout-tests/grid/placement-matrix-tb.mmd new file mode 100644 index 00000000000..a65be24c371 --- /dev/null +++ b/e2e/platform/dev-diagrams/layout-tests/grid/placement-matrix-tb.mmd @@ -0,0 +1,18 @@ +--- +config: + layout: grid + grid: + columns: 3 + rowGap: 32 + columnGap: 36 +--- +flowchart TB + A["Short"]@{ row: 1, column: 1 } + B["Row only wide label"]@{ row: 1 } + C["Column only"]@{ column: 3 } + D["Auto one"] + E["Auto two"] + F["Sparse tall
    node"]@{ row: 9, column: 7 } + G["Disconnected"] + + A --> D diff --git a/e2e/platform/dev-diagrams/layout-tests/grid/placement-matrix-tb.sizes.json b/e2e/platform/dev-diagrams/layout-tests/grid/placement-matrix-tb.sizes.json new file mode 100644 index 00000000000..ff3ac221d45 --- /dev/null +++ b/e2e/platform/dev-diagrams/layout-tests/grid/placement-matrix-tb.sizes.json @@ -0,0 +1,45 @@ +{ + "metadata": { + "captureVersion": 1, + "capturedAt": "2026-09-21T01:20:21.241Z", + "capturedFrom": "browser-capture layout-tests/grid/placement-matrix-tb.mmd theme=default look=classic", + "sourceSha256": "abd1af03fb3203c649be41f15fce24226881d44866afd73e83b1d07fb5d3b1b6" + }, + "nodes": [ + { + "id": "A", + "width": 152, + "height": 45 + }, + { + "id": "B", + "width": 152, + "height": 66 + }, + { + "id": "C", + "width": 152, + "height": 45 + }, + { + "id": "D", + "width": 152, + "height": 45 + }, + { + "id": "E", + "width": 152, + "height": 45 + }, + { + "id": "F", + "width": 152, + "height": 66 + }, + { + "id": "G", + "width": 152, + "height": 45 + } + ] +} diff --git a/e2e/platform/dev-diagrams/layout-tests/grid/routing-cell-aware-empty-cell.mmd b/e2e/platform/dev-diagrams/layout-tests/grid/routing-cell-aware-empty-cell.mmd new file mode 100644 index 00000000000..705fdc3ea7b --- /dev/null +++ b/e2e/platform/dev-diagrams/layout-tests/grid/routing-cell-aware-empty-cell.mmd @@ -0,0 +1,13 @@ +--- +config: + layout: grid +--- +flowchart TB + v1["V1"]@{ row: 1, column: 1 } + v2p["V2 Preview"]@{ row: 2, column: 2} + v2["V2"]@{ row: 1, column: 3 } + v3["V3"]@{ row: 1, column: 4 } + + v1 --> v2 + v2 --> v3 + v1 --> v2p diff --git a/e2e/platform/dev-diagrams/layout-tests/grid/routing-cell-aware-empty-cell.sizes.json b/e2e/platform/dev-diagrams/layout-tests/grid/routing-cell-aware-empty-cell.sizes.json new file mode 100644 index 00000000000..6e966ca96eb --- /dev/null +++ b/e2e/platform/dev-diagrams/layout-tests/grid/routing-cell-aware-empty-cell.sizes.json @@ -0,0 +1,29 @@ +{ + "nodes": [ + { + "id": "v1", + "width": 180, + "height": 54 + }, + { + "id": "v2p", + "width": 180, + "height": 54 + }, + { + "id": "v2", + "width": 180, + "height": 54 + }, + { + "id": "v3", + "width": 180, + "height": 54 + } + ], + "metadata": { + "captureVersion": 1, + "sourceSha256": "970887280b509d6f0c679e81127bec6779d276eba43a5bf6925f3dac7182c046", + "capturedFrom": "synthetic routing fixture; fixed nodes=180x54" + } +} diff --git a/e2e/platform/dev-diagrams/layout-tests/grid/routing-group-member.mmd b/e2e/platform/dev-diagrams/layout-tests/grid/routing-group-member.mmd new file mode 100644 index 00000000000..a6cccc4f516 --- /dev/null +++ b/e2e/platform/dev-diagrams/layout-tests/grid/routing-group-member.mmd @@ -0,0 +1,17 @@ +--- +config: + layout: grid + grid: + rowGap: 72 + columnGap: 96 +--- +flowchart TB + subgraph G["Group"] + M["Member"]@{ row: 1, column: 1 } + end + G@{ row: 1, column: 1 } + O["Outside"]@{ row: 2, column: 4 } + + G --> M + M --> G + M -->|very long HTML label that must detour cleanly| O diff --git a/e2e/platform/dev-diagrams/layout-tests/grid/routing-group-member.sizes.json b/e2e/platform/dev-diagrams/layout-tests/grid/routing-group-member.sizes.json new file mode 100644 index 00000000000..59f07b94172 --- /dev/null +++ b/e2e/platform/dev-diagrams/layout-tests/grid/routing-group-member.sizes.json @@ -0,0 +1,24 @@ +{ + "nodes": [ + { + "id": "M", + "width": 180, + "height": 54 + }, + { + "id": "O", + "width": 180, + "height": 54 + }, + { + "id": "edge-label-M-O-L_M_O_0", + "width": 120, + "height": 120 + } + ], + "metadata": { + "captureVersion": 1, + "sourceSha256": "608674cd1dbeb32a9fc508e6e2211a4f7d4e42b382571e585d14fab0cb29f27b", + "capturedFrom": "synthetic routing fixture; fixed nodes=180x54 edge-label=120x120" + } +} diff --git a/e2e/platform/dev-diagrams/layout-tests/grid/routing-hierarchy-portals.mmd b/e2e/platform/dev-diagrams/layout-tests/grid/routing-hierarchy-portals.mmd new file mode 100644 index 00000000000..ef130107107 --- /dev/null +++ b/e2e/platform/dev-diagrams/layout-tests/grid/routing-hierarchy-portals.mmd @@ -0,0 +1,22 @@ +--- +config: + layout: grid + grid: + rowGap: 72 + columnGap: 96 +--- +flowchart LR + subgraph LEFT["Left group"] + subgraph NESTED["Nested group"] + SOURCE["Source"]@{ row: 1, column: 1 } + end + NESTED@{ row: 1, column: 1 } + end + LEFT@{ row: 1, column: 1 } + + subgraph RIGHT["Right group"] + TARGET["Target"]@{ row: 1, column: 1 } + end + RIGHT@{ row: 1, column: 2 } + + SOURCE --> TARGET diff --git a/e2e/platform/dev-diagrams/layout-tests/grid/routing-hierarchy-portals.sizes.json b/e2e/platform/dev-diagrams/layout-tests/grid/routing-hierarchy-portals.sizes.json new file mode 100644 index 00000000000..087e4dc9a7a --- /dev/null +++ b/e2e/platform/dev-diagrams/layout-tests/grid/routing-hierarchy-portals.sizes.json @@ -0,0 +1,20 @@ +{ + "metadata": { + "captureVersion": 1, + "capturedAt": "2026-09-22T03:36:00.000Z", + "capturedFrom": "browser-capture layout-tests/grid/routing-hierarchy-portals.mmd theme=default look=classic", + "sourceSha256": "c7dab532783a775a310b98da12cd6871ecf666c23da1136d9692d76dc74c265e" + }, + "nodes": [ + { + "id": "SOURCE", + "width": 152, + "height": 45 + }, + { + "id": "TARGET", + "width": 152, + "height": 45 + } + ] +} diff --git a/e2e/platform/dev-diagrams/layout-tests/grid/routing-loops-parallel-lr.mmd b/e2e/platform/dev-diagrams/layout-tests/grid/routing-loops-parallel-lr.mmd new file mode 100644 index 00000000000..72a1f38c7aa --- /dev/null +++ b/e2e/platform/dev-diagrams/layout-tests/grid/routing-loops-parallel-lr.mmd @@ -0,0 +1,20 @@ +--- +config: + layout: grid + grid: + rowGap: 40 + columnGap: 60 +--- +flowchart LR + Loop["Loop node"]@{ row: 1, column: 1 } + Source([Source])@{ row: 1, column: 2 } + Target([Target])@{ row: 1, column: 3 } + Lone["Disconnected tail"]@{ row: 2, column: 1 } + + Loop --> Loop + Loop --> Loop + Loop --> Loop + Source --> Target + Source --> Target + Source --> Target + Target --> Source diff --git a/e2e/platform/dev-diagrams/layout-tests/grid/routing-loops-parallel-lr.sizes.json b/e2e/platform/dev-diagrams/layout-tests/grid/routing-loops-parallel-lr.sizes.json new file mode 100644 index 00000000000..a5432127414 --- /dev/null +++ b/e2e/platform/dev-diagrams/layout-tests/grid/routing-loops-parallel-lr.sizes.json @@ -0,0 +1,30 @@ +{ + "metadata": { + "captureVersion": 1, + "capturedAt": "2026-09-21T01:25:29.195Z", + "capturedFrom": "browser-capture layout-tests/grid/routing-loops-parallel-lr.mmd theme=default look=classic", + "sourceSha256": "608f3406afa9ed39385b165a04c399f6bd789b3d060b6769fc0beda39248e26b" + }, + "nodes": [ + { + "id": "Loop", + "width": 152, + "height": 45 + }, + { + "id": "Source", + "width": 171.2268829345703, + "height": 45 + }, + { + "id": "Target", + "width": 171.2268829345703, + "height": 45 + }, + { + "id": "Lone", + "width": 152, + "height": 66 + } + ] +} diff --git a/e2e/platform/dev-diagrams/layout-tests/grid/routing-outside-member.mmd b/e2e/platform/dev-diagrams/layout-tests/grid/routing-outside-member.mmd new file mode 100644 index 00000000000..3c317818941 --- /dev/null +++ b/e2e/platform/dev-diagrams/layout-tests/grid/routing-outside-member.mmd @@ -0,0 +1,15 @@ +--- +config: + layout: grid + grid: + rowGap: 40 + columnGap: 48 +--- +flowchart TB + subgraph G["Group"] + M["Member"]@{ row: 1, column: 1 } + end + G@{ row: 1, column: 1 } + O["Outside"]@{ row: 1, column: 2 } + + O --> M diff --git a/e2e/platform/dev-diagrams/layout-tests/grid/routing-outside-member.sizes.json b/e2e/platform/dev-diagrams/layout-tests/grid/routing-outside-member.sizes.json new file mode 100644 index 00000000000..e6aeef93e38 --- /dev/null +++ b/e2e/platform/dev-diagrams/layout-tests/grid/routing-outside-member.sizes.json @@ -0,0 +1,20 @@ +{ + "metadata": { + "captureVersion": 1, + "capturedAt": "2026-09-21T01:20:21.802Z", + "capturedFrom": "browser-capture layout-tests/grid/routing-outside-member.mmd theme=default look=classic", + "sourceSha256": "2d7a7fb4d22df53d5972ea7447b54dfe2de8ae16ba52bb7497562bbad123540b" + }, + "nodes": [ + { + "id": "M", + "width": 152, + "height": 45 + }, + { + "id": "O", + "width": 152, + "height": 45 + } + ] +} diff --git a/e2e/platform/dev-diagrams/layout-tests/grid/simple.mmd b/e2e/platform/dev-diagrams/layout-tests/grid/simple.mmd new file mode 100644 index 00000000000..3f10d6751e6 --- /dev/null +++ b/e2e/platform/dev-diagrams/layout-tests/grid/simple.mmd @@ -0,0 +1,13 @@ +--- +config: + layout: grid + grid: + rowGap: 40 + columnGap: 40 +--- +flowchart TB + A["Start"]@{ row: 1, column: 1 } + B["Review"]@{ row: 2, column: 1 } + C["Done"]@{ row: 2, column: 2 } + A --> B + B --> C diff --git a/e2e/platform/dev-diagrams/layout-tests/grid/simple.sizes.json b/e2e/platform/dev-diagrams/layout-tests/grid/simple.sizes.json new file mode 100644 index 00000000000..e736cb09ec6 --- /dev/null +++ b/e2e/platform/dev-diagrams/layout-tests/grid/simple.sizes.json @@ -0,0 +1,24 @@ +{ + "nodes": [ + { + "id": "A", + "width": 180, + "height": 54 + }, + { + "id": "B", + "width": 180, + "height": 54 + }, + { + "id": "C", + "width": 180, + "height": 54 + } + ], + "metadata": { + "captureVersion": 1, + "sourceSha256": "02fd41c407cad767a825bab2c2526fdd9f6f749b241769ba1d8ab7d540d35219", + "capturedFrom": "synthetic routing fixture; fixed nodes=180x54" + } +} diff --git a/e2e/platform/dev-diagrams/layout-tests/grid/singleton-alignments.mmd b/e2e/platform/dev-diagrams/layout-tests/grid/singleton-alignments.mmd new file mode 100644 index 00000000000..7d66ff7a623 --- /dev/null +++ b/e2e/platform/dev-diagrams/layout-tests/grid/singleton-alignments.mmd @@ -0,0 +1,25 @@ +--- +config: + layout: grid + grid: + rowGap: 32 + columnGap: 36 +--- +flowchart TB + LT["LT"]@{ row: 1, column: 1, horizontalAlign: left, verticalAlign: top } + CT["CT"]@{ row: 1, column: 2, horizontalAlign: center, verticalAlign: top } + RT["RT"]@{ row: 1, column: 3, horizontalAlign: right, verticalAlign: top } + LC["LC"]@{ row: 2, column: 1, horizontalAlign: left, verticalAlign: center } + CC["CC"]@{ row: 2, column: 2, horizontalAlign: center, verticalAlign: center } + RC["RC"]@{ row: 2, column: 3, horizontalAlign: right, verticalAlign: center } + LB["LB"]@{ row: 3, column: 1, horizontalAlign: left, verticalAlign: bottom } + CB["CB"]@{ row: 3, column: 2, horizontalAlign: center, verticalAlign: bottom } + RB["RB"]@{ row: 3, column: 3, horizontalAlign: right, verticalAlign: bottom } + + W1([Wide peer])@{ row: 4, column: 1 } + W2([Wide peer])@{ row: 4, column: 2 } + W3([Wide peer])@{ row: 4, column: 3 } + + T1["Tall
    peer
    row one"]@{ row: 1, column: 4 } + T2["Tall
    peer
    row two"]@{ row: 2, column: 4 } + T3["Tall
    peer
    row three"]@{ row: 3, column: 4 } diff --git a/e2e/platform/dev-diagrams/layout-tests/grid/singleton-alignments.sizes.json b/e2e/platform/dev-diagrams/layout-tests/grid/singleton-alignments.sizes.json new file mode 100644 index 00000000000..4dfdf70d36e --- /dev/null +++ b/e2e/platform/dev-diagrams/layout-tests/grid/singleton-alignments.sizes.json @@ -0,0 +1,85 @@ +{ + "metadata": { + "captureVersion": 1, + "capturedAt": "2026-09-21T01:21:22.906Z", + "capturedFrom": "browser-capture layout-tests/grid/singleton-alignments.mmd theme=default look=classic", + "sourceSha256": "a8ad5ddbb78989c2cf3be5fc1172ec12ae7342b4ceb1a8e5f29b1daeae586d96" + }, + "nodes": [ + { + "id": "LT", + "width": 152, + "height": 45 + }, + { + "id": "CT", + "width": 152, + "height": 45 + }, + { + "id": "RT", + "width": 152, + "height": 45 + }, + { + "id": "LC", + "width": 152, + "height": 45 + }, + { + "id": "CC", + "width": 152, + "height": 45 + }, + { + "id": "RC", + "width": 152, + "height": 45 + }, + { + "id": "LB", + "width": 152, + "height": 45 + }, + { + "id": "CB", + "width": 152, + "height": 45 + }, + { + "id": "RB", + "width": 152, + "height": 45 + }, + { + "id": "W1", + "width": 171.2268829345703, + "height": 45 + }, + { + "id": "W2", + "width": 171.2268829345703, + "height": 45 + }, + { + "id": "W3", + "width": 171.2268829345703, + "height": 45 + }, + { + "id": "T1", + "width": 152, + "height": 87 + }, + { + "id": "T2", + "width": 152, + "height": 87 + }, + { + "id": "T3", + "width": 152, + "height": 87 + } + ] +} diff --git a/e2e/platform/dev-diagrams/layout-tests/grid/stack-default.mmd b/e2e/platform/dev-diagrams/layout-tests/grid/stack-default.mmd new file mode 100644 index 00000000000..a8e509ec2b4 --- /dev/null +++ b/e2e/platform/dev-diagrams/layout-tests/grid/stack-default.mmd @@ -0,0 +1,17 @@ +--- +config: + layout: grid +--- +flowchart TB + TopLeft["Top left"]@{ row: 1, column: 1, horizontalAlign: left, verticalAlign: top } + TopRight([Top right])@{ row: 1, column: 1, horizontalAlign: right, verticalAlign: top } + TopPeer["Tall peer
    line two
    line three"]@{ row: 1, column: 2 } + + CenterLeft["Center left"]@{ row: 2, column: 1, horizontalAlign: left, verticalAlign: center } + CenterRight([Center right])@{ row: 2, column: 1, horizontalAlign: right, verticalAlign: center } + CenterPeer["Tall peer
    line two
    line three"]@{ row: 2, column: 2 } + + BottomLeft["Bottom left"]@{ row: 3, column: 1, horizontalAlign: left, verticalAlign: bottom } + BottomCenter["Bottom center"]@{ row: 3, column: 1, horizontalAlign: center, verticalAlign: bottom } + BottomRight([Bottom right])@{ row: 3, column: 1, horizontalAlign: right, verticalAlign: bottom } + BottomPeer["Tall peer
    line two
    line three"]@{ row: 3, column: 2 } diff --git a/e2e/platform/dev-diagrams/layout-tests/grid/stack-default.sizes.json b/e2e/platform/dev-diagrams/layout-tests/grid/stack-default.sizes.json new file mode 100644 index 00000000000..b88ecb661f7 --- /dev/null +++ b/e2e/platform/dev-diagrams/layout-tests/grid/stack-default.sizes.json @@ -0,0 +1,60 @@ +{ + "metadata": { + "captureVersion": 1, + "capturedAt": "2026-09-21T01:21:52.083Z", + "capturedFrom": "browser-capture layout-tests/grid/stack-default.mmd theme=default look=classic", + "sourceSha256": "1ed4ec683b1811c8582ad565b15a136f9decf9def094b0bcf3f9cd3a250a5570" + }, + "nodes": [ + { + "id": "TopLeft", + "width": 152, + "height": 45 + }, + { + "id": "TopRight", + "width": 171.2268829345703, + "height": 45 + }, + { + "id": "TopPeer", + "width": 152, + "height": 87 + }, + { + "id": "CenterLeft", + "width": 152, + "height": 45 + }, + { + "id": "CenterRight", + "width": 171.2268829345703, + "height": 45 + }, + { + "id": "CenterPeer", + "width": 152, + "height": 87 + }, + { + "id": "BottomLeft", + "width": 152, + "height": 45 + }, + { + "id": "BottomCenter", + "width": 152, + "height": 45 + }, + { + "id": "BottomRight", + "width": 171.2268829345703, + "height": 45 + }, + { + "id": "BottomPeer", + "width": 152, + "height": 87 + } + ] +} diff --git a/e2e/platform/dev-diagrams/layout-tests/grid/stack-gap-zero.mmd b/e2e/platform/dev-diagrams/layout-tests/grid/stack-gap-zero.mmd new file mode 100644 index 00000000000..6416bebadaa --- /dev/null +++ b/e2e/platform/dev-diagrams/layout-tests/grid/stack-gap-zero.mmd @@ -0,0 +1,10 @@ +--- +config: + layout: grid + grid: + cellGap: 0 +--- +flowchart TB + A["Gap zero first"]@{ row: 1, column: 1, verticalAlign: center } + B["Gap zero second"]@{ row: 1, column: 1, verticalAlign: center } + Peer["Tall peer
    line two"]@{ row: 1, column: 2 } diff --git a/e2e/platform/dev-diagrams/layout-tests/grid/stack-gap-zero.sizes.json b/e2e/platform/dev-diagrams/layout-tests/grid/stack-gap-zero.sizes.json new file mode 100644 index 00000000000..5c99a79a484 --- /dev/null +++ b/e2e/platform/dev-diagrams/layout-tests/grid/stack-gap-zero.sizes.json @@ -0,0 +1,25 @@ +{ + "metadata": { + "captureVersion": 1, + "capturedAt": "2026-09-21T01:20:22.528Z", + "capturedFrom": "browser-capture layout-tests/grid/stack-gap-zero.mmd theme=default look=classic", + "sourceSha256": "b1914185dd8cca0f8a85ea8fa9fcaadb3b484411b93ad81a3a540a5b92d2146b" + }, + "nodes": [ + { + "id": "A", + "width": 152, + "height": 45 + }, + { + "id": "B", + "width": 152, + "height": 45 + }, + { + "id": "Peer", + "width": 152, + "height": 66 + } + ] +} diff --git a/e2e/rendering/layout/grid-layout.spec.ts b/e2e/rendering/layout/grid-layout.spec.ts new file mode 100644 index 00000000000..263d6fa1a3f --- /dev/null +++ b/e2e/rendering/layout/grid-layout.spec.ts @@ -0,0 +1,551 @@ +import { readFile } from 'node:fs/promises'; +import { test, expect } from '@playwright/test'; +import { assertDiagramNotError, diagramSvg, renderGraph } from '../../helpers/util.ts'; + +const assertFiniteViewBox = async (page: Parameters[0]) => { + const svg = diagramSvg(page).first(); + const viewBox = (await svg.getAttribute('viewBox')) ?? ''; + const numbers = viewBox.split(/\s+/).map((value) => Number(value)); + expect(numbers).toHaveLength(4); + for (const value of numbers) { + expect(Number.isFinite(value)).toBe(true); + } + expect(numbers[2]).toBeGreaterThan(0); + expect(numbers[3]).toBeGreaterThan(0); +}; + +const assertFiniteEdgePaths = async (page: Parameters[0]) => { + const pathData = await page + .locator('svg .edgePaths path') + .evaluateAll((paths) => paths.map((path) => path.getAttribute('d') ?? '')); + expect(pathData.length).toBeGreaterThan(0); + for (const d of pathData) { + expect(d.length).toBeGreaterThan(0); + expect(d.includes('NaN')).toBe(false); + expect(d.includes('undefined')).toBe(false); + } +}; + +const nodeCenter = async (page: Parameters[0], label: string) => { + const box = await page.locator('svg g.node').filter({ hasText: label }).first().boundingBox(); + expect(box).not.toBeNull(); + return { + x: box!.x + box!.width / 2, + y: box!.y + box!.height / 2, + }; +}; + +test.describe('grid layout rendering', () => { + test('renders the error diagram for an invalid grid placement', async ({ page }, testInfo) => { + await renderGraph( + page, + testInfo, + `--- +config: + layout: grid +--- +flowchart TB + A["Invalid placement"]@{ row: 0, column: 1 }`, + { rejectErrorDiagram: false, screenshot: false } + ); + + const svg = page.locator('svg').first(); + await expect(svg.locator('.error-icon').first()).toBeVisible(); + await expect(svg.locator('.error-text').first()).toHaveText('Syntax error in text'); + await expect(svg.locator('.error-text')).toContainText([ + 'GRID_INVALID_COORDINATE: Invalid row for placement "A"', + ]); + }); + + test('flowchart grid keeps accessibility text, links, callbacks, and inline placement ordering', async ({ + page, + }, testInfo) => { + await renderGraph( + page, + testInfo, + `--- +config: + layout: grid + grid: + rowGap: 32 + columnGap: 40 +--- +flowchart TB + accTitle: Grid flowchart + accDescr: Flowchart rendered with the built-in grid layout. + A["Start"]@{ row: 1, column: 1 } + B["Review"]@{ row: 2, column: 1 } + C["Docs"]@{ row: 2, column: 2 } + D["Callback"]@{ row: 3, column: 1 } + click C href "https://example.com" "Open docs" + click D call gridCallback() "Run callback" + A --> B + B --> C + B --> D`, + { securityLevel: 'loose', screenshot: false } + ); + + const svg = diagramSvg(page).first(); + await expect(svg).toHaveAttribute('aria-roledescription', /flowchart/); + await assertFiniteViewBox(page); + await assertFiniteEdgePaths(page); + await expect(page.locator('svg title')).toContainText('Grid flowchart'); + await expect(page.locator('svg desc')).toContainText( + 'Flowchart rendered with the built-in grid layout.' + ); + await expect(page.locator('svg a')).toHaveCount(1); + + await page.evaluate(() => { + const target = window as Window & { + gridCallback?: (...args: unknown[]) => void; + gridCallbackCalls?: unknown[][]; + }; + target.gridCallbackCalls = []; + target.gridCallback = (...args: unknown[]) => { + target.gridCallbackCalls?.push(args); + }; + }); + const callbackNode = page.locator('svg g.node').filter({ hasText: 'Callback' }).first(); + await expect(callbackNode).toHaveClass(/clickable/); + await callbackNode.click(); + await page.waitForFunction( + () => + ((window as Window & { gridCallbackCalls?: unknown[][] }).gridCallbackCalls?.length ?? + 0) === 1 + ); + const callbackCalls = await page.evaluate( + () => (window as Window & { gridCallbackCalls?: unknown[][] }).gridCallbackCalls ?? [] + ); + expect(callbackCalls).toEqual([['D']]); + + const transforms = await page.locator('svg .node').evaluateAll((nodes) => + nodes + .map((node) => ({ + text: node.textContent ?? '', + transform: node.getAttribute('transform') ?? '', + })) + .filter((entry) => entry.text.includes('Start') || entry.text.includes('Review')) + ); + + const parseTranslate = (value: string) => { + const match = /translate\(([\d.-]+),\s*([\d.-]+)\)/.exec(value); + return match ? { x: Number(match[1]), y: Number(match[2]) } : { x: 0, y: 0 }; + }; + + const start = transforms.find((entry) => entry.text.includes('Start')); + const review = transforms.find((entry) => entry.text.includes('Review')); + expect(start).toBeDefined(); + expect(review).toBeDefined(); + expect(parseTranslate(start!.transform).y).toBeLessThan(parseTranslate(review!.transform).y); + }); + + const columnCases = [ + { + name: 'agentflow', + source: `--- +config: + layout: grid + grid: + columns: 1 +--- +agentflow-beta TB + a["Start"] + b["Review"] + a --> b`, + }, + { + name: 'class', + source: `--- +config: + layout: grid + grid: + columns: 1 +--- +classDiagram + class A + class B + A --> B`, + }, + { + name: 'state', + source: `--- +config: + layout: grid + grid: + columns: 1 +--- +stateDiagram-v2 + [*] --> A + A --> B`, + }, + { + name: 'er', + source: `--- +config: + layout: grid + grid: + columns: 1 +--- +erDiagram + CUSTOMER ||--o{ ORDER : places`, + }, + { + name: 'requirement', + source: `--- +config: + layout: grid + grid: + columns: 1 +--- +requirementDiagram + requirement checkout_req { + id: 1 + text: Orders must be payable online. + risk: high + verifymethod: test + } + element checkout_service { + type: service + } + checkout_service - satisfies -> checkout_req`, + }, + { + name: 'usecase', + source: `--- +config: + layout: grid + grid: + columns: 1 +--- +usecase-beta +direction TB +actor Customer +Login("Sign in") +Customer --> Login`, + }, + ]; + + for (const item of columnCases) { + test(`${item.name} renders with grid.columns=1`, async ({ page }, testInfo) => { + await renderGraph(page, testInfo, item.source, { + screenshot: false, + name: `grid-${item.name}`, + }); + await assertFiniteViewBox(page); + await assertFiniteEdgePaths(page); + }); + } + + const authoredPlacementCases = [ + { + name: 'class-authored-placement-ids', + leftLabel: 'Second class', + rightLabel: 'First class', + source: `--- +config: + layout: grid + grid: + placements: + ClassOne: { row: 1, column: 2 } + ClassTwo: { row: 1, column: 1 } +--- +classDiagram + class ClassOne["First class"] + class ClassTwo["Second class"] + ClassOne --> ClassTwo`, + }, + { + name: 'state-authored-placement-ids', + leftLabel: 'Review state', + rightLabel: 'Draft state', + source: `--- +config: + layout: grid + grid: + placements: + Draft: { row: 1, column: 2 } + Review: { row: 1, column: 1 } +--- +stateDiagram-v2 + state "Draft state" as Draft + state "Review state" as Review + Draft --> Review`, + }, + { + name: 'requirement-authored-placement-ids', + leftLabel: 'checkout_service', + rightLabel: 'checkout_req', + source: `--- +config: + layout: grid + grid: + placements: + checkout_req: { row: 1, column: 2 } + checkout_service: { row: 1, column: 1 } +--- +requirementDiagram + requirement checkout_req { + id: 1 + text: Orders must be payable online. + risk: high + verifymethod: test + } + element checkout_service { + type: service + } + checkout_service - satisfies -> checkout_req`, + }, + { + name: 'usecase-authored-placement-ids', + leftLabel: 'Sign in', + rightLabel: 'Customer', + source: `--- +config: + layout: grid + grid: + placements: + Customer: { row: 1, column: 2 } + Login: { row: 1, column: 1 } +--- +usecase-beta + actor Customer + Login("Sign in") + Customer --> Login`, + }, + ]; + + for (const item of authoredPlacementCases) { + test(`${item.name} uses authored ids for configured grid columns`, async ({ + page, + }, testInfo) => { + await renderGraph(page, testInfo, item.source, { + screenshot: false, + name: `grid-${item.name}`, + }); + await assertFiniteViewBox(page); + await assertFiniteEdgePaths(page); + + const left = await nodeCenter(page, item.leftLabel); + const right = await nodeCenter(page, item.rightLabel); + expect(left.x).toBeLessThan(right.x); + }); + } + + test('ER authored ids determine configured grid rows and columns', async ({ page }, testInfo) => { + await renderGraph( + page, + testInfo, + `--- +config: + layout: grid + grid: + placements: + CUSTOMER: { row: 1, column: 1 } + ORDER: { row: 1, column: 2 } + PAYMENT: { row: 2, column: 2 } + LINE_ITEM: { row: 1, column: 3 } +--- +erDiagram + CUSTOMER ||--o{ ORDER : places + ORDER ||--|{ LINE_ITEM : contains + ORDER ||--o| PAYMENT : has`, + { screenshot: false, name: 'grid-er-authored-placement-ids' } + ); + + const customer = await nodeCenter(page, 'CUSTOMER'); + const order = await nodeCenter(page, 'ORDER'); + const lineItem = await nodeCenter(page, 'LINE_ITEM'); + const payment = await nodeCenter(page, 'PAYMENT'); + expect(customer.x).toBeLessThan(order.x); + expect(order.x).toBeLessThan(lineItem.x); + expect(order.y).toBeLessThan(payment.y); + }); + + test('mindmap authored ids determine configured grid rows and columns', async ({ + page, + }, testInfo) => { + await renderGraph( + page, + testInfo, + `--- +config: + layout: grid + grid: + placements: + root: { row: 2, column: 2 } + Plan: { row: 1, column: 1 } + Build: { row: 3, column: 1 } + Test: { row: 1, column: 3 } + Deploy: { row: 3, column: 3 } +--- +mindmap + root((Release)) + Plan + Build + Test + Deploy`, + { screenshot: false, name: 'grid-mindmap-authored-placement-ids' } + ); + + const release = await nodeCenter(page, 'Release'); + const plan = await nodeCenter(page, 'Plan'); + const build = await nodeCenter(page, 'Build'); + const test = await nodeCenter(page, 'Test'); + const deploy = await nodeCenter(page, 'Deploy'); + expect(plan.x).toBeLessThan(release.x); + expect(release.x).toBeLessThan(test.x); + expect(plan.y).toBeLessThan(release.y); + expect(release.y).toBeLessThan(build.y); + expect(build.y).toBeCloseTo(deploy.y, 0); + }); + + test('grid flowchart also renders with SVG labels', async ({ page }, testInfo) => { + await renderGraph( + page, + testInfo, + `--- +config: + layout: grid + grid: + columns: 1 +--- +flowchart TB + A -->|next| B + B -->|finish| C`, + { htmlLabels: false, screenshot: false, name: 'grid-svg-labels' } + ); + + await assertFiniteViewBox(page); + await assertFiniteEdgePaths(page); + }); + + test('grid renders the medium3 real-corpus flowchart with redux/neo', async ({ page }) => { + const source = await readFile( + new URL('../../platform/dev-diagrams/performance/flowcharts/medium3.mmd', import.meta.url), + 'utf8' + ); + + await page.goto('/iife.html'); + await page.waitForFunction(() => window.mermaid !== undefined); + await page.evaluate( + async ({ diagramSource }) => { + const mount = document.createElement('div'); + mount.id = 'grid-medium3-corpus'; + document.body.innerHTML = ''; + document.body.append(mount); + + await window.mermaid.initialize({ + startOnLoad: false, + layout: 'grid', + theme: 'redux', + look: 'neo', + maxTextSize: 1_000_000, + maxEdges: 1_000_000, + }); + + const { svg, bindFunctions } = await window.mermaid.render( + 'grid-medium3-corpus-svg', + diagramSource + ); + mount.innerHTML = svg; + bindFunctions?.(mount); + (window as Window & { rendered?: boolean }).rendered = true; + }, + { diagramSource: source } + ); + + await assertFiniteViewBox(page); + await assertFiniteEdgePaths(page); + await assertDiagramNotError(page); + }); + + test('grid flowchart keeps group titles, HTML labels, loops, and parallel routes structurally valid', async ({ + page, + }) => { + const source = `--- +config: + layout: grid + grid: + rowGap: 72 + columnGap: 96 +--- +flowchart LR + subgraph G["Group"] + M["Member"]@{ row: 1, column: 1 } + end + G@{ row: 1, column: 1 } + O["Outside"]@{ row: 2, column: 4 } + Loop["Loop"]@{ row: 1, column: 5 } + Source([Source])@{ row: 2, column: 5 } + Target([Target])@{ row: 2, column: 6 } + + G --> M + M --> G + M -->|HTML label| O + O --> M + Loop --> Loop + Loop --> Loop + Loop --> Loop + Source --> Target + Source --> Target + Source --> Target + Target --> Source`; + + await page.goto('/iife.html'); + await page.waitForFunction(() => window.mermaid !== undefined); + await page.evaluate( + async ({ diagramSource }) => { + const mount = document.createElement('div'); + mount.id = 'grid-routing-structural'; + document.body.replaceChildren(mount); + + await window.mermaid.initialize({ + startOnLoad: false, + securityLevel: 'loose', + }); + + const { svg, bindFunctions } = await window.mermaid.render( + 'grid-routing-structural-svg', + diagramSource + ); + mount.innerHTML = svg; + bindFunctions?.(mount); + }, + { diagramSource: source } + ); + + await assertFiniteViewBox(page); + await assertFiniteEdgePaths(page); + await assertDiagramNotError(page); + await expect(page.locator('svg')).toContainText('Group'); + await expect(page.locator('svg')).toContainText('HTML label'); + + const pathData = await page + .locator('svg .edgePaths path') + .evaluateAll((paths) => paths.map((path) => path.getAttribute('d') ?? '')); + expect(pathData.length).toBeGreaterThanOrEqual(10); + expect(new Set(pathData).size).toBe(pathData.length); + }); + + for (const look of ['classic', 'handDrawn', 'neo'] as const) { + test(`grid renders with the ${look} look`, async ({ page }, testInfo) => { + await renderGraph( + page, + testInfo, + `--- +config: + layout: grid + theme: forest + grid: + columns: 1 +--- +flowchart TB + A --> B + B --> C`, + { look, screenshot: true, name: `grid-look-${look}` } + ); + await assertFiniteViewBox(page); + await assertFiniteEdgePaths(page); + }); + } +}); diff --git a/packages/mermaid/src/Diagram.ts b/packages/mermaid/src/Diagram.ts index 1fdfc08e643..b7832c49249 100644 --- a/packages/mermaid/src/Diagram.ts +++ b/packages/mermaid/src/Diagram.ts @@ -5,6 +5,7 @@ import { UnknownDiagramError } from './errors.js'; import defaultConfig from './defaultConfig.js'; import { log } from './logger.js'; import { encodeEntities } from './utils.js'; +import { validateGridPlacementCoordinates } from './utils/gridPlacement.js'; import type { DetailedError } from './utils.js'; import type { DiagramCode, DiagramDefinition, DiagramMetadata } from './diagram-api/types.js'; @@ -81,6 +82,10 @@ export class Diagram { db.setFrontmatterLineOffset?.(code.frontmatterLineOffset); } await parser.parse(textToParse); + if (config.layout === 'grid') { + validateGridPlacementCoordinates(config.grid?.placements, []); + } + db.validate?.(); return new Diagram(type, textToParse, db, parser, renderer); } diff --git a/packages/mermaid/src/config.spec.ts b/packages/mermaid/src/config.spec.ts index 9cfc9b4685f..ca4bc135a74 100644 --- a/packages/mermaid/src/config.spec.ts +++ b/packages/mermaid/src/config.spec.ts @@ -86,6 +86,22 @@ describe('when working with site config', () => { expect(currentConfig.railroad?.fontSize).toBe(18); expect(currentConfig.railroad?.fontFamily).toBe('Courier New'); }); + + it('removes grid placement ids beginning with double underscores from directives', () => { + configApi.addDirective({ + grid: { + placements: { + __start: { row: 1, column: 1 }, + start: { row: 1, column: 2 }, + }, + }, + }); + + expect(configApi.getConfig().grid?.placements).toEqual({ + start: { row: 1, column: 2 }, + }); + }); + it('should set reset config properly', () => { const config_0 = { fontFamily: 'foo-font', fontSize: 150 }; configApi.setSiteConfig(config_0); diff --git a/packages/mermaid/src/config.type.ts b/packages/mermaid/src/config.type.ts index 3b48aaa6424..bbc599410bc 100644 --- a/packages/mermaid/src/config.type.ts +++ b/packages/mermaid/src/config.type.ts @@ -50,6 +50,20 @@ export type SankeyNodeAlignment = 'left' | 'right' | 'center' | 'justify'; * Configuration options to pass to the `dompurify` library. */ export type DOMPurifyConfiguration = import('dompurify').Config; +/** + * Horizontal alignment for items within a grid cell. + * + * This interface was referenced by `MermaidConfig`'s JSON-Schema + * via the `definition` "GridHorizontalAlign". + */ +export type GridHorizontalAlign = 'left' | 'center' | 'right'; +/** + * Vertical alignment for items or stacks within a grid cell. + * + * This interface was referenced by `MermaidConfig`'s JSON-Schema + * via the `definition` "GridVerticalAlign". + */ +export type GridVerticalAlign = 'top' | 'center' | 'bottom'; /** * The style of labels in the sankey diagram. * @@ -254,6 +268,7 @@ export interface MermaidConfig { */ orientFeedbackEdges?: boolean; }; + grid?: GridLayoutConfig; darkMode?: boolean; /** * Flag for setting whether or not a html tag should be used for rendering labels on nodes and edges. @@ -372,6 +387,93 @@ export interface MermaidConfig { */ suppressErrorRendering?: boolean; } +/** + * This interface was referenced by `MermaidConfig`'s JSON-Schema + * via the `definition` "GridLayoutConfig". + */ +export interface GridLayoutConfig { + /** + * Mapping of authored placement ids or emitted node ids to explicit grid placements. Arrays target repeated ids in source order; a single object is equivalent to a one-item array. + */ + placements?: { + [k: string]: GridPlacement | [GridPlacement, ...GridPlacement[]]; + }; + /** + * Non-negative integer candidate column count for auto-placed items; 0 means automatic. + */ + columns?: number; + /** + * Pixel gap between occupied rows. + */ + rowGap?: number; + /** + * Pixel gap between occupied columns. + */ + columnGap?: number; + /** + * Pixel gap between stacked items that share an explicit cell. + */ + cellGap?: number; + /** + * Minimum pixel padding inside groups on every side. + */ + containerPadding?: number; + /** + * Clearance between a measured group title and its child grid. + */ + titleGap?: number; + /** + * Default horizontal alignment within each grid cell. + */ + horizontalAlign?: 'left' | 'center' | 'right'; + /** + * Default vertical alignment for each grid cell stack. + */ + verticalAlign?: 'top' | 'center' | 'bottom'; + /** + * Curve style used to render grid edges. + */ + curve?: + | 'basis' + | 'bumpX' + | 'bumpY' + | 'cardinal' + | 'catmullRom' + | 'linear' + | 'monotoneX' + | 'monotoneY' + | 'natural' + | 'step' + | 'stepAfter' + | 'stepBefore' + | 'rounded'; + /** + * Pixel radius used for grid edges when curve is rounded. + */ + edgeCornerRadius?: number; +} +/** + * This interface was referenced by `MermaidConfig`'s JSON-Schema + * via the `definition` "GridPlacement". + */ +export interface GridPlacement { + /** + * Positive integer row number local to the item's direct parent grid. + */ + row?: number; + /** + * Positive integer column number local to the item's direct parent grid. + */ + column?: number; + /** + * Horizontal alignment within the resolved grid cell. + */ + horizontalAlign?: 'left' | 'center' | 'right'; + /** + * Vertical alignment for the resolved grid cell stack. + */ + verticalAlign?: 'top' | 'center' | 'bottom'; +} /** * The object containing configurations specific for flowcharts * diff --git a/packages/mermaid/src/diagram-api/types.ts b/packages/mermaid/src/diagram-api/types.ts index c0caf6b1efc..c3c0e3f17bc 100644 --- a/packages/mermaid/src/diagram-api/types.ts +++ b/packages/mermaid/src/diagram-api/types.ts @@ -60,6 +60,7 @@ export interface DiagramDB { bindFunctions?: (element: Element) => void; setErrorMessage?: (message: string) => void; getErrorMessage?: () => string | undefined; + validate?: () => void; /** * Opt in to source-faithful parsing. diff --git a/packages/mermaid/src/diagrams/agentflow/agentflowDb.ts b/packages/mermaid/src/diagrams/agentflow/agentflowDb.ts index e5fdc2bb167..87f8e918a48 100644 --- a/packages/mermaid/src/diagrams/agentflow/agentflowDb.ts +++ b/packages/mermaid/src/diagrams/agentflow/agentflowDb.ts @@ -18,6 +18,7 @@ import { setDiagramTitle, getDiagramTitle, } from '../common/commonDb.js'; +import { stripPrototypeKeys } from '../common/sanitizeMetadata.js'; import { createTooltip } from '../common/svgDrawCommon.js'; import type { AgentflowElementMapping, @@ -111,33 +112,6 @@ const SUBGRAPH_TYPE_TO_SHAPE: Record, ClusterS flow: 'flowGroup', }; -/** - * Remove `__proto__` / `constructor` / `prototype` own keys from parsed `@{ }` - * metadata, recursively. - * - * js-yaml defines `__proto__` with `Object.defineProperty` and the metadata - * spreads copy it as an own property, so nothing is polluted inside mermaid. - * But `metadata` and `getSemanticModel()` are consumer-facing, and a downstream - * `merge(target, node.metadata)` over an authored `__proto__` key would - * pollute. Strip it at the parse boundary so no consumer inherits the hazard. - */ -function stripPrototypeKeys(value: T): T { - if (Array.isArray(value)) { - return value.map((entry) => stripPrototypeKeys(entry)) as T; - } - if (value === null || typeof value !== 'object') { - return value; - } - const clean: Record = {}; - for (const key of Object.keys(value)) { - if (key === '__proto__' || key === 'constructor' || key === 'prototype') { - continue; - } - clean[key] = stripPrototypeKeys((value as Record)[key]); - } - return clean as T; -} - /** * Strip line-trailing commas from a multi-line `@{ … }` metadata body. * diff --git a/packages/mermaid/src/diagrams/agentflow/parser/agentflow-metadata-no-validation.spec.ts b/packages/mermaid/src/diagrams/agentflow/parser/agentflow-metadata-no-validation.spec.ts index 47646aa7a39..c557cda9609 100644 --- a/packages/mermaid/src/diagrams/agentflow/parser/agentflow-metadata-no-validation.spec.ts +++ b/packages/mermaid/src/diagrams/agentflow/parser/agentflow-metadata-no-validation.spec.ts @@ -33,6 +33,19 @@ describe('issue #64: parser does not validate metadata (semantics owns it)', () expect(diags(db)).toHaveLength(0); }); + it('keeps grid metadata on a vertex without validating it', () => { + const db = parse(`agentflow-beta TB + a["A"] + a@{ row: 2, column: 3, horizontalAlign: "left", verticalAlign: "bottom" }`); + expect(vertex(db, 'a')?.metadata).toMatchObject({ + row: 2, + column: 3, + horizontalAlign: 'left', + verticalAlign: 'bottom', + }); + expect(diags(db)).toHaveLength(0); + }); + it('keeps an unknown metadata key on a flow without warning', () => { const db = parse(`agentflow-beta TB flow f["F"] diff --git a/packages/mermaid/src/diagrams/common/sanitizeMetadata.ts b/packages/mermaid/src/diagrams/common/sanitizeMetadata.ts new file mode 100644 index 00000000000..aabe108f57b --- /dev/null +++ b/packages/mermaid/src/diagrams/common/sanitizeMetadata.ts @@ -0,0 +1,22 @@ +/** + * Removes prototype-shaped own keys at the parser boundary before metadata is exposed or merged. + * + * Rebuilding objects also prevents a downstream consumer from accidentally treating an authored + * `__proto__` property as merge instructions. Values otherwise remain opaque to diagram adapters. + */ +export function stripPrototypeKeys(value: T): T { + if (Array.isArray(value)) { + return value.map((entry) => stripPrototypeKeys(entry)) as T; + } + if (value === null || typeof value !== 'object') { + return value; + } + const clean: Record = {}; + for (const key of Object.keys(value)) { + if (key === '__proto__' || key === 'constructor' || key === 'prototype') { + continue; + } + clean[key] = stripPrototypeKeys((value as Record)[key]); + } + return clean as T; +} diff --git a/packages/mermaid/src/diagrams/er/erDb.spec.js b/packages/mermaid/src/diagrams/er/erDb.spec.js index 9187c3ee2f2..2efea65192a 100644 --- a/packages/mermaid/src/diagrams/er/erDb.spec.js +++ b/packages/mermaid/src/diagrams/er/erDb.spec.js @@ -94,8 +94,11 @@ describe('ErDB subgraph behavior', () => { expect(innerCluster.parentId).toBe(outerId); expect(customerNode.parentId).toBe(outerId); expect(customerNode.isGroup).toBe(false); + // Generated IDs remain graph identities; authored entity names are the stable placement keys. + expect(customerNode.placementId).toBe('A'); expect(accountNode.parentId).toBe(innerId); expect(accountNode.isGroup).toBe(false); + expect(accountNode.placementId).toBe('B'); }); it('returns edges connected to subgraphs', () => { diff --git a/packages/mermaid/src/diagrams/er/erDb.ts b/packages/mermaid/src/diagrams/er/erDb.ts index 14dfb09f6b3..9c70ae8aa9a 100644 --- a/packages/mermaid/src/diagrams/er/erDb.ts +++ b/packages/mermaid/src/diagrams/er/erDb.ts @@ -62,6 +62,8 @@ export class ErDB implements DiagramDB { if (!this.entities.has(name)) { this.entities.set(name, { id: `entity-${name}-${this.entities.size}`, + // Keep the generated rendering ID stable while exposing the authored entity name to layouts. + placementId: name, label: name, attributes: [], alias, diff --git a/packages/mermaid/src/diagrams/er/erTypes.ts b/packages/mermaid/src/diagrams/er/erTypes.ts index 1f465261d7a..f1000965f90 100644 --- a/packages/mermaid/src/diagrams/er/erTypes.ts +++ b/packages/mermaid/src/diagrams/er/erTypes.ts @@ -1,5 +1,7 @@ export interface EntityNode { id: string; + /** Authored entity name used by placement maps; `id` remains the generated rendering identity. */ + placementId: string; label: string; attributes: Attribute[]; alias: string; diff --git a/packages/mermaid/src/diagrams/flowchart/flowDb.spec.ts b/packages/mermaid/src/diagrams/flowchart/flowDb.spec.ts index a8ed924bc27..4e5a5ec7afc 100644 --- a/packages/mermaid/src/diagrams/flowchart/flowDb.spec.ts +++ b/packages/mermaid/src/diagrams/flowchart/flowDb.spec.ts @@ -201,14 +201,14 @@ describe('flow db collapsible subgraphs', () => { addVertex('A'); addVertex('B'); flowDb.addSubGraph({ text: 'sub1' }, ['A', 'B'], { text: 'My Group', type: 'text' }); - attachMeta('sub1', ' algorithm: elk.box '); + attachMeta('sub1', ' algorithm: elk.box, note: ignored '); const { nodes } = flowDb.getData(); const sub = nodes.find((n) => n.id === 'sub1'); expect(sub?.isGroup).toBe(true); // Without this the `@{ algorithm: … }` a user writes on a flowchart // subgraph never reached the layout engine. - expect(sub?.metadata).toMatchObject({ algorithm: 'elk.box' }); + expect(sub?.metadata).toEqual({ algorithm: 'elk.box' }); }); it('renders a collapsed subgraph as a single collapsedGroup node and hides its members', () => { @@ -281,6 +281,59 @@ describe('flow db collapsible subgraphs', () => { }); }); +describe('flow db metadata propagation', () => { + it('forwards only grid metadata on emitted leaf nodes', () => { + flow.parser.yy = new FlowDB(); + flow.parser.yy.clear(); + flow.parser.parse(`flowchart TD + A@{ row: 2, column: 3, horizontalAlign: left, verticalAlign: bottom, note: "kept" }`); + + const { nodes } = flow.parser.yy.getData(); + const node = nodes.find((item: { id: string }) => item.id === 'A'); + expect(node?.metadata).toEqual({ + row: 2, + column: 3, + horizontalAlign: 'left', + verticalAlign: 'bottom', + }); + }); + + it('preserves inline grid metadata for node ids beginning with double underscores', () => { + flow.parser.yy = new FlowDB(); + flow.parser.yy.clear(); + flow.parser.parse(`flowchart TD + __start@{ row: 2, column: 3 }`); + + const { nodes } = flow.parser.yy.getData(); + const node = nodes.find((item: { id: string }) => item.id === '__start'); + expect(node?.metadata).toEqual({ + row: 2, + column: 3, + }); + }); + + it('strips prototype-shaped metadata keys before forwarding them', () => { + flow.parser.yy = new FlowDB(); + flow.parser.yy.clear(); + flow.parser.parse(`flowchart TD + A@{ + row: 1 + __proto__: + polluted: true + constructor: "drop" + }`); + + const { nodes } = flow.parser.yy.getData(); + const metadata = + nodes.find((item: { id: string; metadata?: Record }) => item.id === 'A') + ?.metadata ?? {}; + expect(metadata).toMatchObject({ row: 1 }); + expect(Object.keys(metadata)).not.toContain('__proto__'); + expect(Object.keys(metadata)).not.toContain('constructor'); + expect(({} as Record).polluted).toBeUndefined(); + }); +}); + describe('flow db direction', () => { let flowDb: FlowDB; beforeEach(() => { diff --git a/packages/mermaid/src/diagrams/flowchart/flowDb.ts b/packages/mermaid/src/diagrams/flowchart/flowDb.ts index 3b2cd013f4a..6c6f39c82c0 100644 --- a/packages/mermaid/src/diagrams/flowchart/flowDb.ts +++ b/packages/mermaid/src/diagrams/flowchart/flowDb.ts @@ -7,6 +7,7 @@ import { isValidShape, type ShapeID } from '../../rendering-util/rendering-eleme import type { Edge, Node } from '../../rendering-util/types.js'; import type { EdgeMetaData, NodeMetaData } from '../../types.js'; import utils, { getEdgeId } from '../../utils.js'; +import { validateGridPlacementCoordinates } from '../../utils/gridPlacement.js'; import common from '../common/common.js'; import { setAccTitle, @@ -17,6 +18,7 @@ import { setDiagramTitle, getDiagramTitle, } from '../common/commonDb.js'; +import { stripPrototypeKeys } from '../common/sanitizeMetadata.js'; import { createTooltip } from '../common/svgDrawCommon.js'; import type { FlowClass, @@ -33,6 +35,29 @@ interface LinkData { } const MERMAID_DOM_ID_PREFIX = 'flowchart-'; +const GRID_LAYOUT_METADATA_KEYS = ['row', 'column', 'horizontalAlign', 'verticalAlign'] as const; + +// Flowchart metadata is a broad author-facing object. Forward only the fields understood by layout +// code so renderer nodes do not become a second, unsanitized metadata API. +function pickFlowchartLayoutMetadata( + metadata: NodeMetaData | undefined, + includeContainerAlgorithm = false +): Record | undefined { + if (!metadata) { + return undefined; + } + const source = metadata as unknown as Record; + const layoutMetadata: Record = {}; + for (const key of GRID_LAYOUT_METADATA_KEYS) { + if (Object.hasOwn(source, key)) { + layoutMetadata[key] = source[key]; + } + } + if (includeContainerAlgorithm && Object.hasOwn(source, 'algorithm')) { + layoutMetadata.algorithm = source.algorithm; + } + return Object.keys(layoutMetadata).length > 0 ? layoutMetadata : undefined; +} // We are using arrow functions assigned to class instance fields instead of methods as they are required by flow JISON export class FlowDB implements DiagramDB { @@ -148,7 +173,7 @@ export class FlowDB implements DiagramDB { } else { yamlData = metadata + '\n'; } - doc = yaml.load(yamlData, { schema: yaml.JSON_SCHEMA }) as NodeMetaData; + doc = stripPrototypeKeys(yaml.load(yamlData, { schema: yaml.JSON_SCHEMA }) as NodeMetaData); } // Check if this is metadata for an already-declared subgraph @@ -233,6 +258,8 @@ export class FlowDB implements DiagramDB { } if (doc !== undefined) { + vertex.metadata = { ...vertex.metadata, ...doc }; + if (doc.shape) { if (doc.shape !== doc.shape.toLowerCase() || doc.shape.includes('_')) { throw new Error(`No such shape: ${doc.shape}. Shape names should be lowercase.`); @@ -1075,6 +1102,7 @@ You have to call mermaid.initialize.` assetWidth: vertex.assetWidth, assetHeight: vertex.assetHeight, constraint: vertex.constraint, + metadata: pickFlowchartLayoutMetadata(vertex.metadata), }; if (isGroup) { nodes.push({ @@ -1259,12 +1287,7 @@ You have to call mermaid.initialize.` isGroup: true, look: config.look, colorIndex: declarationIndex.get(subGraph.id), - // Forwarded so layout engines can read per-container settings such as - // `@{ algorithm: elk.box }`. `view` is consumed above; everything else - // is opaque here and simply passed through. The cast is the - // interface-vs-index-signature gap: `NodeMetaData` is an interface, so - // it is not structurally assignable to `Record`. - metadata: subGraph.metadata as Record | undefined, + metadata: pickFlowchartLayoutMetadata(subGraph.metadata, true), }); } } @@ -1337,6 +1360,14 @@ You have to call mermaid.initialize.` return { nodes, edges, other: {}, config }; } + public validate() { + const config = getConfig(); + if (config.layout !== 'grid') { + return; + } + validateGridPlacementCoordinates(undefined, [...this.vertices.values(), ...this.subGraphs]); + } + public defaultConfig() { return defaultConfig.flowchart; } diff --git a/packages/mermaid/src/diagrams/flowchart/parser/flow-edges.spec.js b/packages/mermaid/src/diagrams/flowchart/parser/flow-edges.spec.js index f8c7a51629c..62c206cb4e1 100644 --- a/packages/mermaid/src/diagrams/flowchart/parser/flow-edges.spec.js +++ b/packages/mermaid/src/diagrams/flowchart/parser/flow-edges.spec.js @@ -133,6 +133,20 @@ A@{ shape: 'rect' } expect(edges[0].id).toBe('id1'); }); + it('keeps grid metadata on a node while still parsing nearby edge ids', function () { + flow.parser.parse(`flowchart LR +A id1@--> B +A@{ row: 2, column: 3, horizontalAlign: left, verticalAlign: top }`); + + const vertex = flow.parser.yy.getVertices().get('A'); + expect(vertex.metadata).toMatchObject({ + row: 2, + column: 3, + horizontalAlign: 'left', + verticalAlign: 'top', + }); + expect(flow.parser.yy.getEdges()[0].id).toBe('id1'); + }); }); describe('double ended edges with ids and labels', function () { doubleEndedEdges.forEach((edgeType) => { diff --git a/packages/mermaid/src/diagrams/flowchart/types.ts b/packages/mermaid/src/diagrams/flowchart/types.ts index 89d4e6566e2..d1241605290 100644 --- a/packages/mermaid/src/diagrams/flowchart/types.ts +++ b/packages/mermaid/src/diagrams/flowchart/types.ts @@ -46,6 +46,8 @@ export interface FlowVertex { defaultWidth?: number; imageAspectRatio?: number; constraint?: 'on' | 'off'; + /** Sanitized authored metadata retained until getData selects layout-owned fields. */ + metadata?: NodeMetaData; } export interface FlowText { diff --git a/packages/mermaid/src/diagrams/mindmap/mindmapDb.getData.test.ts b/packages/mermaid/src/diagrams/mindmap/mindmapDb.getData.test.ts index 61c4fcdc16f..bf3fc665bf7 100644 --- a/packages/mermaid/src/diagrams/mindmap/mindmapDb.getData.test.ts +++ b/packages/mermaid/src/diagrams/mindmap/mindmapDb.getData.test.ts @@ -2,6 +2,13 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { MindmapDB } from './mindmapDb.js'; import type { MindmapLayoutNode, MindmapLayoutEdge } from './mindmapDb.js'; import type { Edge } from '../../rendering-util/types.js'; +import { + buildGridSourceOrder, + readGridConfig, + resolveGridPlacements, +} from '../../rendering-util/layout-algorithms/grid/placement.js'; +import type * as ConfigModule from '../../config.js'; +import { getUserDefinedConfig } from '../../config.js'; // Mock the getConfig function vi.mock('../../diagram-api/diagramAPI.js', () => ({ @@ -15,10 +22,19 @@ vi.mock('../../diagram-api/diagramAPI.js', () => ({ })), })); +vi.mock('../../config.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getUserDefinedConfig: vi.fn(() => ({})), + }; +}); + describe('MindmapDb getData function', () => { let db: MindmapDB; beforeEach(() => { + vi.mocked(getUserDefinedConfig).mockReturnValue({}); db = new MindmapDB(); // Clear the database before each test db.clear(); @@ -51,12 +67,15 @@ describe('MindmapDb getData function', () => { const rootNode = (result.nodes as MindmapLayoutNode[]).find((n) => n.id === '0'); expect(rootNode).toBeDefined(); expect(rootNode?.label).toBe('Root Node'); + // Numeric IDs preserve tree and edge identity; authored IDs remain usable in layout config. + expect(rootNode?.placementId).toBe('root'); expect(rootNode?.level).toBe(0); // Check child nodes const child1 = (result.nodes as MindmapLayoutNode[]).find((n) => n.id === '1'); expect(child1).toBeDefined(); expect(child1?.label).toBe('Child 1'); + expect(child1?.placementId).toBe('child1'); expect(child1?.level).toBe(1); // Check edges @@ -142,6 +161,41 @@ describe('MindmapDb getData function', () => { expect(uniqueIds.size).toBe(3); // All IDs should be unique }); + it('preserves duplicate authored node IDs for occurrence-indexed grid placement', () => { + db.addNode(0, 'root', 'Root', 0); + db.addNode(1, 'duplicate', 'First', 0); + db.addNode(1, 'duplicate', 'Second', 0); + + const result = db.getData(); + const duplicates = result.nodes.filter((node) => node.placementId === 'duplicate'); + const config = readGridConfig({ + ...result, + config: { + ...result.config, + grid: { + placements: { + duplicate: [ + { row: 1, column: 1 }, + { row: 1, column: 2 }, + ], + }, + }, + }, + }); + const { placements } = resolveGridPlacements( + result.nodes, + buildGridSourceOrder(result.nodes), + config + ); + const duplicatePlacements = placements.filter(({ item }) => item.placementId === 'duplicate'); + + expect(duplicates.map((node) => node.id)).toEqual(['1', '2']); + expect(duplicatePlacements).toEqual([ + expect.objectContaining({ item: expect.objectContaining({ id: '1' }), row: 1, column: 1 }), + expect.objectContaining({ item: expect.objectContaining({ id: '2' }), row: 1, column: 2 }), + ]); + }); + it('should handle nodes with missing optional properties', () => { db.addNode(0, 'root', 'Root Node', 0); diff --git a/packages/mermaid/src/diagrams/mindmap/mindmapDb.ts b/packages/mermaid/src/diagrams/mindmap/mindmapDb.ts index dfc80f98019..23812202335 100644 --- a/packages/mermaid/src/diagrams/mindmap/mindmapDb.ts +++ b/packages/mermaid/src/diagrams/mindmap/mindmapDb.ts @@ -269,6 +269,8 @@ export class MindmapDB { const processedNode: MindmapLayoutNode = { id: node.id.toString(), + // Edges use the numeric tree ID; layout configuration should use the authored node ID. + placementId: node.nodeId, domId: 'node_' + node.id.toString(), label: node.descr, labelType: 'markdown', diff --git a/packages/mermaid/src/docs/.vitepress/config.ts b/packages/mermaid/src/docs/.vitepress/config.ts index 3ee017ee9aa..146b3ef24e9 100644 --- a/packages/mermaid/src/docs/.vitepress/config.ts +++ b/packages/mermaid/src/docs/.vitepress/config.ts @@ -164,6 +164,7 @@ function sidebarSyntax() { collapsed: false, items: [ { text: 'Flowchart', link: '/syntax/flowchart' }, + { text: 'Grid Layout', link: '/syntax/grid-layout' }, { text: 'Swimlanes Diagram', link: '/syntax/swimlanes' }, { text: 'Sequence Diagram', link: '/syntax/sequenceDiagram' }, { text: 'Class Diagram', link: '/syntax/classDiagram' }, diff --git a/packages/mermaid/src/docs/community/layout-makers-guide.md b/packages/mermaid/src/docs/community/layout-makers-guide.md index eac2089a0ec..f727e38e77b 100644 --- a/packages/mermaid/src/docs/community/layout-makers-guide.md +++ b/packages/mermaid/src/docs/community/layout-makers-guide.md @@ -97,18 +97,18 @@ So write the core as one exported function, and have both the browser and your t ## A minimal layout -The examples from here on build a layout called `grid`. No such layout ships with Mermaid. It stands in for whatever you are writing, and the code below is what you would write to create it. +The examples from here on build a layout called `simple-grid`. Mermaid ships a production `grid` layout under `rendering-util/layout-algorithms/grid/`; the snippets here stay intentionally minimal and pedagogical so you can focus on the layout-maker contract rather than production complexity. Put the algorithm in `packages/mermaid/src/rendering-util/layout-algorithms//`. This one arranges leaf nodes in a grid and connects them with straight lines: ```ts -// layout-algorithms/grid/layoutCore.ts +// layout-algorithms/simple-grid/layoutCore.ts import type { LayoutData } from '../../types.js'; const GAP = 60; /** DOM-free: positions come from measured sizes only. */ -export function runGridLayoutCore(data4Layout: LayoutData): void { +export function runSimpleGridLayoutCore(data4Layout: LayoutData): void { const leaves = data4Layout.nodes.filter((node) => !node.isGroup); const columns = Math.ceil(Math.sqrt(leaves.length)); const cell = Math.max(...leaves.map((n) => Math.max(n.width ?? 0, n.height ?? 0))) + GAP; @@ -134,11 +134,11 @@ export function runGridLayoutCore(data4Layout: LayoutData): void { ``` ```ts -// layout-algorithms/grid/index.ts +// layout-algorithms/simple-grid/index.ts import { createCommonLayoutRenderer } from '../common/index.js'; -import { runGridLayoutCore } from './layoutCore.js'; +import { runSimpleGridLayoutCore } from './layoutCore.js'; -export const render = createCommonLayoutRenderer({ runLayoutCore: runGridLayoutCore }); +export const render = createCommonLayoutRenderer({ runLayoutCore: runSimpleGridLayoutCore }); ``` That renders. It is also wrong in most of the ways a layout can be wrong. @@ -152,7 +152,10 @@ registerLayoutLoaders([ { name: 'dagre', loader: async () => await import('./layout-algorithms/dagre/index.js') }, { name: 'swimlane', loader: async () => await import('./layout-algorithms/swimlanes/index.js') }, // your new layout - { name: 'grid', loader: async () => await import('./layout-algorithms/grid/index.js') }, + { + name: 'simple-grid', + loader: async () => await import('./layout-algorithms/simple-grid/index.js'), + }, ]); ``` @@ -161,7 +164,7 @@ The loader is lazy, so the code only downloads when a diagram asks for it. `cose ```text --- config: - layout: grid + layout: simple-grid --- flowchart TB A --> B @@ -466,16 +469,16 @@ Open `/dev/` and you get the explorer: the fixture tree on one side, the diagram For a diagram that is not in the fixture tree, copy the standalone page template instead: ```bash -cp demos/dev/example.html demos/dev/grid.html +cp demos/dev/example.html demos/dev/simple-grid.html ``` -That lands at `/dev/grid.html` on the same server. Put the diagram in the page and name your layout in the frontmatter: +That lands at `/dev/simple-grid.html` on the same server. Put the diagram in the page and name your layout in the frontmatter: ```html
     ---
     config:
    -  layout: grid
    +  layout: simple-grid
     ---
     flowchart TB
       A --> B
    @@ -569,7 +572,9 @@ import type { LayoutLoaderDefinition } from 'mermaid';
     
     const loader = async () => await import('./render.js');
     
    -const layouts: LayoutLoaderDefinition[] = [{ name: 'grid', loader, algorithm: 'grid.compact' }];
    +const layouts: LayoutLoaderDefinition[] = [
    +  { name: 'simple-grid', loader, algorithm: 'simple-grid.compact' },
    +];
     
     export default layouts;
     ```
    diff --git a/packages/mermaid/src/docs/config/layouts.md b/packages/mermaid/src/docs/config/layouts.md
    index 96b0380ce18..a472da19abf 100644
    --- a/packages/mermaid/src/docs/config/layouts.md
    +++ b/packages/mermaid/src/docs/config/layouts.md
    @@ -30,6 +30,7 @@ build can opt back in.
     
     - **elk** (default): [ELK (Eclipse Layout Kernel)](https://www.eclipse.org/elk/). Bundled with Mermaid; no setup required. Specific ELK algorithms can be selected as `elk.stress`, `elk.force`, `elk.mrtree`, `elk.sporeOverlap`, `elk.box`, and `elk.rectpacking`.
     - **dagre**: Dagre layout for layered graphs
    +- **grid**: Built-in deterministic row/column layout for unified renderers. See [Grid Layout](/syntax/grid-layout.html).
     - **cose-bilkent**: Cose Bilkent layout for force-directed graphs
     - **tidy-tree**: Tidy tree layout for hierarchical diagrams, from the [`@mermaid-js/layout-tidy-tree`](https://www.npmjs.com/package/@mermaid-js/layout-tidy-tree) package [Tidy Tree Configuration](/config/tidy-tree)
     
    diff --git a/packages/mermaid/src/docs/config/schema-docs/config-defs-grid-layout-config.md b/packages/mermaid/src/docs/config/schema-docs/config-defs-grid-layout-config.md
    new file mode 100644
    index 00000000000..357595fa803
    --- /dev/null
    +++ b/packages/mermaid/src/docs/config/schema-docs/config-defs-grid-layout-config.md
    @@ -0,0 +1,28 @@
    +# Grid Layout Config
    +
    +Configuration for Mermaid's built-in `grid` layout.
    +
    +## Properties
    +
    +| Property           | Type                                                                                                                                             | Default   | Description                                                                  |
    +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | --------- | ---------------------------------------------------------------------------- |
    +| `placements`       | `Record`                                                                                                                  | `{}`      | Placement map keyed by an authored placement id or emitted node id.          |
    +| `columns`          | `integer`                                                                                                                                        | `0`       | Non-negative candidate column count for auto-placement. `0` means automatic. |
    +| `rowGap`           | `number`                                                                                                                                         | `50`      | Pixel gap between occupied rows.                                             |
    +| `columnGap`        | `number`                                                                                                                                         | `50`      | Pixel gap between occupied columns.                                          |
    +| `cellGap`          | `number`                                                                                                                                         | `20`      | Pixel gap between items that explicitly share one cell.                      |
    +| `containerPadding` | `number`                                                                                                                                         | `20`      | Minimum padding inside groups.                                               |
    +| `titleGap`         | `number`                                                                                                                                         | `8`       | Clearance between a measured group title and the child grid.                 |
    +| `horizontalAlign`  | `left \| center \| right`                                                                                                                        | `center`  | Default horizontal item alignment.                                           |
    +| `verticalAlign`    | `top \| center \| bottom`                                                                                                                        | `center`  | Default vertical stack alignment.                                            |
    +| `curve`            | `basis \| bumpX \| bumpY \| cardinal \| catmullRom \| linear \| monotoneX \| monotoneY \| natural \| step \| stepAfter \| stepBefore \| rounded` | `rounded` | Curve style used to render grid edges.                                       |
    +| `edgeCornerRadius` | `number`                                                                                                                                         | `5`       | Pixel radius used for grid edges when `curve` is `rounded`.                  |
    +
    +## `GridPlacement`
    +
    +| Property          | Type                      | Description                                              |
    +| ----------------- | ------------------------- | -------------------------------------------------------- |
    +| `row`             | `integer`                 | Positive row number local to the direct parent group.    |
    +| `column`          | `integer`                 | Positive column number local to the direct parent group. |
    +| `horizontalAlign` | `left \| center \| right` | Item alignment within the resolved cell.                 |
    +| `verticalAlign`   | `top \| center \| bottom` | Stack alignment within the resolved cell.                |
    diff --git a/packages/mermaid/src/docs/syntax/agentflow.md b/packages/mermaid/src/docs/syntax/agentflow.md
    index c1956a9e01c..8a2317ea9f1 100644
    --- a/packages/mermaid/src/docs/syntax/agentflow.md
    +++ b/packages/mermaid/src/docs/syntax/agentflow.md
    @@ -34,6 +34,8 @@ Agentflow diagrams use the `redux-color` theme and the `neo` look by default, an
     diagram type does — see [Per-diagram defaults](../config/theming.md#per-diagram-defaults) for
     the list and for the order in which Mermaid decides.
     
    +For deterministic row and column placement, use the [grid layout](/syntax/grid-layout.html).
    +
     The same diagram, drawn both ways:
     
     ### With the defaults
    diff --git a/packages/mermaid/src/docs/syntax/classDiagram.md b/packages/mermaid/src/docs/syntax/classDiagram.md
    index d01af517545..d2b4df3977b 100644
    --- a/packages/mermaid/src/docs/syntax/classDiagram.md
    +++ b/packages/mermaid/src/docs/syntax/classDiagram.md
    @@ -43,6 +43,8 @@ Class diagrams use the `redux-color` theme and the `neo` look by default, and ar
     does — see [Per-diagram defaults](../config/theming.md#per-diagram-defaults) for the list and
     for the order in which Mermaid decides.
     
    +For deterministic row and column placement, use the [grid layout](/syntax/grid-layout.html).
    +
     The same diagram, drawn both ways:
     
     ### With the defaults
    diff --git a/packages/mermaid/src/docs/syntax/entityRelationshipDiagram.md b/packages/mermaid/src/docs/syntax/entityRelationshipDiagram.md
    index dab8e4fe430..cd59dca0102 100644
    --- a/packages/mermaid/src/docs/syntax/entityRelationshipDiagram.md
    +++ b/packages/mermaid/src/docs/syntax/entityRelationshipDiagram.md
    @@ -50,6 +50,8 @@ Entity relationship diagrams use the `redux-color` theme and the `neo` look by d
     does — see [Per-diagram defaults](../config/theming.md#per-diagram-defaults) for the list and
     for the order in which Mermaid decides.
     
    +For deterministic row and column placement, use the [grid layout](/syntax/grid-layout.html).
    +
     The same diagram, drawn both ways:
     
     ### With the defaults
    diff --git a/packages/mermaid/src/docs/syntax/flowchart.md b/packages/mermaid/src/docs/syntax/flowchart.md
    index af435803d96..e228fa19802 100644
    --- a/packages/mermaid/src/docs/syntax/flowchart.md
    +++ b/packages/mermaid/src/docs/syntax/flowchart.md
    @@ -108,6 +108,8 @@ Flowcharts use the `redux-color` theme and the `neo` look by default, and are la
     does — see [Per-diagram defaults](../config/theming.md#per-diagram-defaults) for the list and
     for the order in which Mermaid decides.
     
    +For deterministic row and column placement, use the [grid layout](/syntax/grid-layout.html).
    +
     The same diagram, drawn both ways:
     
     ### With the defaults
    diff --git a/packages/mermaid/src/docs/syntax/grid-layout.md b/packages/mermaid/src/docs/syntax/grid-layout.md
    new file mode 100644
    index 00000000000..678187db812
    --- /dev/null
    +++ b/packages/mermaid/src/docs/syntax/grid-layout.md
    @@ -0,0 +1,391 @@
    +# Grid Layout (v+)
    +
    +## Introduction
    +
    +Use `layout: grid` when you want deterministic row and column placement instead of a layout derived mainly from edge direction. You can place every item explicitly, constrain only a row or column, or let Mermaid fill the remaining cells automatically.
    +
    +Grid layout supports flowchart, agentflow, state, class, entity relationship, requirement, use case, and mindmap diagrams. Flowchart and agentflow support inline `@{ ... }` placement metadata. All supported diagram types can use `config.grid.placements`.
    +
    +## Enable the layout
    +
    +```mermaid-example
    +---
    +config:
    +  layout: grid
    +---
    +flowchart TB
    +  A@{ row: 1, column: 1 } --> B@{ row: 1, column: 2 }
    +  B --> C@{ row: 2, column: 2 }
    +```
    +
    +## Examples for other diagram types
    +
    +The flowchart example above uses inline placement metadata. The following examples use automatic grid placement for the other supported diagram types.
    +
    +### Agentflow
    +
    +```mermaid-example
    +---
    +config:
    +  layout: grid
    +---
    +agentflow-beta
    +  request["Receive request"]@{ shape: input, row: 1, column: 1 }
    +  plan["Plan work"]@{ shape: task, row: 1, column: 2  }
    +  run["Run tool"]@{ shape: tool, row: 1, column: 3  }
    +  publish["Publish result"]@{ shape: action, row: 1, column: 4  }
    +
    +  request --> plan --> run --> publish
    +```
    +
    +### State diagram
    +
    +```mermaid-example
    +---
    +config:
    +  layout: grid
    +  grid:
    +    columnGap: 300
    +    placements:
    +      Draft: { row: 1, column: 1 }
    +      Review: { row: 1, column: 2 }
    +      Published: { row: 2, column: 2 }
    +---
    +stateDiagram-v2
    +
    +  Draft --> Review
    +  Review --> Published
    +  Review --> Draft : changes requested
    +```
    +
    +### Class diagram
    +
    +```mermaid-example
    +---
    +config:
    +  layout: grid
    +  grid:
    +   columnGap: 100
    +   rowGap: 100
    +   placements:
    +    Customer: { row: 1, column: 1 }
    +    Order: {row: 1, column: 2}
    +    Payment: {row: 2, column: 2}
    +    Shipment: {row: 1, column: 3}
    +---
    +classDiagram
    +  Customer --> Order : places
    +  Customer --> Payment : makes
    +  Order --> Payment : pays with
    +  Order --> Shipment : creates
    +```
    +
    +### Entity relationship diagram
    +
    +```mermaid-example
    +---
    +config:
    +  layout: grid
    +  grid:
    +   columnGap: 150
    +   rowGap: 100
    +   placements:
    +    CUSTOMER: { row: 1, column: 1 }
    +    ORDER: { row: 1, column: 2 }
    +    PAYMENT: { row: 2, column: 2 }
    +    LINE_ITEM: { row: 1, column: 3 }
    +---
    +erDiagram
    +  CUSTOMER ||--o{ ORDER : places
    +  ORDER ||--|{ LINE_ITEM : contains
    +  ORDER ||--o| PAYMENT : has
    +```
    +
    +### Requirement diagram
    +
    +```mermaid-example
    +---
    +config:
    +  layout: grid
    +  grid:
    +   columnGap: 200
    +---
    +requirementDiagram
    +  requirement checkout {
    +    id: 1
    +    text: "Customers can complete an order"
    +    risk: high
    +    verifymethod: test
    +  }
    +
    +  element storefront {
    +    type: service
    +    docref: "checkout"
    +  }
    +
    +  storefront - satisfies -> checkout
    +```
    +
    +### Use case diagram
    +
    +```mermaid-example
    +---
    +config:
    +  layout: grid
    +  grid:
    +    placements:
    +      Customer: {row: 2, column: 1}
    +      Browse: {row: 1, column: 2}
    +      Checkout: {row: 2, column: 2}
    +      Track: {row: 3, column: 2}
    +---
    +usecase-beta
    +  actor Customer
    +  Browse("Browse products")
    +  Checkout("Complete checkout")
    +  Track("Track order")
    +
    +  Customer --> Browse
    +  Customer --> Checkout
    +  Customer --> Track
    +```
    +
    +### Mindmap
    +
    +```mermaid-example
    +---
    +config:
    +  layout: grid
    +  grid:
    +    placements:
    +      root: {row: 2, column: 2}
    +      Plan: {row: 1, column: 1}
    +      Build: {row: 1, column: 3}
    +      Test: {row: 1, column: 4}
    +      Deploy: {row: 3, column: 3}
    +---
    +mindmap
    +  root((Release))
    +    Plan
    +    Build
    +      Test
    +    Deploy
    +```
    +
    +## Placement metadata
    +
    +Flowchart and agentflow nodes and expanded subgraphs accept four grid-specific metadata keys:
    +
    +- `row`
    +- `column`
    +- `horizontalAlign`
    +- `verticalAlign`
    +
    +Coordinates are:
    +
    +- positive integers starting at `1`
    +- local to the direct parent group
    +- sparse but collapsed (rows `1` and `100` render as adjacent occupied tracks)
    +
    +Invalid `row` or `column` values are rejected during parsing with an error that identifies the placement and field.
    +
    +When inline metadata and `config.grid.placements` both set the same property, inline metadata takes precedence.
    +
    +```mermaid-example
    +---
    +config:
    +  layout: grid
    +  grid:
    +    rowGap: 32
    +    columnGap: 40
    +    cellGap: 12
    +---
    +flowchart TB
    +  A@{ row: 1, column: 1 }
    +  B@{ row: 2, column: 1 }
    +  C@{ row: 2, column: 2, horizontalAlign: right }
    +  A --> B
    +  B --> C
    +```
    +
    +## Placement maps
    +
    +All supported diagram types can use the global placement map:
    +
    +```mermaid-example
    +---
    +config:
    +  layout: grid
    +  grid:
    +    placements:
    +      A: { row: 1, column: 1 }
    +      B: { row: 2, column: 1 }
    +      C: { row: 2, column: 2 }
    +---
    +flowchart TB
    +  A --> B
    +  B --> C
    +```
    +
    +Placement-map keys use authored node identifiers when the diagram type provides them. This includes flowchart, agentflow, entity relationship, and mindmap identifiers. Other diagram types use the node ids they emit for layout.
    +
    +When an authored identifier occurs more than once, use an array to place each occurrence in source
    +order. A single placement object is equivalent to an array with one entry. Occurrences without a
    +matching entry are placed automatically, and extra entries are ignored with a console warning.
    +Inline metadata still takes precedence over the matching placement-map entry.
    +
    +```mermaid-example
    +---
    +config:
    +  layout: grid
    +  grid:
    +    placements:
    +      service:
    +        - { row: 1, column: 1 }
    +        - { row: 1, column: 2 }
    +---
    +mindmap
    +  root((Services))
    +    service[API]
    +    service[Worker]
    +    service[Automatically placed]
    +```
    +
    +Placement-map keys beginning with `__` are removed by Mermaid's recursive configuration sanitizer
    +as a prototype-pollution precaution. For flowchart and agentflow nodes with such ids, use inline
    +placement metadata instead.
    +
    +Placements for ids that are not present in the diagram are ignored with a console warning. This
    +allows a shared or generated placement map to contain entries for optional nodes.
    +
    +## Automatic placement
    +
    +If either coordinate is omitted, Mermaid fills it deterministically:
    +
    +- explicit `(row, column)` cells are reserved first
    +- `row` only picks the first free column in that row
    +- `column` only picks the first free row in that column
    +- when both are omitted, Mermaid scans row-major using `grid.columns` when it is greater than `0`; otherwise, it uses `ceil(sqrt(itemCount))`
    +
    +Automatic placement runs independently within each group. Two or more direct siblings with the same explicit `(row, column)` share one cell as a vertical stack in declaration order.
    +
    +## Alignment and stacking
    +
    +- `horizontalAlign`: `left`, `center`, `right`
    +- `verticalAlign`: `top`, `center`, `bottom`
    +
    +Horizontal alignment applies to each item. Vertical alignment applies to the whole stack in an explicitly shared cell. Items in the same cell must resolve to the same vertical alignment; otherwise, Mermaid reports `GRID_CELL_ALIGNMENT_CONFLICT`.
    +
    +```mermaid-example
    +---
    +config:
    +  layout: grid
    +  grid:
    +    cellGap: 25
    +---
    +flowchart TB
    +  A["Alpha"]@{ row: 1, column: 1, horizontalAlign: left, verticalAlign: bottom }
    +  B["Beta"]@{ row: 1, column: 1, horizontalAlign: right, verticalAlign: bottom }
    +  A --> B
    +```
    +
    +## Nested groups
    +
    +Groups are laid out recursively from the inside out. Child coordinates are always local to the immediate parent group; diagram direction (`TB`, `BT`, `LR`, `RL`) does not rotate or mirror the authored grid.
    +
    +```mermaid-example
    +---
    +config:
    +  layout: grid
    +  grid:
    +    placements:
    +      A: { row: 1, column: 1 }
    +      B: { row: 2, column: 1 }
    +      C: { row: 1, column: 2 }
    +      G: { row: 1, column: 1 }
    +---
    +flowchart TB
    +  subgraph G["Group One"]
    +    A["Alpha"]
    +    B["Beta"]
    +  end
    +  C["Peer"]
    +  A --> C
    +  B --> C
    +```
    +
    +## Configuration
    +
    +| Setting            | Default   | Purpose                                                             |
    +| ------------------ | --------- | ------------------------------------------------------------------- |
    +| `placements`       | `{}`      | Maps node ids to placement values.                                  |
    +| `columns`          | `0`       | Sets the auto-placement column count. `0` selects it automatically. |
    +| `rowGap`           | `50`      | Sets the gap between occupied rows.                                 |
    +| `columnGap`        | `50`      | Sets the gap between occupied columns.                              |
    +| `cellGap`          | `20`      | Sets the gap between items stacked in one cell.                     |
    +| `containerPadding` | `20`      | Sets the minimum padding inside groups.                             |
    +| `titleGap`         | `8`       | Sets the clearance between a group title and its child grid.        |
    +| `horizontalAlign`  | `center`  | Sets the default horizontal alignment within a cell.                |
    +| `verticalAlign`    | `center`  | Sets the default vertical alignment for a cell stack.               |
    +| `curve`            | `rounded` | Sets the edge rendering curve.                                      |
    +| `edgeCornerRadius` | `5`       | Sets the corner radius for `rounded` edges.                         |
    +
    +For accepted values and validation rules, see the [grid layout configuration reference](/config/schema-docs/config-defs-grid-layout-config.html).
    +
    +## Edge routing
    +
    +Grid layout calculates edge routes after positioning the nodes and groups. The calculated route:
    +
    +- uses horizontal and vertical segments
    +- avoids measured nodes, unrelated groups, and group titles
    +- may pass through unused space within a grid cell
    +- crosses group boundaries when connecting nodes in different groups
    +- attempts to keep parallel and reverse edges on separate ports and lanes
    +
    +In dense nested diagrams, parallel hierarchy edges can share part of an internal corridor while keeping distinct endpoint ports. If no valid route exists around node or group geometry, Mermaid reports `GRID_ROUTE_NOT_FOUND`.
    +
    +Edge labels are placed after the initial routes are calculated. Mermaid attempts to reroute edges around those labels. If it cannot find a safe bounded detour, an edge can pass through another edge's label rather than failing the whole diagram.
    +
    +## Edge curves
    +
    +Grid edges use `rounded` rendering by default. Set `grid.curve` to any Mermaid
    +flowchart curve style, including `basis`, `cardinal`, `catmullRom`, `step`, or
    +`rounded`.
    +
    +`edgeCornerRadius` controls the corner radius for `rounded` edges. It is ignored
    +by other curve styles, and each corner is automatically limited by the adjacent
    +segment lengths.
    +
    +```mermaid-example
    +---
    +config:
    +  layout: grid
    +  grid:
    +    curve: rounded
    +    edgeCornerRadius: 10
    +---
    +flowchart TB
    +  A@{ row: 1, column: 1 } --> B@{ row: 2, column: 2 }
    +```
    +
    +```mermaid-example
    +---
    +config:
    +  layout: grid
    +  grid:
    +    curve: linear
    +---
    +flowchart TB
    +  A@{ row: 1, column: 1 } --> B@{ row: 2, column: 2 }
    +```
    +
    +`linear` and `rounded` preserve the calculated route corridor. Other curves interpolate between the obstacle-aware route points and can move outside that corridor. The interpolated curve is not revalidated against obstacles.
    +
    +## Current limits
    +
    +Grid layout does **not** support:
    +
    +- row or column spanning
    +- in-cell layouts other than vertical stacking; use a nested subgraph to create a separate grid within a cell
    +- track-level row/column alignment declarations
    +- manual absolute coordinates or manual edge waypoints
    diff --git a/packages/mermaid/src/docs/syntax/mindmap.md b/packages/mermaid/src/docs/syntax/mindmap.md
    index ef25e0e8d0c..44adb04d0a5 100644
    --- a/packages/mermaid/src/docs/syntax/mindmap.md
    +++ b/packages/mermaid/src/docs/syntax/mindmap.md
    @@ -276,6 +276,8 @@ cspell:ignore Buzan
     
     ## Layouts
     
    +For deterministic row and column placement, use the [grid layout](/syntax/grid-layout.html).
    +
     Mermaid also supports a Tidy Tree layout for mindmaps.
     
     ```
    diff --git a/packages/mermaid/src/docs/syntax/requirementDiagram.md b/packages/mermaid/src/docs/syntax/requirementDiagram.md
    index 0b5247b05b6..dc9446fd619 100644
    --- a/packages/mermaid/src/docs/syntax/requirementDiagram.md
    +++ b/packages/mermaid/src/docs/syntax/requirementDiagram.md
    @@ -27,6 +27,8 @@ Requirement diagrams use the `redux-color` theme and the `neo` look by default,
     does — see [Per-diagram defaults](../config/theming.md#per-diagram-defaults) for the list and
     for the order in which Mermaid decides.
     
    +For deterministic row and column placement, use the [grid layout](/syntax/grid-layout.html).
    +
     The same diagram, drawn both ways:
     
     ### With the defaults
    diff --git a/packages/mermaid/src/docs/syntax/stateDiagram.md b/packages/mermaid/src/docs/syntax/stateDiagram.md
    index 2e744a24982..9379754d54d 100644
    --- a/packages/mermaid/src/docs/syntax/stateDiagram.md
    +++ b/packages/mermaid/src/docs/syntax/stateDiagram.md
    @@ -45,6 +45,8 @@ State diagrams use the `redux-color` theme and the `neo` look by default, and ar
     does — see [Per-diagram defaults](../config/theming.md#per-diagram-defaults) for the list and
     for the order in which Mermaid decides.
     
    +For deterministic row and column placement, use the [grid layout](/syntax/grid-layout.html).
    +
     The same diagram, drawn both ways:
     
     ### With the defaults
    diff --git a/packages/mermaid/src/docs/syntax/usecase.md b/packages/mermaid/src/docs/syntax/usecase.md
    index b3de07a3d5e..88327099ad6 100644
    --- a/packages/mermaid/src/docs/syntax/usecase.md
    +++ b/packages/mermaid/src/docs/syntax/usecase.md
    @@ -22,6 +22,8 @@ Use case diagrams use the `redux-color` theme and the `neo` look by default, and
     does — see [Per-diagram defaults](../config/theming.md#per-diagram-defaults) for the list and
     for the order in which Mermaid decides.
     
    +For deterministic row and column placement, use the [grid layout](/syntax/grid-layout.html).
    +
     The same diagram, drawn both ways:
     
     ### With the defaults
    diff --git a/packages/mermaid/src/mermaidAPI.spec.ts b/packages/mermaid/src/mermaidAPI.spec.ts
    index 2629f0757e9..2d0d057d31e 100644
    --- a/packages/mermaid/src/mermaidAPI.spec.ts
    +++ b/packages/mermaid/src/mermaidAPI.spec.ts
    @@ -862,6 +862,52 @@ graph TD;A--x|text including URL space|B;`)
               }
             `);
         });
    +
    +    it('rejects invalid inline grid coordinates', async () => {
    +      await expect(
    +        mermaidAPI.parse(`---
    +config:
    +  layout: grid
    +---
    +flowchart TB
    +  A@{ row: 0 }`)
    +      ).rejects.toThrow('GRID_INVALID_COORDINATE: Invalid row for placement "A"');
    +    });
    +
    +    it('rejects invalid frontmatter grid coordinates', async () => {
    +      await expect(
    +        mermaidAPI.parse(`---
    +config:
    +  layout: grid
    +  grid:
    +    placements:
    +      A:
    +        row: 0
    +---
    +flowchart TB
    +  A`)
    +      ).rejects.toThrow('GRID_INVALID_COORDINATE: Invalid row for placement "A"');
    +    });
    +
    +    it('rejects invalid initialized grid coordinates', async () => {
    +      mermaidAPI.initialize({});
    +      mermaidAPI.initialize({
    +        layout: 'grid',
    +        grid: {
    +          placements: {
    +            A: { row: 0 },
    +          },
    +        },
    +      });
    +
    +      try {
    +        await expect(mermaidAPI.parse('flowchart TB\n  A')).rejects.toThrow(
    +          'GRID_INVALID_COORDINATE: Invalid row for placement "A"'
    +        );
    +      } finally {
    +        mermaidAPI.initialize({});
    +      }
    +    });
       });
     
       describe('render', () => {
    @@ -971,6 +1017,30 @@ treeView-beta
           expect(texts).toContain('Syntax error in text');
           expect(texts.some((text) => text.includes('Edge limit exceeded'))).toBe(true);
         });
    +
    +    jsdomIt('shows invalid grid coordinates in the error diagram', async () => {
    +      await expect(
    +        mermaidAPI.render(
    +          'flowchart-invalid-grid-coordinate',
    +          `---
    +config:
    +  layout: grid
    +---
    +flowchart TB
    +  A@{ row: 0 }`
    +        )
    +      ).rejects.toThrow('GRID_INVALID_COORDINATE: Invalid row for placement "A"');
    +
    +      const texts = [...document.querySelectorAll('.error-text')].map(
    +        (element) => element.textContent ?? ''
    +      );
    +      expect(texts).toContain('Syntax error in text');
    +      expect(
    +        texts.some((text) =>
    +          text.includes('GRID_INVALID_COORDINATE: Invalid row for placement "A"')
    +        )
    +      ).toBe(true);
    +    });
       });
     
       describe('getDiagramFromText', () => {
    diff --git a/packages/mermaid/src/rendering-util/edgeCornerRadius.spec.ts b/packages/mermaid/src/rendering-util/edgeCornerRadius.spec.ts
    new file mode 100644
    index 00000000000..45e209f9f46
    --- /dev/null
    +++ b/packages/mermaid/src/rendering-util/edgeCornerRadius.spec.ts
    @@ -0,0 +1,16 @@
    +import { describe, expect, it } from 'vitest';
    +import { DEFAULT_EDGE_CORNER_RADIUS, resolveEdgeCornerRadius } from './edgeCornerRadius.js';
    +
    +describe('resolveEdgeCornerRadius', () => {
    +  it('accepts finite non-negative values', () => {
    +    expect(resolveEdgeCornerRadius(0)).toBe(0);
    +    expect(resolveEdgeCornerRadius(12)).toBe(12);
    +  });
    +
    +  it('uses the shared default for invalid values', () => {
    +    expect(resolveEdgeCornerRadius(-1)).toBe(DEFAULT_EDGE_CORNER_RADIUS);
    +    expect(resolveEdgeCornerRadius(Number.NaN)).toBe(DEFAULT_EDGE_CORNER_RADIUS);
    +    expect(resolveEdgeCornerRadius(Number.POSITIVE_INFINITY)).toBe(DEFAULT_EDGE_CORNER_RADIUS);
    +    expect(resolveEdgeCornerRadius('5')).toBe(DEFAULT_EDGE_CORNER_RADIUS);
    +  });
    +});
    diff --git a/packages/mermaid/src/rendering-util/edgeCornerRadius.ts b/packages/mermaid/src/rendering-util/edgeCornerRadius.ts
    new file mode 100644
    index 00000000000..4f05be5deda
    --- /dev/null
    +++ b/packages/mermaid/src/rendering-util/edgeCornerRadius.ts
    @@ -0,0 +1,10 @@
    +// Keep corner geometry consistent between the initial renderer and any later path rewrite.
    +export const DEFAULT_EDGE_CORNER_RADIUS = 5;
    +
    +export function resolveEdgeCornerRadius(value: unknown): number {
    +  // A useful maximum depends on each corner's angle and adjacent segment lengths, so both rounded
    +  // path generators clamp the effective radius locally instead of imposing an arbitrary global cap.
    +  return typeof value === 'number' && Number.isFinite(value) && value >= 0
    +    ? value
    +    : DEFAULT_EDGE_CORNER_RADIUS;
    +}
    diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/ddlt/backends.ts b/packages/mermaid/src/rendering-util/layout-algorithms/ddlt/backends.ts
    index 3e0db2ebc40..80d659899cd 100644
    --- a/packages/mermaid/src/rendering-util/layout-algorithms/ddlt/backends.ts
    +++ b/packages/mermaid/src/rendering-util/layout-algorithms/ddlt/backends.ts
    @@ -2,6 +2,8 @@ import type { LayoutData } from '../../types.js';
     import { createEdgeLabelNodes } from '../swimlanes/edgeLabelNodes.js';
     import { prepareLayoutForSwimlanes } from '../swimlanes/helpers.js';
     import { runSwimlaneLayoutCore } from '../swimlanes/layoutCore.js';
    +import { prepareGridLayout } from '../grid/edgeLabels.js';
    +import { runGridLayoutCore } from '../grid/layoutCore.js';
     import type { LayoutTestBackend, LayoutTestBackendId, OrthogonalTrace } from './types.js';
     import { applyFixtureContentSizesStrict, applyFixtureLabelSizesStrict } from './fixtureSizes.js';
     import { parseMmdFileToLayoutData } from './parseToLayoutData.js';
    @@ -62,9 +64,21 @@ export function runSwimlanesDdlt(layout: LayoutData, sizes: SizesFixture): void
       (layout as LayoutData & { direction?: string }).direction = direction;
     }
     
    +/**
    + * Mirrors the production grid prepare/measure/layout sequence while replacing DOM measurement
    + * with captured fixture sizes. Keep this adapter thin so DDLT exercises production layout code.
    + */
    +export function runGridDdlt(layout: LayoutData, sizes: SizesFixture): void {
    +  prepareGridLayout(layout);
    +  applyFixtureContentSizesStrict(layout, sizes);
    +  (layout as { layoutAlgorithm?: string }).layoutAlgorithm = 'grid';
    +  runGridLayoutCore(layout);
    +}
    +
     /**
      * Parse `.mmd`, apply fixture sizes, then run the given backend (mutates returned `LayoutData`).
    - * Only `'swimlanes'` is supported on this branch; `'domus-orthogonal'` throws.
    + * Backends that need fixture sizes between preparation and layout are dispatched here.
    + * Unsupported backends throw rather than silently falling back to a different algorithm.
      */
     export async function parseApplySizesAndLayout(
       mmdPath: string,
    @@ -72,6 +86,11 @@ export async function parseApplySizesAndLayout(
       backendId: LayoutTestBackendId,
       _options?: { trace?: OrthogonalTrace }
     ): Promise {
    +  if (backendId === 'grid') {
    +    const layout = await parseMmdFileToLayoutData(mmdPath, { stampFlowchartRendererFields: true });
    +    runGridDdlt(layout, sizes);
    +    return layout;
    +  }
       if (backendId !== 'swimlanes') {
         domusBackendUnavailable();
       }
    @@ -81,8 +100,17 @@ export async function parseApplySizesAndLayout(
       return layout;
     }
     
    -/** Returns a DOM-free layout runner. `swimlanes` must use `parseApplySizesAndLayout()` (needs fixture sizes mid-pipeline); `domus-orthogonal` throws. */
    +/**
    + * Returns a DOM-free runner only for backends whose complete pipeline can run without a browser.
    + * Swimlanes must use `parseApplySizesAndLayout()` because it injects fixture sizes mid-pipeline.
    + */
     export function getLayoutTestBackend(_id: LayoutTestBackendId): LayoutTestBackend {
    +  if (_id === 'grid') {
    +    return (layout: LayoutData) => {
    +      prepareGridLayout(layout);
    +      runGridLayoutCore(layout);
    +    };
    +  }
       domusBackendUnavailable();
     }
     
    @@ -90,5 +118,10 @@ export function backendsForProfile(profile: DdltFixtureProfile): LayoutTestBacke
       if (profile === 'swimlanes') {
         return ['swimlanes'];
       }
    +  if (profile === 'grid') {
    +    return ['grid'];
    +  }
    +  // Legacy flowchart fixtures retain their historical DOMUS backend rather than changing meaning
    +  // when new profiles are added.
       return ['domus-orthogonal'];
     }
    diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/ddlt/discoverFixtures.ts b/packages/mermaid/src/rendering-util/layout-algorithms/ddlt/discoverFixtures.ts
    index 02d96beb5a2..46142a66b66 100644
    --- a/packages/mermaid/src/rendering-util/layout-algorithms/ddlt/discoverFixtures.ts
    +++ b/packages/mermaid/src/rendering-util/layout-algorithms/ddlt/discoverFixtures.ts
    @@ -16,7 +16,14 @@ function walkSizesFiles(dir: string, acc: string[]): void {
     }
     
     function defaultProfileForRelId(relId: string): DdltFixtureProfile {
    -  return relId.startsWith('swimlanes/') ? 'swimlanes' : 'flowchart-domus';
    +  // Directory names are the compatibility fallback for fixtures predating manifest profiles.
    +  if (relId.startsWith('swimlanes/')) {
    +    return 'swimlanes';
    +  }
    +  if (relId.startsWith('grid/')) {
    +    return 'grid';
    +  }
    +  return 'flowchart-domus';
     }
     
     function loadManifest(baseDir: string): DdltManifest {
    diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/ddlt/layout-fixtures.ddlt.spec.ts b/packages/mermaid/src/rendering-util/layout-algorithms/ddlt/layout-fixtures.ddlt.spec.ts
    index 8a599c5ef24..7b93fbd63ff 100644
    --- a/packages/mermaid/src/rendering-util/layout-algorithms/ddlt/layout-fixtures.ddlt.spec.ts
    +++ b/packages/mermaid/src/rendering-util/layout-algorithms/ddlt/layout-fixtures.ddlt.spec.ts
    @@ -5,6 +5,7 @@ import { discoverLayoutTestFixtures } from './discoverFixtures.js';
     import { loadDdltFixture } from './loadDdltFixture.js';
     
     const SWIMLANE_TOTAL_SCORE_WITH_10_NODE_PLACEMENT_BASELINE = 11754;
    +const GRID_TOTAL_SCORE_BASELINE = 10980;
     
     describe('DDLT layout-test fixture sweep', () => {
       it('aggregate validateLayout report — swimlanes', { timeout: 20_000 }, async () => {
    @@ -36,4 +37,42 @@ describe('DDLT layout-test fixture sweep', () => {
           SWIMLANE_TOTAL_SCORE_WITH_10_NODE_PLACEMENT_BASELINE
         );
       });
    +
    +  it('validates the grid fixture score baseline', { timeout: 20_000 }, async () => {
    +    const fixtures = discoverLayoutTestFixtures().filter((fixture) => fixture.profile === 'grid');
    +    const items = [];
    +
    +    expect(fixtures.map((fixture) => fixture.id)).toEqual(
    +      expect.arrayContaining([
    +        'grid/simple',
    +        'grid/group-stack',
    +        'grid/placement-matrix-tb',
    +        'grid/placement-matrix-lr',
    +        'grid/singleton-alignments',
    +        'grid/stack-default',
    +        'grid/stack-gap-zero',
    +        'grid/routing-group-member',
    +        'grid/routing-outside-member',
    +        'grid/routing-loops-parallel-lr',
    +        'grid/routing-cell-aware-empty-cell',
    +        'grid/routing-hierarchy-portals',
    +      ])
    +    );
    +
    +    for (const fixture of fixtures) {
    +      const layout = await loadDdltFixture(fixture.id, { backendId: 'grid' });
    +      items.push({ id: fixture.id, result: validateLayout(layout) });
    +    }
    +
    +    const report = combineValidateLayoutResults(items);
    +    console.log('DDLT-GRID-AGG:', JSON.stringify(report, null, 2));
    +
    +    const exemptIds = new Set(
    +      fixtures.filter((fixture) => fixture.allowLevel1Failure).map((fixture) => fixture.id)
    +    );
    +    expect([...exemptIds]).toEqual([]);
    +    const nonExemptInvalid = report.byCase.filter((row) => !exemptIds.has(row.id) && !row.valid);
    +    expect(nonExemptInvalid.map((row) => `${row.id}: ${row.issueTypes.join(', ')}`)).toEqual([]);
    +    expect(report.totalScore).toBeGreaterThanOrEqual(GRID_TOTAL_SCORE_BASELINE);
    +  });
     });
    diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/ddlt/types.ts b/packages/mermaid/src/rendering-util/layout-algorithms/ddlt/types.ts
    index 5731a0cbc50..f5a6829e7f8 100644
    --- a/packages/mermaid/src/rendering-util/layout-algorithms/ddlt/types.ts
    +++ b/packages/mermaid/src/rendering-util/layout-algorithms/ddlt/types.ts
    @@ -25,8 +25,8 @@ export interface SizesFixture {
       nodes: FixtureNodeSize[];
     }
     
    -/** How to prepare parsed `LayoutData` before running a layout backend. */
    -export type DdltFixtureProfile = 'flowchart-domus' | 'swimlanes';
    +/** Selects the diagram-specific preparation contract used before a DDLT backend runs. */
    +export type DdltFixtureProfile = 'flowchart-domus' | 'swimlanes' | 'grid';
     
     export interface DdltManifestEntry {
       /** Relative path without extension, e.g. `swimlanes/1-simple` or `Company-simp`. */
    @@ -40,7 +40,8 @@ export interface DdltManifest {
       fixtures?: DdltManifestEntry[];
     }
     
    -export type LayoutTestBackendId = 'domus-orthogonal' | 'swimlanes';
    +/** Runtime backend selected from a fixture profile; kept separate so profiles can fan out later. */
    +export type LayoutTestBackendId = 'domus-orthogonal' | 'swimlanes' | 'grid';
     
     /** Pure layout stage: mutates `layout` in place (DOM-free). */
     export type LayoutTestBackend = (layout: LayoutData) => void | Promise;
    diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/elk/lineHops.ts b/packages/mermaid/src/rendering-util/layout-algorithms/elk/lineHops.ts
    index c554fd94b11..29c9c013000 100644
    --- a/packages/mermaid/src/rendering-util/layout-algorithms/elk/lineHops.ts
    +++ b/packages/mermaid/src/rendering-util/layout-algorithms/elk/lineHops.ts
    @@ -51,9 +51,11 @@ export function applyElkLineJumps(
             Array.isArray(edge.points) && edge.points.length >= 2
         )
         .map((edge) => ({
    +      // Line-hop rewriting is generic rendering work, so carry the edge's complete path style.
           id: edge.id,
           points: edge.points,
           curve: edge.curve,
    +      cornerRadius: edge.cornerRadius,
           arrowTypeStart: edge.arrowTypeStart,
           arrowTypeEnd: edge.arrowTypeEnd,
         }));
    diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/grid/edge-routing.md b/packages/mermaid/src/rendering-util/layout-algorithms/grid/edge-routing.md
    new file mode 100644
    index 00000000000..e1d523bd4cc
    --- /dev/null
    +++ b/packages/mermaid/src/rendering-util/layout-algorithms/grid/edge-routing.md
    @@ -0,0 +1,143 @@
    +# Grid edge routing
    +
    +Grid layout calculates edge paths automatically after it measures and positions the diagram. Users control item placement and spacing; they do not provide absolute edge coordinates or waypoints.
    +
    +For grid syntax and configuration, see the [grid layout documentation](../../../docs/syntax/grid-layout.md).
    +
    +## Routing behavior
    +
    +The calculated route is an orthogonal polyline made from horizontal and vertical segments. The router:
    +
    +- avoids measured nodes, unrelated groups, and group titles
    +- may use unused space within a grid cell
    +- crosses group boundaries when connecting nodes in different groups
    +- chooses attachment sides and coordinates automatically
    +- keeps an edge aligned with a shared endpoint on its straight port; other incident edges share
    +  that side only when their port can sit at least 24px away, and otherwise prefer an unreserved side
    +- produces the same route for the same diagram and configuration
    +
    +Grid cells are placement regions, not obstacles. An edge can cross an unused part of a cell as long as it does not cross protected node or group geometry.
    +
    +## Routing algorithms and migration status
    +
    +Grid routing currently combines a deterministic corridor router with a sparse visibility router.
    +Both are production paths.
    +
    +For an ordinary unbundled edge whose endpoints share a container, Mermaid first considers the
    +corridor route. It uses that route only when:
    +
    +- the selected endpoint ports are legal
    +- the complete route passes measured-geometry validation
    +- the route length equals the Manhattan lower bound between those ports
    +
    +In that case, sparse search cannot produce a shorter route, so Mermaid avoids building a visibility
    +topology and running A\*. If any condition fails, the edge uses sparse visibility routing instead.
    +Self-loops and parallel or reverse bundles also use sparse routing.
    +
    +Hierarchy routing is partially migrated. Bundles, hierarchy edges whose endpoints have no unrelated
    +incident edges, and plans whose corridor route fails validation use sparse routing through every
    +group-boundary segment. A single hierarchy edge can still use validated corridor segments when at
    +least one endpoint is shared with another edge. For an edge between a group and one of its
    +descendants, the segment in the lowest common ancestor uses sparse routing even when an ascent or
    +descent segment still uses corridor routing.
    +
    +The corridor router also remains the fallback for defined sparse topology and search resource
    +limits. Mermaid commits that fallback only when it passes route validation; an unchecked route is
    +never returned.
    +
    +Test-only topology caps, search caps, and dual-route comparison disable the ordinary fast path so
    +those tests exercise sparse topology, search, and fallback behavior. Normal routing and performance
    +tests exercise the production fast path separately.
    +
    +Removing the corridor router requires more than replacing its call sites. Sparse routing must first:
    +
    +- route the remaining shared-endpoint hierarchy cases without introducing shared route segments or
    +  crossings
    +- meet the large-diagram performance target without the ordinary-edge fast path
    +- pass generated nested-hierarchy, label, self-loop, bundle, and fallback coverage
    +- complete release-level route and performance validation
    +
    +After those gates hold, the corridor router, corridor metadata, resource fallback, compatibility
    +instrumentation, and obsolete tests can be removed together.
    +
    +## Search budgets
    +
    +Sparse routing has two deterministic search-state limits:
    +
    +- a per-search cap that prevents one endpoint or portal search from consuming unbounded work
    +- an invocation cap shared by all sparse searches in one grid render
    +
    +The shared cap bounds total routing CPU work across all edges and containers. Edges are processed in
    +a deterministic order, so the allocation and resulting geometry are reproducible. If an edge has
    +already found a valid candidate before a later candidate reaches the cap, Mermaid keeps that valid
    +candidate. After the invocation budget is exhausted, later routes that still require sparse search
    +use the validated corridor fallback. Edges accepted by the corridor fast path do not consume the
    +sparse-search budget.
    +
    +Every sparse-search attempt consumes the shared invocation budget, including endpoint or portal
    +candidates and bundle attempts whose routing state is later rolled back for a relaxed retry.
    +Checkpoints restore committed routing state, not work already performed.
    +
    +This is a safety bound, not a fairness guarantee. A complex edge early in the route order can leave
    +less search capacity for unrelated later edges. When the shared invocation budget is first
    +exhausted, Mermaid emits one warning with the first affected edge and container. Individual
    +fallbacks remain available through routing instrumentation and debug logging.
    +
    +## Nested groups
    +
    +For an edge that crosses a group boundary, the router selects a legal crossing point that avoids the group title and corners. An edge that crosses several nested groups is assembled from routes within each group.
    +
    +If the router cannot find a valid path around node or group geometry, Mermaid reports `GRID_ROUTE_NOT_FOUND` instead of returning an unchecked route.
    +
    +## Parallel, reverse, and self-loop edges
    +
    +Parallel and reverse edges use separate endpoint ports and lanes when the available geometry permits it. Self-loops use separate ports on the same node.
    +
    +Unrelated edges in the same container are also kept off each other's corridors. A route that would run parallel to an earlier edge closer than 24px is re-routed through other ports or corridors, so edges that must cross do so at a single perpendicular point; crossing itself is allowed. Layouts with more than 64 edges in a container keep the faster corridor routes, so overlaps can remain there.
    +
    +Dense nested diagrams can require a hierarchy edge to share part of an internal corridor with a parallel or reverse edge. Endpoint ports remain distinct, but part of the rendered paths can overlap.
    +
    +## Edge labels
    +
    +Measured labels between direct sibling nodes on adjacent rows or columns can raise only the shared
    +track gap they need. The configured `rowGap` and `columnGap` remain defaults, but Mermaid treats
    +them as minimums when a direct adjacent label needs more room. Other boundaries keep their
    +configured gap.
    +
    +This preallocation applies only when the endpoints share a container and a row or column, occupy
    +consecutive populated tracks, and the label fits the cross-axis track geometry. Non-adjacent,
    +diagonal, hierarchy, and obstructed cases keep the normal routing and label fallback.
    +
    +Labelled self-loops are sized as a whole route. Mermaid first expands the U-shaped outer leg and,
    +when the node side cannot hold the required port span, tries one bounded symmetric shoulder route.
    +This keeps the common case to three segments instead of splicing a generic detour into a terminal
    +leg.
    +
    +Labels are still finalized after the initial routes are calculated. Mermaid attempts to move
    +affected routes around the complete set of label reservations. If that is not possible, it retries
    +individual labels.
    +
    +As a final fallback, an edge can pass through another edge's label rather than failing the whole diagram. Node, group, title, marker-clearance, and own-label-anchor checks still apply.
    +
    +## Edge curves
    +
    +The orthogonal points are the calculated route. `grid.curve` controls how the shared renderer draws those points:
    +
    +- `rounded` preserves the route with rounded corners and is the default
    +- `linear` preserves the route with square corners
    +- other supported curves interpolate between the points and can move outside the calculated corridor
    +
    +Interpolated curves other than `linear` and `rounded` are not revalidated against obstacles. Use `linear` or `rounded` when preserving the obstacle-aware route is important.
    +
    +`grid.edgeCornerRadius` applies only to `rounded` edges. Each corner radius is limited by the lengths of its adjacent segments.
    +
    +## Improve crowded routes
    +
    +If a diagram cannot be routed or has too many overlapping paths:
    +
    +- increase `rowGap`, `columnGap`, or `cellGap`
    +- move crowded nodes to different rows or columns
    +- split a crowded cell into a nested subgraph
    +- use `linear` or `rounded` instead of an interpolating curve
    +
    +Grid routing is bounded to prevent a difficult diagram from consuming unbounded time or memory. Increasing spacing or simplifying the placement usually gives the router more legal corridors.
    diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/grid/edgeLabels.spec.ts b/packages/mermaid/src/rendering-util/layout-algorithms/grid/edgeLabels.spec.ts
    new file mode 100644
    index 00000000000..8c2b14b1ff2
    --- /dev/null
    +++ b/packages/mermaid/src/rendering-util/layout-algorithms/grid/edgeLabels.spec.ts
    @@ -0,0 +1,1003 @@
    +import { describe, expect, it } from 'vitest';
    +import type { Edge, LayoutData, Node } from '../../types.js';
    +import { normalizePolyline } from '../layout-utils/geometry.js';
    +import { validateLayout as validateSharedLayout } from '../layout-utils/validateLayout.js';
    +import {
    +  createGridEdgeLabelInstrumentation,
    +  prepareGridLayout,
    +  positionGridEdgeLabels,
    +} from './edgeLabels.js';
    +import { runGridLayoutCore } from './layoutCore.js';
    +import { createGridRoutingInstrumentation } from './routerInstrumentation.js';
    +
    +const validateLayout = (data: LayoutData) =>
    +  validateSharedLayout(data, { requireEdgeLabelCenter: true });
    +
    +function node(id: string): Node {
    +  return {
    +    id,
    +    isGroup: false,
    +    shape: 'rect',
    +    width: 80,
    +    height: 40,
    +  } as Node;
    +}
    +
    +function edge(id: string, label?: string): Edge {
    +  return {
    +    id,
    +    start: 'a',
    +    end: 'b',
    +    label,
    +    labelStyle: ['fill:red'],
    +  } as Edge;
    +}
    +
    +function manualNode(id: string, x: number, y: number, width = 80, height = 40): Node {
    +  return {
    +    id,
    +    x,
    +    y,
    +    width,
    +    height,
    +    isGroup: false,
    +    shape: 'rect',
    +  } as Node;
    +}
    +
    +function manualEdge(
    +  id: string,
    +  start: string,
    +  end: string,
    +  points: { x: number; y: number }[],
    +  label?: string
    +): Edge {
    +  return {
    +    id,
    +    start,
    +    end,
    +    points,
    +    label,
    +    arrowTypeStart: 'none',
    +    arrowTypeEnd: 'arrow_point',
    +    curve: 'linear',
    +    type: 'arrow_point',
    +  } as Edge;
    +}
    +
    +function rangeOverlapLength(
    +  firstStart: number,
    +  firstEnd: number,
    +  secondStart: number,
    +  secondEnd: number
    +) {
    +  const start = Math.max(Math.min(firstStart, firstEnd), Math.min(secondStart, secondEnd));
    +  const end = Math.min(Math.max(firstStart, firstEnd), Math.max(secondStart, secondEnd));
    +  return Math.max(0, end - start);
    +}
    +
    +function expectNoNonAdjacentConflicts(points: NonNullable): void {
    +  const segments = normalizePolyline(points).segments;
    +  for (let firstIndex = 0; firstIndex < segments.length; firstIndex++) {
    +    for (let secondIndex = firstIndex + 2; secondIndex < segments.length; secondIndex++) {
    +      const first = segments[firstIndex];
    +      const second = segments[secondIndex];
    +      if (first.orientation !== second.orientation || first.orientation === 'Z') {
    +        continue;
    +      }
    +      const sameAxis =
    +        first.orientation === 'H'
    +          ? Math.abs(first.a.y - second.a.y) <= 1e-6
    +          : Math.abs(first.a.x - second.a.x) <= 1e-6;
    +      const overlap =
    +        first.orientation === 'H'
    +          ? rangeOverlapLength(first.a.x, first.b.x, second.a.x, second.b.x)
    +          : rangeOverlapLength(first.a.y, first.b.y, second.a.y, second.b.y);
    +      if (sameAxis) {
    +        expect(overlap).toBeLessThanOrEqual(1e-6);
    +        continue;
    +      }
    +      if (overlap >= 8) {
    +        const gap =
    +          first.orientation === 'H'
    +            ? Math.abs(first.a.y - second.a.y)
    +            : Math.abs(first.a.x - second.a.x);
    +        expect(gap).toBeGreaterThanOrEqual(7);
    +      }
    +    }
    +  }
    +}
    +
    +describe('grid edge label helpers', () => {
    +  it('expands an adjacent boundary so a measured label stays on the direct route', () => {
    +    const data: LayoutData = {
    +      nodes: [node('a'), node('b')],
    +      edges: [
    +        {
    +          ...edge('labelled', 'long label'),
    +          arrowTypeStart: 'none',
    +          arrowTypeEnd: 'arrow_point',
    +          curve: 'linear',
    +          type: 'arrow_point',
    +        } as Edge,
    +      ],
    +      config: {
    +        layout: 'grid',
    +      } as LayoutData['config'],
    +    };
    +
    +    prepareGridLayout(data);
    +    const labelNode = data.nodes.find((item) => item.id === data.edges[0].labelNodeId)!;
    +    labelNode.width = 90;
    +    labelNode.height = 20;
    +
    +    runGridLayoutCore(data);
    +
    +    const [a, b] = data.nodes;
    +    expect((b.x ?? 0) - (b.width ?? 0) / 2 - ((a.x ?? 0) + (a.width ?? 0) / 2)).toBe(116);
    +    expect(normalizePolyline(data.edges[0].points ?? []).segments).toHaveLength(1);
    +    expect(labelNode).toMatchObject({ x: 138, y: 20 });
    +    expect(validateLayout(data).ok).toBe(true);
    +  });
    +
    +  it('produces the same geometry when reused layout data contains stale label positions', () => {
    +    const createData = (): LayoutData => {
    +      const data: LayoutData = {
    +        nodes: [
    +          { ...node('a'), metadata: { row: 1, column: 1 } },
    +          { ...node('b'), metadata: { row: 1, column: 3 } },
    +          { ...node('c'), metadata: { row: 2, column: 1 } },
    +          { ...node('d'), metadata: { row: 2, column: 3 } },
    +        ],
    +        edges: [
    +          { ...edge('top', 'top label'), start: 'a', end: 'b' },
    +          { ...edge('bottom', 'bottom label'), start: 'c', end: 'd' },
    +        ],
    +        config: { layout: 'grid' } as LayoutData['config'],
    +      };
    +      prepareGridLayout(data);
    +      for (const edge of data.edges) {
    +        const labelNode = data.nodes.find((item) => item.id === edge.labelNodeId)!;
    +        labelNode.width = 80;
    +        labelNode.height = 20;
    +      }
    +      return data;
    +    };
    +    const fresh = createData();
    +    const reused = createData();
    +    runGridLayoutCore(fresh);
    +    runGridLayoutCore(reused);
    +    const topLabel = reused.nodes.find((item) => item.id === reused.edges[0].labelNodeId)!;
    +    const bottomLabel = reused.nodes.find((item) => item.id === reused.edges[1].labelNodeId)!;
    +    bottomLabel.x = topLabel.x;
    +    bottomLabel.y = topLabel.y;
    +
    +    runGridLayoutCore(reused);
    +
    +    expect(
    +      reused.nodes.map((item) => ({
    +        id: item.id,
    +        x: item.x,
    +        y: item.y,
    +        width: item.width,
    +        height: item.height,
    +      }))
    +    ).toEqual(
    +      fresh.nodes.map((item) => ({
    +        id: item.id,
    +        x: item.x,
    +        y: item.y,
    +        width: item.width,
    +        height: item.height,
    +      }))
    +    );
    +    expect(reused.edges.map((item) => item.points)).toEqual(fresh.edges.map((item) => item.points));
    +  });
    +
    +  it('keeps a labelled self-loop from overlapping its own route', () => {
    +    const data: LayoutData = {
    +      nodes: [node('a')],
    +      edges: [
    +        {
    +          id: 'loop',
    +          start: 'a',
    +          end: 'a',
    +          label: 'wide loop label',
    +          arrowTypeStart: 'none',
    +          arrowTypeEnd: 'arrow_point',
    +          curve: 'linear',
    +          type: 'arrow_point',
    +        } as Edge,
    +      ],
    +      config: {
    +        layout: 'grid',
    +      } as LayoutData['config'],
    +    };
    +
    +    prepareGridLayout(data);
    +    const labelNode = data.nodes.find((item) => item.id === data.edges[0].labelNodeId)!;
    +    labelNode.width = 90;
    +    labelNode.height = 20;
    +    const metrics = createGridRoutingInstrumentation();
    +
    +    runGridLayoutCore(data, { metrics });
    +
    +    expect(data.edges[0].points).toBeDefined();
    +    expect(normalizePolyline(data.edges[0].points!).segments).toHaveLength(3);
    +    expectNoNonAdjacentConflicts(data.edges[0].points!);
    +    expect(labelNode.x).toBeLessThan((data.nodes[0].x ?? 0) - (data.nodes[0].width ?? 0) / 2);
    +    expect(labelNode.y).toBe(20);
    +    expect(metrics.labelAwareSelfLoopCandidates).toBe(1);
    +    expect(metrics.labelAwareSelfLoopsCommitted).toBe(1);
    +    expect(metrics.labelAwareSelfLoopFallbacks).toBe(0);
    +    expect(validateLayout(data).ok).toBe(true);
    +  });
    +
    +  it('uses the same labelled self-loop geometry during resource fallback', () => {
    +    const data: LayoutData = {
    +      nodes: [node('a')],
    +      edges: [
    +        {
    +          id: 'loop',
    +          start: 'a',
    +          end: 'a',
    +          label: 'wide loop label',
    +          arrowTypeStart: 'none',
    +          arrowTypeEnd: 'arrow_point',
    +          curve: 'linear',
    +          type: 'arrow_point',
    +        } as Edge,
    +      ],
    +      config: {
    +        layout: 'grid',
    +      } as LayoutData['config'],
    +    };
    +    prepareGridLayout(data);
    +    const labelNode = data.nodes.find((item) => item.id === data.edges[0].labelNodeId)!;
    +    labelNode.width = 90;
    +    labelNode.height = 20;
    +    const metrics = createGridRoutingInstrumentation();
    +
    +    runGridLayoutCore(data, {
    +      metrics,
    +      routing: { topologyCaps: { maxVertices: 1 } },
    +    });
    +
    +    expect(normalizePolyline(data.edges[0].points ?? []).segments).toHaveLength(3);
    +    expect(metrics.resourceLimitFallbacks).toBe(1);
    +    expect(metrics.labelAwareSelfLoopsCommitted).toBe(1);
    +    expect(validateLayout(data).ok).toBe(true);
    +  });
    +
    +  it('places a fitting label on its existing segment without a routing pass', () => {
    +    const data: LayoutData = {
    +      nodes: [manualNode('a', 40, 100, 20, 20), manualNode('b', 360, 100, 20, 20)],
    +      edges: [
    +        manualEdge(
    +          'labelled',
    +          'a',
    +          'b',
    +          [
    +            { x: 50, y: 100 },
    +            { x: 350, y: 100 },
    +          ],
    +          'fits'
    +        ),
    +      ],
    +      config: { layout: 'grid' } as LayoutData['config'],
    +    };
    +    prepareGridLayout(data);
    +    const labelNode = data.nodes.find((item) => item.id === data.edges[0].labelNodeId)!;
    +    labelNode.width = 40;
    +    labelNode.height = 20;
    +    const originalPoints = structuredClone(data.edges[0].points);
    +    const metrics = createGridEdgeLabelInstrumentation();
    +    const routingMetrics = createGridRoutingInstrumentation();
    +
    +    positionGridEdgeLabels(data, metrics, routingMetrics);
    +
    +    expect(data.edges[0].points).toEqual(originalPoints);
    +    expect(labelNode).toMatchObject({ x: 200, y: 100 });
    +    expect(metrics.labelPasses).toBe(1);
    +    expect(metrics.frozenReservations).toBe(1);
    +    expect(metrics.impactedEdgeReroutes).toBe(0);
    +    expect(routingMetrics.labelOverlayBuilds).toBe(1);
    +    expect(routingMetrics.labelOverlayVertices).toBe(4);
    +  });
    +
    +  it('ignores stale label positions from a previous layout', () => {
    +    const createData = (): LayoutData => {
    +      const data: LayoutData = {
    +        nodes: [
    +          manualNode('top-start', 40, 100, 20, 20),
    +          manualNode('top-end', 360, 100, 20, 20),
    +          manualNode('bottom-start', 40, 200, 20, 20),
    +          manualNode('bottom-end', 360, 200, 20, 20),
    +        ],
    +        edges: [
    +          manualEdge(
    +            'top',
    +            'top-start',
    +            'top-end',
    +            [
    +              { x: 50, y: 100 },
    +              { x: 350, y: 100 },
    +            ],
    +            'top label'
    +          ),
    +          manualEdge(
    +            'bottom',
    +            'bottom-start',
    +            'bottom-end',
    +            [
    +              { x: 50, y: 200 },
    +              { x: 350, y: 200 },
    +            ],
    +            'bottom label'
    +          ),
    +        ],
    +        config: { layout: 'grid' } as LayoutData['config'],
    +      };
    +      prepareGridLayout(data);
    +      for (const edge of data.edges) {
    +        const labelNode = data.nodes.find((item) => item.id === edge.labelNodeId)!;
    +        labelNode.width = 80;
    +        labelNode.height = 20;
    +      }
    +      return data;
    +    };
    +    const fresh = createData();
    +    const reused = createData();
    +    const staleLabel = reused.nodes.find((item) => item.id === reused.edges[1].labelNodeId)!;
    +    staleLabel.x = 200;
    +    staleLabel.y = 100;
    +
    +    positionGridEdgeLabels(fresh);
    +    positionGridEdgeLabels(reused);
    +
    +    expect(reused.edges.map((edge) => edge.points)).toEqual(fresh.edges.map((edge) => edge.points));
    +    expect(
    +      reused.edges.map((edge) => {
    +        const labelNode = reused.nodes.find((item) => item.id === edge.labelNodeId)!;
    +        return { x: labelNode.x, y: labelNode.y };
    +      })
    +    ).toEqual(
    +      fresh.edges.map((edge) => {
    +        const labelNode = fresh.nodes.find((item) => item.id === edge.labelNodeId)!;
    +        return { x: labelNode.x, y: labelNode.y };
    +      })
    +    );
    +  });
    +
    +  it('parks degenerate-route labels without aborting valid label placement', () => {
    +    const data: LayoutData = {
    +      nodes: [
    +        manualNode('empty-start', 40, 100),
    +        manualNode('empty-end', 360, 100),
    +        manualNode('point-start', 40, 200),
    +        manualNode('point-end', 360, 200),
    +        manualNode('valid-start', 40, 300),
    +        manualNode('valid-end', 360, 300),
    +      ],
    +      edges: [
    +        manualEdge('empty', 'empty-start', 'empty-end', [], 'empty route'),
    +        manualEdge(
    +          'point',
    +          'point-start',
    +          'point-end',
    +          [
    +            { x: 200, y: 200 },
    +            { x: 200, y: 200 },
    +          ],
    +          'point route'
    +        ),
    +        manualEdge(
    +          'valid',
    +          'valid-start',
    +          'valid-end',
    +          [
    +            { x: 80, y: 300 },
    +            { x: 320, y: 300 },
    +          ],
    +          'valid route'
    +        ),
    +      ],
    +      config: {
    +        layout: 'grid',
    +      } as LayoutData['config'],
    +    };
    +    prepareGridLayout(data);
    +    for (const edge of data.edges) {
    +      const labelNode = data.nodes.find((item) => item.id === edge.labelNodeId)!;
    +      labelNode.width = 70;
    +      labelNode.height = 20;
    +    }
    +    const metrics = createGridEdgeLabelInstrumentation();
    +
    +    positionGridEdgeLabels(data, metrics);
    +
    +    const labelFor = (edgeId: string) => {
    +      const edge = data.edges.find((item) => item.id === edgeId)!;
    +      return data.nodes.find((item) => item.id === edge.labelNodeId)!;
    +    };
    +    expect(labelFor('empty')).toMatchObject({ x: 40, y: 100 });
    +    expect(labelFor('point')).toMatchObject({ x: 200, y: 200 });
    +    expect(labelFor('valid')).toMatchObject({ x: 200, y: 300 });
    +    expect(data.edges[0].points).toEqual([]);
    +    expect(data.edges[1].points).toEqual([
    +      { x: 200, y: 200 },
    +      { x: 200, y: 200 },
    +    ]);
    +    expect(metrics.degenerateLabelRoutesSkipped).toBe(2);
    +    expect(metrics.labelPasses).toBe(1);
    +  });
    +
    +  it('counts labels skipped before measurement', () => {
    +    const data: LayoutData = {
    +      nodes: [manualNode('a', 40, 100), manualNode('b', 360, 100)],
    +      edges: [
    +        manualEdge(
    +          'unmeasured',
    +          'a',
    +          'b',
    +          [
    +            { x: 80, y: 100 },
    +            { x: 320, y: 100 },
    +          ],
    +          'waiting for measurement'
    +        ),
    +      ],
    +      config: { layout: 'grid' } as LayoutData['config'],
    +    };
    +    prepareGridLayout(data);
    +    const labelNode = data.nodes.find((item) => item.id === data.edges[0].labelNodeId)!;
    +    const metrics = createGridEdgeLabelInstrumentation();
    +
    +    positionGridEdgeLabels(data, metrics);
    +
    +    expect(labelNode).toMatchObject({ width: 0, height: 0 });
    +    expect(labelNode.x).toBeUndefined();
    +    expect(labelNode.y).toBeUndefined();
    +    expect(metrics.unmeasuredLabelsSkipped).toBe(1);
    +    expect(metrics.labelPasses).toBe(0);
    +  });
    +
    +  it('falls back to anchored route midpoints when the shared index-work cap is exhausted', () => {
    +    const data: LayoutData = {
    +      nodes: [manualNode('a', 40, 100), manualNode('b', 360, 100)],
    +      edges: [
    +        manualEdge(
    +          'capped',
    +          'a',
    +          'b',
    +          [
    +            { x: 80, y: 100 },
    +            { x: 320, y: 100 },
    +          ],
    +          'bounded work'
    +        ),
    +      ],
    +      config: { layout: 'grid' } as LayoutData['config'],
    +    };
    +    prepareGridLayout(data);
    +    const labelNode = data.nodes.find((item) => item.id === data.edges[0].labelNodeId)!;
    +    labelNode.width = 80;
    +    labelNode.height = 20;
    +    const originalPoints = structuredClone(data.edges[0].points);
    +    const metrics = createGridEdgeLabelInstrumentation();
    +    const routingMetrics = createGridRoutingInstrumentation();
    +
    +    positionGridEdgeLabels(data, metrics, routingMetrics, { maxIndexWork: 0 });
    +
    +    expect(data.edges[0].points).toEqual(originalPoints);
    +    expect(labelNode).toMatchObject({ x: 200, y: 100 });
    +    expect(metrics.labelPasses).toBe(1);
    +    expect(metrics.indexWorkUnits).toBe(0);
    +    expect(metrics.indexWorkLimitFallbacks).toBe(1);
    +    expect(routingMetrics.resourceLimitFallbacks).toBe(1);
    +    expect(routingMetrics.fallbackReasons.label_index_work_cap).toBe(1);
    +    expect(validateLayout(data)).toMatchObject({ ok: true, issues: [] });
    +  });
    +
    +  it('reroutes an owning edge while preserving its frozen label anchor', () => {
    +    const data: LayoutData = {
    +      nodes: [
    +        manualNode('left', 40, 160, 20, 20),
    +        manualNode('right', 460, 160, 20, 20),
    +        manualNode('top-1', 220, 40, 20, 20),
    +        manualNode('bottom-1', 220, 280, 20, 20),
    +        manualNode('top-2', 280, 40, 20, 20),
    +        manualNode('bottom-2', 280, 280, 20, 20),
    +        manualNode('owner-blocker', 250, 160, 70, 70),
    +      ],
    +      edges: [
    +        manualEdge(
    +          'owner',
    +          'left',
    +          'right',
    +          [
    +            { x: 50, y: 160 },
    +            { x: 450, y: 160 },
    +          ],
    +          'owner label'
    +        ),
    +        manualEdge('foreign-1', 'top-1', 'bottom-1', [
    +          { x: 220, y: 50 },
    +          { x: 220, y: 270 },
    +        ]),
    +        manualEdge('foreign-2', 'top-2', 'bottom-2', [
    +          { x: 280, y: 50 },
    +          { x: 280, y: 270 },
    +        ]),
    +      ],
    +      config: { layout: 'grid' } as LayoutData['config'],
    +    };
    +    prepareGridLayout(data);
    +    const labelNode = data.nodes.find((item) => item.id === data.edges[0].labelNodeId)!;
    +    labelNode.width = 300;
    +    labelNode.height = 28;
    +    const metrics = createGridEdgeLabelInstrumentation();
    +    const routingMetrics = createGridRoutingInstrumentation();
    +
    +    positionGridEdgeLabels(data, metrics, routingMetrics);
    +
    +    expect(data.edges.slice(1).map((edge) => edge.points)).toEqual([
    +      [
    +        { x: 220, y: 50 },
    +        { x: 220, y: 270 },
    +      ],
    +      [
    +        { x: 280, y: 50 },
    +        { x: 280, y: 270 },
    +      ],
    +    ]);
    +    expect(data.edges[0].points?.length).toBeGreaterThan(2);
    +    expect(metrics.labelPasses).toBe(2);
    +    expect(metrics.indexWorkUnits).toBeGreaterThan(0);
    +    expect(metrics.indexWorkLimitFallbacks).toBe(0);
    +    expect(routingMetrics.labelOverlayBuilds).toBe(1);
    +    expect(routingMetrics.labelOverlayVertices).toBe(4);
    +    expect(metrics.frozenReservations).toBe(1);
    +    expect(metrics.impactedEdgeReroutes).toBeGreaterThanOrEqual(1);
    +    expect(metrics.preservedAnchors).toBeGreaterThan(0);
    +    expect(metrics.rerouteCandidatesEvaluated).toBeGreaterThan(0);
    +    expect(
    +      metrics.rerouteRejectedBlockedRect +
    +        metrics.rerouteRejectedProtectedObstacle +
    +        metrics.rerouteRejectedLabelAnchor
    +    ).toBeLessThanOrEqual(metrics.rerouteCandidatesEvaluated);
    +    expect(metrics.rerouteRejectedProtectedObstacle).toBeGreaterThan(0);
    +  });
    +
    +  it('rolls every route and label position back after two-pass non-convergence', () => {
    +    const data: LayoutData = {
    +      nodes: [
    +        manualNode('a', 200, 200),
    +        manualNode('left', 95, 200, 110, 180),
    +        manualNode('right', 305, 200, 110, 180),
    +        manualNode('top', 200, 95, 180, 110),
    +        manualNode('bottom', 200, 305, 180, 110),
    +      ],
    +      edges: [
    +        manualEdge(
    +          'loop',
    +          'a',
    +          'a',
    +          [
    +            { x: 240, y: 190 },
    +            { x: 280, y: 190 },
    +            { x: 280, y: 210 },
    +            { x: 240, y: 210 },
    +          ],
    +          'impossible'
    +        ),
    +      ],
    +      config: { layout: 'grid' } as LayoutData['config'],
    +    };
    +    prepareGridLayout(data);
    +    const labelNode = data.nodes.find((item) => item.id === data.edges[0].labelNodeId)!;
    +    labelNode.width = 260;
    +    labelNode.height = 180;
    +    labelNode.x = 123;
    +    labelNode.y = 456;
    +    const originalPoints = structuredClone(data.edges[0].points);
    +    const metrics = createGridEdgeLabelInstrumentation();
    +    const routingMetrics = createGridRoutingInstrumentation();
    +
    +    expect(() => positionGridEdgeLabels(data, metrics, routingMetrics)).toThrowError(
    +      expect.objectContaining({ code: 'GRID_ROUTE_NOT_FOUND' })
    +    );
    +    expect(data.edges[0].points).toEqual(originalPoints);
    +    expect(labelNode.x).toBe(123);
    +    expect(labelNode.y).toBe(456);
    +    expect(metrics.labelPasses).toBe(2);
    +    expect(metrics.rollbacks).toBe(1);
    +    expect(routingMetrics.resourceLimitFallbacks).toBe(0);
    +    expect(routingMetrics.fallbackValidationFailures).toBe(0);
    +  });
    +
    +  it('creates measurable label nodes without leaving duplicate edge labels behind', () => {
    +    const data: LayoutData = {
    +      nodes: [node('a'), node('b')],
    +      edges: [edge('e1', 'hello')],
    +      config: {} as LayoutData['config'],
    +    };
    +
    +    prepareGridLayout(data);
    +
    +    expect(data.edges[0].label).toBeUndefined();
    +    expect(data.edges[0].labelNodeId).toBeDefined();
    +    const labelNode = data.nodes.find((item) => item.id === data.edges[0].labelNodeId);
    +    expect(labelNode).toMatchObject({
    +      isEdgeLabel: true,
    +      label: 'hello',
    +      shape: 'labelRect',
    +    });
    +  });
    +
    +  it('reuses existing label helpers and avoids collisions with helper-shaped user ids', () => {
    +    const existingHelperId = 'edge-label-a-b-prepared';
    +    const data: LayoutData = {
    +      nodes: [
    +        node('a'),
    +        node('b'),
    +        node('edge-label-user-node'),
    +        {
    +          id: existingHelperId,
    +          label: 'existing',
    +          edgeStart: 'a',
    +          edgeEnd: 'b',
    +          shape: 'labelRect',
    +          width: 0,
    +          height: 0,
    +          isEdgeLabel: true,
    +          isDummy: true,
    +          isGroup: false,
    +        } as Node,
    +      ],
    +      edges: [
    +        {
    +          ...edge('prepared', 'updated'),
    +          labelNodeId: existingHelperId,
    +        } as Edge,
    +        {
    +          id: 'fresh',
    +          start: 'a',
    +          end: 'edge-label-user-node',
    +          label: 'new label',
    +          labelStyle: ['fill:blue'],
    +        } as Edge,
    +      ],
    +      config: {} as LayoutData['config'],
    +    };
    +
    +    prepareGridLayout(data);
    +
    +    expect(data.edges[0]).toMatchObject({ labelNodeId: existingHelperId, label: undefined });
    +    expect(data.nodes.find((item) => item.id === existingHelperId)).toMatchObject({
    +      id: existingHelperId,
    +      isEdgeLabel: true,
    +      label: 'updated',
    +    });
    +
    +    const freshLabelId = data.edges[1].labelNodeId;
    +    expect(freshLabelId).toBeDefined();
    +    expect(freshLabelId).not.toBe('edge-label-user-node');
    +    expect(data.nodes.find((item) => item.id === freshLabelId)).toMatchObject({
    +      isEdgeLabel: true,
    +      label: 'new label',
    +      shape: 'labelRect',
    +    });
    +    expect(
    +      data.nodes.filter((item) => (item as { isEdgeLabel?: boolean }).isEdgeLabel)
    +    ).toHaveLength(2);
    +  });
    +
    +  it('throws GRID_ROUTE_NOT_FOUND when no safe label placement exists', () => {
    +    const data: LayoutData = {
    +      nodes: [
    +        manualNode('a', 200, 200),
    +        manualNode('left', 95, 200, 110, 180),
    +        manualNode('right', 305, 200, 110, 180),
    +        manualNode('top', 200, 95, 180, 110),
    +        manualNode('bottom', 200, 305, 180, 110),
    +      ],
    +      edges: [
    +        manualEdge(
    +          'loop',
    +          'a',
    +          'a',
    +          [
    +            { x: 240, y: 190 },
    +            { x: 280, y: 190 },
    +            { x: 280, y: 160 },
    +            { x: 320, y: 160 },
    +            { x: 320, y: 240 },
    +            { x: 280, y: 240 },
    +            { x: 280, y: 210 },
    +            { x: 240, y: 210 },
    +          ],
    +          'loop label'
    +        ),
    +      ],
    +      config: {
    +        layout: 'grid',
    +      } as LayoutData['config'],
    +    };
    +
    +    prepareGridLayout(data);
    +    const labelNode = data.nodes.find((item) => item.id === data.edges[0].labelNodeId);
    +    if (labelNode) {
    +      labelNode.width = 260;
    +      labelNode.height = 180;
    +    }
    +
    +    expect(() => positionGridEdgeLabels(data)).toThrowError(
    +      expect.objectContaining({ code: 'GRID_ROUTE_NOT_FOUND' })
    +    );
    +  });
    +
    +  it('reroutes crossing vertical edges around a horizontal edge label', () => {
    +    const data: LayoutData = {
    +      nodes: [
    +        manualNode('start', 50, 150),
    +        manualNode('target', 450, 150),
    +        manualNode('top-1', 150, 50, 20, 20),
    +        manualNode('bottom-1', 150, 250, 20, 20),
    +        manualNode('top-2', 200, 50, 20, 20),
    +        manualNode('bottom-2', 200, 250, 20, 20),
    +        manualNode('top-3', 250, 50, 20, 20),
    +        manualNode('bottom-3', 250, 250, 20, 20),
    +        manualNode('top-4', 300, 50, 20, 20),
    +        manualNode('bottom-4', 300, 250, 20, 20),
    +        manualNode('top-5', 350, 50, 20, 20),
    +        manualNode('bottom-5', 350, 250, 20, 20),
    +      ],
    +      edges: [
    +        manualEdge(
    +          'labelled-horizontal',
    +          'start',
    +          'target',
    +          [
    +            { x: 90, y: 150 },
    +            { x: 410, y: 150 },
    +          ],
    +          'g1'
    +        ),
    +        manualEdge('v-1', 'top-1', 'bottom-1', [
    +          { x: 150, y: 60 },
    +          { x: 150, y: 240 },
    +        ]),
    +        manualEdge('v-2', 'top-2', 'bottom-2', [
    +          { x: 200, y: 60 },
    +          { x: 200, y: 240 },
    +        ]),
    +        manualEdge('v-3', 'top-3', 'bottom-3', [
    +          { x: 250, y: 60 },
    +          { x: 250, y: 240 },
    +        ]),
    +        manualEdge('v-4', 'top-4', 'bottom-4', [
    +          { x: 300, y: 60 },
    +          { x: 300, y: 240 },
    +        ]),
    +        manualEdge('v-5', 'top-5', 'bottom-5', [
    +          { x: 350, y: 60 },
    +          { x: 350, y: 240 },
    +        ]),
    +      ],
    +      config: {
    +        layout: 'grid',
    +      } as LayoutData['config'],
    +    };
    +
    +    prepareGridLayout(data);
    +    const labelNode = data.nodes.find((node) => node.id === data.edges[0].labelNodeId);
    +    if (labelNode) {
    +      labelNode.width = 180;
    +      labelNode.height = 20;
    +    }
    +
    +    const metrics = createGridEdgeLabelInstrumentation();
    +    positionGridEdgeLabels(data, metrics);
    +
    +    const report = validateLayout(data);
    +    expect(report).toMatchObject({ ok: true, issues: [] });
    +    expect(
    +      data.edges
    +        .filter((item) => item.id.startsWith('v-'))
    +        .some((item) => (item.points?.length ?? 0) > 2)
    +    ).toBe(true);
    +    expect(metrics.impactedEdgeReroutes).toBeGreaterThan(1);
    +    expect(metrics.rerouteCandidatesEvaluated).toBeGreaterThan(0);
    +    expect(labelNode?.x).toEqual(expect.any(Number));
    +    expect(labelNode?.y).toEqual(expect.any(Number));
    +  });
    +
    +  it('scopes reroute lanes to the blocking label and detour side', () => {
    +    const crossingCount = 40;
    +    const nodes: Node[] = [manualNode('start', -500, 300), manualNode('target', 1800, 300)];
    +    const edges: Edge[] = [
    +      manualEdge(
    +        'labelled-horizontal',
    +        'start',
    +        'target',
    +        [
    +          { x: -460, y: 300 },
    +          { x: 1760, y: 300 },
    +        ],
    +        'wide label'
    +      ),
    +    ];
    +    for (let index = 0; index < crossingCount; index++) {
    +      const ordinal = index + 1;
    +      const x = 100 + ordinal * 26;
    +      nodes.push(
    +        manualNode(`top-${ordinal}`, x, -100, 20, 20),
    +        manualNode(`bottom-${ordinal}`, x, 700, 20, 20)
    +      );
    +      edges.push(
    +        manualEdge(`v-${ordinal}`, `top-${ordinal}`, `bottom-${ordinal}`, [
    +          { x, y: -90 },
    +          { x, y: 690 },
    +        ])
    +      );
    +    }
    +    const data: LayoutData = {
    +      nodes,
    +      edges,
    +      config: {
    +        layout: 'grid',
    +      } as LayoutData['config'],
    +    };
    +
    +    prepareGridLayout(data);
    +    const labelNode = data.nodes.find((node) => node.id === data.edges[0].labelNodeId)!;
    +    labelNode.width = 1100;
    +    labelNode.height = 20;
    +
    +    positionGridEdgeLabels(data);
    +
    +    const labelLeft = labelNode.x! - labelNode.width / 2;
    +    const labelRight = labelNode.x! + labelNode.width / 2;
    +    const firstRightCrossing = data.edges
    +      .slice(1)
    +      .find((edge) => edge.points![0].x > labelNode.x!)!;
    +    const xs = firstRightCrossing.points!.map((point) => point.x);
    +    const overshoot = Math.max(labelLeft - Math.min(...xs), Math.max(...xs) - labelRight, 0);
    +    expect(overshoot).toBeLessThanOrEqual(48);
    +    expect(validateLayout(data).ok).toBe(true);
    +  });
    +
    +  it('keeps crowded vertical label routing structurally valid', () => {
    +    const data: LayoutData = {
    +      nodes: [
    +        manualNode('top', 250, 40),
    +        manualNode('bottom', 250, 390),
    +        manualNode('left-1', 50, 130, 20, 20),
    +        manualNode('right-1', 450, 130, 20, 20),
    +        manualNode('left-2', 50, 180, 20, 20),
    +        manualNode('right-2', 450, 180, 20, 20),
    +        manualNode('left-3', 50, 230, 20, 20),
    +        manualNode('right-3', 450, 230, 20, 20),
    +        manualNode('left-4', 50, 280, 20, 20),
    +        manualNode('right-4', 450, 280, 20, 20),
    +        manualNode('left-5', 50, 330, 20, 20),
    +        manualNode('right-5', 450, 330, 20, 20),
    +      ],
    +      edges: [
    +        manualEdge(
    +          'labelled-vertical',
    +          'top',
    +          'bottom',
    +          [
    +            { x: 250, y: 60 },
    +            { x: 250, y: 370 },
    +          ],
    +          'm10'
    +        ),
    +        manualEdge('h-1', 'left-1', 'right-1', [
    +          { x: 60, y: 130 },
    +          { x: 440, y: 130 },
    +        ]),
    +        manualEdge('h-2', 'left-2', 'right-2', [
    +          { x: 60, y: 210 },
    +          { x: 440, y: 210 },
    +        ]),
    +        manualEdge('h-3', 'left-3', 'right-3', [
    +          { x: 60, y: 290 },
    +          { x: 440, y: 290 },
    +        ]),
    +      ],
    +      config: {
    +        layout: 'grid',
    +      } as LayoutData['config'],
    +    };
    +
    +    prepareGridLayout(data);
    +    const labelNode = data.nodes.find((node) => node.id === data.edges[0].labelNodeId);
    +    if (labelNode) {
    +      labelNode.width = 42;
    +      labelNode.height = 24;
    +    }
    +
    +    positionGridEdgeLabels(data);
    +
    +    const report = validateLayout(data);
    +    expect(report).toMatchObject({ ok: true, issues: [] });
    +    expect(labelNode?.x).toEqual(expect.any(Number));
    +    expect(labelNode?.y).toEqual(expect.any(Number));
    +  });
    +
    +  it('detours a label around tall endpoints when the direct gap is too short', () => {
    +    const data: LayoutData = {
    +      nodes: [manualNode('source', 260, 240, 220, 260), manualNode('target', 523, 240, 180, 220)],
    +      edges: [
    +        manualEdge(
    +          'narrow-gap',
    +          'source',
    +          'target',
    +          [
    +            { x: 370, y: 240 },
    +            { x: 433, y: 240 },
    +          ],
    +          'CWM'
    +        ),
    +      ],
    +      config: {
    +        layout: 'grid',
    +      } as LayoutData['config'],
    +    };
    +
    +    prepareGridLayout(data);
    +    const labelNode = data.nodes.find((node) => node.id === data.edges[0].labelNodeId);
    +    if (labelNode) {
    +      labelNode.width = 66;
    +      labelNode.height = 39;
    +    }
    +
    +    positionGridEdgeLabels(data);
    +
    +    expect(data.edges[0].points?.length).toBeGreaterThan(2);
    +    expect(labelNode?.x).toEqual(expect.any(Number));
    +    expect(labelNode?.y).toEqual(expect.any(Number));
    +    expect(Math.abs((labelNode?.y ?? 0) - 240)).toBeGreaterThan(100);
    +  });
    +
    +  it('considers terminal dogleg segments when the interior segment is too short for the label', () => {
    +    const data: LayoutData = {
    +      nodes: [manualNode('start', 80, 120, 40, 40), manualNode('end', 520, 180, 40, 40)],
    +      edges: [
    +        manualEdge(
    +          'dogleg-label',
    +          'start',
    +          'end',
    +          [
    +            { x: 100, y: 120 },
    +            { x: 320, y: 120 },
    +            { x: 320, y: 180 },
    +            { x: 500, y: 180 },
    +          ],
    +          'terminal segment'
    +        ),
    +      ],
    +      config: {
    +        layout: 'grid',
    +      } as LayoutData['config'],
    +    };
    +
    +    prepareGridLayout(data);
    +    const labelNode = data.nodes.find((node) => node.id === data.edges[0].labelNodeId);
    +    if (labelNode) {
    +      labelNode.width = 140;
    +      labelNode.height = 78;
    +    }
    +
    +    positionGridEdgeLabels(data);
    +
    +    const report = validateLayout(data);
    +    expect(report).toMatchObject({ ok: true, issues: [] });
    +    expect(labelNode?.x).toEqual(expect.any(Number));
    +    expect(labelNode?.y).toEqual(expect.any(Number));
    +    expect(labelNode?.x).not.toBeCloseTo(320, 0);
    +  });
    +});
    diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/grid/edgeLabels.ts b/packages/mermaid/src/rendering-util/layout-algorithms/grid/edgeLabels.ts
    new file mode 100644
    index 00000000000..a444da85afd
    --- /dev/null
    +++ b/packages/mermaid/src/rendering-util/layout-algorithms/grid/edgeLabels.ts
    @@ -0,0 +1,2582 @@
    +import type { Point } from '../../../types.js';
    +import type { Edge, LayoutData, Node, NonClusterNode } from '../../types.js';
    +import { normalizePolyline, segmentsCross, type Segment } from '../layout-utils/geometry.js';
    +import {
    +  clamp,
    +  compareCodeUnits,
    +  hasTerminalMarker,
    +  orthogonalPolylineContainsPoint,
    +  polylineIntersectsRect,
    +  rectForNode,
    +  segmentIntersectsRectInterior,
    +  terminalMarkerClearanceRect,
    +} from '../layout-utils/helpers.js';
    +import type { Rect } from '../layout-utils/types.js';
    +import {
    +  GRID_EDGE_END_MARKER_CLEARANCE,
    +  GRID_LABEL_CLEARANCE,
    +  gridLabelRequiredSegmentLength,
    +} from './labelGeometry.js';
    +import type { GridRoutingInstrumentation } from './routerInstrumentation.js';
    +import { GRID_LABEL_PREFIX, gridError, isEdgeLabelNode, isFinitePositiveNumber } from './types.js';
    +
    +// Label placement is a post-routing transaction: reserve label rectangles, reroute affected
    +// orthogonal edges, then publish only a pass whose labels and preserved anchors all validate.
    +const LABEL_CLEARANCE = GRID_LABEL_CLEARANCE;
    +const LABEL_GUTTER = 14;
    +const LABEL_GUTTER_STEP = 18;
    +const MAX_GUTTER_STEPS = 16;
    +const MAX_DETOUR_LINE_CANDIDATES = 128;
    +const MAX_DETOUR_CENTER_CANDIDATES = 64;
    +// Match the router's invocation-wide search budget: retries share one deterministic work ceiling.
    +const DEFAULT_MAX_INDEX_WORK = 2_000_000;
    +const EDGE_END_MARKER_CLEARANCE = GRID_EDGE_END_MARKER_CLEARANCE;
    +const LABEL_EPSILON = 1e-6;
    +const MIN_SELF_PARALLEL_GAP = 7;
    +const MIN_SELF_PARALLEL_OVERLAP = 8;
    +
    +interface Interval {
    +  start: number;
    +  end: number;
    +}
    +
    +interface PlacedLabel {
    +  edgeId: string;
    +  nodeId: string;
    +  center: Point;
    +  rect: Rect;
    +}
    +
    +interface SegmentCandidate {
    +  segment: Segment;
    +  segmentIndex: number;
    +  order: number;
    +}
    +
    +interface LabelWorkItem {
    +  edge: Edge;
    +  labelNode: Node;
    +  sourceIndex: number;
    +}
    +
    +type EdgePointOverrides = Map;
    +
    +interface Bounds {
    +  left: number;
    +  right: number;
    +  top: number;
    +  bottom: number;
    +}
    +
    +function rangeOverlapLength(
    +  firstStart: number,
    +  firstEnd: number,
    +  secondStart: number,
    +  secondEnd: number
    +) {
    +  const start = Math.max(Math.min(firstStart, firstEnd), Math.min(secondStart, secondEnd));
    +  const end = Math.min(Math.max(firstStart, firstEnd), Math.max(secondStart, secondEnd));
    +  return Math.max(0, end - start);
    +}
    +
    +function polylineHasSelfConflict(points: readonly Point[]): boolean {
    +  const segments = normalizePolyline([...points]).segments;
    +  for (let firstIndex = 0; firstIndex < segments.length; firstIndex++) {
    +    const first = segments[firstIndex];
    +    for (let secondIndex = firstIndex + 2; secondIndex < segments.length; secondIndex++) {
    +      const second = segments[secondIndex];
    +      if (segmentsCross(first, second)) {
    +        return true;
    +      }
    +      if (first.orientation !== second.orientation || first.orientation === 'Z') {
    +        continue;
    +      }
    +
    +      const sameAxis =
    +        first.orientation === 'H'
    +          ? Math.abs(first.a.y - second.a.y) <= LABEL_EPSILON
    +          : Math.abs(first.a.x - second.a.x) <= LABEL_EPSILON;
    +      const overlap =
    +        first.orientation === 'H'
    +          ? rangeOverlapLength(first.a.x, first.b.x, second.a.x, second.b.x)
    +          : rangeOverlapLength(first.a.y, first.b.y, second.a.y, second.b.y);
    +      if (sameAxis && overlap > LABEL_EPSILON) {
    +        return true;
    +      }
    +      const gap =
    +        first.orientation === 'H'
    +          ? Math.abs(first.a.y - second.a.y)
    +          : Math.abs(first.a.x - second.a.x);
    +      if (!sameAxis && gap < MIN_SELF_PARALLEL_GAP && overlap >= MIN_SELF_PARALLEL_OVERLAP) {
    +        return true;
    +      }
    +    }
    +  }
    +  return false;
    +}
    +
    +interface ObstacleEntry {
    +  id: string;
    +  bounds: Bounds;
    +  rect: Rect;
    +  nodeId?: string;
    +}
    +
    +interface BorderEntry {
    +  id: string;
    +  bounds: Bounds;
    +  rect: Rect;
    +}
    +
    +interface EdgeSegmentEntry {
    +  id: string;
    +  bounds: Bounds;
    +  edgeId: string;
    +  segment: Segment;
    +}
    +
    +interface EdgeGeometryCache {
    +  points: Point[];
    +  segments: Segment[];
    +  candidateSegments: SegmentCandidate[];
    +  markerRects: Rect[];
    +}
    +
    +export interface GridEdgeLabelInstrumentation {
    +  obstacleRectQueries: number;
    +  obstacleBandQueries: number;
    +  obstaclePolylineQueries: number;
    +  obstacleCandidatesVisited: number;
    +  segmentRectQueries: number;
    +  segmentBandQueries: number;
    +  segmentCandidatesVisited: number;
    +  foreignEdgeLookups: number;
    +  labelPasses: number;
    +  frozenReservations: number;
    +  impactedEdgeReroutes: number;
    +  preservedAnchors: number;
    +  rerouteCandidatesEvaluated: number;
    +  rerouteRejectedBlockedRect: number;
    +  rerouteRejectedProtectedObstacle: number;
    +  rerouteRejectedLabelAnchor: number;
    +  rerouteRejectedDegenerateSpan: number;
    +  reroutePartialProgressCandidates: number;
    +  individualLabelRerouteAttempts: number;
    +  individualLabelRerouteSuccesses: number;
    +  labelOverlapFallbacks: number;
    +  labelOverlapReservations: number;
    +  degenerateLabelRoutesSkipped: number;
    +  unmeasuredLabelsSkipped: number;
    +  indexWorkUnits: number;
    +  indexWorkLimitFallbacks: number;
    +  rollbacks: number;
    +  indexCoordinateCount: number;
    +}
    +
    +export interface GridEdgeLabelOptions {
    +  maxIndexWork?: number;
    +}
    +
    +interface EdgeLabelContext {
    +  // These indexes are updated with every provisional route so later searches see one coherent
    +  // geometry snapshot rather than rescanning stale edge and obstacle arrays.
    +  edgeById: Map;
    +  edgeGeometryById: Map;
    +  obstacleIndex: CompressedBoundsIndex;
    +  placedLabelIndex: CompressedBoundsIndex;
    +  groupBorderIndex: CompressedBoundsIndex;
    +  segmentIndex: CompressedBoundsIndex;
    +  searchBounds: Bounds;
    +  metrics?: GridEdgeLabelInstrumentation;
    +}
    +
    +type ObstacleQueryMetric =
    +  | 'obstacleRectQueries'
    +  | 'obstacleBandQueries'
    +  | 'obstaclePolylineQueries';
    +type SegmentQueryMetric = 'segmentRectQueries' | 'segmentBandQueries';
    +
    +export function createGridEdgeLabelInstrumentation(): GridEdgeLabelInstrumentation {
    +  return {
    +    obstacleRectQueries: 0,
    +    obstacleBandQueries: 0,
    +    obstaclePolylineQueries: 0,
    +    obstacleCandidatesVisited: 0,
    +    segmentRectQueries: 0,
    +    segmentBandQueries: 0,
    +    segmentCandidatesVisited: 0,
    +    foreignEdgeLookups: 0,
    +    labelPasses: 0,
    +    frozenReservations: 0,
    +    impactedEdgeReroutes: 0,
    +    preservedAnchors: 0,
    +    rerouteCandidatesEvaluated: 0,
    +    rerouteRejectedBlockedRect: 0,
    +    rerouteRejectedProtectedObstacle: 0,
    +    rerouteRejectedLabelAnchor: 0,
    +    rerouteRejectedDegenerateSpan: 0,
    +    reroutePartialProgressCandidates: 0,
    +    individualLabelRerouteAttempts: 0,
    +    individualLabelRerouteSuccesses: 0,
    +    labelOverlapFallbacks: 0,
    +    labelOverlapReservations: 0,
    +    degenerateLabelRoutesSkipped: 0,
    +    unmeasuredLabelsSkipped: 0,
    +    indexWorkUnits: 0,
    +    indexWorkLimitFallbacks: 0,
    +    rollbacks: 0,
    +    indexCoordinateCount: 0,
    +  };
    +}
    +
    +/**
    + * A dynamic coordinate-compressed bounds index. Entries are keyed by their
    + * actual boundaries; no bucket is allocated for any coordinate between them.
    + * Label routing mutates these indexes often enough that a sorted sweep on the
    + * compressed entries is both simpler and more predictable than rebuilding a
    + * tree after every provisional reservation.
    + */
    +class LabelIndexWorkLimitError extends Error {
    +  constructor() {
    +    super('Grid label index work cap exceeded');
    +    this.name = 'LabelIndexWorkLimitError';
    +  }
    +}
    +
    +class LabelIndexWorkBudget {
    +  private used = 0;
    +
    +  constructor(
    +    private readonly limit: number,
    +    private readonly metrics?: GridEdgeLabelInstrumentation
    +  ) {}
    +
    +  charge(units: number): void {
    +    if (units <= 0) {
    +      return;
    +    }
    +    if (this.used + units > this.limit) {
    +      throw new LabelIndexWorkLimitError();
    +    }
    +    this.used += units;
    +    incrementMetric(this.metrics, 'indexWorkUnits', units);
    +  }
    +}
    +
    +class CompressedBoundsIndex {
    +  private readonly entriesById = new Map();
    +  private sortedByLeft: T[] = [];
    +  private dirty = false;
    +
    +  constructor(
    +    private readonly workBudget: LabelIndexWorkBudget,
    +    private readonly metrics?: GridEdgeLabelInstrumentation
    +  ) {}
    +
    +  upsert(entry: T): void {
    +    const isNew = !this.entriesById.has(entry.id);
    +    this.entriesById.set(entry.id, entry);
    +    this.dirty = true;
    +    if (isNew && this.metrics) {
    +      // Four stored boundary coordinates per entry, independent of their span.
    +      this.metrics.indexCoordinateCount += 4;
    +    }
    +  }
    +
    +  remove(id: string): void {
    +    if (this.entriesById.delete(id)) {
    +      this.dirty = true;
    +    }
    +  }
    +
    +  query(bounds: Bounds): T[] {
    +    if (this.dirty) {
    +      const entryCount = this.entriesById.size;
    +      // Charge a deterministic n log n estimate before sorting so the cap is independent of the
    +      // JavaScript engine's comparison order.
    +      this.workBudget.charge(entryCount * Math.max(1, Math.ceil(Math.log2(entryCount + 1))));
    +      this.sortedByLeft = [...this.entriesById.values()].sort(
    +        (a, b) => a.bounds.left - b.bounds.left || compareCodeUnits(a.id, b.id)
    +      );
    +      this.dirty = false;
    +    }
    +    const result: T[] = [];
    +    for (const entry of this.sortedByLeft) {
    +      this.workBudget.charge(1);
    +      if (entry.bounds.left > bounds.right) {
    +        break;
    +      }
    +      if (boundsOverlap(entry.bounds, bounds)) {
    +        result.push(entry);
    +      }
    +    }
    +    return result;
    +  }
    +}
    +
    +function rectOverlap(a: Rect, b: Rect): boolean {
    +  return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
    +}
    +
    +function labelRectAt(node: Node, center: Point): Rect {
    +  const width = node.width ?? 0;
    +  const height = node.height ?? 0;
    +  return {
    +    cx: center.x,
    +    cy: center.y,
    +    left: center.x - width / 2,
    +    right: center.x + width / 2,
    +    top: center.y - height / 2,
    +    bottom: center.y + height / 2,
    +  };
    +}
    +
    +function groupTitleRect(node: Node): Rect | null {
    +  const rect = node.groupTitleRect;
    +  if (!rect) {
    +    return null;
    +  }
    +  return {
    +    cx: (rect.left + rect.right) / 2,
    +    cy: (rect.top + rect.bottom) / 2,
    +    left: rect.left,
    +    right: rect.right,
    +    top: rect.top,
    +    bottom: rect.bottom,
    +  };
    +}
    +
    +function incrementMetric(
    +  metrics: GridEdgeLabelInstrumentation | undefined,
    +  key: keyof GridEdgeLabelInstrumentation,
    +  delta = 1
    +): void {
    +  if (metrics) {
    +    metrics[key] += delta;
    +  }
    +}
    +
    +function clonePoint(point: Point): Point {
    +  return { ...point };
    +}
    +
    +function boundsForRect(rect: Rect): Bounds {
    +  return {
    +    left: rect.left,
    +    right: rect.right,
    +    top: rect.top,
    +    bottom: rect.bottom,
    +  };
    +}
    +
    +function boundsForSegment(segment: Segment): Bounds {
    +  return {
    +    left: Math.min(segment.a.x, segment.b.x),
    +    right: Math.max(segment.a.x, segment.b.x),
    +    top: Math.min(segment.a.y, segment.b.y),
    +    bottom: Math.max(segment.a.y, segment.b.y),
    +  };
    +}
    +
    +function boundsForPoints(points: Point[]): Bounds | null {
    +  if (points.length === 0) {
    +    return null;
    +  }
    +  let left = points[0].x;
    +  let right = points[0].x;
    +  let top = points[0].y;
    +  let bottom = points[0].y;
    +  for (const point of points.slice(1)) {
    +    left = Math.min(left, point.x);
    +    right = Math.max(right, point.x);
    +    top = Math.min(top, point.y);
    +    bottom = Math.max(bottom, point.y);
    +  }
    +  return { left, right, top, bottom };
    +}
    +
    +function mergeBounds(a: Bounds, b: Bounds): Bounds {
    +  return {
    +    left: Math.min(a.left, b.left),
    +    right: Math.max(a.right, b.right),
    +    top: Math.min(a.top, b.top),
    +    bottom: Math.max(a.bottom, b.bottom),
    +  };
    +}
    +
    +function expandBounds(bounds: Bounds, padding: number): Bounds {
    +  return {
    +    left: bounds.left - padding,
    +    right: bounds.right + padding,
    +    top: bounds.top - padding,
    +    bottom: bounds.bottom + padding,
    +  };
    +}
    +
    +function boundsOverlap(a: Bounds, b: Bounds): boolean {
    +  return a.left <= b.right && a.right >= b.left && a.top <= b.bottom && a.bottom >= b.top;
    +}
    +
    +function candidateSegmentsFromSegments(segments: Segment[]): SegmentCandidate[] {
    +  const indexes =
    +    segments.length <= 2
    +      ? segments.map((_, index) => index)
    +      : [...segments.slice(1, -1).map((_, index) => index + 1), 0, segments.length - 1];
    +  return indexes.map((segmentIndex, order) => ({
    +    segment: segments[segmentIndex],
    +    segmentIndex,
    +    order,
    +  }));
    +}
    +
    +function markerClearanceRectsFromPoints(edge: Edge, points: Point[]): Rect[] {
    +  const terminals = ['start', 'end'] as const;
    +  return terminals
    +    .filter((terminal) => hasTerminalMarker(edge, terminal))
    +    .map((terminal) =>
    +      terminalMarkerClearanceRect(points, terminal, EDGE_END_MARKER_CLEARANCE, 7, LABEL_EPSILON)
    +    )
    +    .filter((rect): rect is Rect => rect !== null);
    +}
    +
    +function buildEdgeGeometry(edge: Edge, points: Point[]): EdgeGeometryCache {
    +  const normalized = normalizePolyline(points);
    +  return {
    +    points: normalized.points,
    +    segments: normalized.segments,
    +    candidateSegments: candidateSegmentsFromSegments(normalized.segments),
    +    markerRects: markerClearanceRectsFromPoints(edge, normalized.points),
    +  };
    +}
    +
    +function segmentLength(segment: Segment): number {
    +  return segment.orientation === 'H'
    +    ? Math.abs(segment.b.x - segment.a.x)
    +    : Math.abs(segment.b.y - segment.a.y);
    +}
    +
    +function borderCutsRect(rect: Rect, border: Rect): boolean {
    +  const corners: Point[] = [
    +    { x: border.left, y: border.top },
    +    { x: border.right, y: border.top },
    +    { x: border.right, y: border.bottom },
    +    { x: border.left, y: border.bottom },
    +  ];
    +  for (let i = 0; i < corners.length; i++) {
    +    if (segmentIntersectsRectInterior(corners[i], corners[(i + 1) % corners.length], rect)) {
    +      return true;
    +    }
    +  }
    +  return false;
    +}
    +
    +function hasFinitePosition(node: Node): boolean {
    +  return Number.isFinite(node.x) && Number.isFinite(node.y);
    +}
    +
    +function pointsForEdge(
    +  edge: Edge,
    +  overrides?: EdgePointOverrides,
    +  context?: EdgeLabelContext
    +): Point[] {
    +  const overridePoints = overrides?.get(edge.id);
    +  if (overridePoints) {
    +    return normalizePolyline(overridePoints).points;
    +  }
    +  const cached = context?.edgeGeometryById.get(edge.id);
    +  if (cached) {
    +    return cached.points;
    +  }
    +  return normalizePolyline(edge.points ?? []).points;
    +}
    +
    +function markerClearanceRectsForEdge(
    +  edge: Edge,
    +  overrides?: EdgePointOverrides,
    +  context?: EdgeLabelContext
    +): Rect[] {
    +  if (!overrides?.has(edge.id)) {
    +    const cached = context?.edgeGeometryById.get(edge.id);
    +    if (cached) {
    +      return cached.markerRects;
    +    }
    +  }
    +  return markerClearanceRectsFromPoints(edge, pointsForEdge(edge, overrides, context));
    +}
    +
    +function rangeOverlaps(aStart: number, aEnd: number, bStart: number, bEnd: number): boolean {
    +  return (
    +    Math.max(Math.min(aStart, aEnd), Math.min(bStart, bEnd)) <
    +    Math.min(Math.max(aStart, aEnd), Math.max(bStart, bEnd)) - LABEL_EPSILON
    +  );
    +}
    +
    +function clampInterval(interval: Interval, low: number, high: number): Interval | null {
    +  const start = clamp(Math.min(interval.start, interval.end), low, high);
    +  const end = clamp(Math.max(interval.start, interval.end), low, high);
    +  return end - start > LABEL_EPSILON ? { start, end } : null;
    +}
    +
    +function mergeBlockedLabelIntervals(intervals: Interval[], low: number, high: number): Interval[] {
    +  const clamped = intervals
    +    .map((interval) => clampInterval(interval, low, high))
    +    .filter((interval): interval is Interval => interval !== null)
    +    .sort((a, b) => a.start - b.start || a.end - b.end);
    +
    +  if (clamped.length === 0) {
    +    return [];
    +  }
    +
    +  const merged: Interval[] = [clamped[0]];
    +  for (let index = 1; index < clamped.length; index++) {
    +    const current = clamped[index];
    +    const previous = merged[merged.length - 1];
    +    if (current.start <= previous.end + LABEL_EPSILON) {
    +      previous.end = Math.max(previous.end, current.end);
    +    } else {
    +      merged.push(current);
    +    }
    +  }
    +  return merged;
    +}
    +
    +function centerCandidatesFromIntervals(
    +  low: number,
    +  high: number,
    +  blocked: Interval[],
    +  preferred: number
    +): number[] {
    +  if (high - low <= LABEL_EPSILON) {
    +    return [];
    +  }
    +
    +  const allowed: Interval[] = [];
    +  let cursor = low;
    +  for (const interval of mergeBlockedLabelIntervals(blocked, low, high)) {
    +    if (interval.start - cursor > LABEL_EPSILON) {
    +      allowed.push({ start: cursor, end: interval.start });
    +    }
    +    cursor = Math.max(cursor, interval.end);
    +  }
    +  if (high - cursor > LABEL_EPSILON) {
    +    allowed.push({ start: cursor, end: high });
    +  }
    +
    +  const candidates = allowed.map((interval) => ({
    +    value: clamp(preferred, interval.start, interval.end),
    +    start: interval.start,
    +    end: interval.end,
    +  }));
    +  candidates.sort((a, b) => {
    +    const preferredDelta = Math.abs(a.value - preferred) - Math.abs(b.value - preferred);
    +    if (Math.abs(preferredDelta) > LABEL_EPSILON) {
    +      return preferredDelta;
    +    }
    +    const widthDelta = b.end - b.start - (a.end - a.start);
    +    if (Math.abs(widthDelta) > LABEL_EPSILON) {
    +      return widthDelta;
    +    }
    +    return a.value - b.value;
    +  });
    +
    +  const seen = new Set();
    +  const values: number[] = [];
    +  for (const candidate of candidates) {
    +    const rounded = Math.round(candidate.value * 1e6) / 1e6;
    +    if (seen.has(rounded)) {
    +      continue;
    +    }
    +    seen.add(rounded);
    +    values.push(candidate.value);
    +  }
    +  return values;
    +}
    +
    +function obstacleRectForNode(node: Node): Rect | null {
    +  if (node.isGroup) {
    +    return null;
    +  }
    +  if (isEdgeLabelNode(node) && !hasFinitePosition(node)) {
    +    return null;
    +  }
    +  return rectForNode(node);
    +}
    +
    +function candidateSegments(edge: Edge, context?: EdgeLabelContext): SegmentCandidate[] {
    +  const cached = context?.edgeGeometryById.get(edge.id);
    +  if (cached) {
    +    return cached.candidateSegments;
    +  }
    +  return candidateSegmentsFromSegments(normalizePolyline(edge.points ?? []).segments);
    +}
    +
    +function storeEdgeGeometry(
    +  context: EdgeLabelContext,
    +  edgeId: string,
    +  geometry: EdgeGeometryCache
    +): void {
    +  const previous = context.edgeGeometryById.get(edgeId);
    +  if (previous) {
    +    previous.segments.forEach((_, index) => context.segmentIndex.remove(`${edgeId}:${index}`));
    +  }
    +
    +  context.edgeGeometryById.set(edgeId, geometry);
    +  geometry.segments.forEach((segment, index) => {
    +    context.segmentIndex.upsert({
    +      id: `${edgeId}:${index}`,
    +      edgeId,
    +      segment,
    +      bounds: boundsForSegment(segment),
    +    });
    +  });
    +}
    +
    +function setEdgePoints(context: EdgeLabelContext, edge: Edge, points: Point[]): void {
    +  const geometry = buildEdgeGeometry(edge, points);
    +  edge.points = geometry.points.map(clonePoint);
    +  storeEdgeGeometry(context, edge.id, geometry);
    +}
    +
    +function upsertPlacedLabelObstacle(context: EdgeLabelContext, placedLabel: PlacedLabel): void {
    +  context.placedLabelIndex.upsert({
    +    id: `label:${placedLabel.nodeId}`,
    +    nodeId: placedLabel.nodeId,
    +    rect: placedLabel.rect,
    +    bounds: boundsForRect(placedLabel.rect),
    +  });
    +}
    +
    +function createEdgeLabelContext(
    +  data: LayoutData,
    +  workBudget: LabelIndexWorkBudget,
    +  metrics?: GridEdgeLabelInstrumentation
    +): EdgeLabelContext {
    +  // A fresh context is the isolation boundary for one placement pass. Nothing indexed here is
    +  // reused after rollback because route proposals and label reservations are pass-local.
    +  const context: EdgeLabelContext = {
    +    edgeById: new Map(),
    +    edgeGeometryById: new Map(),
    +    obstacleIndex: new CompressedBoundsIndex(workBudget, metrics),
    +    placedLabelIndex: new CompressedBoundsIndex(workBudget, metrics),
    +    groupBorderIndex: new CompressedBoundsIndex(workBudget, metrics),
    +    segmentIndex: new CompressedBoundsIndex(workBudget, metrics),
    +    searchBounds: { left: 0, right: 0, top: 0, bottom: 0 },
    +    metrics,
    +  };
    +
    +  let aggregateBounds: Bounds | null = null;
    +  const rememberBounds = (bounds: Bounds) => {
    +    aggregateBounds = aggregateBounds ? mergeBounds(aggregateBounds, bounds) : { ...bounds };
    +  };
    +
    +  for (const node of data.nodes) {
    +    if (node.isGroup) {
    +      const borderRect = rectForNode(node);
    +      const borderBounds = boundsForRect(borderRect);
    +      context.groupBorderIndex.upsert({
    +        id: `border:${node.id}`,
    +        rect: borderRect,
    +        bounds: borderBounds,
    +      });
    +      rememberBounds(borderBounds);
    +
    +      const titleRect = groupTitleRect(node);
    +      if (titleRect) {
    +        const titleBounds = boundsForRect(titleRect);
    +        context.obstacleIndex.upsert({
    +          id: `title:${node.id}`,
    +          rect: titleRect,
    +          bounds: titleBounds,
    +        });
    +        rememberBounds(titleBounds);
    +      }
    +      continue;
    +    }
    +
    +    const obstacleRect = obstacleRectForNode(node);
    +    if (!obstacleRect) {
    +      continue;
    +    }
    +
    +    const entry: ObstacleEntry = {
    +      id: `${isEdgeLabelNode(node) ? 'label' : 'node'}:${node.id}`,
    +      rect: obstacleRect,
    +      bounds: boundsForRect(obstacleRect),
    +      nodeId: node.id,
    +    };
    +    if (isEdgeLabelNode(node)) {
    +      context.placedLabelIndex.upsert(entry);
    +    } else {
    +      context.obstacleIndex.upsert(entry);
    +    }
    +    rememberBounds(entry.bounds);
    +  }
    +
    +  for (const edge of data.edges) {
    +    context.edgeById.set(edge.id, edge);
    +    const geometry = buildEdgeGeometry(edge, edge.points ?? []);
    +    storeEdgeGeometry(context, edge.id, geometry);
    +    const pointBounds = boundsForPoints(geometry.points);
    +    if (pointBounds) {
    +      rememberBounds(pointBounds);
    +    }
    +  }
    +
    +  context.searchBounds = expandBounds(
    +    aggregateBounds ?? { left: 0, right: 0, top: 0, bottom: 0 },
    +    128
    +  );
    +  return context;
    +}
    +
    +function queryObstacleEntries(
    +  context: EdgeLabelContext,
    +  bounds: Bounds,
    +  metricKey: ObstacleQueryMetric,
    +  options: {
    +    excludeNodeId?: string;
    +    skipNodeIds?: ReadonlySet;
    +  } = {}
    +): ObstacleEntry[] {
    +  incrementMetric(context.metrics, metricKey);
    +
    +  const collected: ObstacleEntry[] = [];
    +  const appendEntries = (entries: ObstacleEntry[]) => {
    +    for (const entry of entries) {
    +      if (
    +        entry.nodeId &&
    +        (entry.nodeId === options.excludeNodeId || options.skipNodeIds?.has(entry.nodeId))
    +      ) {
    +        continue;
    +      }
    +      collected.push(entry);
    +    }
    +  };
    +
    +  appendEntries(context.obstacleIndex.query(bounds));
    +  appendEntries(context.placedLabelIndex.query(bounds));
    +  incrementMetric(context.metrics, 'obstacleCandidatesVisited', collected.length);
    +  return collected;
    +}
    +
    +function queryForeignSegments(
    +  context: EdgeLabelContext,
    +  bounds: Bounds,
    +  selfEdgeId: string,
    +  overrides: EdgePointOverrides | undefined,
    +  metricKey: SegmentQueryMetric
    +): EdgeSegmentEntry[] {
    +  incrementMetric(context.metrics, metricKey);
    +
    +  const overriddenEdgeIds = overrides ? new Set(overrides.keys()) : undefined;
    +  const collected: EdgeSegmentEntry[] = [];
    +  const indexedEntries = context.segmentIndex.query(bounds);
    +  for (const entry of indexedEntries) {
    +    if (entry.edgeId === selfEdgeId || overriddenEdgeIds?.has(entry.edgeId)) {
    +      continue;
    +    }
    +    collected.push(entry);
    +  }
    +
    +  let visited = indexedEntries.length;
    +  if (overrides) {
    +    for (const [edgeId, points] of overrides) {
    +      if (edgeId === selfEdgeId) {
    +        continue;
    +      }
    +      const edge = context.edgeById.get(edgeId);
    +      if (!edge) {
    +        continue;
    +      }
    +      const geometry = buildEdgeGeometry(edge, points);
    +      geometry.segments.forEach((segment, index) => {
    +        const segmentBounds = boundsForSegment(segment);
    +        if (!boundsOverlap(segmentBounds, bounds)) {
    +          return;
    +        }
    +        visited += 1;
    +        collected.push({
    +          id: `override:${edgeId}:${index}`,
    +          edgeId,
    +          segment,
    +          bounds: segmentBounds,
    +        });
    +      });
    +    }
    +  }
    +
    +  incrementMetric(context.metrics, 'segmentCandidatesVisited', visited);
    +  return collected;
    +}
    +
    +function foreignEdgeIdsIntersectingRect(
    +  context: EdgeLabelContext,
    +  rect: Rect,
    +  selfEdgeId: string,
    +  overrides?: EdgePointOverrides
    +): string[] {
    +  incrementMetric(context.metrics, 'foreignEdgeLookups');
    +  const ids = new Set();
    +  for (const entry of queryForeignSegments(
    +    context,
    +    boundsForRect(rect),
    +    selfEdgeId,
    +    overrides,
    +    'segmentRectQueries'
    +  )) {
    +    if (segmentIntersectsRectInterior(entry.segment.a, entry.segment.b, rect)) {
    +      ids.add(entry.edgeId);
    +    }
    +  }
    +  return [...ids].sort(compareCodeUnits);
    +}
    +
    +function obstacleBandQueryBounds(segment: Segment, labelNode: Node): Bounds | null {
    +  if (!isFinitePositiveNumber(labelNode.width) || !isFinitePositiveNumber(labelNode.height)) {
    +    return null;
    +  }
    +
    +  if (segment.orientation === 'H') {
    +    return {
    +      left: Math.min(segment.a.x, segment.b.x) - labelNode.width / 2,
    +      right: Math.max(segment.a.x, segment.b.x) + labelNode.width / 2,
    +      top: segment.a.y - labelNode.height / 2,
    +      bottom: segment.a.y + labelNode.height / 2,
    +    };
    +  }
    +
    +  return {
    +    left: segment.a.x - labelNode.width / 2,
    +    right: segment.a.x + labelNode.width / 2,
    +    top: Math.min(segment.a.y, segment.b.y) - labelNode.height / 2,
    +    bottom: Math.max(segment.a.y, segment.b.y) + labelNode.height / 2,
    +  };
    +}
    +
    +function detourLineQueryBounds(segment: Segment, labelNode: Node, searchBounds: Bounds): Bounds {
    +  if (segment.orientation === 'H') {
    +    const alongPadding = (labelNode.width ?? 0) + LABEL_CLEARANCE * 2 + LABEL_GUTTER;
    +    return {
    +      left: Math.min(segment.a.x, segment.b.x) - alongPadding,
    +      right: Math.max(segment.a.x, segment.b.x) + alongPadding,
    +      top: searchBounds.top,
    +      bottom: searchBounds.bottom,
    +    };
    +  }
    +
    +  const alongPadding = (labelNode.height ?? 0) + LABEL_CLEARANCE * 2 + LABEL_GUTTER;
    +  return {
    +    left: searchBounds.left,
    +    right: searchBounds.right,
    +    top: Math.min(segment.a.y, segment.b.y) - alongPadding,
    +    bottom: Math.max(segment.a.y, segment.b.y) + alongPadding,
    +  };
    +}
    +
    +function rerouteLineQueryBounds(segment: Segment, searchBounds: Bounds): Bounds {
    +  if (segment.orientation === 'H') {
    +    return {
    +      left: Math.min(segment.a.x, segment.b.x) - LABEL_CLEARANCE,
    +      right: Math.max(segment.a.x, segment.b.x) + LABEL_CLEARANCE,
    +      top: searchBounds.top,
    +      bottom: searchBounds.bottom,
    +    };
    +  }
    +
    +  return {
    +    left: searchBounds.left,
    +    right: searchBounds.right,
    +    top: Math.min(segment.a.y, segment.b.y) - LABEL_CLEARANCE,
    +    bottom: Math.max(segment.a.y, segment.b.y) + LABEL_CLEARANCE,
    +  };
    +}
    +
    +function rectSafeForLabel(
    +  rect: Rect,
    +  edge: Edge,
    +  labelNodeId: string,
    +  context: EdgeLabelContext,
    +  overrides?: EdgePointOverrides,
    +  allowedForeignEdgeIds?: ReadonlySet
    +): boolean {
    +  for (const obstacle of queryObstacleEntries(context, boundsForRect(rect), 'obstacleRectQueries', {
    +    excludeNodeId: labelNodeId,
    +  })) {
    +    if (rectOverlap(rect, obstacle.rect)) {
    +      return false;
    +    }
    +  }
    +
    +  for (const border of context.groupBorderIndex.query(boundsForRect(rect))) {
    +    if (borderCutsRect(rect, border.rect)) {
    +      return false;
    +    }
    +  }
    +
    +  for (const clearanceRect of markerClearanceRectsForEdge(edge, overrides, context)) {
    +    if (rectOverlap(rect, clearanceRect)) {
    +      return false;
    +    }
    +  }
    +
    +  for (const foreignSegment of queryForeignSegments(
    +    context,
    +    boundsForRect(rect),
    +    edge.id,
    +    overrides,
    +    'segmentRectQueries'
    +  )) {
    +    if (allowedForeignEdgeIds?.has(foreignSegment.edgeId)) {
    +      continue;
    +    }
    +    if (segmentIntersectsRectInterior(foreignSegment.segment.a, foreignSegment.segment.b, rect)) {
    +      return false;
    +    }
    +  }
    +
    +  return true;
    +}
    +
    +function requiredSegmentLength(node: Node, segment: Segment): number {
    +  return segment.orientation === 'Z'
    +    ? 0
    +    : gridLabelRequiredSegmentLength(node, segment.orientation);
    +}
    +
    +function blockedCenterIntervals(
    +  edge: Edge,
    +  labelNode: Node,
    +  segment: Segment,
    +  context: EdgeLabelContext,
    +  overrides: EdgePointOverrides | undefined,
    +  includeForeignEdges: boolean
    +): { low: number; high: number; blocked: Interval[] } | null {
    +  if (!isFinitePositiveNumber(labelNode.width) || !isFinitePositiveNumber(labelNode.height)) {
    +    return null;
    +  }
    +  const labelWidth = labelNode.width;
    +  const labelHeight = labelNode.height;
    +
    +  const alongHalf =
    +    segment.orientation === 'H'
    +      ? labelWidth / 2 + LABEL_CLEARANCE
    +      : labelHeight / 2 + LABEL_CLEARANCE;
    +  const low =
    +    segment.orientation === 'H'
    +      ? Math.min(segment.a.x, segment.b.x) + alongHalf
    +      : Math.min(segment.a.y, segment.b.y) + alongHalf;
    +  const high =
    +    segment.orientation === 'H'
    +      ? Math.max(segment.a.x, segment.b.x) - alongHalf
    +      : Math.max(segment.a.y, segment.b.y) - alongHalf;
    +  if (high - low <= LABEL_EPSILON) {
    +    return null;
    +  }
    +
    +  // Project every obstacle onto the candidate segment. The remaining interval gaps are the only
    +  // centers worth testing, which keeps placement deterministic and avoids pixel-by-pixel search.
    +  const blocked: Interval[] = [];
    +  const bandBounds = obstacleBandQueryBounds(segment, labelNode);
    +  if (!bandBounds) {
    +    return null;
    +  }
    +  const addRect = (rect: Rect) => {
    +    if (segment.orientation === 'H') {
    +      const bandTop = segment.a.y - labelHeight / 2;
    +      const bandBottom = segment.a.y + labelHeight / 2;
    +      if (rangeOverlaps(bandTop, bandBottom, rect.top, rect.bottom)) {
    +        blocked.push({
    +          start: rect.left - labelWidth / 2,
    +          end: rect.right + labelWidth / 2,
    +        });
    +      }
    +      return;
    +    }
    +
    +    const bandLeft = segment.a.x - labelWidth / 2;
    +    const bandRight = segment.a.x + labelWidth / 2;
    +    if (rangeOverlaps(bandLeft, bandRight, rect.left, rect.right)) {
    +      blocked.push({
    +        start: rect.top - labelHeight / 2,
    +        end: rect.bottom + labelHeight / 2,
    +      });
    +    }
    +  };
    +
    +  for (const obstacle of queryObstacleEntries(context, bandBounds, 'obstacleBandQueries', {
    +    excludeNodeId: labelNode.id,
    +  })) {
    +    addRect(obstacle.rect);
    +  }
    +  for (const clearanceRect of markerClearanceRectsForEdge(edge, overrides, context)) {
    +    addRect(clearanceRect);
    +  }
    +
    +  if (includeForeignEdges) {
    +    for (const foreignSegment of queryForeignSegments(
    +      context,
    +      bandBounds,
    +      edge.id,
    +      overrides,
    +      'segmentBandQueries'
    +    )) {
    +      const segmentEntry = foreignSegment.segment;
    +      if (segment.orientation === 'H') {
    +        const bandTop = segment.a.y - labelNode.height / 2;
    +        const bandBottom = segment.a.y + labelNode.height / 2;
    +        if (segmentEntry.orientation === 'H') {
    +          if (bandTop < segmentEntry.a.y && bandBottom > segmentEntry.a.y) {
    +            blocked.push({
    +              start: Math.min(segmentEntry.a.x, segmentEntry.b.x) - labelNode.width / 2,
    +              end: Math.max(segmentEntry.a.x, segmentEntry.b.x) + labelNode.width / 2,
    +            });
    +          }
    +        } else if (
    +          rangeOverlaps(
    +            bandTop,
    +            bandBottom,
    +            Math.min(segmentEntry.a.y, segmentEntry.b.y),
    +            Math.max(segmentEntry.a.y, segmentEntry.b.y)
    +          )
    +        ) {
    +          blocked.push({
    +            start: segmentEntry.a.x - labelNode.width / 2,
    +            end: segmentEntry.a.x + labelNode.width / 2,
    +          });
    +        }
    +        continue;
    +      }
    +
    +      const bandLeft = segment.a.x - labelNode.width / 2;
    +      const bandRight = segment.a.x + labelNode.width / 2;
    +      if (segmentEntry.orientation === 'V') {
    +        if (bandLeft < segmentEntry.a.x && bandRight > segmentEntry.a.x) {
    +          blocked.push({
    +            start: Math.min(segmentEntry.a.y, segmentEntry.b.y) - labelNode.height / 2,
    +            end: Math.max(segmentEntry.a.y, segmentEntry.b.y) + labelNode.height / 2,
    +          });
    +        }
    +      } else if (
    +        rangeOverlaps(
    +          bandLeft,
    +          bandRight,
    +          Math.min(segmentEntry.a.x, segmentEntry.b.x),
    +          Math.max(segmentEntry.a.x, segmentEntry.b.x)
    +        )
    +      ) {
    +        blocked.push({
    +          start: segmentEntry.a.y - labelNode.height / 2,
    +          end: segmentEntry.a.y + labelNode.height / 2,
    +        });
    +      }
    +    }
    +  }
    +
    +  return { low, high, blocked };
    +}
    +
    +function placementOnExistingSegment(
    +  edge: Edge,
    +  labelNode: Node,
    +  segment: Segment,
    +  context: EdgeLabelContext
    +): Point | null {
    +  if (segmentLength(segment) < requiredSegmentLength(labelNode, segment)) {
    +    return null;
    +  }
    +
    +  const intervalData = blockedCenterIntervals(edge, labelNode, segment, context, undefined, true);
    +  if (!intervalData) {
    +    return null;
    +  }
    +
    +  const preferred =
    +    segment.orientation === 'H'
    +      ? (Math.min(segment.a.x, segment.b.x) + Math.max(segment.a.x, segment.b.x)) / 2
    +      : (Math.min(segment.a.y, segment.b.y) + Math.max(segment.a.y, segment.b.y)) / 2;
    +
    +  const candidates = centerCandidatesFromIntervals(
    +    intervalData.low,
    +    intervalData.high,
    +    intervalData.blocked,
    +    preferred
    +  );
    +  for (const candidate of candidates) {
    +    const center =
    +      segment.orientation === 'H'
    +        ? { x: candidate, y: segment.a.y }
    +        : { x: segment.a.x, y: candidate };
    +    const rect = labelRectAt(labelNode, center);
    +    if (rectSafeForLabel(rect, edge, labelNode.id, context)) {
    +      return center;
    +    }
    +  }
    +
    +  return null;
    +}
    +
    +function placementOnExistingSegmentWithReroutes(
    +  edge: Edge,
    +  labelNode: Node,
    +  segment: Segment,
    +  context: EdgeLabelContext,
    +  placedLabelsByEdgeId: Map
    +): Point | null {
    +  const intervalData = blockedCenterIntervals(edge, labelNode, segment, context, undefined, false);
    +  if (!intervalData) {
    +    return null;
    +  }
    +
    +  const preferred =
    +    segment.orientation === 'H'
    +      ? (Math.min(segment.a.x, segment.b.x) + Math.max(segment.a.x, segment.b.x)) / 2
    +      : (Math.min(segment.a.y, segment.b.y) + Math.max(segment.a.y, segment.b.y)) / 2;
    +  const candidates = centerCandidatesFromIntervals(
    +    intervalData.low,
    +    intervalData.high,
    +    intervalData.blocked,
    +    preferred
    +  );
    +
    +  for (const candidate of candidates) {
    +    const center =
    +      segment.orientation === 'H'
    +        ? { x: candidate, y: segment.a.y }
    +        : { x: segment.a.x, y: candidate };
    +    const rect = labelRectAt(labelNode, center);
    +    const overrides: EdgePointOverrides = new Map();
    +    const foreignEdges = foreignEdgeIdsIntersectingRect(context, rect, edge.id)
    +      .map((edgeId) => context.edgeById.get(edgeId))
    +      .filter((foreignEdge): foreignEdge is Edge => foreignEdge !== undefined);
    +
    +    let ok = true;
    +    for (const foreignEdge of foreignEdges) {
    +      const reroutedPoints = rerouteForeignEdgeAroundRect(
    +        foreignEdge,
    +        rect,
    +        context,
    +        placedLabelsByEdgeId,
    +        overrides
    +      );
    +      if (!reroutedPoints) {
    +        ok = false;
    +        break;
    +      }
    +      overrides.set(foreignEdge.id, reroutedPoints);
    +    }
    +
    +    if (!ok) {
    +      continue;
    +    }
    +    if (!rectSafeForLabel(rect, edge, labelNode.id, context, overrides)) {
    +      continue;
    +    }
    +
    +    for (const [edgeId, points] of overrides) {
    +      const foreignEdge = context.edgeById.get(edgeId);
    +      if (foreignEdge) {
    +        setEdgePoints(context, foreignEdge, points);
    +      }
    +    }
    +    return center;
    +  }
    +
    +  return null;
    +}
    +
    +function polylineHitsProtectedObstacles(
    +  points: Point[],
    +  context: EdgeLabelContext,
    +  skipNodeIds: ReadonlySet
    +): boolean {
    +  for (const segment of normalizePolyline(points).segments) {
    +    for (const obstacle of queryObstacleEntries(
    +      context,
    +      boundsForSegment(segment),
    +      'obstaclePolylineQueries',
    +      { skipNodeIds }
    +    )) {
    +      if (segmentIntersectsRectInterior(segment.a, segment.b, obstacle.rect)) {
    +        return true;
    +      }
    +    }
    +  }
    +  return false;
    +}
    +
    +function labelStillAnchored(
    +  edgeId: string,
    +  points: Point[],
    +  placedLabelsByEdgeId: Map
    +): boolean {
    +  const placed = placedLabelsByEdgeId.get(edgeId);
    +  // Owner reroutes may change the supporting segment, but the reserved center must remain on the
    +  // polyline. Merely touching the label border would leave the rendered label beside its edge.
    +  return !placed || orthogonalPolylineContainsPoint(points, placed.center, LABEL_EPSILON);
    +}
    +
    +function detourOffsets(crossHalfSize: number): number[] {
    +  const out: number[] = [];
    +  for (let step = 0; step < MAX_GUTTER_STEPS; step++) {
    +    const offset = crossHalfSize + LABEL_GUTTER + step * LABEL_GUTTER_STEP;
    +    out.push(-offset, offset);
    +  }
    +  return out;
    +}
    +
    +function uniqueSortedCandidates(values: number[], anchor: number): number[] {
    +  const rounded = new Map();
    +  for (const value of values) {
    +    const key = Math.round(value * 1e6) / 1e6;
    +    if (!rounded.has(key)) {
    +      rounded.set(key, value);
    +    }
    +  }
    +  return [...rounded.values()].sort((a, b) => {
    +    const distanceDelta = Math.abs(a - anchor) - Math.abs(b - anchor);
    +    if (Math.abs(distanceDelta) > LABEL_EPSILON) {
    +      return distanceDelta;
    +    }
    +    return a - b;
    +  });
    +}
    +
    +function detourLineCandidates(
    +  edge: Edge,
    +  labelNode: Node,
    +  segment: Segment,
    +  context: EdgeLabelContext
    +): number[] {
    +  if (!isFinitePositiveNumber(labelNode.width) || !isFinitePositiveNumber(labelNode.height)) {
    +    return [];
    +  }
    +
    +  const labelWidth = labelNode.width;
    +  const labelHeight = labelNode.height;
    +  const anchor = segment.orientation === 'H' ? segment.a.y : segment.a.x;
    +  const crossHalfSize = segment.orientation === 'H' ? labelHeight / 2 : labelWidth / 2;
    +  const candidates = detourOffsets(crossHalfSize).map((offset) => anchor + offset);
    +  const addRect = (rect: Rect) => {
    +    if (segment.orientation === 'H') {
    +      candidates.push(rect.top - crossHalfSize - LABEL_GUTTER);
    +      candidates.push(rect.bottom + crossHalfSize + LABEL_GUTTER);
    +      return;
    +    }
    +    candidates.push(rect.left - crossHalfSize - LABEL_GUTTER);
    +    candidates.push(rect.right + crossHalfSize + LABEL_GUTTER);
    +  };
    +
    +  for (const obstacle of queryObstacleEntries(
    +    context,
    +    detourLineQueryBounds(segment, labelNode, context.searchBounds),
    +    'obstacleBandQueries',
    +    { excludeNodeId: labelNode.id }
    +  )) {
    +    addRect(obstacle.rect);
    +  }
    +  for (const clearanceRect of markerClearanceRectsForEdge(edge, undefined, context)) {
    +    addRect(clearanceRect);
    +  }
    +
    +  return uniqueSortedCandidates(candidates, anchor).slice(0, MAX_DETOUR_LINE_CANDIDATES);
    +}
    +
    +function rerouteLineCandidates(
    +  segment: Segment,
    +  blockedRect: Rect,
    +  context: EdgeLabelContext,
    +  skipNodeIds: ReadonlySet,
    +  lane?: RerouteLane
    +): number[] {
    +  const anchor = segment.orientation === 'H' ? segment.a.y : segment.a.x;
    +  const candidates: number[] = [];
    +  for (const offset of detourOffsets(0)) {
    +    if (segment.orientation === 'H') {
    +      candidates.push(offset < 0 ? blockedRect.top + offset : blockedRect.bottom + offset);
    +    } else {
    +      candidates.push(offset < 0 ? blockedRect.left + offset : blockedRect.right + offset);
    +    }
    +  }
    +
    +  const addRect = (rect: Rect) => {
    +    if (segment.orientation === 'H') {
    +      candidates.push(rect.top - LABEL_GUTTER);
    +      candidates.push(rect.bottom + LABEL_GUTTER);
    +      return;
    +    }
    +    candidates.push(rect.left - LABEL_GUTTER);
    +    candidates.push(rect.right + LABEL_GUTTER);
    +  };
    +
    +  for (const obstacle of queryObstacleEntries(
    +    context,
    +    rerouteLineQueryBounds(segment, context.searchBounds),
    +    'obstacleBandQueries',
    +    { skipNodeIds }
    +  )) {
    +    addRect(obstacle.rect);
    +  }
    +
    +  const sorted = uniqueSortedCandidates(candidates, anchor);
    +  const sideCandidates =
    +    lane?.orientation === segment.orientation
    +      ? sorted.filter((candidate) => {
    +          if (lane.side === 'top') {
    +            return candidate < blockedRect.top;
    +          }
    +          if (lane.side === 'bottom') {
    +            return candidate > blockedRect.bottom;
    +          }
    +          if (lane.side === 'left') {
    +            return candidate < blockedRect.left;
    +          }
    +          return candidate > blockedRect.right;
    +        })
    +      : sorted;
    +  return sideCandidates.slice(0, MAX_DETOUR_LINE_CANDIDATES);
    +}
    +
    +function detourCenterCandidates(
    +  edge: Edge,
    +  labelNode: Node,
    +  segment: Segment,
    +  detourLineCoord: number,
    +  context: EdgeLabelContext
    +): number[] {
    +  if (!isFinitePositiveNumber(labelNode.width) || !isFinitePositiveNumber(labelNode.height)) {
    +    return [];
    +  }
    +
    +  const labelWidth = labelNode.width;
    +  const labelHeight = labelNode.height;
    +  const detourSegment: Segment =
    +    segment.orientation === 'H'
    +      ? {
    +          orientation: 'H',
    +          a: { x: segment.a.x, y: detourLineCoord },
    +          b: { x: segment.b.x, y: detourLineCoord },
    +        }
    +      : {
    +          orientation: 'V',
    +          a: { x: detourLineCoord, y: segment.a.y },
    +          b: { x: detourLineCoord, y: segment.b.y },
    +        };
    +
    +  const intervalData = blockedCenterIntervals(
    +    edge,
    +    labelNode,
    +    detourSegment,
    +    context,
    +    undefined,
    +    false
    +  );
    +  if (!intervalData) {
    +    const preferred =
    +      segment.orientation === 'H'
    +        ? (Math.min(segment.a.x, segment.b.x) + Math.max(segment.a.x, segment.b.x)) / 2
    +        : (Math.min(segment.a.y, segment.b.y) + Math.max(segment.a.y, segment.b.y)) / 2;
    +    const candidates = [preferred];
    +    const queryBounds = obstacleBandQueryBounds(detourSegment, labelNode);
    +    const addRect = (rect: Rect) => {
    +      if (segment.orientation === 'H') {
    +        candidates.push(rect.left - LABEL_GUTTER - labelWidth / 2);
    +        candidates.push(rect.right + LABEL_GUTTER + labelWidth / 2);
    +        return;
    +      }
    +      candidates.push(rect.top - LABEL_GUTTER - labelHeight / 2);
    +      candidates.push(rect.bottom + LABEL_GUTTER + labelHeight / 2);
    +    };
    +    if (queryBounds) {
    +      for (const obstacle of queryObstacleEntries(context, queryBounds, 'obstacleBandQueries', {
    +        excludeNodeId: labelNode.id,
    +      })) {
    +        addRect(obstacle.rect);
    +      }
    +    }
    +    return uniqueSortedCandidates(candidates, preferred).slice(0, MAX_DETOUR_CENTER_CANDIDATES);
    +  }
    +
    +  const preferred =
    +    segment.orientation === 'H'
    +      ? (Math.min(segment.a.x, segment.b.x) + Math.max(segment.a.x, segment.b.x)) / 2
    +      : (Math.min(segment.a.y, segment.b.y) + Math.max(segment.a.y, segment.b.y)) / 2;
    +
    +  return centerCandidatesFromIntervals(
    +    intervalData.low,
    +    intervalData.high,
    +    intervalData.blocked,
    +    preferred
    +  );
    +}
    +
    +function detourReturnCoordinate(
    +  sourceLine: number,
    +  detourLine: number,
    +  labelHalfSize: number
    +): number {
    +  const direction = detourLine >= sourceLine ? 1 : -1;
    +  return detourLine + direction * (labelHalfSize + LABEL_CLEARANCE);
    +}
    +
    +interface DetourAxis {
    +  along(point: Point): number;
    +  cross(point: Point): number;
    +  point(along: number, cross: number): Point;
    +  labelAlongSize(labelNode: Node): number;
    +  labelCrossSize(labelNode: Node): number;
    +}
    +
    +const HORIZONTAL_DETOUR_AXIS: DetourAxis = {
    +  along: (point) => point.x,
    +  cross: (point) => point.y,
    +  point: (along, cross) => ({ x: along, y: cross }),
    +  labelAlongSize: (labelNode) => labelNode.width ?? 0,
    +  labelCrossSize: (labelNode) => labelNode.height ?? 0,
    +};
    +
    +const VERTICAL_DETOUR_AXIS: DetourAxis = {
    +  along: (point) => point.y,
    +  cross: (point) => point.x,
    +  point: (along, cross) => ({ x: cross, y: along }),
    +  labelAlongSize: (labelNode) => labelNode.height ?? 0,
    +  labelCrossSize: (labelNode) => labelNode.width ?? 0,
    +};
    +
    +function detourAxis(segment: Segment): DetourAxis {
    +  return segment.orientation === 'H' ? HORIZONTAL_DETOUR_AXIS : VERTICAL_DETOUR_AXIS;
    +}
    +
    +function buildLabelDetourPoints(
    +  points: Point[],
    +  segmentIndex: number,
    +  segment: Segment,
    +  labelNode: Node,
    +  centerAlong: number,
    +  detourLineCoord: number
    +): Point[] | null {
    +  if (!isFinitePositiveNumber(labelNode.width) || !isFinitePositiveNumber(labelNode.height)) {
    +    return null;
    +  }
    +
    +  // Keep the original terminal-adjacent anchors intact; marker clearance and node clipping own
    +  // endpoint geometry, while label placement owns only the interior detour.
    +  const a = points[segmentIndex];
    +  const b = points[segmentIndex + 1];
    +  const axis = detourAxis(segment);
    +  const sourceLine = axis.cross(segment.a);
    +  const aAlong = axis.along(a);
    +  const bAlong = axis.along(b);
    +  const span = axis.labelAlongSize(labelNode) + LABEL_CLEARANCE * 2;
    +  const detourStart = centerAlong - span / 2;
    +  const detourEnd = centerAlong + span / 2;
    +  const extendsBeforeStart = detourStart < Math.min(aAlong, bAlong) - LABEL_EPSILON;
    +  const extendsAfterEnd = detourEnd > Math.max(aAlong, bAlong) + LABEL_EPSILON;
    +  const alongDirection = bAlong >= aAlong ? 1 : -1;
    +  const segmentInset = Math.max(
    +    0,
    +    Math.min(EDGE_END_MARKER_CLEARANCE, Math.abs(bAlong - aAlong) / 2 - LABEL_EPSILON)
    +  );
    +  const entryAlong = aAlong + alongDirection * segmentInset;
    +  const exitAlong = bAlong - alongDirection * segmentInset;
    +  if (!extendsBeforeStart && !extendsAfterEnd) {
    +    return normalizePolyline([
    +      ...points.slice(0, segmentIndex),
    +      a,
    +      axis.point(detourStart, sourceLine),
    +      axis.point(detourStart, detourLineCoord),
    +      axis.point(detourEnd, detourLineCoord),
    +      axis.point(detourEnd, sourceLine),
    +      b,
    +      ...points.slice(segmentIndex + 2),
    +    ]).points;
    +  }
    +
    +  const returnLine = detourReturnCoordinate(
    +    sourceLine,
    +    detourLineCoord,
    +    axis.labelCrossSize(labelNode) / 2
    +  );
    +  const detourPoints: Point[] = [...points.slice(0, segmentIndex), a];
    +  if (extendsBeforeStart) {
    +    detourPoints.push(
    +      axis.point(entryAlong, sourceLine),
    +      axis.point(entryAlong, returnLine),
    +      axis.point(detourStart, returnLine),
    +      axis.point(detourStart, detourLineCoord)
    +    );
    +  } else {
    +    detourPoints.push(
    +      axis.point(detourStart, sourceLine),
    +      axis.point(detourStart, detourLineCoord)
    +    );
    +  }
    +  detourPoints.push(axis.point(detourEnd, detourLineCoord));
    +  if (extendsAfterEnd) {
    +    detourPoints.push(
    +      axis.point(detourEnd, returnLine),
    +      axis.point(exitAlong, returnLine),
    +      axis.point(exitAlong, sourceLine)
    +    );
    +  } else {
    +    detourPoints.push(axis.point(detourEnd, sourceLine));
    +  }
    +  detourPoints.push(b, ...points.slice(segmentIndex + 2));
    +  return normalizePolyline(detourPoints).points;
    +}
    +
    +function buildExtendedLabelDetourPoints(
    +  points: Point[],
    +  segmentIndex: number,
    +  segment: Segment,
    +  labelNode: Node,
    +  centerAlong: number,
    +  detourLineCoord: number,
    +  separateEntryAndExit: boolean
    +): Point[] | null {
    +  if (!isFinitePositiveNumber(labelNode.width) || !isFinitePositiveNumber(labelNode.height)) {
    +    return null;
    +  }
    +
    +  const a = points[segmentIndex];
    +  const b = points[segmentIndex + 1];
    +  // Self-loop terminal segments are short; quarter points keep the entry and exit lanes distinct.
    +  const insetDivisor = separateEntryAndExit ? 4 : 2;
    +  const axis = detourAxis(segment);
    +  const sourceLine = axis.cross(segment.a);
    +  const aAlong = axis.along(a);
    +  const bAlong = axis.along(b);
    +  const span = axis.labelAlongSize(labelNode) + LABEL_CLEARANCE * 2;
    +  const detourStart = centerAlong - span / 2;
    +  const detourEnd = centerAlong + span / 2;
    +  const alongDirection = bAlong >= aAlong ? 1 : -1;
    +  const segmentInset = Math.max(
    +    0,
    +    Math.min(EDGE_END_MARKER_CLEARANCE, Math.abs(bAlong - aAlong) / insetDivisor - LABEL_EPSILON)
    +  );
    +  const entryAlong = aAlong + alongDirection * segmentInset;
    +  const exitAlong = bAlong - alongDirection * segmentInset;
    +  const returnLine = detourReturnCoordinate(
    +    sourceLine,
    +    detourLineCoord,
    +    axis.labelCrossSize(labelNode) / 2
    +  );
    +  return normalizePolyline([
    +    ...points.slice(0, segmentIndex),
    +    a,
    +    axis.point(entryAlong, sourceLine),
    +    axis.point(entryAlong, detourLineCoord),
    +    axis.point(detourStart, detourLineCoord),
    +    axis.point(detourEnd, detourLineCoord),
    +    axis.point(detourEnd, returnLine),
    +    axis.point(exitAlong, returnLine),
    +    axis.point(exitAlong, sourceLine),
    +    b,
    +    ...points.slice(segmentIndex + 2),
    +  ]).points;
    +}
    +
    +function externalDetourCenterCandidates(segment: Segment, labelNode: Node): number[] {
    +  if (!isFinitePositiveNumber(labelNode.width) || !isFinitePositiveNumber(labelNode.height)) {
    +    return [];
    +  }
    +
    +  if (segment.orientation === 'H') {
    +    const minX = Math.min(segment.a.x, segment.b.x);
    +    const maxX = Math.max(segment.a.x, segment.b.x);
    +    return [minX - labelNode.width / 2 - LABEL_GUTTER, maxX + labelNode.width / 2 + LABEL_GUTTER];
    +  }
    +
    +  const minY = Math.min(segment.a.y, segment.b.y);
    +  const maxY = Math.max(segment.a.y, segment.b.y);
    +  return [minY - labelNode.height / 2 - LABEL_GUTTER, maxY + labelNode.height / 2 + LABEL_GUTTER];
    +}
    +
    +interface BlockedIntersectionScore {
    +  count: number;
    +  length: number;
    +}
    +
    +function blockedIntersectionScore(points: readonly Point[], rect: Rect): BlockedIntersectionScore {
    +  let count = 0;
    +  let length = 0;
    +  for (const segment of normalizePolyline([...points]).segments) {
    +    if (!segmentIntersectsRectInterior(segment.a, segment.b, rect)) {
    +      continue;
    +    }
    +    count++;
    +    if (segment.orientation === 'H') {
    +      length += Math.max(
    +        0,
    +        Math.min(Math.max(segment.a.x, segment.b.x), rect.right) -
    +          Math.max(Math.min(segment.a.x, segment.b.x), rect.left)
    +      );
    +    } else if (segment.orientation === 'V') {
    +      length += Math.max(
    +        0,
    +        Math.min(Math.max(segment.a.y, segment.b.y), rect.bottom) -
    +          Math.max(Math.min(segment.a.y, segment.b.y), rect.top)
    +      );
    +    }
    +  }
    +  return { count, length };
    +}
    +
    +function reducesBlockedIntersections(
    +  candidate: readonly Point[],
    +  blockedRect: Rect,
    +  baseline: BlockedIntersectionScore
    +): boolean {
    +  const score = blockedIntersectionScore(candidate, blockedRect);
    +  return (
    +    score.count < baseline.count ||
    +    (score.count === baseline.count && score.length < baseline.length - LABEL_EPSILON)
    +  );
    +}
    +
    +interface RerouteLane {
    +  key: string;
    +  orientation: 'H' | 'V';
    +  side: 'top' | 'bottom' | 'left' | 'right';
    +}
    +
    +function rerouteLane(
    +  points: readonly Point[],
    +  blockedRect: Rect,
    +  blockingReservations: readonly PlacedLabel[]
    +): RerouteLane | null {
    +  const segment = normalizePolyline([...points]).segments.find((candidate) =>
    +    segmentIntersectsRectInterior(candidate.a, candidate.b, blockedRect)
    +  );
    +  if (!segment || segment.orientation === 'Z') {
    +    return null;
    +  }
    +
    +  const side =
    +    segment.orientation === 'H'
    +      ? Math.abs(segment.a.y - blockedRect.top) <= Math.abs(blockedRect.bottom - segment.a.y)
    +        ? 'top'
    +        : 'bottom'
    +      : Math.abs(segment.a.x - blockedRect.left) <= Math.abs(blockedRect.right - segment.a.x)
    +        ? 'left'
    +        : 'right';
    +  const reservationIds = blockingReservations
    +    .map((reservation) => reservation.edgeId)
    +    .sort(compareCodeUnits);
    +  return {
    +    key: JSON.stringify([reservationIds, segment.orientation, side]),
    +    orientation: segment.orientation,
    +    side,
    +  };
    +}
    +
    +function rerouteSegmentAroundRect(
    +  edge: Edge,
    +  points: Point[],
    +  segmentIndex: number,
    +  blockedRect: Rect,
    +  context: EdgeLabelContext,
    +  placedLabelsByEdgeId: Map,
    +  lane?: RerouteLane
    +): Point[] | null {
    +  const normalized = normalizePolyline(points);
    +  const baselineScore = blockedIntersectionScore(normalized.points, blockedRect);
    +  const segment = normalized.segments[segmentIndex];
    +  if (!segment || segment.orientation === 'Z') {
    +    return null;
    +  }
    +
    +  const a = normalized.points[segmentIndex];
    +  const b = normalized.points[segmentIndex + 1];
    +  const ownLabelNodeId = placedLabelsByEdgeId.get(edge.id)?.nodeId;
    +  const skipNodeIds = new Set();
    +  if (ownLabelNodeId) {
    +    skipNodeIds.add(ownLabelNodeId);
    +  }
    +
    +  // One detour may remove only the first intersection with a wide label. Accept strictly
    +  // monotonic progress here; the bounded outer loop completes the reroute segment by segment.
    +  if (segment.orientation === 'H') {
    +    const minX = Math.min(a.x, b.x);
    +    const maxX = Math.max(a.x, b.x);
    +    const entry = Math.max(minX, blockedRect.left - LABEL_CLEARANCE);
    +    const exit = Math.min(maxX, blockedRect.right + LABEL_CLEARANCE);
    +    if (exit - entry <= LABEL_EPSILON) {
    +      incrementMetric(context.metrics, 'rerouteRejectedDegenerateSpan');
    +      return null;
    +    }
    +
    +    for (const y of rerouteLineCandidates(segment, blockedRect, context, skipNodeIds, lane)) {
    +      incrementMetric(context.metrics, 'rerouteCandidatesEvaluated');
    +      const candidatePoints = normalizePolyline([
    +        ...normalized.points.slice(0, segmentIndex),
    +        a,
    +        { x: entry, y: segment.a.y },
    +        { x: entry, y },
    +        { x: exit, y },
    +        { x: exit, y: segment.a.y },
    +        b,
    +        ...normalized.points.slice(segmentIndex + 2),
    +      ]).points;
    +      if (
    +        polylineIntersectsRect(candidatePoints, blockedRect) &&
    +        !reducesBlockedIntersections(candidatePoints, blockedRect, baselineScore)
    +      ) {
    +        incrementMetric(context.metrics, 'rerouteRejectedBlockedRect');
    +        continue;
    +      }
    +      if (polylineIntersectsRect(candidatePoints, blockedRect)) {
    +        incrementMetric(context.metrics, 'reroutePartialProgressCandidates');
    +      }
    +      if (polylineHitsProtectedObstacles(candidatePoints, context, skipNodeIds)) {
    +        incrementMetric(context.metrics, 'rerouteRejectedProtectedObstacle');
    +        continue;
    +      }
    +      if (!labelStillAnchored(edge.id, candidatePoints, placedLabelsByEdgeId)) {
    +        incrementMetric(context.metrics, 'rerouteRejectedLabelAnchor');
    +        continue;
    +      }
    +      return candidatePoints;
    +    }
    +    return null;
    +  }
    +
    +  const minY = Math.min(a.y, b.y);
    +  const maxY = Math.max(a.y, b.y);
    +  const entry = Math.max(minY, blockedRect.top - LABEL_CLEARANCE);
    +  const exit = Math.min(maxY, blockedRect.bottom + LABEL_CLEARANCE);
    +  if (exit - entry <= LABEL_EPSILON) {
    +    incrementMetric(context.metrics, 'rerouteRejectedDegenerateSpan');
    +    return null;
    +  }
    +
    +  for (const x of rerouteLineCandidates(segment, blockedRect, context, skipNodeIds, lane)) {
    +    incrementMetric(context.metrics, 'rerouteCandidatesEvaluated');
    +    const candidatePoints = normalizePolyline([
    +      ...normalized.points.slice(0, segmentIndex),
    +      a,
    +      { x: segment.a.x, y: entry },
    +      { x, y: entry },
    +      { x, y: exit },
    +      { x: segment.a.x, y: exit },
    +      b,
    +      ...normalized.points.slice(segmentIndex + 2),
    +    ]).points;
    +    if (
    +      polylineIntersectsRect(candidatePoints, blockedRect) &&
    +      !reducesBlockedIntersections(candidatePoints, blockedRect, baselineScore)
    +    ) {
    +      incrementMetric(context.metrics, 'rerouteRejectedBlockedRect');
    +      continue;
    +    }
    +    if (polylineIntersectsRect(candidatePoints, blockedRect)) {
    +      incrementMetric(context.metrics, 'reroutePartialProgressCandidates');
    +    }
    +    if (polylineHitsProtectedObstacles(candidatePoints, context, skipNodeIds)) {
    +      incrementMetric(context.metrics, 'rerouteRejectedProtectedObstacle');
    +      continue;
    +    }
    +    if (!labelStillAnchored(edge.id, candidatePoints, placedLabelsByEdgeId)) {
    +      incrementMetric(context.metrics, 'rerouteRejectedLabelAnchor');
    +      continue;
    +    }
    +    return candidatePoints;
    +  }
    +
    +  return null;
    +}
    +
    +function rerouteForeignEdgeAroundRect(
    +  edge: Edge,
    +  blockedRect: Rect,
    +  context: EdgeLabelContext,
    +  placedLabelsByEdgeId: Map,
    +  overrides: EdgePointOverrides,
    +  lane?: RerouteLane
    +): Point[] | null {
    +  let currentPoints = pointsForEdge(edge, overrides, context);
    +  for (let attempt = 0; attempt < 8; attempt++) {
    +    if (!polylineIntersectsRect(currentPoints, blockedRect)) {
    +      return currentPoints;
    +    }
    +
    +    const normalized = normalizePolyline(currentPoints);
    +    const segmentIndex = normalized.points.findIndex((point, index) => {
    +      if (index === normalized.points.length - 1) {
    +        return false;
    +      }
    +      return segmentIntersectsRectInterior(point, normalized.points[index + 1], blockedRect);
    +    });
    +    if (segmentIndex < 0) {
    +      return currentPoints;
    +    }
    +
    +    const rerouted = rerouteSegmentAroundRect(
    +      edge,
    +      normalized.points,
    +      segmentIndex,
    +      blockedRect,
    +      context,
    +      placedLabelsByEdgeId,
    +      lane
    +    );
    +    if (!rerouted) {
    +      return null;
    +    }
    +    currentPoints = rerouted;
    +  }
    +
    +  return polylineIntersectsRect(currentPoints, blockedRect) ? null : currentPoints;
    +}
    +
    +function rerouteForeignEdgeAroundLabels(
    +  edge: Edge,
    +  reservations: readonly PlacedLabel[],
    +  context: EdgeLabelContext,
    +  placedLabelsByEdgeId: Map,
    +  overrides: EdgePointOverrides
    +): Point[] | null {
    +  const initialPoints = pointsForEdge(edge, overrides, context);
    +  let currentPoints = initialPoints;
    +  // A detour around a later label can re-enter an earlier reservation, so make one deterministic
    +  // repair pass and one convergence pass before abandoning the edge-level recovery.
    +  for (let pass = 0; pass < 2; pass++) {
    +    let changed = false;
    +    for (const reservation of reservations) {
    +      if (!polylineIntersectsRect(currentPoints, reservation.rect)) {
    +        continue;
    +      }
    +      incrementMetric(context.metrics, 'individualLabelRerouteAttempts');
    +      overrides.set(edge.id, currentPoints);
    +      const rerouted = rerouteForeignEdgeAroundRect(
    +        edge,
    +        reservation.rect,
    +        context,
    +        placedLabelsByEdgeId,
    +        overrides
    +      );
    +      if (!rerouted) {
    +        overrides.set(edge.id, initialPoints);
    +        return null;
    +      }
    +      incrementMetric(context.metrics, 'individualLabelRerouteSuccesses');
    +      currentPoints = rerouted;
    +      changed = true;
    +    }
    +    if (
    +      reservations.every((reservation) => !polylineIntersectsRect(currentPoints, reservation.rect))
    +    ) {
    +      return currentPoints;
    +    }
    +    if (!changed) {
    +      break;
    +    }
    +  }
    +  overrides.set(edge.id, initialPoints);
    +  return null;
    +}
    +
    +function labelPlacementWithExtendedDetour(
    +  edge: Edge,
    +  labelNode: Node,
    +  segmentCandidate: SegmentCandidate,
    +  context: EdgeLabelContext,
    +  placedLabelsByEdgeId: Map
    +): Point | null {
    +  if (!isFinitePositiveNumber(labelNode.width) || !isFinitePositiveNumber(labelNode.height)) {
    +    return null;
    +  }
    +
    +  const basePoints = pointsForEdge(edge, undefined, context);
    +  const segment = segmentCandidate.segment;
    +
    +  for (const detourLineCoord of detourLineCandidates(edge, labelNode, segment, context)) {
    +    const centerCandidates = uniqueSortedCandidates(
    +      externalDetourCenterCandidates(segment, labelNode),
    +      segment.orientation === 'H'
    +        ? (Math.min(segment.a.x, segment.b.x) + Math.max(segment.a.x, segment.b.x)) / 2
    +        : (Math.min(segment.a.y, segment.b.y) + Math.max(segment.a.y, segment.b.y)) / 2
    +    );
    +
    +    for (const centerAlong of centerCandidates) {
    +      const candidatePoints = buildExtendedLabelDetourPoints(
    +        basePoints,
    +        segmentCandidate.segmentIndex,
    +        segment,
    +        labelNode,
    +        centerAlong,
    +        detourLineCoord,
    +        edge.start === edge.end
    +      );
    +      if (!candidatePoints) {
    +        continue;
    +      }
    +
    +      if (edge.start === edge.end && polylineHasSelfConflict(candidatePoints)) {
    +        continue;
    +      }
    +
    +      if (polylineHitsProtectedObstacles(candidatePoints, context, new Set([labelNode.id]))) {
    +        continue;
    +      }
    +
    +      const center =
    +        segment.orientation === 'H'
    +          ? { x: centerAlong, y: detourLineCoord }
    +          : { x: detourLineCoord, y: centerAlong };
    +      const rect = labelRectAt(labelNode, center);
    +      const overrides: EdgePointOverrides = new Map([[edge.id, candidatePoints]]);
    +
    +      const foreignEdges = foreignEdgeIdsIntersectingRect(context, rect, edge.id, overrides)
    +        .map((edgeId) => context.edgeById.get(edgeId))
    +        .filter((foreignEdge): foreignEdge is Edge => foreignEdge !== undefined);
    +
    +      let ok = true;
    +      for (const foreignEdge of foreignEdges) {
    +        const reroutedPoints = rerouteForeignEdgeAroundRect(
    +          foreignEdge,
    +          rect,
    +          context,
    +          placedLabelsByEdgeId,
    +          overrides
    +        );
    +        if (!reroutedPoints) {
    +          ok = false;
    +          break;
    +        }
    +        overrides.set(foreignEdge.id, reroutedPoints);
    +      }
    +
    +      if (!ok) {
    +        continue;
    +      }
    +      if (!rectSafeForLabel(rect, edge, labelNode.id, context, overrides)) {
    +        continue;
    +      }
    +
    +      setEdgePoints(context, edge, candidatePoints);
    +      for (const [edgeId, points] of overrides) {
    +        if (edgeId === edge.id) {
    +          continue;
    +        }
    +        const foreignEdge = context.edgeById.get(edgeId);
    +        if (foreignEdge) {
    +          setEdgePoints(context, foreignEdge, points);
    +        }
    +      }
    +      return center;
    +    }
    +  }
    +
    +  return null;
    +}
    +
    +function labelPlacementWithDetour(
    +  edge: Edge,
    +  labelNode: Node,
    +  segmentCandidate: SegmentCandidate,
    +  context: EdgeLabelContext,
    +  placedLabelsByEdgeId: Map
    +): Point | null {
    +  if (!isFinitePositiveNumber(labelNode.width) || !isFinitePositiveNumber(labelNode.height)) {
    +    return null;
    +  }
    +
    +  const basePoints = pointsForEdge(edge, undefined, context);
    +  const segment = segmentCandidate.segment;
    +
    +  for (const detourLineCoord of detourLineCandidates(edge, labelNode, segment, context)) {
    +    const centerCandidates = detourCenterCandidates(
    +      edge,
    +      labelNode,
    +      segment,
    +      detourLineCoord,
    +      context
    +    );
    +    for (const centerAlong of centerCandidates) {
    +      const candidatePoints = buildLabelDetourPoints(
    +        basePoints,
    +        segmentCandidate.segmentIndex,
    +        segment,
    +        labelNode,
    +        centerAlong,
    +        detourLineCoord
    +      );
    +      if (!candidatePoints) {
    +        continue;
    +      }
    +
    +      if (edge.start === edge.end && polylineHasSelfConflict(candidatePoints)) {
    +        continue;
    +      }
    +
    +      if (polylineHitsProtectedObstacles(candidatePoints, context, new Set([labelNode.id]))) {
    +        continue;
    +      }
    +
    +      const center =
    +        segment.orientation === 'H'
    +          ? { x: centerAlong, y: detourLineCoord }
    +          : { x: detourLineCoord, y: centerAlong };
    +      const rect = labelRectAt(labelNode, center);
    +      const overrides: EdgePointOverrides = new Map([[edge.id, candidatePoints]]);
    +
    +      const foreignEdges = foreignEdgeIdsIntersectingRect(context, rect, edge.id, overrides)
    +        .map((edgeId) => context.edgeById.get(edgeId))
    +        .filter((foreignEdge): foreignEdge is Edge => foreignEdge !== undefined);
    +
    +      let ok = true;
    +      for (const foreignEdge of foreignEdges) {
    +        const reroutedPoints = rerouteForeignEdgeAroundRect(
    +          foreignEdge,
    +          rect,
    +          context,
    +          placedLabelsByEdgeId,
    +          overrides
    +        );
    +        if (!reroutedPoints) {
    +          ok = false;
    +          break;
    +        }
    +        overrides.set(foreignEdge.id, reroutedPoints);
    +      }
    +
    +      if (!ok) {
    +        continue;
    +      }
    +      if (!rectSafeForLabel(rect, edge, labelNode.id, context, overrides)) {
    +        continue;
    +      }
    +
    +      setEdgePoints(context, edge, candidatePoints);
    +      for (const [edgeId, points] of overrides) {
    +        if (edgeId === edge.id) {
    +          continue;
    +        }
    +        const foreignEdge = context.edgeById.get(edgeId);
    +        if (foreignEdge) {
    +          setEdgePoints(context, foreignEdge, points);
    +        }
    +      }
    +      return center;
    +    }
    +  }
    +
    +  return null;
    +}
    +
    +function isPreparedLabelNode(node: Node | undefined): node is NonClusterNode {
    +  return Boolean(node && isEdgeLabelNode(node));
    +}
    +
    +function parkDegenerateLabel(
    +  edge: Edge,
    +  labelNode: Node,
    +  nodeById: ReadonlyMap
    +): void {
    +  const routePoint = edge.points?.find(
    +    (point) => Number.isFinite(point.x) && Number.isFinite(point.y)
    +  );
    +  const source = edge.start ? nodeById.get(edge.start) : undefined;
    +  const target = edge.end ? nodeById.get(edge.end) : undefined;
    +  const fallback =
    +    routePoint ?? (source && hasFinitePosition(source) ? source : undefined) ?? target;
    +  if (fallback && Number.isFinite(fallback.x) && Number.isFinite(fallback.y)) {
    +    labelNode.x = fallback.x;
    +    labelNode.y = fallback.y;
    +    return;
    +  }
    +  delete labelNode.x;
    +  delete labelNode.y;
    +}
    +
    +function placeLabelAtRouteMidpoint(
    +  edge: Edge,
    +  labelNode: Node,
    +  nodeById: ReadonlyMap
    +): void {
    +  let longestSegment: Segment | undefined;
    +  for (const segment of normalizePolyline(edge.points ?? []).segments) {
    +    if (segment.orientation === 'Z') {
    +      continue;
    +    }
    +    if (!longestSegment || segmentLength(segment) > segmentLength(longestSegment)) {
    +      longestSegment = segment;
    +    }
    +  }
    +  if (!longestSegment) {
    +    parkDegenerateLabel(edge, labelNode, nodeById);
    +    return;
    +  }
    +  labelNode.x = (longestSegment.a.x + longestSegment.b.x) / 2;
    +  labelNode.y = (longestSegment.a.y + longestSegment.b.y) / 2;
    +}
    +
    +function preferredGridLabelNodeId(edge: Edge): string {
    +  return `${GRID_LABEL_PREFIX}${edge.start ?? ''}-${edge.end ?? ''}-${edge.id}`;
    +}
    +
    +function ensureUniqueGridLabelNodeId(edge: Edge, nodeById: Map): string {
    +  const baseId = edge.labelNodeId ?? preferredGridLabelNodeId(edge);
    +  let candidateId = baseId;
    +  let suffix = 1;
    +  while (true) {
    +    if (!nodeById.has(candidateId)) {
    +      return candidateId;
    +    }
    +    candidateId = `${baseId}-${suffix}`;
    +    suffix += 1;
    +  }
    +}
    +
    +export function prepareGridLayout(data: LayoutData): void {
    +  // Labels become measured helper nodes before grid sizing, but remain outside the placement
    +  // forest. The original edge text is cleared so the shared renderer does not draw it twice.
    +  const nodeById = new Map();
    +  for (const node of data.nodes) {
    +    nodeById.set(node.id, node);
    +  }
    +
    +  const newNodes: NonClusterNode[] = [];
    +  for (const edge of data.edges) {
    +    if (!edge.label || edge.label.length === 0) {
    +      continue;
    +    }
    +
    +    const sourceNode = edge.start ? nodeById.get(edge.start) : undefined;
    +    const existingHelper = edge.labelNodeId ? nodeById.get(edge.labelNodeId) : undefined;
    +    if (isPreparedLabelNode(existingHelper)) {
    +      existingHelper.label = edge.label;
    +      existingHelper.labelStyle = Array.isArray(edge.labelStyle)
    +        ? edge.labelStyle[0]
    +        : (edge.labelStyle ?? '');
    +      existingHelper.edgeStart = edge.start ?? '';
    +      existingHelper.edgeEnd = edge.end ?? '';
    +      if (sourceNode?.dir) {
    +        existingHelper.dir = sourceNode.dir;
    +      }
    +      edge.label = undefined;
    +      (edge as Edge & { text?: string }).text = undefined;
    +      continue;
    +    }
    +
    +    const labelNodeId = ensureUniqueGridLabelNodeId(edge, nodeById);
    +    const labelNode: NonClusterNode = {
    +      id: labelNodeId,
    +      label: edge.label,
    +      edgeStart: edge.start ?? '',
    +      edgeEnd: edge.end ?? '',
    +      shape: 'labelRect',
    +      width: 0,
    +      height: 0,
    +      isEdgeLabel: true,
    +      isDummy: true,
    +      isGroup: false,
    +      parentId: undefined,
    +      labelStyle: Array.isArray(edge.labelStyle) ? edge.labelStyle[0] : (edge.labelStyle ?? ''),
    +      ...(sourceNode?.dir ? { dir: sourceNode.dir } : {}),
    +    };
    +    newNodes.push(labelNode);
    +    nodeById.set(labelNodeId, labelNode);
    +    edge.labelNodeId = labelNodeId;
    +    edge.label = undefined;
    +    (edge as Edge & { text?: string }).text = undefined;
    +  }
    +
    +  data.nodes.push(...newNodes);
    +}
    +
    +export function positionGridEdgeLabels(
    +  data: LayoutData,
    +  instrumentation?: GridEdgeLabelInstrumentation,
    +  routingInstrumentation?: GridRoutingInstrumentation,
    +  options: GridEdgeLabelOptions = {}
    +): void {
    +  const nodeById = new Map();
    +  for (const node of data.nodes) {
    +    nodeById.set(node.id, node);
    +  }
    +  // Coordinates belong to the previous invocation. Retain them only for rollback; the current
    +  // placement pass must build its obstacle index from labels placed during this invocation.
    +  const labelNodes = data.nodes.filter(isEdgeLabelNode);
    +  const baseLabelPositions = new Map(
    +    labelNodes.map((labelNode) => [labelNode.id, { x: labelNode.x, y: labelNode.y }])
    +  );
    +  for (const labelNode of labelNodes) {
    +    delete labelNode.x;
    +    delete labelNode.y;
    +  }
    +
    +  const preparedLabels = data.edges
    +    .map((edge, sourceIndex) => {
    +      if (!edge.labelNodeId) {
    +        return null;
    +      }
    +      const labelNode = nodeById.get(edge.labelNodeId);
    +      if (!labelNode) {
    +        return null;
    +      }
    +      if (!isFinitePositiveNumber(labelNode.width) || !isFinitePositiveNumber(labelNode.height)) {
    +        incrementMetric(instrumentation, 'unmeasuredLabelsSkipped');
    +        return null;
    +      }
    +      return { edge, labelNode, sourceIndex };
    +    })
    +    .filter((entry): entry is LabelWorkItem => entry !== null);
    +
    +  if (preparedLabels.length === 0) {
    +    return;
    +  }
    +
    +  // The outer snapshot is the transaction boundary for the whole label phase. If both placement
    +  // passes fail, restore every route and label coordinate exactly as routing produced them.
    +  const baseEdgePoints = new Map(
    +    data.edges.map((edge) => [edge.id, edge.points?.map((point) => ({ ...point }))])
    +  );
    +
    +  const restoreBaseEdgePoints = (): void => {
    +    for (const edge of data.edges) {
    +      const points = baseEdgePoints.get(edge.id);
    +      if (points) {
    +        edge.points = points.map((point) => ({ ...point }));
    +      } else {
    +        delete edge.points;
    +      }
    +    }
    +  };
    +
    +  const restoreBaseState = (): void => {
    +    restoreBaseEdgePoints();
    +    for (const labelNode of labelNodes) {
    +      const position = baseLabelPositions.get(labelNode.id);
    +      if (position && Number.isFinite(position.x) && Number.isFinite(position.y)) {
    +        labelNode.x = position.x;
    +        labelNode.y = position.y;
    +      } else {
    +        delete labelNode.x;
    +        delete labelNode.y;
    +      }
    +    }
    +  };
    +
    +  const workItems = preparedLabels
    +    .filter(({ edge, labelNode }) => {
    +      if (normalizePolyline(edge.points ?? []).segments.length > 0) {
    +        return true;
    +      }
    +      parkDegenerateLabel(edge, labelNode, nodeById);
    +      incrementMetric(instrumentation, 'degenerateLabelRoutesSkipped');
    +      return false;
    +    })
    +    .sort((a, b) => a.sourceIndex - b.sourceIndex || compareCodeUnits(a.edge.id, b.edge.id));
    +  if (workItems.length === 0) {
    +    return;
    +  }
    +  const configuredIndexWork = options.maxIndexWork ?? DEFAULT_MAX_INDEX_WORK;
    +  const indexWorkLimit =
    +    Number.isFinite(configuredIndexWork) && configuredIndexWork >= 0
    +      ? Math.floor(configuredIndexWork)
    +      : DEFAULT_MAX_INDEX_WORK;
    +  const indexWorkBudget = new LabelIndexWorkBudget(indexWorkLimit, instrumentation);
    +  let lastError: unknown;
    +  for (let pass = 1; pass <= 2; pass++) {
    +    // Pass 1 prefers existing segments and foreign-edge reroutes. Pass 2 additionally permits
    +    // owner-edge detours, rebuilding every route from the original routing result.
    +    incrementMetric(instrumentation, 'labelPasses');
    +    const context = createEdgeLabelContext(data, indexWorkBudget, instrumentation);
    +    const placedLabelsByEdgeId = new Map();
    +    const allowedOverlapsByLabelEdgeId = new Map>();
    +    const passStartPoints = new Map(
    +      data.edges.map((edge) => [edge.id, pointsForEdge(edge, undefined, context).map(clonePoint)])
    +    );
    +    const proposedOwnerRoutes = new Map();
    +    try {
    +      for (const { edge, labelNode } of workItems) {
    +        const segments = [...candidateSegments(edge, context)].sort((a, b) => {
    +          const lengthDelta = segmentLength(b.segment) - segmentLength(a.segment);
    +          if (Math.abs(lengthDelta) > LABEL_EPSILON) {
    +            return lengthDelta;
    +          }
    +          return a.order - b.order;
    +        });
    +
    +        let labelCenter: Point | null = null;
    +        for (const segmentCandidate of segments) {
    +          labelCenter = placementOnExistingSegment(
    +            edge,
    +            labelNode,
    +            segmentCandidate.segment,
    +            context
    +          );
    +          if (labelCenter) {
    +            break;
    +          }
    +        }
    +
    +        if (!labelCenter) {
    +          for (const segmentCandidate of segments) {
    +            labelCenter = placementOnExistingSegmentWithReroutes(
    +              edge,
    +              labelNode,
    +              segmentCandidate.segment,
    +              context,
    +              placedLabelsByEdgeId
    +            );
    +            if (labelCenter) {
    +              break;
    +            }
    +          }
    +        }
    +
    +        if (pass === 2 && !labelCenter) {
    +          for (const segmentCandidate of segments) {
    +            labelCenter = labelPlacementWithDetour(
    +              edge,
    +              labelNode,
    +              segmentCandidate,
    +              context,
    +              placedLabelsByEdgeId
    +            );
    +            if (labelCenter) {
    +              break;
    +            }
    +          }
    +        }
    +
    +        if (pass === 2 && !labelCenter) {
    +          for (const segmentCandidate of segments) {
    +            labelCenter = labelPlacementWithExtendedDetour(
    +              edge,
    +              labelNode,
    +              segmentCandidate,
    +              context,
    +              placedLabelsByEdgeId
    +            );
    +            if (labelCenter) {
    +              break;
    +            }
    +          }
    +        }
    +
    +        if (!labelCenter) {
    +          throw gridError('GRID_ROUTE_NOT_FOUND', `Could not place label for edge "${edge.id}"`, {
    +            edgeId: edge.id,
    +            labelNodeId: labelNode.id,
    +            labelWidth: labelNode.width,
    +            labelHeight: labelNode.height,
    +            pointCount: edge.points?.length ?? 0,
    +            points: edge.points?.map((point) => ({ ...point })) ?? [],
    +            segments: segments.map(({ segment }) => ({
    +              orientation: segment.orientation,
    +              length: segmentLength(segment),
    +              a: { ...segment.a },
    +              b: { ...segment.b },
    +            })),
    +          });
    +        }
    +
    +        labelNode.x = labelCenter.x;
    +        labelNode.y = labelCenter.y;
    +        const placedLabel: PlacedLabel = {
    +          edgeId: edge.id,
    +          nodeId: labelNode.id,
    +          center: labelCenter,
    +          rect: labelRectAt(labelNode, labelCenter),
    +        };
    +        placedLabelsByEdgeId.set(edge.id, placedLabel);
    +        upsertPlacedLabelObstacle(context, placedLabel);
    +      }
    +
    +      // Once every label has a provisional home, freeze the complete reservation set before
    +      // rerouting. This prevents result order from changing which labels an edge must avoid.
    +      const frozenLabels = [...placedLabelsByEdgeId.values()];
    +      incrementMetric(instrumentation, 'frozenReservations', frozenLabels.length);
    +      if (routingInstrumentation) {
    +        routingInstrumentation.labelOverlayBuilds++;
    +        routingInstrumentation.labelOverlayVertices += frozenLabels.length * 4;
    +      }
    +
    +      // The placement search is provisional: capture its route proposals, roll
    +      // the routes back to the pass boundary, and only then commit each
    +      // impacted edge once against the complete frozen reservation set.
    +      for (const edge of data.edges) {
    +        const passStart = passStartPoints.get(edge.id) ?? [];
    +        const proposed = pointsForEdge(edge, undefined, context);
    +        if (JSON.stringify(proposed) !== JSON.stringify(passStart)) {
    +          proposedOwnerRoutes.set(edge.id, proposed.map(clonePoint));
    +        }
    +        setEdgePoints(context, edge, passStart.map(clonePoint));
    +      }
    +      for (const [edgeId, points] of proposedOwnerRoutes) {
    +        if (!placedLabelsByEdgeId.has(edgeId)) {
    +          continue;
    +        }
    +        const owner = context.edgeById.get(edgeId);
    +        if (owner) {
    +          setEdgePoints(context, owner, points);
    +        }
    +      }
    +
    +      const impacted = new Set(proposedOwnerRoutes.keys());
    +      for (const edge of data.edges) {
    +        const ownLabel = placedLabelsByEdgeId.get(edge.id);
    +        const points = pointsForEdge(edge, undefined, context);
    +        for (const reservation of frozenLabels) {
    +          if (reservation.edgeId !== edge.id && polylineIntersectsRect(points, reservation.rect)) {
    +            impacted.add(edge.id);
    +          }
    +        }
    +        if (ownLabel && !labelStillAnchored(edge.id, points, placedLabelsByEdgeId)) {
    +          impacted.add(edge.id);
    +        }
    +      }
    +
    +      const rerouteLaneOrdinals = new Map();
    +      for (const edge of data.edges) {
    +        if (!impacted.has(edge.id)) {
    +          continue;
    +        }
    +        let points = pointsForEdge(edge, undefined, context);
    +        const overrides: EdgePointOverrides = new Map([[edge.id, points]]);
    +        const blockingReservations = frozenLabels.filter(
    +          (reservation) =>
    +            reservation.edgeId !== edge.id && polylineIntersectsRect(points, reservation.rect)
    +        );
    +        if (blockingReservations.length > 0) {
    +          const combinedRect = blockingReservations.slice(1).reduce(
    +            (combined, reservation) => ({
    +              cx:
    +                (Math.min(combined.left, reservation.rect.left) +
    +                  Math.max(combined.right, reservation.rect.right)) /
    +                2,
    +              cy:
    +                (Math.min(combined.top, reservation.rect.top) +
    +                  Math.max(combined.bottom, reservation.rect.bottom)) /
    +                2,
    +              left: Math.min(combined.left, reservation.rect.left),
    +              right: Math.max(combined.right, reservation.rect.right),
    +              top: Math.min(combined.top, reservation.rect.top),
    +              bottom: Math.max(combined.bottom, reservation.rect.bottom),
    +            }),
    +            { ...blockingReservations[0].rect }
    +          );
    +          const lane = rerouteLane(points, combinedRect, blockingReservations);
    +          const rerouteOrdinal = lane ? (rerouteLaneOrdinals.get(lane.key) ?? 0) : 0;
    +          // Leave one full separation lane beyond the 6 px label clearance;
    +          // only routes avoiding the same reservation set on the same side share an ordinal.
    +          const lanePadding = (rerouteOrdinal + 2) * 12;
    +          const blockedRect: Rect = {
    +            cx: combinedRect.cx,
    +            cy: combinedRect.cy,
    +            left: combinedRect.left - lanePadding,
    +            right: combinedRect.right + lanePadding,
    +            top: combinedRect.top - lanePadding,
    +            bottom: combinedRect.bottom + lanePadding,
    +          };
    +          let rerouted = rerouteForeignEdgeAroundRect(
    +            edge,
    +            blockedRect,
    +            context,
    +            placedLabelsByEdgeId,
    +            overrides,
    +            lane ?? undefined
    +          );
    +          const usedScopedLane = rerouted !== null;
    +          rerouted ??= rerouteForeignEdgeAroundLabels(
    +            edge,
    +            blockingReservations,
    +            context,
    +            placedLabelsByEdgeId,
    +            overrides
    +          );
    +          if (rerouted) {
    +            points = rerouted;
    +            overrides.set(edge.id, points);
    +            if (lane && usedScopedLane) {
    +              rerouteLaneOrdinals.set(lane.key, rerouteOrdinal + 1);
    +            }
    +          } else {
    +            // Preserving a valid edge and anchored labels is safer than failing the whole layout.
    +            // Record the unavoidable overlap so final validation permits only this edge-label pair.
    +            incrementMetric(instrumentation, 'labelOverlapFallbacks');
    +            for (const reservation of blockingReservations) {
    +              if (!polylineIntersectsRect(points, reservation.rect)) {
    +                continue;
    +              }
    +              const allowedEdges =
    +                allowedOverlapsByLabelEdgeId.get(reservation.edgeId) ?? new Set();
    +              if (!allowedEdges.has(edge.id)) {
    +                incrementMetric(instrumentation, 'labelOverlapReservations');
    +              }
    +              allowedEdges.add(edge.id);
    +              allowedOverlapsByLabelEdgeId.set(reservation.edgeId, allowedEdges);
    +            }
    +          }
    +        }
    +        if (!labelStillAnchored(edge.id, points, placedLabelsByEdgeId)) {
    +          throw gridError('GRID_ROUTE_NOT_FOUND', `Label anchor was lost for edge "${edge.id}"`, {
    +            edgeId: edge.id,
    +            pass,
    +          });
    +        }
    +        if (placedLabelsByEdgeId.has(edge.id)) {
    +          incrementMetric(instrumentation, 'preservedAnchors');
    +        }
    +        setEdgePoints(context, edge, points);
    +        incrementMetric(instrumentation, 'impactedEdgeReroutes');
    +      }
    +      for (const { edge, labelNode } of workItems) {
    +        const reservation = placedLabelsByEdgeId.get(edge.id);
    +        if (
    +          !reservation ||
    +          !labelStillAnchored(
    +            edge.id,
    +            pointsForEdge(edge, undefined, context),
    +            placedLabelsByEdgeId
    +          ) ||
    +          !rectSafeForLabel(
    +            reservation.rect,
    +            edge,
    +            labelNode.id,
    +            context,
    +            undefined,
    +            allowedOverlapsByLabelEdgeId.get(edge.id)
    +          )
    +        ) {
    +          throw gridError('GRID_ROUTE_NOT_FOUND', `Label pass ${pass} did not validate`, {
    +            edgeId: edge.id,
    +            pass,
    +          });
    +        }
    +      }
    +      return;
    +    } catch (error) {
    +      if (error instanceof LabelIndexWorkLimitError) {
    +        restoreBaseEdgePoints();
    +        for (const { edge, labelNode } of workItems) {
    +          placeLabelAtRouteMidpoint(edge, labelNode, nodeById);
    +        }
    +        incrementMetric(instrumentation, 'indexWorkLimitFallbacks');
    +        if (routingInstrumentation) {
    +          routingInstrumentation.resourceLimitFallbacks++;
    +          routingInstrumentation.fallbackReasons.label_index_work_cap++;
    +        }
    +        return;
    +      }
    +      lastError = error;
    +      // A failed pass may contain detours around provisional label positions. Restore routing
    +      // before retrying so the next pass depends only on the labels it successfully places.
    +      restoreBaseEdgePoints();
    +      for (const { labelNode } of workItems) {
    +        delete labelNode.x;
    +        delete labelNode.y;
    +      }
    +    }
    +  }
    +
    +  restoreBaseState();
    +  incrementMetric(instrumentation, 'rollbacks');
    +  if (lastError instanceof Error) {
    +    throw lastError;
    +  }
    +  throw gridError('GRID_ROUTE_NOT_FOUND', 'Could not place one or more grid edge labels');
    +}
    diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/grid/groups.spec.ts b/packages/mermaid/src/rendering-util/layout-algorithms/grid/groups.spec.ts
    new file mode 100644
    index 00000000000..a0e25585f3a
    --- /dev/null
    +++ b/packages/mermaid/src/rendering-util/layout-algorithms/grid/groups.spec.ts
    @@ -0,0 +1,89 @@
    +import { describe, expect, it } from 'vitest';
    +import type { Node } from '../../types.js';
    +import { buildGridForest, isAncestorGroup } from './groups.js';
    +
    +function group(id: string, parentId?: string): Node {
    +  return {
    +    id,
    +    parentId,
    +    isGroup: true,
    +    shape: 'rect',
    +    width: 100,
    +    height: 80,
    +  } as Node;
    +}
    +
    +function leaf(id: string, parentId?: string): Node {
    +  return {
    +    id,
    +    parentId,
    +    isGroup: false,
    +    shape: 'rect',
    +    width: 60,
    +    height: 40,
    +  } as Node;
    +}
    +
    +describe('grid groups', () => {
    +  it('builds a stable post-order forest and ancestor checks', () => {
    +    const nodes = [
    +      group('outer'),
    +      leaf('a', 'outer'),
    +      group('inner', 'outer'),
    +      leaf('b', 'inner'),
    +      leaf('root'),
    +    ];
    +
    +    const forest = buildGridForest(nodes);
    +    expect(forest.rootChildren.map((node) => node.id)).toEqual(['outer', 'root']);
    +    expect(forest.postOrderGroups.map((node) => node.id)).toEqual(['inner', 'outer']);
    +    expect(isAncestorGroup('outer', forest.nodeById.get('b'), forest.nodeById)).toBe(true);
    +    expect(isAncestorGroup('inner', forest.nodeById.get('a'), forest.nodeById)).toBe(false);
    +  });
    +
    +  it('rejects missing parents and cycles', () => {
    +    expect(() => buildGridForest([leaf('orphan', 'missing')])).toThrow(/GRID_INVALID_CONTAINMENT/);
    +
    +    expect(() => buildGridForest([group('a', 'b'), group('b', 'a')])).toThrow(
    +      /GRID_INVALID_CONTAINMENT/
    +    );
    +  });
    +
    +  it('rejects the reserved synthetic root id', () => {
    +    expect(() => buildGridForest([group('__grid_root__')])).toThrow(
    +      /Node id "__grid_root__" is reserved/
    +    );
    +  });
    +
    +  it.each([
    +    {
    +      name: 'regular nodes',
    +      nodes: [leaf('duplicate'), leaf('duplicate')],
    +    },
    +    {
    +      name: 'an edge-label helper and a regular node',
    +      nodes: [{ ...leaf('duplicate'), isEdgeLabel: true } as Node, leaf('duplicate')],
    +    },
    +  ])('rejects duplicate ids for $name', ({ nodes }) => {
    +    expect(() => buildGridForest(nodes)).toThrow(
    +      'GRID_INVALID_CONTAINMENT: Duplicate node id "duplicate"'
    +    );
    +  });
    +
    +  it('supports 15,000 levels of valid nesting without overflowing the call stack', () => {
    +    const depth = 15_000;
    +    const nodes: Node[] = [group('g0')];
    +    for (let index = 1; index < depth; index++) {
    +      nodes.push(group(`g${index}`, `g${index - 1}`));
    +    }
    +    nodes.push(leaf('terminal', `g${depth - 1}`));
    +
    +    const forest = buildGridForest(nodes);
    +
    +    expect(forest.rootChildren.map((node) => node.id)).toEqual(['g0']);
    +    expect(forest.postOrderGroups).toHaveLength(depth);
    +    expect(forest.postOrderGroups[0].id).toBe(`g${depth - 1}`);
    +    expect(forest.postOrderGroups[forest.postOrderGroups.length - 1].id).toBe('g0');
    +    expect(isAncestorGroup('g0', forest.nodeById.get('terminal'), forest.nodeById)).toBe(true);
    +  });
    +});
    diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/grid/groups.ts b/packages/mermaid/src/rendering-util/layout-algorithms/grid/groups.ts
    new file mode 100644
    index 00000000000..810001db165
    --- /dev/null
    +++ b/packages/mermaid/src/rendering-util/layout-algorithms/grid/groups.ts
    @@ -0,0 +1,191 @@
    +import type { Node } from '../../types.js';
    +import {
    +  ROOT_CONTAINER_ID,
    +  type GridContainerId,
    +  type GridForest,
    +  gridError,
    +  isEdgeLabelNode,
    +} from './types.js';
    +
    +/*
    + * Builds the containment model used by every later grid-layout phase.
    + *
    + * The returned forest excludes synthetic edge-label nodes from the geometry hierarchy, validates
    + * every parent reference, and orders groups children-first so nested containers can be measured
    + * before their parents.
    + */
    +function asGroup(node: Node): (Node & { isGroup: true }) | undefined {
    +  return node.isGroup ? (node as Node & { isGroup: true }) : undefined;
    +}
    +
    +export function buildGridForest(nodes: Node[]): GridForest {
    +  const nodeById = new Map();
    +  const groupById = new Map();
    +  const helperNodeIds = new Set();
    +
    +  for (const node of nodes) {
    +    if (node.id === ROOT_CONTAINER_ID) {
    +      throw gridError(
    +        'GRID_INVALID_CONTAINMENT',
    +        `Node id "${ROOT_CONTAINER_ID}" is reserved for the grid layout root`,
    +        { traversedIds: [node.id] }
    +      );
    +    }
    +    if (nodeById.has(node.id)) {
    +      throw gridError('GRID_INVALID_CONTAINMENT', `Duplicate node id "${node.id}"`, {
    +        traversedIds: [node.id],
    +      });
    +    }
    +    nodeById.set(node.id, node);
    +    if (isEdgeLabelNode(node)) {
    +      helperNodeIds.add(node.id);
    +      continue;
    +    }
    +    const group = asGroup(node);
    +    if (group) {
    +      groupById.set(group.id, group);
    +    }
    +  }
    +
    +  const childrenByParent = new Map();
    +  childrenByParent.set(ROOT_CONTAINER_ID, []);
    +
    +  for (const node of nodes) {
    +    if (helperNodeIds.has(node.id)) {
    +      continue;
    +    }
    +    const parentId = node.parentId ?? ROOT_CONTAINER_ID;
    +    if (parentId !== ROOT_CONTAINER_ID) {
    +      const parent = groupById.get(parentId);
    +      if (!parent) {
    +        throw gridError(
    +          'GRID_INVALID_CONTAINMENT',
    +          `Missing parent "${parentId}" for "${node.id}"`,
    +          {
    +            traversedIds: [node.id, parentId],
    +          }
    +        );
    +      }
    +    }
    +    if (!childrenByParent.has(parentId)) {
    +      childrenByParent.set(parentId, []);
    +    }
    +    childrenByParent.get(parentId)!.push(node);
    +  }
    +
    +  const state = new Map();
    +  const postOrderGroups: (Node & { isGroup: true })[] = [];
    +
    +  // Use an explicit stack rather than recursion so deeply nested diagrams do not consume the
    +  // JavaScript call stack. State 1 means "on the active path"; encountering it again is a cycle.
    +  interface VisitFrame {
    +    groupId: string;
    +    childGroups: (Node & { isGroup: true })[];
    +    nextChildIndex: number;
    +  }
    +
    +  const directChildGroups = (groupId: string): (Node & { isGroup: true })[] =>
    +    (childrenByParent.get(groupId) ?? [])
    +      .map((child) => asGroup(child))
    +      .filter((group): group is Node & { isGroup: true } => group !== undefined);
    +
    +  const visit = (startGroupId: string): void => {
    +    const currentState = state.get(startGroupId) ?? 0;
    +    if (currentState === 1) {
    +      throw gridError('GRID_INVALID_CONTAINMENT', `Containment cycle at "${startGroupId}"`, {
    +        traversedIds: [startGroupId],
    +      });
    +    }
    +    if (currentState === 2) {
    +      return;
    +    }
    +
    +    state.set(startGroupId, 1);
    +    const stack: VisitFrame[] = [
    +      {
    +        groupId: startGroupId,
    +        childGroups: directChildGroups(startGroupId),
    +        nextChildIndex: 0,
    +      },
    +    ];
    +
    +    while (stack.length > 0) {
    +      const frame = stack[stack.length - 1];
    +      const child = frame.childGroups[frame.nextChildIndex];
    +
    +      if (child) {
    +        frame.nextChildIndex += 1;
    +        const childState = state.get(child.id) ?? 0;
    +        if (childState === 1) {
    +          throw gridError('GRID_INVALID_CONTAINMENT', `Containment cycle at "${child.id}"`, {
    +            traversedIds: [...stack.map((item) => item.groupId), child.id],
    +          });
    +        }
    +        if (childState === 2) {
    +          continue;
    +        }
    +
    +        state.set(child.id, 1);
    +        stack.push({
    +          groupId: child.id,
    +          childGroups: directChildGroups(child.id),
    +          nextChildIndex: 0,
    +        });
    +        continue;
    +      }
    +
    +      stack.pop();
    +      state.set(frame.groupId, 2);
    +      const group = groupById.get(frame.groupId);
    +      if (group) {
    +        postOrderGroups.push(group);
    +      }
    +    }
    +  };
    +
    +  for (const rootChild of childrenByParent.get(ROOT_CONTAINER_ID) ?? []) {
    +    const group = asGroup(rootChild);
    +    if (group) {
    +      visit(group.id);
    +    }
    +  }
    +
    +  // Normally every group is reached from a root child. This second pass also validates malformed
    +  // disconnected components and produces a useful cycle error instead of silently dropping them.
    +  for (const groupId of groupById.keys()) {
    +    if ((state.get(groupId) ?? 0) === 0) {
    +      visit(groupId);
    +    }
    +  }
    +
    +  return {
    +    nodeById,
    +    groupById,
    +    childrenByParent,
    +    rootChildren: childrenByParent.get(ROOT_CONTAINER_ID) ?? [],
    +    postOrderGroups,
    +    helperNodeIds,
    +  };
    +}
    +
    +export function isAncestorGroup(
    +  ancestorId: string,
    +  node: Node | undefined,
    +  nodeById: Map
    +): boolean {
    +  let current = node;
    +  const seen = new Set();
    +  // The forest builder already rejects cycles. The guard keeps this predicate safe when callers
    +  // provide a partially constructed or independently sourced node map.
    +  while (current?.parentId) {
    +    if (current.parentId === ancestorId) {
    +      return true;
    +    }
    +    if (seen.has(current.parentId)) {
    +      return false;
    +    }
    +    seen.add(current.parentId);
    +    current = nodeById.get(current.parentId);
    +  }
    +  return false;
    +}
    diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/grid/index.ts b/packages/mermaid/src/rendering-util/layout-algorithms/grid/index.ts
    new file mode 100644
    index 00000000000..9bf486b2fc1
    --- /dev/null
    +++ b/packages/mermaid/src/rendering-util/layout-algorithms/grid/index.ts
    @@ -0,0 +1,12 @@
    +import { createCommonLayoutRenderer } from '../common/index.js';
    +import { prepareGridLayout } from './edgeLabels.js';
    +import { runGridLayoutCore } from './layoutCore.js';
    +
    +// Keep this adapter deliberately small: the common renderer owns the public render lifecycle,
    +// while grid-specific preparation and layout remain reusable by DDLT and focused tests.
    +export { prepareGridLayout, runGridLayoutCore };
    +
    +export const render = createCommonLayoutRenderer({
    +  prepareLayout: prepareGridLayout,
    +  runLayoutCore: (data4Layout) => runGridLayoutCore(data4Layout),
    +});
    diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/grid/labelGeometry.spec.ts b/packages/mermaid/src/rendering-util/layout-algorithms/grid/labelGeometry.spec.ts
    new file mode 100644
    index 00000000000..5397f992048
    --- /dev/null
    +++ b/packages/mermaid/src/rendering-util/layout-algorithms/grid/labelGeometry.spec.ts
    @@ -0,0 +1,46 @@
    +import { describe, expect, it } from 'vitest';
    +import type { Node } from '../../types.js';
    +import {
    +  GRID_EDGE_END_MARKER_CLEARANCE,
    +  GRID_LABEL_CLEARANCE,
    +  GRID_LABEL_SPAN_MARGIN,
    +  gridLabelRequiredSegmentLength,
    +  gridLabelSpanRequirement,
    +} from './labelGeometry.js';
    +
    +function label(width: number | undefined, height: number | undefined): Node {
    +  return { id: 'label', width, height, isGroup: false } as Node;
    +}
    +
    +describe('grid label geometry', () => {
    +  it('maps horizontal and vertical label dimensions to their route axes', () => {
    +    expect(gridLabelSpanRequirement(label(90, 20), 'H')).toEqual({
    +      along: 90,
    +      cross: 20,
    +      startInset: GRID_EDGE_END_MARKER_CLEARANCE,
    +      endInset: GRID_EDGE_END_MARKER_CLEARANCE,
    +      requiredAlongSpan: 90 + GRID_EDGE_END_MARKER_CLEARANCE * 2 + GRID_LABEL_SPAN_MARGIN,
    +    });
    +    expect(gridLabelSpanRequirement(label(90, 20), 'V')).toEqual({
    +      along: 20,
    +      cross: 90,
    +      startInset: GRID_EDGE_END_MARKER_CLEARANCE,
    +      endInset: GRID_EDGE_END_MARKER_CLEARANCE,
    +      requiredAlongSpan: 20 + GRID_EDGE_END_MARKER_CLEARANCE * 2 + GRID_LABEL_SPAN_MARGIN,
    +    });
    +  });
    +
    +  it('retains the existing nonterminal segment clearance calculation', () => {
    +    expect(gridLabelRequiredSegmentLength(label(90, 20), 'H')).toBe(90 + GRID_LABEL_CLEARANCE * 2);
    +    expect(gridLabelRequiredSegmentLength(label(90, 20), 'V')).toBe(20 + GRID_LABEL_CLEARANCE * 2);
    +  });
    +
    +  it.each([
    +    [undefined, 20],
    +    [90, undefined],
    +    [0, 20],
    +    [90, Number.NaN],
    +  ])('rejects an invalid %s by %s measurement', (width, height) => {
    +    expect(gridLabelSpanRequirement(label(width, height), 'H')).toBeNull();
    +  });
    +});
    diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/grid/labelGeometry.ts b/packages/mermaid/src/rendering-util/layout-algorithms/grid/labelGeometry.ts
    new file mode 100644
    index 00000000000..d2ac44ca4e5
    --- /dev/null
    +++ b/packages/mermaid/src/rendering-util/layout-algorithms/grid/labelGeometry.ts
    @@ -0,0 +1,45 @@
    +import type { Node } from '../../types.js';
    +import type { GridOrientation } from './types.js';
    +import { isFinitePositiveNumber } from './types.js';
    +
    +export const GRID_LABEL_CLEARANCE = 6;
    +export const GRID_EDGE_END_MARKER_CLEARANCE = 12;
    +export const GRID_LABEL_SPAN_MARGIN = 2;
    +
    +export interface GridLabelSpanRequirement {
    +  along: number;
    +  cross: number;
    +  startInset: number;
    +  endInset: number;
    +  requiredAlongSpan: number;
    +}
    +
    +export function gridLabelSpanRequirement(
    +  labelNode: Node,
    +  orientation: GridOrientation
    +): GridLabelSpanRequirement | null {
    +  if (!isFinitePositiveNumber(labelNode.width) || !isFinitePositiveNumber(labelNode.height)) {
    +    return null;
    +  }
    +
    +  const along = orientation === 'H' ? labelNode.width : labelNode.height;
    +  const cross = orientation === 'H' ? labelNode.height : labelNode.width;
    +  const startInset = Math.max(GRID_LABEL_CLEARANCE, GRID_EDGE_END_MARKER_CLEARANCE);
    +  const endInset = Math.max(GRID_LABEL_CLEARANCE, GRID_EDGE_END_MARKER_CLEARANCE);
    +
    +  return {
    +    along,
    +    cross,
    +    startInset,
    +    endInset,
    +    requiredAlongSpan: along + startInset + endInset + GRID_LABEL_SPAN_MARGIN,
    +  };
    +}
    +
    +export function gridLabelRequiredSegmentLength(
    +  labelNode: Node,
    +  orientation: GridOrientation
    +): number {
    +  const along = orientation === 'H' ? labelNode.width : labelNode.height;
    +  return isFinitePositiveNumber(along) ? along + GRID_LABEL_CLEARANCE * 2 : 0;
    +}
    diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/grid/labelSpacing.spec.ts b/packages/mermaid/src/rendering-util/layout-algorithms/grid/labelSpacing.spec.ts
    new file mode 100644
    index 00000000000..ead3135e439
    --- /dev/null
    +++ b/packages/mermaid/src/rendering-util/layout-algorithms/grid/labelSpacing.spec.ts
    @@ -0,0 +1,131 @@
    +import { describe, expect, it } from 'vitest';
    +import type { Edge, LayoutData, Node } from '../../types.js';
    +import { buildGridForest } from './groups.js';
    +import { buildGridLabelRequirements, deriveGridContainerLabelGaps } from './labelSpacing.js';
    +import type { GridCellStack, GridResolvedPlacement } from './types.js';
    +import { ROOT_CONTAINER_ID } from './types.js';
    +
    +function node(id: string, width = 80, height = 40, parentId?: string): Node {
    +  return { id, width, height, parentId, isGroup: false } as Node;
    +}
    +
    +function label(id: string, width: number, height: number): Node {
    +  return { id, width, height, isGroup: false, isEdgeLabel: true } as Node;
    +}
    +
    +function placement(item: Node, row: number, column: number): GridResolvedPlacement {
    +  return {
    +    item,
    +    row,
    +    column,
    +    horizontalAlign: 'center',
    +    verticalAlign: 'center',
    +    sourceOrder: 0,
    +    explicitRow: true,
    +    explicitColumn: true,
    +    explicitCell: true,
    +  };
    +}
    +
    +function cells(...values: GridResolvedPlacement[]): Map {
    +  return new Map(
    +    values.map((value) => [
    +      `${value.row}:${value.column}`,
    +      {
    +        row: value.row,
    +        column: value.column,
    +        items: [value],
    +        width: value.item.width ?? 0,
    +        height: value.item.height ?? 0,
    +        verticalAlign: 'center',
    +      },
    +    ])
    +  );
    +}
    +
    +describe('grid label spacing', () => {
    +  it('indexes only measured labels whose endpoints share a direct container', () => {
    +    const a = node('a');
    +    const b = node('b');
    +    const helper = label('edge-label-ab', 90, 20);
    +    const edge = { id: 'ab', start: 'a', end: 'b', labelNodeId: helper.id } as Edge;
    +    const data = { nodes: [a, b, helper], edges: [edge] } as LayoutData;
    +
    +    const requirements = buildGridLabelRequirements(data, buildGridForest(data.nodes));
    +
    +    expect(requirements.byContainer.get(ROOT_CONTAINER_ID)).toHaveLength(1);
    +    expect(requirements.selfLoops.size).toBe(0);
    +  });
    +
    +  it('uses the maximum label span for a canonical adjacent boundary', () => {
    +    const a = node('a');
    +    const b = node('b');
    +    const first = label('first', 60, 20);
    +    const second = label('second', 90, 20);
    +    const entries = [
    +      { edge: { id: 'ab', start: 'a', end: 'b' } as Edge, labelNode: first },
    +      { edge: { id: 'ba', start: 'b', end: 'a' } as Edge, labelNode: second },
    +    ];
    +
    +    const gaps = deriveGridContainerLabelGaps(
    +      entries,
    +      cells(placement(a, 1, 1), placement(b, 1, 2)),
    +      new Map([[1, 40]]),
    +      new Map([
    +        [1, 80],
    +        [2, 80],
    +      ]),
    +      [1],
    +      [1, 2],
    +      50,
    +      50
    +    );
    +
    +    expect(gaps.columnGapAfter).toEqual(new Map([[1, 116]]));
    +    expect(gaps.rowGapAfter.size).toBe(0);
    +  });
    +
    +  it('skips non-adjacent endpoints and cross-axis track collisions', () => {
    +    const a = node('a');
    +    const b = node('b');
    +    const helper = label('label', 90, 120);
    +    const entries = [{ edge: { id: 'ab', start: 'a', end: 'b' } as Edge, labelNode: helper }];
    +
    +    const gaps = deriveGridContainerLabelGaps(
    +      entries,
    +      cells(placement(a, 1, 1), placement(b, 1, 3)),
    +      new Map([
    +        [1, 40],
    +        [2, 40],
    +      ]),
    +      new Map([
    +        [1, 80],
    +        [2, 80],
    +        [3, 80],
    +      ]),
    +      [1, 2],
    +      [1, 2, 3],
    +      10,
    +      50
    +    );
    +    expect(gaps.columnGapAfter.size).toBe(0);
    +
    +    const adjacent = deriveGridContainerLabelGaps(
    +      entries,
    +      cells(placement(a, 1, 1), placement(b, 1, 2)),
    +      new Map([
    +        [1, 40],
    +        [2, 40],
    +      ]),
    +      new Map([
    +        [1, 80],
    +        [2, 80],
    +      ]),
    +      [1, 2],
    +      [1, 2],
    +      10,
    +      50
    +    );
    +    expect(adjacent.columnGapAfter.size).toBe(0);
    +  });
    +});
    diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/grid/labelSpacing.ts b/packages/mermaid/src/rendering-util/layout-algorithms/grid/labelSpacing.ts
    new file mode 100644
    index 00000000000..92f2b2df7bb
    --- /dev/null
    +++ b/packages/mermaid/src/rendering-util/layout-algorithms/grid/labelSpacing.ts
    @@ -0,0 +1,201 @@
    +import type { Edge, LayoutData, Node } from '../../types.js';
    +import { gridLabelSpanRequirement, type GridLabelSpanRequirement } from './labelGeometry.js';
    +import type { GridRoutingInstrumentation } from './routerInstrumentation.js';
    +import type {
    +  GridCellStack,
    +  GridContainerId,
    +  GridForest,
    +  GridOrientation,
    +  GridResolvedPlacement,
    +} from './types.js';
    +import { ROOT_CONTAINER_ID } from './types.js';
    +
    +interface GridLabelledEdge {
    +  edge: Edge;
    +  labelNode: Node;
    +}
    +
    +export interface GridLabelRequirements {
    +  byContainer: Map;
    +  selfLoops: Map;
    +}
    +
    +export interface GridContainerLabelGaps {
    +  rowGapAfter: Map;
    +  columnGapAfter: Map;
    +}
    +
    +export function buildGridLabelRequirements(
    +  data: LayoutData,
    +  forest: GridForest,
    +  metrics?: GridRoutingInstrumentation
    +): GridLabelRequirements {
    +  const byContainer = new Map();
    +  const selfLoops = new Map();
    +
    +  for (const edge of data.edges) {
    +    if (!edge.start || !edge.end || !edge.labelNodeId) {
    +      continue;
    +    }
    +    if (metrics) {
    +      metrics.labelSpacingEdgesExamined++;
    +    }
    +    const labelNode = forest.nodeById.get(edge.labelNodeId);
    +    const source = forest.nodeById.get(edge.start);
    +    const target = forest.nodeById.get(edge.end);
    +    if (!labelNode || !source || !target) {
    +      continue;
    +    }
    +    if (edge.start === edge.end) {
    +      const horizontal = gridLabelSpanRequirement(labelNode, 'H');
    +      if (horizontal) {
    +        selfLoops.set(edge.id, horizontal);
    +      }
    +      continue;
    +    }
    +    const sourceContainer = source.parentId ?? ROOT_CONTAINER_ID;
    +    const targetContainer = target.parentId ?? ROOT_CONTAINER_ID;
    +    if (sourceContainer !== targetContainer) {
    +      continue;
    +    }
    +    const entries = byContainer.get(sourceContainer);
    +    if (entries) {
    +      entries.push({ edge, labelNode });
    +    } else {
    +      byContainer.set(sourceContainer, [{ edge, labelNode }]);
    +    }
    +  }
    +
    +  return { byContainer, selfLoops };
    +}
    +
    +function trackOrigins(
    +  tracks: readonly number[],
    +  sizes: ReadonlyMap,
    +  gap: number
    +): Map {
    +  const origins = new Map();
    +  let cursor = 0;
    +  for (const track of tracks) {
    +    origins.set(track, cursor);
    +    cursor += (sizes.get(track) ?? 0) + gap;
    +  }
    +  return origins;
    +}
    +
    +function crossAxisFits(
    +  track: number,
    +  crossSize: number,
    +  tracks: readonly number[],
    +  sizes: ReadonlyMap,
    +  gap: number
    +): boolean {
    +  const origins = trackOrigins(tracks, sizes, gap);
    +  const origin = origins.get(track);
    +  const size = sizes.get(track);
    +  if (origin === undefined || size === undefined) {
    +    return false;
    +  }
    +  const center = origin + size / 2;
    +  const low = center - crossSize / 2;
    +  const high = center + crossSize / 2;
    +  return tracks.every((other) => {
    +    if (other === track) {
    +      return true;
    +    }
    +    const otherOrigin = origins.get(other)!;
    +    const otherEnd = otherOrigin + (sizes.get(other) ?? 0);
    +    return high <= otherOrigin || low >= otherEnd;
    +  });
    +}
    +
    +function placementByNodeId(cells: ReadonlyMap) {
    +  const placements = new Map();
    +  for (const cell of cells.values()) {
    +    for (const placement of cell.items) {
    +      placements.set(placement.item.id, placement);
    +    }
    +  }
    +  return placements;
    +}
    +
    +function areConsecutive(first: number, second: number, tracks: readonly number[]): boolean {
    +  const low = Math.min(first, second);
    +  const high = Math.max(first, second);
    +  const index = tracks.indexOf(low);
    +  return index >= 0 && tracks[index + 1] === high;
    +}
    +
    +function addGapRequirement(gaps: Map, track: number, required: number): void {
    +  gaps.set(track, Math.max(gaps.get(track) ?? 0, required));
    +}
    +
    +export function deriveGridContainerLabelGaps(
    +  entries: readonly GridLabelledEdge[],
    +  cells: ReadonlyMap,
    +  rowHeights: ReadonlyMap,
    +  columnWidths: ReadonlyMap,
    +  sortedRows: readonly number[],
    +  sortedColumns: readonly number[],
    +  configuredRowGap: number,
    +  configuredColumnGap: number,
    +  metrics?: GridRoutingInstrumentation
    +): GridContainerLabelGaps {
    +  const rowGapAfter = new Map();
    +  const columnGapAfter = new Map();
    +  const placements = placementByNodeId(cells);
    +
    +  const requirementFor = (
    +    entry: GridLabelledEdge,
    +    orientation: GridOrientation
    +  ): GridLabelSpanRequirement | null => gridLabelSpanRequirement(entry.labelNode, orientation);
    +
    +  for (const entry of entries) {
    +    const source = entry.edge.start ? placements.get(entry.edge.start) : undefined;
    +    const target = entry.edge.end ? placements.get(entry.edge.end) : undefined;
    +    if (!source || !target) {
    +      continue;
    +    }
    +    if (source.row === target.row && areConsecutive(source.column, target.column, sortedColumns)) {
    +      const requirement = requirementFor(entry, 'H');
    +      if (
    +        requirement &&
    +        crossAxisFits(source.row, requirement.cross, sortedRows, rowHeights, configuredRowGap)
    +      ) {
    +        if (metrics) {
    +          metrics.labelSpacingEligibleEdges++;
    +        }
    +        addGapRequirement(
    +          columnGapAfter,
    +          Math.min(source.column, target.column),
    +          requirement.requiredAlongSpan
    +        );
    +      }
    +      continue;
    +    }
    +    if (source.column === target.column && areConsecutive(source.row, target.row, sortedRows)) {
    +      const requirement = requirementFor(entry, 'V');
    +      if (
    +        requirement &&
    +        crossAxisFits(
    +          source.column,
    +          requirement.cross,
    +          sortedColumns,
    +          columnWidths,
    +          configuredColumnGap
    +        )
    +      ) {
    +        if (metrics) {
    +          metrics.labelSpacingEligibleEdges++;
    +        }
    +        addGapRequirement(
    +          rowGapAfter,
    +          Math.min(source.row, target.row),
    +          requirement.requiredAlongSpan
    +        );
    +      }
    +    }
    +  }
    +
    +  return { rowGapAfter, columnGapAfter };
    +}
    diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/grid/layoutCore.spec.ts b/packages/mermaid/src/rendering-util/layout-algorithms/grid/layoutCore.spec.ts
    new file mode 100644
    index 00000000000..e193308f3db
    --- /dev/null
    +++ b/packages/mermaid/src/rendering-util/layout-algorithms/grid/layoutCore.spec.ts
    @@ -0,0 +1,375 @@
    +import { describe, expect, it } from 'vitest';
    +import type { Edge, LayoutData, Node } from '../../types.js';
    +import { prepareGridLayout } from './edgeLabels.js';
    +import { runGridLayoutCore } from './layoutCore.js';
    +
    +function leaf(
    +  id: string,
    +  width: number,
    +  height: number,
    +  metadata?: Record,
    +  parentId?: string
    +): Node {
    +  return {
    +    id,
    +    parentId,
    +    isGroup: false,
    +    shape: 'rect',
    +    width,
    +    height,
    +    metadata,
    +  } as Node;
    +}
    +
    +function group(
    +  id: string,
    +  label: string,
    +  parentId?: string,
    +  metadata?: Record
    +): Node {
    +  return {
    +    id,
    +    parentId,
    +    label,
    +    labelBBox: { width: 60, height: 20 },
    +    isGroup: true,
    +    shape: 'rect',
    +    metadata,
    +  } as Node;
    +}
    +
    +function layout(nodes: Node[], grid: Record = {}): LayoutData {
    +  return {
    +    nodes,
    +    edges: [],
    +    config: {
    +      grid,
    +    } as LayoutData['config'],
    +  };
    +}
    +
    +function geometrySnapshot(nodes: Node[]) {
    +  return nodes.map((node) => ({
    +    id: node.id,
    +    x: node.x,
    +    y: node.y,
    +    width: node.width,
    +    height: node.height,
    +    groupTitleRect: node.groupTitleRect,
    +  }));
    +}
    +
    +describe('grid layout core', () => {
    +  it('handles an empty graph', () => {
    +    const data = layout([]);
    +
    +    const result = runGridLayoutCore(data);
    +
    +    expect(data.nodes).toEqual([]);
    +    expect(data.edges).toEqual([]);
    +    expect(result.forest.rootChildren).toEqual([]);
    +    expect(result.itemMeta.size).toBe(0);
    +  });
    +
    +  it('assigns finite geometry to a single automatically placed node', () => {
    +    const data = layout([leaf('only', 40, 20)]);
    +
    +    const result = runGridLayoutCore(data);
    +
    +    expect(data.nodes[0]).toMatchObject({ x: 20, y: 10, width: 40, height: 20 });
    +    expect(result.itemMeta.get('only')).toMatchObject({ row: 1, column: 1 });
    +  });
    +
    +  it('marks routed edges with their rendering capabilities', () => {
    +    const data = layout(
    +      [
    +        leaf('source', 40, 20, { row: 1, column: 1 }),
    +        leaf('target', 40, 20, { row: 1, column: 2 }),
    +      ],
    +      { curve: 'linear' }
    +    );
    +    data.edges.push({ id: 'source-target', start: 'source', end: 'target' } as Edge);
    +
    +    runGridLayoutCore(data);
    +
    +    expect(data.edges[0]).toMatchObject({
    +      portClipping: 'outline-orthogonal',
    +      skipCornerFix: true,
    +    });
    +  });
    +
    +  it('expands only the labelled vertical boundary', () => {
    +    const data = layout(
    +      [
    +        leaf('source', 80, 40, { row: 1, column: 1 }),
    +        leaf('target', 80, 40, { row: 2, column: 1 }),
    +        leaf('peer', 80, 40, { row: 3, column: 1 }),
    +      ],
    +      { rowGap: 10 }
    +    );
    +    data.edges.push({
    +      id: 'source-target',
    +      start: 'source',
    +      end: 'target',
    +      label: 'tall',
    +    } as Edge);
    +    prepareGridLayout(data);
    +    const labelNode = data.nodes.find((node) => node.id === data.edges[0].labelNodeId)!;
    +    labelNode.width = 20;
    +    labelNode.height = 60;
    +
    +    runGridLayoutCore(data);
    +
    +    const byId = new Map(data.nodes.map((node) => [node.id, node]));
    +    const source = byId.get('source')!;
    +    const target = byId.get('target')!;
    +    const peer = byId.get('peer')!;
    +    expect((target.y ?? 0) - 20 - ((source.y ?? 0) + 20)).toBe(86);
    +    expect((peer.y ?? 0) - 20 - ((target.y ?? 0) + 20)).toBe(10);
    +  });
    +
    +  it('sizes stacked cells and applies horizontal/vertical alignment', () => {
    +    const data = layout(
    +      [
    +        leaf('stack-left', 40, 20, {
    +          row: 1,
    +          column: 1,
    +          horizontalAlign: 'left',
    +          verticalAlign: 'bottom',
    +        }),
    +        leaf('stack-right', 20, 20, {
    +          row: 1,
    +          column: 1,
    +          horizontalAlign: 'right',
    +          verticalAlign: 'bottom',
    +        }),
    +        leaf('peer', 100, 100, { row: 1, column: 2 }),
    +      ],
    +      { cellGap: 10 }
    +    );
    +
    +    runGridLayoutCore(data);
    +    const byId = new Map(data.nodes.map((node) => [node.id, node]));
    +
    +    expect(byId.get('stack-left')).toMatchObject({ x: 20, y: 60 });
    +    expect(byId.get('stack-right')).toMatchObject({ x: 30, y: 90 });
    +    expect(byId.get('peer')).toMatchObject({ x: 140, y: 50 });
    +  });
    +
    +  it('returns a forest backed by the caller-owned nodes', () => {
    +    const owner = group('owner', 'Owner');
    +    const child = leaf('child', 40, 20, { row: 1, column: 1 }, owner.id);
    +    const peer = leaf('peer', 40, 20, { row: 1, column: 2 });
    +    const data = layout([owner, child, peer]);
    +
    +    const result = runGridLayoutCore(data);
    +
    +    expect(result.forest.nodeById.get(owner.id)).toBe(owner);
    +    expect(result.forest.nodeById.get(child.id)).toBe(child);
    +    expect(result.forest.groupById.get(owner.id)).toBe(owner);
    +    expect(result.forest.childrenByParent.get(owner.id)).toEqual([child]);
    +    expect(result.forest.rootChildren).toEqual([owner, peer]);
    +    expect(result.forest.postOrderGroups).toEqual([owner]);
    +  });
    +
    +  it('sizes nested groups bottom-up and keeps coordinates direction-independent', () => {
    +    const build = (direction: string) =>
    +      layout(
    +        [
    +          group('outer', 'Outer'),
    +          group('middle', 'Middle', 'outer', { row: 1, column: 1 }),
    +          leaf('inner', 60, 30, { row: 1, column: 1 }, 'middle'),
    +          leaf('sibling', 80, 40, { row: 1, column: 2 }, 'outer'),
    +          leaf('root-leaf', 50, 20, { row: 1, column: 2 }),
    +        ],
    +        {}
    +      ) as LayoutData & { direction?: string };
    +
    +    const tb = build('TB');
    +    tb.direction = 'TB';
    +    runGridLayoutCore(tb);
    +
    +    const lr = build('LR');
    +    lr.direction = 'LR';
    +    runGridLayoutCore(lr);
    +
    +    const byId = new Map(tb.nodes.map((node) => [node.id, node]));
    +    const outer = byId.get('outer')!;
    +    const middle = byId.get('middle')!;
    +    const inner = byId.get('inner')!;
    +
    +    expect(outer.groupTitleRect).toBeDefined();
    +    expect(middle.groupTitleRect).toBeDefined();
    +
    +    const outerLeft = (outer.x ?? 0) - (outer.width ?? 0) / 2;
    +    const outerRight = (outer.x ?? 0) + (outer.width ?? 0) / 2;
    +    const outerTop = (outer.y ?? 0) - (outer.height ?? 0) / 2;
    +    const outerBottom = (outer.y ?? 0) + (outer.height ?? 0) / 2;
    +    const middleLeft = (middle.x ?? 0) - (middle.width ?? 0) / 2;
    +    const middleRight = (middle.x ?? 0) + (middle.width ?? 0) / 2;
    +    const middleTop = (middle.y ?? 0) - (middle.height ?? 0) / 2;
    +    const middleBottom = (middle.y ?? 0) + (middle.height ?? 0) / 2;
    +
    +    expect(outer.groupTitleRect).toMatchObject({ left: outerLeft, top: outerTop });
    +    expect(middle.groupTitleRect).toMatchObject({ left: middleLeft, top: middleTop });
    +    expect(middleLeft).toBeGreaterThanOrEqual(outerLeft);
    +    expect(middleRight).toBeLessThanOrEqual(outerRight);
    +    expect(middleTop).toBeGreaterThanOrEqual(outerTop);
    +    expect(middleBottom).toBeLessThanOrEqual(outerBottom);
    +    expect(inner.x ?? 0).toBeGreaterThan(middleLeft);
    +    expect(inner.x ?? 0).toBeLessThan(middleRight);
    +    expect(inner.y ?? 0).toBeGreaterThan(middleTop);
    +    expect(inner.y ?? 0).toBeLessThan(middleBottom);
    +
    +    const tbCoords = tb.nodes.map((node) => [
    +      node.id,
    +      node.x,
    +      node.y,
    +      node.width,
    +      node.height,
    +      node.groupTitleRect,
    +    ]);
    +    const lrCoords = lr.nodes.map((node) => [
    +      node.id,
    +      node.x,
    +      node.y,
    +      node.width,
    +      node.height,
    +      node.groupTitleRect,
    +    ]);
    +    expect(lrCoords).toEqual(tbCoords);
    +  });
    +
    +  it('centers content and routing corridors when the title determines the group width', () => {
    +    const container = group('wide-title', 'Wide title');
    +    container.labelBBox = { width: 200, height: 20 };
    +    const child = leaf('child', 40, 20, { row: 1, column: 1 }, container.id);
    +    const data = layout([container, child]);
    +
    +    const result = runGridLayoutCore(data);
    +    const containerMeta = result.containers.get(container.id);
    +
    +    expect(container).toMatchObject({ x: 120, width: 240 });
    +    expect(child).toMatchObject({ x: 120 });
    +    expect(containerMeta).toMatchObject({
    +      contentLeft: 100,
    +      contentRight: 140,
    +      verticalCorridors: [80, 160],
    +    });
    +  });
    +
    +  it('precomputes boundary and midpoint corridors for sparse rows and columns', () => {
    +    const data = layout([
    +      leaf('top-left', 40, 20, { row: 1, column: 1 }),
    +      leaf('bottom-right', 60, 30, { row: 100, column: 50 }),
    +    ]);
    +
    +    const result = runGridLayoutCore(data);
    +    const topLeft = result.itemMeta.get('top-left');
    +    const bottomRight = result.itemMeta.get('bottom-right');
    +
    +    expect(topLeft).toMatchObject({
    +      leftCorridorX: -25,
    +      rightCorridorX: 65,
    +      topCorridorY: -25,
    +      bottomCorridorY: 45,
    +    });
    +    expect(bottomRight).toMatchObject({
    +      leftCorridorX: 65,
    +      rightCorridorX: 175,
    +      topCorridorY: 45,
    +      bottomCorridorY: 125,
    +    });
    +  });
    +
    +  it.each([
    +    {
    +      name: 'invalid coordinates',
    +      code: 'GRID_INVALID_COORDINATE',
    +      build: () =>
    +        layout([
    +          group('g', 'Group'),
    +          leaf('member', 60, 30, { row: 1, column: 1 }, 'g'),
    +          leaf('bad', 40, 20, { row: 0, column: 1 }),
    +        ]),
    +    },
    +    {
    +      name: 'stack alignment conflicts',
    +      code: 'GRID_CELL_ALIGNMENT_CONFLICT',
    +      build: () =>
    +        layout([
    +          leaf('stack-top', 40, 20, { row: 1, column: 1, verticalAlign: 'top' }),
    +          leaf('stack-bottom', 40, 20, { row: 1, column: 1, verticalAlign: 'bottom' }),
    +          leaf('peer', 80, 80, { row: 1, column: 2 }),
    +        ]),
    +    },
    +  ])('reports $name before mutating any geometry', ({ build, code }) => {
    +    const data = build();
    +    const before = geometrySnapshot(data.nodes);
    +
    +    let thrown: unknown;
    +    try {
    +      runGridLayoutCore(data);
    +    } catch (error) {
    +      thrown = error;
    +    }
    +
    +    expect(thrown).toMatchObject({ code });
    +    expect(geometrySnapshot(data.nodes)).toEqual(before);
    +  });
    +
    +  it.each([
    +    { id: 'zero-width', width: 0, height: 20 },
    +    { id: 'zero-height', width: 40, height: 0 },
    +  ])('reports the positive-size requirement for $id nodes', ({ id, width, height }) => {
    +    const data = layout([leaf(id, width, height)]);
    +
    +    expect(() => runGridLayoutCore(data)).toThrow(
    +      `GRID_MISSING_MEASUREMENT: Node "${id}" must have width and height greater than 0 (width: ${width}, height: ${height})`
    +    );
    +  });
    +
    +  it('lays out 15,000 nested groups without recursion overflow', () => {
    +    const depth = 15_000;
    +    const nodes: Node[] = [group('g0', 'Group 0')];
    +    for (let index = 1; index < depth; index++) {
    +      nodes.push(
    +        group(`g${index}`, `Group ${index}`, `g${index - 1}`, {
    +          row: 1,
    +          column: 1,
    +        })
    +      );
    +    }
    +    nodes.push(
    +      leaf(
    +        'terminal',
    +        60,
    +        30,
    +        {
    +          row: 1,
    +          column: 1,
    +        },
    +        `g${depth - 1}`
    +      )
    +    );
    +
    +    const data = layout(nodes);
    +    const result = runGridLayoutCore(data);
    +    const terminal = data.nodes.find((node) => node.id === 'terminal');
    +    const rootGroup = data.nodes.find((node) => node.id === 'g0');
    +    const deepestGroup = data.nodes.find((node) => node.id === `g${depth - 1}`);
    +
    +    expect(result.forest.postOrderGroups).toHaveLength(depth);
    +    expect(rootGroup).toBeDefined();
    +    expect(deepestGroup).toBeDefined();
    +    expect(terminal).toBeDefined();
    +    expect(Number.isFinite(rootGroup?.x)).toBe(true);
    +    expect(Number.isFinite(rootGroup?.y)).toBe(true);
    +    expect(Number.isFinite(rootGroup?.width)).toBe(true);
    +    expect(Number.isFinite(rootGroup?.height)).toBe(true);
    +    expect(Number.isFinite(deepestGroup?.x)).toBe(true);
    +    expect(Number.isFinite(deepestGroup?.y)).toBe(true);
    +    expect(Number.isFinite(terminal?.x)).toBe(true);
    +    expect(Number.isFinite(terminal?.y)).toBe(true);
    +  });
    +});
    diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/grid/layoutCore.ts b/packages/mermaid/src/rendering-util/layout-algorithms/grid/layoutCore.ts
    new file mode 100644
    index 00000000000..dfe3dbb06a3
    --- /dev/null
    +++ b/packages/mermaid/src/rendering-util/layout-algorithms/grid/layoutCore.ts
    @@ -0,0 +1,561 @@
    +import type { LayoutData, Node } from '../../types.js';
    +import { positionGridEdgeLabels } from './edgeLabels.js';
    +import { buildGridForest } from './groups.js';
    +import { buildGridLabelRequirements, deriveGridContainerLabelGaps } from './labelSpacing.js';
    +import {
    +  buildGridSourceOrder,
    +  readGridConfig,
    +  resolveGridPlacements,
    +  validateGridPlacementMap,
    +} from './placement.js';
    +import { routeGridEdges, type GridRoutingTestOptions } from './router.js';
    +import type { GridRoutingInstrumentation } from './routerInstrumentation.js';
    +import {
    +  GRID_DEFAULTS,
    +  ROOT_CONTAINER_ID,
    +  type GridContainerId,
    +  type GridContainerLayoutMeta,
    +  type GridItemLayoutMeta,
    +  type GridForest,
    +  type GridLayoutResult,
    +  type GridLayoutData,
    +  gridError,
    +  isEdgeLabelNode,
    +  isFinitePositiveNumber,
    +} from './types.js';
    +
    +/*
    + * Computes grid geometry in two coordinate systems:
    + *
    + * 1. Each container lays out its direct children in local coordinates, children-first.
    + * 2. A top-down pass translates those local results into absolute diagram coordinates.
    + *
    + * The public entry point runs both phases on a clone and commits only after the complete geometry
    + * succeeds, so invalid input cannot leave the caller's graph partially positioned.
    + */
    +const GROUP_ROUTING_GUTTER = 24;
    +const GROUP_ROUTING_CLEARANCE = 20;
    +
    +function titleBandFor(node: Node, titleGap: number): number {
    +  if (!node.label || !node.labelBBox || !(node.labelBBox.height > 0)) {
    +    return 0;
    +  }
    +  return node.labelBBox.height + titleGap;
    +}
    +
    +function ensureMeasuredNode(node: Node): void {
    +  if (!isFinitePositiveNumber(node.width) || !isFinitePositiveNumber(node.height)) {
    +    throw gridError(
    +      'GRID_MISSING_MEASUREMENT',
    +      `Node "${node.id}" must have width and height greater than 0 (width: ${node.width}, height: ${node.height})`,
    +      {
    +        nodeId: node.id,
    +        width: node.width,
    +        height: node.height,
    +      }
    +    );
    +  }
    +}
    +
    +function measureChild(node: Node): { width: number; height: number } {
    +  ensureMeasuredNode(node);
    +  return { width: node.width!, height: node.height! };
    +}
    +
    +function layoutContainer(
    +  containerId: GridContainerId,
    +  result: GridLayoutResult,
    +  containers: Map,
    +  itemMeta: Map,
    +  metrics?: GridRoutingInstrumentation
    +): void {
    +  const { forest, config, sourceOrder } = result;
    +  const group = containerId === ROOT_CONTAINER_ID ? undefined : forest.groupById.get(containerId);
    +  const padding = group ? Math.max(group.padding ?? 0, config.containerPadding) : 0;
    +  const titleBand = group ? titleBandFor(group, config.titleGap) : 0;
    +  const routingGutter = group ? GROUP_ROUTING_GUTTER : 0;
    +  const contentTop = group ? padding + titleBand + routingGutter : 0;
    +
    +  const directItems = (forest.childrenByParent.get(containerId) ?? []).filter(
    +    (node) => !isEdgeLabelNode(node)
    +  );
    +
    +  const { cells } = resolveGridPlacements(directItems, sourceOrder, config);
    +  const cellEntries = [...cells.values()].sort((a, b) => a.row - b.row || a.column - b.column);
    +
    +  // A cell may contain an authored vertical stack. Columns and rows are sized from the largest
    +  // complete stack in that track, which keeps neighboring cells aligned.
    +  const columnWidths = new Map();
    +  const rowHeights = new Map();
    +  const rows = new Set();
    +  const columns = new Set();
    +
    +  for (const cell of cellEntries) {
    +    let stackWidth = 0;
    +    let stackHeight = 0;
    +    for (const placement of cell.items) {
    +      const size = measureChild(placement.item);
    +      stackWidth = Math.max(stackWidth, size.width);
    +      stackHeight += size.height;
    +    }
    +    if (cell.items.length > 1) {
    +      stackHeight += config.cellGap * (cell.items.length - 1);
    +    }
    +    cell.width = stackWidth;
    +    cell.height = stackHeight;
    +    rows.add(cell.row);
    +    columns.add(cell.column);
    +    columnWidths.set(cell.column, Math.max(columnWidths.get(cell.column) ?? 0, stackWidth));
    +    rowHeights.set(cell.row, Math.max(rowHeights.get(cell.row) ?? 0, stackHeight));
    +  }
    +
    +  const sortedRows = [...rows].sort((a, b) => a - b);
    +  const sortedColumns = [...columns].sort((a, b) => a - b);
    +  const labelGaps = deriveGridContainerLabelGaps(
    +    result.labelRequirements.byContainer.get(containerId) ?? [],
    +    cells,
    +    rowHeights,
    +    columnWidths,
    +    sortedRows,
    +    sortedColumns,
    +    config.rowGap,
    +    config.columnGap,
    +    metrics
    +  );
    +  const rowGapAfter = (row: number) => Math.max(config.rowGap, labelGaps.rowGapAfter.get(row) ?? 0);
    +  const columnGapAfter = (column: number) =>
    +    Math.max(config.columnGap, labelGaps.columnGapAfter.get(column) ?? 0);
    +  if (metrics) {
    +    for (const gap of labelGaps.rowGapAfter.values()) {
    +      if (gap > config.rowGap) {
    +        metrics.labelSpacingBoundariesExpanded++;
    +        metrics.labelSpacingPixelsAdded += gap - config.rowGap;
    +      }
    +    }
    +    for (const gap of labelGaps.columnGapAfter.values()) {
    +      if (gap > config.columnGap) {
    +        metrics.labelSpacingBoundariesExpanded++;
    +        metrics.labelSpacingPixelsAdded += gap - config.columnGap;
    +      }
    +    }
    +  }
    +
    +  const contentWidth = sortedColumns.reduce(
    +    (total, column, index) =>
    +      total +
    +      (columnWidths.get(column) ?? 0) +
    +      (index < sortedColumns.length - 1 ? columnGapAfter(column) : 0),
    +    0
    +  );
    +  // A title sets the minimum group width. If it is wider than the content and its insets,
    +  // center the occupied tracks so the extra horizontal space is split evenly on both sides.
    +  const width = group
    +    ? Math.max(
    +        contentWidth + (padding + routingGutter) * 2,
    +        (group.labelBBox?.width ?? 0) + padding * 2
    +      )
    +    : contentWidth;
    +  const contentLeft = group ? (width - contentWidth) / 2 : 0;
    +
    +  const columnOrigins = new Map();
    +  let xCursor = contentLeft;
    +  for (const [index, column] of sortedColumns.entries()) {
    +    columnOrigins.set(column, xCursor);
    +    xCursor +=
    +      (columnWidths.get(column) ?? 0) +
    +      (index < sortedColumns.length - 1 ? columnGapAfter(column) : 0);
    +  }
    +
    +  const rowOrigins = new Map();
    +  let yCursor = contentTop;
    +  for (const [index, row] of sortedRows.entries()) {
    +    rowOrigins.set(row, yCursor);
    +    yCursor += (rowHeights.get(row) ?? 0) + (index < sortedRows.length - 1 ? rowGapAfter(row) : 0);
    +  }
    +  const gridHeight = sortedRows.length ? yCursor - contentTop : 0;
    +
    +  const outerLeftCorridor = group
    +    ? contentLeft - GROUP_ROUTING_CLEARANCE
    +    : -Math.max(config.columnGap / 2, GRID_DEFAULTS.containerPadding);
    +  const outerRightCorridor = group
    +    ? contentLeft + contentWidth + GROUP_ROUTING_CLEARANCE
    +    : contentLeft + contentWidth + Math.max(config.columnGap / 2, GRID_DEFAULTS.containerPadding);
    +  const outerTopCorridor = group
    +    ? contentTop - GROUP_ROUTING_CLEARANCE
    +    : -Math.max(config.rowGap / 2, GRID_DEFAULTS.containerPadding);
    +  const outerBottomCorridor = group
    +    ? contentTop + gridHeight + GROUP_ROUTING_CLEARANCE
    +    : contentTop + gridHeight + Math.max(config.rowGap / 2, GRID_DEFAULTS.containerPadding);
    +
    +  // Record the empty bands between tracks while geometry is available. The routing layer consumes
    +  // these coordinates later without needing to reconstruct placement decisions.
    +  const verticalCorridors = new Set([outerLeftCorridor, outerRightCorridor]);
    +  const horizontalCorridors = new Set([outerTopCorridor, outerBottomCorridor]);
    +  const leftCorridorByColumn = new Map();
    +  const rightCorridorByColumn = new Map();
    +  const topCorridorByRow = new Map();
    +  const bottomCorridorByRow = new Map();
    +
    +  if (sortedColumns.length > 0) {
    +    leftCorridorByColumn.set(sortedColumns[0], outerLeftCorridor);
    +    rightCorridorByColumn.set(sortedColumns.at(-1)!, outerRightCorridor);
    +  }
    +  for (let index = 0; index < sortedColumns.length - 1; index++) {
    +    const current = sortedColumns[index];
    +    const next = sortedColumns[index + 1];
    +    const corridor =
    +      (columnOrigins.get(current)! + (columnWidths.get(current) ?? 0) + columnOrigins.get(next)!) /
    +      2;
    +    verticalCorridors.add(corridor);
    +    rightCorridorByColumn.set(current, corridor);
    +    leftCorridorByColumn.set(next, corridor);
    +  }
    +  if (sortedRows.length > 0) {
    +    topCorridorByRow.set(sortedRows[0], outerTopCorridor);
    +    bottomCorridorByRow.set(sortedRows.at(-1)!, outerBottomCorridor);
    +  }
    +  for (let index = 0; index < sortedRows.length - 1; index++) {
    +    const current = sortedRows[index];
    +    const next = sortedRows[index + 1];
    +    const corridor =
    +      (rowOrigins.get(current)! + (rowHeights.get(current) ?? 0) + rowOrigins.get(next)!) / 2;
    +    horizontalCorridors.add(corridor);
    +    bottomCorridorByRow.set(current, corridor);
    +    topCorridorByRow.set(next, corridor);
    +  }
    +
    +  for (const cell of cellEntries) {
    +    const cellLeft = columnOrigins.get(cell.column) ?? contentLeft;
    +    const cellTop = rowOrigins.get(cell.row) ?? contentTop;
    +    const cellWidth = columnWidths.get(cell.column) ?? cell.width;
    +    const cellHeight = rowHeights.get(cell.row) ?? cell.height;
    +    const verticalFactor =
    +      cell.verticalAlign === 'top' ? 0 : cell.verticalAlign === 'bottom' ? 1 : 0.5;
    +    const stackTop = cellTop + (cellHeight - cell.height) * verticalFactor;
    +
    +    let itemTop = stackTop;
    +    for (const [itemIndex, placement] of cell.items.entries()) {
    +      const { width, height } = measureChild(placement.item);
    +      const horizontalFactor =
    +        placement.horizontalAlign === 'left' ? 0 : placement.horizontalAlign === 'right' ? 1 : 0.5;
    +      const itemLeft = cellLeft + (cellWidth - width) * horizontalFactor;
    +      placement.item.x = itemLeft + width / 2;
    +      placement.item.y = itemTop + height / 2;
    +      const topCorridorY =
    +        itemIndex > 0
    +          ? itemTop - config.cellGap / 2
    +          : (topCorridorByRow.get(cell.row) ?? outerTopCorridor);
    +      const bottomCorridorY =
    +        itemIndex < cell.items.length - 1
    +          ? itemTop + height + config.cellGap / 2
    +          : (bottomCorridorByRow.get(cell.row) ?? outerBottomCorridor);
    +      if (itemIndex > 0) {
    +        horizontalCorridors.add(topCorridorY);
    +      }
    +      if (itemIndex < cell.items.length - 1) {
    +        horizontalCorridors.add(bottomCorridorY);
    +      }
    +
    +      itemMeta.set(placement.item.id, {
    +        containerId,
    +        row: cell.row,
    +        column: cell.column,
    +        cellLeft,
    +        cellTop,
    +        cellWidth,
    +        cellHeight,
    +        leftCorridorX: leftCorridorByColumn.get(cell.column) ?? outerLeftCorridor,
    +        rightCorridorX: rightCorridorByColumn.get(cell.column) ?? outerRightCorridor,
    +        topCorridorY,
    +        bottomCorridorY,
    +      });
    +
    +      itemTop += height + config.cellGap;
    +    }
    +  }
    +
    +  const height = group ? gridHeight + titleBand + (padding + routingGutter) * 2 : gridHeight;
    +
    +  containers.set(containerId, {
    +    id: containerId,
    +    left: 0,
    +    top: 0,
    +    width,
    +    height,
    +    contentLeft,
    +    contentTop,
    +    contentRight: group ? contentLeft + contentWidth : contentWidth,
    +    contentBottom: group ? contentTop + gridHeight : gridHeight,
    +    verticalCorridors: [...verticalCorridors].sort((a, b) => a - b),
    +    horizontalCorridors: [...horizontalCorridors].sort((a, b) => a - b),
    +  });
    +
    +  if (group) {
    +    group.width = width;
    +    group.height = height;
    +    group.x = width / 2;
    +    group.y = height / 2;
    +    if (titleBand > 0) {
    +      group.groupTitleRect = {
    +        left: 0,
    +        right: width,
    +        top: 0,
    +        bottom: padding + titleBand,
    +      };
    +    } else {
    +      delete group.groupTitleRect;
    +    }
    +  }
    +}
    +
    +function translateRect(
    +  rect: NonNullable,
    +  dx: number,
    +  dy: number
    +): NonNullable {
    +  return {
    +    left: rect.left + dx,
    +    right: rect.right + dx,
    +    top: rect.top + dy,
    +    bottom: rect.bottom + dy,
    +  };
    +}
    +
    +function materializeAbsoluteGeometry(result: GridLayoutResult): void {
    +  const { forest, containers, itemMeta } = result;
    +
    +  // Container layout is local to its parent. Walk top-down with accumulated offsets so nodes,
    +  // title rectangles, cell metadata, and routing corridors all move as one coordinate system.
    +  interface ShiftFrame {
    +    containerId: GridContainerId;
    +    offsetX: number;
    +    offsetY: number;
    +    children: Node[];
    +    nextChildIndex: number;
    +    entered: boolean;
    +  }
    +
    +  const stack: ShiftFrame[] = [
    +    {
    +      containerId: ROOT_CONTAINER_ID,
    +      offsetX: 0,
    +      offsetY: 0,
    +      children: forest.childrenByParent.get(ROOT_CONTAINER_ID) ?? [],
    +      nextChildIndex: 0,
    +      entered: false,
    +    },
    +  ];
    +
    +  while (stack.length > 0) {
    +    const frame = stack[stack.length - 1];
    +
    +    if (!frame.entered) {
    +      frame.entered = true;
    +      const container = containers.get(frame.containerId);
    +      if (container) {
    +        container.left += frame.offsetX;
    +        container.top += frame.offsetY;
    +        container.contentLeft += frame.offsetX;
    +        container.contentRight += frame.offsetX;
    +        container.contentTop += frame.offsetY;
    +        container.contentBottom += frame.offsetY;
    +        container.verticalCorridors = container.verticalCorridors.map(
    +          (value) => value + frame.offsetX
    +        );
    +        container.horizontalCorridors = container.horizontalCorridors.map(
    +          (value) => value + frame.offsetY
    +        );
    +      }
    +    }
    +
    +    const child = frame.children[frame.nextChildIndex];
    +    if (!child) {
    +      stack.pop();
    +      continue;
    +    }
    +    frame.nextChildIndex += 1;
    +
    +    if (isEdgeLabelNode(child)) {
    +      continue;
    +    }
    +
    +    const meta = itemMeta.get(child.id);
    +    if (meta) {
    +      meta.cellLeft += frame.offsetX;
    +      meta.cellTop += frame.offsetY;
    +      meta.leftCorridorX += frame.offsetX;
    +      meta.rightCorridorX += frame.offsetX;
    +      meta.topCorridorY += frame.offsetY;
    +      meta.bottomCorridorY += frame.offsetY;
    +    }
    +
    +    child.x = (child.x ?? 0) + frame.offsetX;
    +    child.y = (child.y ?? 0) + frame.offsetY;
    +
    +    if (!child.isGroup) {
    +      continue;
    +    }
    +
    +    if (child.groupTitleRect) {
    +      child.groupTitleRect = translateRect(
    +        child.groupTitleRect,
    +        child.x - (child.width ?? 0) / 2,
    +        child.y - (child.height ?? 0) / 2
    +      );
    +    }
    +
    +    stack.push({
    +      containerId: child.id,
    +      offsetX: child.x - (child.width ?? 0) / 2,
    +      offsetY: child.y - (child.height ?? 0) / 2,
    +      children: forest.childrenByParent.get(child.id) ?? [],
    +      nextChildIndex: 0,
    +      entered: false,
    +    });
    +  }
    +}
    +
    +function commitGridGeometry(source: LayoutData, target: LayoutData): void {
    +  // Preserve caller-owned node and edge identities. Downstream renderers may already hold
    +  // references to these objects, so only calculated geometry is copied from the working graph.
    +  const sourceNodeById = new Map(source.nodes.map((node) => [node.id, node]));
    +  for (const targetNode of target.nodes) {
    +    const sourceNode = sourceNodeById.get(targetNode.id);
    +    if (!sourceNode) {
    +      continue;
    +    }
    +    targetNode.x = sourceNode.x;
    +    targetNode.y = sourceNode.y;
    +    targetNode.width = sourceNode.width;
    +    targetNode.height = sourceNode.height;
    +    if (sourceNode.groupTitleRect) {
    +      targetNode.groupTitleRect = { ...sourceNode.groupTitleRect };
    +    } else {
    +      delete targetNode.groupTitleRect;
    +    }
    +  }
    +
    +  const sourceEdgeById = new Map(source.edges.map((edge) => [edge.id, edge]));
    +  for (const targetEdge of target.edges) {
    +    const sourceEdge = sourceEdgeById.get(targetEdge.id);
    +    if (!sourceEdge) {
    +      continue;
    +    }
    +    targetEdge.points = sourceEdge.points?.map((point) => ({ ...point }));
    +    targetEdge.curve = sourceEdge.curve;
    +    targetEdge.cornerRadius = sourceEdge.cornerRadius;
    +    targetEdge.portClipping = 'outline-orthogonal';
    +    targetEdge.skipCornerFix = sourceEdge.curve === 'linear';
    +  }
    +}
    +
    +function cloneGridLayoutData(data: GridLayoutData): GridLayoutData {
    +  return {
    +    ...data,
    +    nodes: data.nodes.map((node) => ({
    +      ...node,
    +      labelBBox: node.labelBBox ? { ...node.labelBBox } : node.labelBBox,
    +      groupTitleRect: node.groupTitleRect ? { ...node.groupTitleRect } : node.groupTitleRect,
    +    })),
    +    edges: data.edges.map((edge) => ({
    +      ...edge,
    +      points: edge.points?.map((point) => ({ ...point })),
    +    })),
    +  };
    +}
    +
    +function rebindGridForest(forest: GridForest, nodes: Node[]): GridForest {
    +  // The calculation forest points at cloned nodes. Rebuild each collection against the committed
    +  // caller-owned nodes before exposing the result to later routing and rendering phases.
    +  const nodeById = new Map(nodes.map((node) => [node.id, node]));
    +  const originalNode = (node: Node): Node => {
    +    const original = nodeById.get(node.id);
    +    if (!original) {
    +      throw new Error(`Missing committed grid node "${node.id}"`);
    +    }
    +    return original;
    +  };
    +  const originalGroup = (group: Node & { isGroup: true }): Node & { isGroup: true } => {
    +    const original = originalNode(group);
    +    if (!original.isGroup) {
    +      throw new Error(`Committed grid node "${group.id}" is no longer a group`);
    +    }
    +    return original as Node & { isGroup: true };
    +  };
    +
    +  return {
    +    nodeById,
    +    groupById: new Map(
    +      [...forest.groupById].map(([groupId, group]) => [groupId, originalGroup(group)])
    +    ),
    +    childrenByParent: new Map(
    +      [...forest.childrenByParent].map(([parentId, children]) => [
    +        parentId,
    +        children.map(originalNode),
    +      ])
    +    ),
    +    rootChildren: forest.rootChildren.map(originalNode),
    +    postOrderGroups: forest.postOrderGroups.map(originalGroup),
    +    helperNodeIds: new Set(forest.helperNodeIds),
    +  };
    +}
    +
    +function runGridLayoutCoreInPlace(
    +  data: GridLayoutData,
    +  options: GridLayoutCoreInternalOptions
    +): GridLayoutResult {
    +  const forest = buildGridForest(data.nodes);
    +  const config = readGridConfig(data);
    +  const sourceOrder = buildGridSourceOrder(data.nodes.filter((node) => !isEdgeLabelNode(node)));
    +  validateGridPlacementMap(
    +    data.nodes.filter((node) => !isEdgeLabelNode(node)),
    +    config
    +  );
    +
    +  const result: GridLayoutResult = {
    +    forest,
    +    config,
    +    containers: new Map(),
    +    itemMeta: new Map(),
    +    sourceOrder,
    +    labelRequirements: buildGridLabelRequirements(data, forest, options.metrics),
    +  };
    +
    +  // Post-order is the key sizing invariant: a group becomes a measured child only after all of its
    +  // descendants have established the group's final width and height.
    +  for (const group of forest.postOrderGroups) {
    +    layoutContainer(group.id, result, result.containers, result.itemMeta, options.metrics);
    +  }
    +  layoutContainer(ROOT_CONTAINER_ID, result, result.containers, result.itemMeta, options.metrics);
    +  materializeAbsoluteGeometry(result);
    +  // Routing consumes absolute node bounds and corridor coordinates, so it must run after the
    +  // children-first sizing and top-down translation phases have both completed.
    +  routeGridEdges(data, result, options.metrics, options.routing);
    +  // Labels consume final routes and may transactionally reroute them, so they are the last layout
    +  // phase before the working copy is committed to the render model.
    +  positionGridEdgeLabels(data, undefined, options.metrics);
    +  return result;
    +}
    +
    +/** @internal */
    +export interface GridLayoutCoreInternalOptions {
    +  metrics?: GridRoutingInstrumentation;
    +  routing?: GridRoutingTestOptions;
    +}
    +
    +export function runGridLayoutCore(
    +  data4Layout: LayoutData,
    +  options: GridLayoutCoreInternalOptions = {}
    +): GridLayoutResult {
    +  const data = data4Layout as GridLayoutData;
    +
    +  // Routing and label placement are transactional: a failed recovery must not leave partial
    +  // coordinates on the shared render model. Commit only the geometry from a complete run.
    +  const working = cloneGridLayoutData(data);
    +  const result = runGridLayoutCoreInPlace(working, options);
    +  commitGridGeometry(working, data);
    +  return {
    +    ...result,
    +    forest: rebindGridForest(result.forest, data.nodes),
    +  };
    +}
    diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/grid/performance.spec.ts b/packages/mermaid/src/rendering-util/layout-algorithms/grid/performance.spec.ts
    new file mode 100644
    index 00000000000..8d01c536188
    --- /dev/null
    +++ b/packages/mermaid/src/rendering-util/layout-algorithms/grid/performance.spec.ts
    @@ -0,0 +1,464 @@
    +import { describe, expect, it } from 'vitest';
    +import type { Edge, LayoutData, Node } from '../../types.js';
    +import {
    +  createGridEdgeLabelInstrumentation,
    +  positionGridEdgeLabels,
    +  prepareGridLayout,
    +} from './edgeLabels.js';
    +import { runGridLayoutCore } from './layoutCore.js';
    +import { createGridRoutingInstrumentation } from './routerInstrumentation.js';
    +
    +function node(id: string, metadata?: Record): Node {
    +  return {
    +    id,
    +    isGroup: false,
    +    shape: 'rect',
    +    width: 80,
    +    height: 40,
    +    metadata,
    +  } as Node;
    +}
    +
    +function edge(id: string, start: string, end: string): Edge {
    +  return {
    +    id,
    +    start,
    +    end,
    +    arrowTypeStart: 'none',
    +    arrowTypeEnd: 'arrow_point',
    +  } as Edge;
    +}
    +
    +function signature(layout: LayoutData) {
    +  return JSON.stringify({
    +    nodes: layout.nodes.map((item) => ({
    +      id: item.id,
    +      x: item.x,
    +      y: item.y,
    +      width: item.width,
    +      height: item.height,
    +    })),
    +    edges: layout.edges.map((item) => ({
    +      id: item.id,
    +      points: item.points,
    +    })),
    +  });
    +}
    +
    +function representativeLayout(): LayoutData {
    +  return {
    +    nodes: [
    +      node('A', { row: 1, column: 1 }),
    +      node('B', { row: 2, column: 1 }),
    +      node('C', { row: 2, column: 2 }),
    +      node('D', { row: 3, column: 2 }),
    +      node('E', { row: 4, column: 1 }),
    +    ],
    +    edges: [edge('e1', 'A', 'B'), edge('e2', 'B', 'C'), edge('e3', 'C', 'D'), edge('e4', 'B', 'E')],
    +    config: {
    +      layout: 'grid',
    +      grid: { rowGap: 32, columnGap: 36 },
    +    } as LayoutData['config'],
    +  };
    +}
    +
    +function representativeParallelLayout(): LayoutData {
    +  return {
    +    nodes: [node('A', { row: 1, column: 1 }), node('B', { row: 1, column: 2 })],
    +    edges: [
    +      edge('loop-1', 'A', 'A'),
    +      edge('loop-2', 'A', 'A'),
    +      edge('e1', 'A', 'B'),
    +      edge('e2', 'A', 'B'),
    +      edge('e3', 'A', 'B'),
    +      edge('e4', 'B', 'A'),
    +    ],
    +    config: {
    +      layout: 'grid',
    +      grid: { columnGap: 60 },
    +    } as LayoutData['config'],
    +  };
    +}
    +
    +function largeSyntheticLayout(): LayoutData {
    +  const nodes: Node[] = [];
    +  const edges: Edge[] = [];
    +  for (let index = 0; index < 1000; index++) {
    +    nodes.push(node(`N${index}`));
    +  }
    +  for (let index = 0; index < 500; index++) {
    +    edges.push(edge(`E${index}`, `N${index}`, `N${index + 500}`));
    +  }
    +  return {
    +    nodes,
    +    edges,
    +    config: {
    +      layout: 'grid',
    +      grid: { columns: 32 },
    +    } as LayoutData['config'],
    +  };
    +}
    +
    +function largeSearchLayout(): LayoutData {
    +  const nodes: Node[] = [];
    +  const edges: Edge[] = [];
    +  for (let index = 0; index < 200; index++) {
    +    const row = index * 2 + 1;
    +    nodes.push(
    +      node(`L${index}`, { row, column: 1 }),
    +      node(`R${index}`, { row, column: 3 }),
    +      node(`B${index}`, { row: row + 1, column: 2 })
    +    );
    +    edges.push(edge(`E${index}`, `L${index}`, `R${index}`));
    +  }
    +  return {
    +    nodes,
    +    edges,
    +    config: {
    +      layout: 'grid',
    +      grid: { rowGap: 32, columnGap: 36 },
    +    } as LayoutData['config'],
    +  };
    +}
    +
    +function labelledAdjacencyLayout(edgeCount = 50): LayoutData {
    +  const nodes: Node[] = [];
    +  const edges: Edge[] = [];
    +  for (let index = 0; index < edgeCount; index++) {
    +    const firstColumn = index * 2 + 1;
    +    const sourceId = `LS${index}`;
    +    const targetId = `LT${index}`;
    +    nodes.push(
    +      node(sourceId, { row: 1, column: firstColumn }),
    +      node(targetId, { row: 1, column: firstColumn + 1 })
    +    );
    +    edges.push({ ...edge(`LE${index}`, sourceId, targetId), label: `label-${index}` } as Edge);
    +  }
    +  return {
    +    nodes,
    +    edges,
    +    config: {
    +      layout: 'grid',
    +      grid: { columnGap: 20 },
    +    } as LayoutData['config'],
    +  };
    +}
    +
    +function labelledDeterminismLayout(): LayoutData {
    +  const layout: LayoutData = {
    +    nodes: [node('A', { row: 1, column: 1 }), node('B', { row: 1, column: 2 })],
    +    edges: [
    +      { ...edge('labelled', 'A', 'B'), label: 'wide label' } as Edge,
    +      { ...edge('loop', 'A', 'A'), label: 'loop label' } as Edge,
    +    ],
    +    config: {
    +      layout: 'grid',
    +    } as LayoutData['config'],
    +  };
    +  prepareGridLayout(layout);
    +  for (const helper of layout.nodes.filter(
    +    (item) => (item as Node & { isEdgeLabel?: boolean }).isEdgeLabel
    +  )) {
    +    helper.width = 90;
    +    helper.height = 20;
    +  }
    +  return layout;
    +}
    +
    +function manualNode(id: string, x: number, y: number, width = 40, height = 40): Node {
    +  return {
    +    id,
    +    x,
    +    y,
    +    width,
    +    height,
    +    isGroup: false,
    +    shape: 'rect',
    +  } as Node;
    +}
    +
    +function manualEdge(
    +  id: string,
    +  start: string,
    +  end: string,
    +  points: { x: number; y: number }[],
    +  label?: string
    +): Edge {
    +  return {
    +    id,
    +    start,
    +    end,
    +    points,
    +    label,
    +    arrowTypeStart: 'none',
    +    arrowTypeEnd: 'arrow_point',
    +    curve: 'linear',
    +    type: 'arrow_point',
    +  } as Edge;
    +}
    +
    +function denseLabelRoutingLayout(): LayoutData {
    +  const horizontalLabels = 12;
    +  const verticalCrossings = 18;
    +  const nodes: Node[] = [];
    +  const edges: Edge[] = [];
    +  const firstRowY = 120;
    +  const rowStep = 56;
    +  const leftX = 60;
    +  const rightX = 980;
    +  const topY = 40;
    +  const bottomY = firstRowY + (horizontalLabels - 1) * rowStep + 80;
    +  const firstColumnX = 190;
    +  const columnStep = 36;
    +
    +  for (let row = 0; row < horizontalLabels; row++) {
    +    const y = firstRowY + row * rowStep;
    +    nodes.push(
    +      manualNode(`h-left-${row}`, leftX, y, 20, 20),
    +      manualNode(`h-right-${row}`, rightX, y, 20, 20)
    +    );
    +    edges.push(
    +      manualEdge(
    +        `h-${row}`,
    +        `h-left-${row}`,
    +        `h-right-${row}`,
    +        [
    +          { x: leftX + 20, y },
    +          { x: rightX - 20, y },
    +        ],
    +        `label-${row}`
    +      )
    +    );
    +  }
    +
    +  for (let column = 0; column < verticalCrossings; column++) {
    +    const x = firstColumnX + column * columnStep;
    +    nodes.push(
    +      manualNode(`v-top-${column}`, x, topY, 20, 20),
    +      manualNode(`v-bottom-${column}`, x, bottomY, 20, 20)
    +    );
    +    edges.push(
    +      manualEdge(`v-${column}`, `v-top-${column}`, `v-bottom-${column}`, [
    +        { x, y: topY + 20 },
    +        { x, y: bottomY - 20 },
    +      ])
    +    );
    +  }
    +
    +  return {
    +    nodes,
    +    edges,
    +    config: {
    +      layout: 'grid',
    +    } as LayoutData['config'],
    +  };
    +}
    +
    +function fullSpanFallbackLayout(): LayoutData {
    +  return {
    +    nodes: [
    +      manualNode('start', 40, 150, 20, 20),
    +      manualNode('end', 460, 150, 20, 20),
    +      manualNode('blocker', 250, 150, 80, 80),
    +    ],
    +    edges: [
    +      manualEdge(
    +        'wide-label',
    +        'start',
    +        'end',
    +        [
    +          { x: 50, y: 150 },
    +          { x: 450, y: 150 },
    +        ],
    +        'wide label'
    +      ),
    +    ],
    +    config: {
    +      layout: 'grid',
    +    } as LayoutData['config'],
    +  };
    +}
    +
    +describe('grid determinism and performance', () => {
    +  it('produces byte-equivalent geometry across repeated runs', () => {
    +    const baseline = representativeLayout();
    +    runGridLayoutCore(baseline);
    +    const expected = signature(baseline);
    +
    +    for (let index = 0; index < 100; index++) {
    +      const next = structuredClone(representativeLayout());
    +      runGridLayoutCore(next);
    +      expect(signature(next)).toBe(expected);
    +    }
    +  });
    +
    +  it('keeps parallel/reverse route arrays byte-equivalent across 100 runs', () => {
    +    const baseline = representativeParallelLayout();
    +    runGridLayoutCore(baseline);
    +    const expected = signature(baseline);
    +
    +    for (let index = 0; index < 100; index++) {
    +      const next = structuredClone(representativeParallelLayout());
    +      runGridLayoutCore(next);
    +      expect(signature(next)).toBe(expected);
    +    }
    +  });
    +
    +  it('keeps label-aware spacing and self-loop geometry byte-equivalent across 100 runs', () => {
    +    const baseline = labelledDeterminismLayout();
    +    runGridLayoutCore(baseline);
    +    const expected = signature(baseline);
    +
    +    for (let index = 0; index < 100; index++) {
    +      const next = labelledDeterminismLayout();
    +      runGridLayoutCore(next);
    +      expect(signature(next)).toBe(expected);
    +    }
    +  });
    +
    +  // TODO: Move this wall-clock assertion to a benchmark harness when the repository has one.
    +  // CI runs unit tests with V8 coverage instrumentation, which adds substantial routing overhead.
    +  it.skipIf(Boolean(process.env.CI))(
    +    'lays out 1000 nodes and 500 edges within the 1-second budget',
    +    () => {
    +      const layout = largeSyntheticLayout();
    +      const start = performance.now();
    +      runGridLayoutCore(layout);
    +      const elapsed = performance.now() - start;
    +      expect(elapsed).toBeLessThan(1000);
    +    }
    +  );
    +
    +  // Coverage instrumentation can push these large structural tests past Vitest's 5-second default;
    +  // non-coverage runs remain below it.
    +  it('keeps the 1000-node/500-edge case below structural and resource caps', () => {
    +    const layout = largeSyntheticLayout();
    +    const metrics = createGridRoutingInstrumentation();
    +
    +    runGridLayoutCore(layout, { metrics });
    +
    +    // These counters enforce the large-case complexity and resource contract; valid geometry alone
    +    // would not detect a regression to per-edge topology construction or graph search.
    +    expect(metrics.resourceLimitFallbacks).toBe(0);
    +    expect(metrics.fallbackValidationFailures).toBe(0);
    +    expect(metrics.baseTopologyBuilds).toBe(0);
    +    expect(metrics.searches).toBe(0);
    +    expect(metrics.baseVertices).toBeLessThan(50_000);
    +    expect(metrics.baseAdjacencyEntries).toBeLessThan(200_000);
    +    expect(metrics.endpointOverlayVertices).toBeLessThanOrEqual(metrics.endpointOverlayBuilds * 32);
    +    expect(metrics.expandedStates).toBeLessThan(2_000_000);
    +    expect(metrics.estimatedBytes).toBeLessThan(64 * 1024 * 1024);
    +  }, 10_000);
    +
    +  it('keeps a large search-routed case below structural and resource caps', () => {
    +    const layout = largeSearchLayout();
    +    const metrics = createGridRoutingInstrumentation();
    +
    +    runGridLayoutCore(layout, { metrics });
    +
    +    // These counters prove the large fixture exercises bounded search while sharing one topology;
    +    // output geometry alone cannot expose accidental per-edge topology or unbounded search growth.
    +    expect(metrics.resourceLimitFallbacks).toBe(0);
    +    expect(metrics.fallbackValidationFailures).toBe(0);
    +    expect(metrics.baseTopologyBuilds).toBe(1);
    +    expect(metrics.searches).toBeGreaterThan(0);
    +    expect(metrics.searches).toBeLessThanOrEqual(200);
    +    expect(metrics.baseVertices).toBeLessThan(50_000);
    +    expect(metrics.baseAdjacencyEntries).toBeLessThan(200_000);
    +    expect(metrics.endpointOverlayVertices).toBeLessThanOrEqual(metrics.endpointOverlayBuilds * 32);
    +    expect(metrics.expandedStates).toBeLessThan(2_000_000);
    +    expect(metrics.estimatedBytes).toBeLessThan(64 * 1024 * 1024);
    +  }, 10_000);
    +
    +  it('analyzes labelled boundaries once without adding routing passes', () => {
    +    const layout = labelledAdjacencyLayout();
    +    prepareGridLayout(layout);
    +    for (const helper of layout.nodes.filter(
    +      (item) => (item as Node & { isEdgeLabel?: boolean }).isEdgeLabel
    +    )) {
    +      helper.width = 90;
    +      helper.height = 20;
    +    }
    +    const metrics = createGridRoutingInstrumentation();
    +
    +    runGridLayoutCore(layout, { metrics });
    +
    +    expect(metrics.labelSpacingEdgesExamined).toBe(50);
    +    expect(metrics.labelSpacingEligibleEdges).toBe(50);
    +    expect(metrics.labelSpacingBoundariesExpanded).toBe(50);
    +    expect(metrics.labelSpacingPixelsAdded).toBe(50 * 96);
    +    expect(metrics.baseTopologyBuilds).toBe(0);
    +    expect(metrics.searches).toBe(0);
    +  });
    +
    +  it('uses coordinate-compressed label queries for very large coordinate spans', () => {
    +    const layout = fullSpanFallbackLayout();
    +    for (const node of layout.nodes) {
    +      if (typeof node.x === 'number') {
    +        node.x += 1_000_000_000;
    +      }
    +    }
    +    for (const edge of layout.edges) {
    +      edge.points = edge.points?.map((point) => ({ ...point, x: point.x + 1_000_000_000 }));
    +    }
    +    prepareGridLayout(layout);
    +    const labelNode = layout.nodes.find((node) => node.id === layout.edges[0].labelNodeId);
    +    if (labelNode) {
    +      labelNode.width = 320;
    +      labelNode.height = 28;
    +    }
    +
    +    const metrics = createGridEdgeLabelInstrumentation();
    +    positionGridEdgeLabels(layout, metrics);
    +
    +    expect(Number.isFinite(labelNode?.x)).toBe(true);
    +    expect(Number.isFinite(labelNode?.y)).toBe(true);
    +    expect(layout.edges[0].points?.length).toBeGreaterThan(2);
    +    // Coordinate magnitude must not increase index storage or candidate visitation.
    +    expect(metrics.indexCoordinateCount).toBeLessThan(1_000);
    +    expect(metrics.obstacleCandidatesVisited).toBeLessThan(100);
    +    expect(metrics.segmentCandidatesVisited).toBeLessThan(50);
    +    expect(metrics.foreignEdgeLookups).toBeLessThanOrEqual(5);
    +    expect(metrics.indexWorkUnits).toBeGreaterThan(0);
    +    expect(metrics.indexWorkLimitFallbacks).toBe(0);
    +  });
    +
    +  it('indexes dense label routing instead of rescanning all nodes and edges per candidate', () => {
    +    const layout = denseLabelRoutingLayout();
    +    prepareGridLayout(layout);
    +    for (const node of layout.nodes) {
    +      if ((node as Node & { isEdgeLabel?: boolean }).isEdgeLabel) {
    +        node.width = 56;
    +        node.height = 24;
    +      }
    +    }
    +
    +    const metrics = createGridEdgeLabelInstrumentation();
    +    positionGridEdgeLabels(layout, metrics);
    +
    +    expect(
    +      layout.nodes
    +        .filter((node) => (node as Node & { isEdgeLabel?: boolean }).isEdgeLabel)
    +        .every((node) => Number.isFinite(node.x) && Number.isFinite(node.y))
    +    ).toBe(true);
    +    expect(
    +      layout.edges
    +        .filter((item) => item.id.startsWith('v-'))
    +        .some((item) => (item.points?.length ?? 0) > 2)
    +    ).toBe(true);
    +    // Bound the work performed by indexed queries; successful geometry alone would not reveal a
    +    // regression to scanning every node or edge for each placement candidate.
    +    expect(
    +      metrics.segmentRectQueries +
    +        metrics.segmentBandQueries +
    +        metrics.obstacleRectQueries +
    +        metrics.obstacleBandQueries
    +    ).toBeGreaterThan(0);
    +    expect(metrics.obstacleCandidatesVisited).toBeLessThan(1_000);
    +    expect(metrics.segmentCandidatesVisited).toBeLessThan(1_000);
    +    expect(metrics.foreignEdgeLookups).toBeLessThan(100);
    +    expect(metrics.labelPasses).toBeLessThanOrEqual(2);
    +    expect(metrics.indexWorkUnits).toBeGreaterThan(0);
    +    expect(metrics.indexWorkLimitFallbacks).toBe(0);
    +  });
    +});
    diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/grid/placement.spec.ts b/packages/mermaid/src/rendering-util/layout-algorithms/grid/placement.spec.ts
    new file mode 100644
    index 00000000000..05b4fe1cbd8
    --- /dev/null
    +++ b/packages/mermaid/src/rendering-util/layout-algorithms/grid/placement.spec.ts
    @@ -0,0 +1,358 @@
    +import { describe, expect, it, vi } from 'vitest';
    +import { log } from '../../../logger.js';
    +import type { LayoutData, Node } from '../../types.js';
    +import { readGridConfig, resolveGridPlacements, validateGridPlacementMap } from './placement.js';
    +
    +function node(id: string, metadata?: Record, placementId?: string): Node {
    +  return {
    +    id,
    +    placementId,
    +    isGroup: false,
    +    shape: 'rect',
    +    width: 100,
    +    height: 40,
    +    metadata,
    +  } as Node;
    +}
    +
    +function config(overrides: Record = {}): LayoutData['config'] {
    +  return {
    +    grid: overrides,
    +  } as LayoutData['config'];
    +}
    +
    +describe('grid placement', () => {
    +  it('ignores arrays where grid configuration expects keyed records', () => {
    +    const gridConfig = readGridConfig({
    +      nodes: [],
    +      edges: [],
    +      config: config({
    +        placements: [{ row: 1, column: 1 }],
    +      }),
    +    } as LayoutData);
    +
    +    expect(gridConfig.placements.size).toBe(0);
    +  });
    +
    +  it('defaults the grid curve and corner radius and replaces invalid values', () => {
    +    const defaults = readGridConfig({
    +      nodes: [],
    +      edges: [],
    +      config: config(),
    +    } as LayoutData);
    +    expect(defaults).toMatchObject({ curve: 'rounded', edgeCornerRadius: 5 });
    +
    +    const rounded = readGridConfig({
    +      nodes: [],
    +      edges: [],
    +      config: config({ curve: 'rounded', edgeCornerRadius: 12 }),
    +    } as LayoutData);
    +    expect(rounded).toMatchObject({ curve: 'rounded', edgeCornerRadius: 12 });
    +
    +    const invalid = readGridConfig({
    +      nodes: [],
    +      edges: [],
    +      config: config({ curve: 'unknown', edgeCornerRadius: -1 }),
    +    } as LayoutData);
    +    expect(invalid).toMatchObject({ curve: 'rounded', edgeCornerRadius: 5 });
    +  });
    +
    +  it('resolves full, partial, automatic, sparse, and precedence cases deterministically', () => {
    +    const items = [
    +      node('explicit-a', { row: 1, column: 1 }),
    +      node('explicit-b', { row: 100, column: 3 }),
    +      node('stack-top', { row: 2, column: 2, horizontalAlign: 'left', verticalAlign: 'bottom' }),
    +      node('stack-bottom', {
    +        row: 2,
    +        column: 2,
    +        horizontalAlign: 'right',
    +        verticalAlign: 'bottom',
    +      }),
    +      node('row-only', { row: 1 }),
    +      node('column-only', { column: 1 }),
    +      node('auto-a'),
    +      node('auto-b'),
    +      node('map-only'),
    +      node('metadata-wins', { column: 4, horizontalAlign: 'left' }),
    +    ];
    +    const sourceOrder = new Map(items.map((item, index) => [item.id, index]));
    +    const gridConfig = readGridConfig({
    +      nodes: [],
    +      edges: [],
    +      config: config({
    +        columns: 3,
    +        placements: {
    +          'map-only': { row: 3, column: 3 },
    +          'metadata-wins': { row: 4, column: 2, horizontalAlign: 'right' },
    +        },
    +      }),
    +    } as LayoutData);
    +
    +    const { placements, cells } = resolveGridPlacements(items, sourceOrder, gridConfig);
    +    const byId = new Map(placements.map((placement) => [placement.item.id, placement]));
    +
    +    expect(byId.get('explicit-a')).toMatchObject({ row: 1, column: 1 });
    +    expect(byId.get('explicit-b')).toMatchObject({ row: 100, column: 3 });
    +    expect(cells.get('2:2')?.items.map((item) => item.item.id)).toEqual([
    +      'stack-top',
    +      'stack-bottom',
    +    ]);
    +    expect(byId.get('row-only')).toMatchObject({ row: 1, column: 2 });
    +    expect(byId.get('column-only')).toMatchObject({ row: 2, column: 1 });
    +    expect(byId.get('auto-a')).toMatchObject({ row: 1, column: 3 });
    +    expect(byId.get('auto-b')).toMatchObject({ row: 2, column: 3 });
    +    expect(byId.get('map-only')).toMatchObject({ row: 3, column: 3 });
    +    expect(byId.get('metadata-wins')).toMatchObject({
    +      row: 4,
    +      column: 4,
    +      horizontalAlign: 'left',
    +    });
    +  });
    +
    +  it('advances partial-placement cursors past cells occupied by other placement modes', () => {
    +    const items = [
    +      node('explicit', { row: 1, column: 1 }),
    +      node('row-a', { row: 1 }),
    +      node('column-a', { column: 2 }),
    +      node('auto-a'),
    +      node('row-b', { row: 1 }),
    +      node('column-b', { column: 2 }),
    +      node('auto-b'),
    +    ];
    +    const sourceOrder = new Map(items.map((item, index) => [item.id, index]));
    +    const gridConfig = readGridConfig({
    +      nodes: [],
    +      edges: [],
    +      config: config({ columns: 3 }),
    +    } as LayoutData);
    +
    +    const { placements } = resolveGridPlacements(items, sourceOrder, gridConfig);
    +    const byId = new Map(placements.map((placement) => [placement.item.id, placement]));
    +
    +    expect(byId.get('row-a')).toMatchObject({ row: 1, column: 2 });
    +    expect(byId.get('column-a')).toMatchObject({ row: 2, column: 2 });
    +    expect(byId.get('auto-a')).toMatchObject({ row: 1, column: 3 });
    +    expect(byId.get('row-b')).toMatchObject({ row: 1, column: 4 });
    +    expect(byId.get('column-b')).toMatchObject({ row: 3, column: 2 });
    +    expect(byId.get('auto-b')).toMatchObject({ row: 2, column: 1 });
    +  });
    +
    +  it.each([
    +    { field: 'row', value: 0 },
    +    { field: 'row', value: -1 },
    +    { field: 'row', value: 1.5 },
    +    { field: 'row', value: Number.NaN },
    +    { field: 'row', value: '2' },
    +    { field: 'column', value: 0 },
    +    { field: 'column', value: -1 },
    +    { field: 'column', value: 1.5 },
    +    { field: 'column', value: Number.NaN },
    +    { field: 'column', value: '2' },
    +  ] as const)('throws for invalid $field coordinate $value', ({ field, value }) => {
    +    const items = [node('bad', { [field]: value })];
    +    const sourceOrder = new Map([['bad', 0]]);
    +    const gridConfig = readGridConfig({
    +      nodes: [],
    +      edges: [],
    +      config: config(),
    +    } as LayoutData);
    +    expect(() => resolveGridPlacements(items, sourceOrder, gridConfig)).toThrow(
    +      /GRID_INVALID_COORDINATE/
    +    );
    +  });
    +
    +  it('throws for conflicting stack alignment', () => {
    +    const gridConfig = readGridConfig({
    +      nodes: [],
    +      edges: [],
    +      config: config(),
    +    } as LayoutData);
    +    const conflictItems = [
    +      node('one', { row: 1, column: 1, verticalAlign: 'top' }),
    +      node('two', { row: 1, column: 1, verticalAlign: 'bottom' }),
    +    ];
    +    const conflictOrder = new Map(conflictItems.map((item, index) => [item.id, index]));
    +    expect(() => resolveGridPlacements(conflictItems, conflictOrder, gridConfig)).toThrow(
    +      /GRID_CELL_ALIGNMENT_CONFLICT/
    +    );
    +  });
    +
    +  it('ignores and warns about unknown placement ids', () => {
    +    const placementConfig = readGridConfig({
    +      nodes: [],
    +      edges: [],
    +      config: config({
    +        placements: {
    +          unknown: { row: 1, column: 1 },
    +        },
    +      }),
    +    } as LayoutData);
    +
    +    const warn = vi.spyOn(log, 'warn').mockImplementation(() => undefined);
    +    expect(() => validateGridPlacementMap([node('known')], placementConfig)).not.toThrow();
    +    expect(warn).toHaveBeenCalledWith(
    +      '[grid]',
    +      'Ignoring grid placement for unknown target "unknown"'
    +    );
    +    warn.mockRestore();
    +  });
    +
    +  it('resolves generated nodes exclusively through their authored placement ids', () => {
    +    const items = [
    +      node('entity-CUSTOMER-0', undefined, 'CUSTOMER'),
    +      node('entity-ORDER-1', undefined, 'ORDER'),
    +    ];
    +    const sourceOrder = new Map(items.map((item, index) => [item.id, index]));
    +    const gridConfig = readGridConfig({
    +      nodes: [],
    +      edges: [],
    +      config: config({
    +        placements: {
    +          CUSTOMER: { row: 1, column: 1 },
    +          ORDER: { row: 1, column: 2 },
    +          'entity-ORDER-1': { row: 2, column: 2 },
    +        },
    +      }),
    +    } as LayoutData);
    +
    +    const warn = vi.spyOn(log, 'warn').mockImplementation(() => undefined);
    +    expect(() => validateGridPlacementMap(items, gridConfig)).not.toThrow();
    +    expect(warn).toHaveBeenCalledWith(
    +      '[grid]',
    +      'Ignoring grid placement for unknown target "entity-ORDER-1"'
    +    );
    +    const { placements } = resolveGridPlacements(items, sourceOrder, gridConfig);
    +    const byId = new Map(placements.map((placement) => [placement.item.id, placement]));
    +
    +    expect(byId.get('entity-CUSTOMER-0')).toMatchObject({ row: 1, column: 1 });
    +    expect(byId.get('entity-ORDER-1')).toMatchObject({ row: 1, column: 2 });
    +    warn.mockRestore();
    +  });
    +
    +  it('maps placement arrays to matching authored ids in source order', () => {
    +    const items = [
    +      node('rendered-2', undefined, 'service'),
    +      node('rendered-1', undefined, 'service'),
    +      node('rendered-3', undefined, 'service'),
    +    ];
    +    const sourceOrder = new Map([
    +      ['rendered-1', 0],
    +      ['rendered-2', 1],
    +      ['rendered-3', 2],
    +    ]);
    +    const gridConfig = readGridConfig({
    +      nodes: [],
    +      edges: [],
    +      config: config({
    +        placements: {
    +          service: [
    +            { row: 1, column: 1 },
    +            { row: 1, column: 2 },
    +          ],
    +        },
    +      }),
    +    } as LayoutData);
    +
    +    const { placements } = resolveGridPlacements(items, sourceOrder, gridConfig);
    +    const byId = new Map(placements.map((placement) => [placement.item.id, placement]));
    +
    +    expect(byId.get('rendered-1')).toMatchObject({ row: 1, column: 1 });
    +    expect(byId.get('rendered-2')).toMatchObject({ row: 1, column: 2 });
    +    expect(byId.get('rendered-3')).toMatchObject({ explicitRow: false, explicitColumn: false });
    +  });
    +
    +  it('treats a single placement object as a one-item occurrence array', () => {
    +    const items = [
    +      node('rendered-1', undefined, 'service'),
    +      node('rendered-2', undefined, 'service'),
    +    ];
    +    const sourceOrder = new Map(items.map((item, index) => [item.id, index]));
    +    const gridConfig = readGridConfig({
    +      nodes: [],
    +      edges: [],
    +      config: config({
    +        placements: {
    +          service: { row: 1, column: 2 },
    +        },
    +      }),
    +    } as LayoutData);
    +
    +    const { placements } = resolveGridPlacements(items, sourceOrder, gridConfig);
    +    const byId = new Map(placements.map((placement) => [placement.item.id, placement]));
    +
    +    expect(byId.get('rendered-1')).toMatchObject({ row: 1, column: 2 });
    +    expect(byId.get('rendered-2')).toMatchObject({ explicitRow: false, explicitColumn: false });
    +  });
    +
    +  it('warns when a placement array has more entries than matching nodes', () => {
    +    const item = node('rendered-1', undefined, 'service');
    +    const gridConfig = readGridConfig({
    +      nodes: [],
    +      edges: [],
    +      config: config({
    +        placements: {
    +          service: [{ row: 1 }, { row: 2 }],
    +        },
    +      }),
    +    } as LayoutData);
    +    const warn = vi.spyOn(log, 'warn').mockImplementation(() => undefined);
    +
    +    validateGridPlacementMap([item], gridConfig);
    +
    +    expect(warn).toHaveBeenCalledWith(
    +      '[grid]',
    +      'Ignoring 1 extra grid placement occurrence(s) for target "service"; found 1 matching node(s)'
    +    );
    +    warn.mockRestore();
    +  });
    +
    +  it('reports authored placement ids for invalid generated-node coordinates', () => {
    +    const item = node('entity-CUSTOMER-0', undefined, 'CUSTOMER');
    +    const gridConfig = readGridConfig({
    +      nodes: [],
    +      edges: [],
    +      config: config({
    +        placements: {
    +          CUSTOMER: { row: 0, column: 1 },
    +        },
    +      }),
    +    } as LayoutData);
    +
    +    let thrown: unknown;
    +    try {
    +      resolveGridPlacements([item], new Map([[item.id, 0]]), gridConfig);
    +    } catch (error) {
    +      thrown = error;
    +    }
    +
    +    expect(thrown).toMatchObject({
    +      code: 'GRID_INVALID_COORDINATE',
    +      message:
    +        'GRID_INVALID_COORDINATE: Invalid row for placement "CUSTOMER" (node "entity-CUSTOMER-0")',
    +      details: {
    +        nodeId: 'entity-CUSTOMER-0',
    +        placementId: 'CUSTOMER',
    +        field: 'row',
    +        value: 0,
    +      },
    +    });
    +  });
    +
    +  it('rejects explicit null coordinates instead of treating them as omitted', () => {
    +    const items = [node('bad', { row: null, column: 1 })];
    +    const sourceOrder = new Map([['bad', 0]]);
    +    const gridConfig = readGridConfig({
    +      nodes: [],
    +      edges: [],
    +      config: config({
    +        placements: {
    +          bad: { row: 2, column: 2 },
    +        },
    +      }),
    +    } as LayoutData);
    +
    +    expect(() => resolveGridPlacements(items, sourceOrder, gridConfig)).toThrow(
    +      /GRID_INVALID_COORDINATE/
    +    );
    +  });
    +});
    diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/grid/placement.ts b/packages/mermaid/src/rendering-util/layout-algorithms/grid/placement.ts
    new file mode 100644
    index 00000000000..49e9e5b2136
    --- /dev/null
    +++ b/packages/mermaid/src/rendering-util/layout-algorithms/grid/placement.ts
    @@ -0,0 +1,369 @@
    +import { log } from '../../../logger.js';
    +import type { GridPlacement } from '../../../types.js';
    +import {
    +  isGridHorizontalAlign,
    +  isGridVerticalAlign,
    +  validateGridCoordinate,
    +} from '../../../utils/gridPlacement.js';
    +import { resolveEdgeCornerRadius } from '../../edgeCornerRadius.js';
    +import type { Node } from '../../types.js';
    +import { compareCodeUnits } from '../layout-utils/helpers.js';
    +import {
    +  GRID_DEFAULTS,
    +  type GridCurve,
    +  type GridLayoutConfigNormalized,
    +  type GridResolvedPlacement,
    +  type GridLayoutData,
    +  gridError,
    +  type GridCellStack,
    +} from './types.js';
    +
    +/*
    + * Normalizes placement input and deterministically assigns each direct child of a container to a
    + * grid cell. Placement metadata on a node takes precedence over the container-level placement map.
    + */
    +const GRID_LOG_PREFIX = '[grid]';
    +// Grid accepts the same named curves as the shared edge renderer; rejecting unknown values here
    +// keeps layout metadata serializable and leaves renderer fallback behavior unambiguous.
    +const GRID_CURVES = new Set([
    +  'basis',
    +  'bumpX',
    +  'bumpY',
    +  'cardinal',
    +  'catmullRom',
    +  'linear',
    +  'monotoneX',
    +  'monotoneY',
    +  'natural',
    +  'step',
    +  'stepAfter',
    +  'stepBefore',
    +  'rounded',
    +]);
    +
    +function isObjectRecord(value: unknown): value is Record {
    +  return value !== null && typeof value === 'object' && !Array.isArray(value);
    +}
    +
    +function ownPlacementFrom(value: unknown): Partial {
    +  if (!isObjectRecord(value)) {
    +    return {};
    +  }
    +  const placement: Partial = {};
    +  if (Object.hasOwn(value, 'row')) {
    +    placement.row = value.row as number | undefined;
    +  }
    +  if (Object.hasOwn(value, 'column')) {
    +    placement.column = value.column as number | undefined;
    +  }
    +  if (isGridHorizontalAlign(value.horizontalAlign)) {
    +    placement.horizontalAlign = value.horizontalAlign;
    +  }
    +  if (isGridVerticalAlign(value.verticalAlign)) {
    +    placement.verticalAlign = value.verticalAlign;
    +  }
    +  return placement;
    +}
    +
    +export function buildGridSourceOrder(nodes: Node[]): Map {
    +  const order = new Map();
    +  let index = 0;
    +  for (const node of nodes) {
    +    if (!order.has(node.id)) {
    +      order.set(node.id, index++);
    +    }
    +  }
    +  return order;
    +}
    +
    +export function readGridConfig(data: GridLayoutData): GridLayoutConfigNormalized {
    +  const raw = isObjectRecord(data.config?.grid) ? data.config.grid : {};
    +  const placementsRaw = isObjectRecord(raw.placements) ? raw.placements : {};
    +  const placements = new Map();
    +  for (const key of Object.keys(placementsRaw)) {
    +    const value = placementsRaw[key];
    +    const occurrencePlacements = Array.isArray(value) ? value : [value];
    +    placements.set(
    +      key,
    +      occurrencePlacements.map((placement) => ownPlacementFrom(placement))
    +    );
    +  }
    +
    +  return {
    +    placements,
    +    columns:
    +      typeof raw.columns === 'number' && Number.isFinite(raw.columns) && raw.columns >= 0
    +        ? Math.trunc(raw.columns)
    +        : GRID_DEFAULTS.columns,
    +    rowGap:
    +      typeof raw.rowGap === 'number' && Number.isFinite(raw.rowGap) && raw.rowGap >= 0
    +        ? raw.rowGap
    +        : GRID_DEFAULTS.rowGap,
    +    columnGap:
    +      typeof raw.columnGap === 'number' && Number.isFinite(raw.columnGap) && raw.columnGap >= 0
    +        ? raw.columnGap
    +        : GRID_DEFAULTS.columnGap,
    +    cellGap:
    +      typeof raw.cellGap === 'number' && Number.isFinite(raw.cellGap) && raw.cellGap >= 0
    +        ? raw.cellGap
    +        : GRID_DEFAULTS.cellGap,
    +    containerPadding:
    +      typeof raw.containerPadding === 'number' &&
    +      Number.isFinite(raw.containerPadding) &&
    +      raw.containerPadding >= 0
    +        ? raw.containerPadding
    +        : GRID_DEFAULTS.containerPadding,
    +    titleGap:
    +      typeof raw.titleGap === 'number' && Number.isFinite(raw.titleGap) && raw.titleGap >= 0
    +        ? raw.titleGap
    +        : GRID_DEFAULTS.titleGap,
    +    horizontalAlign: isGridHorizontalAlign(raw.horizontalAlign)
    +      ? raw.horizontalAlign
    +      : GRID_DEFAULTS.horizontalAlign,
    +    verticalAlign: isGridVerticalAlign(raw.verticalAlign)
    +      ? raw.verticalAlign
    +      : GRID_DEFAULTS.verticalAlign,
    +    // Normalize rendering options with placement so every routed edge receives one stable style.
    +    curve:
    +      typeof raw.curve === 'string' && GRID_CURVES.has(raw.curve as GridCurve)
    +        ? (raw.curve as GridCurve)
    +        : GRID_DEFAULTS.curve,
    +    edgeCornerRadius: resolveEdgeCornerRadius(raw.edgeCornerRadius),
    +  };
    +}
    +
    +export function validateGridPlacementMap(
    +  items: Iterable,
    +  config: GridLayoutConfigNormalized
    +): void {
    +  // Diagram adapters may retain a generated rendering ID while exposing the authored ID expected
    +  // by public placement maps. Fall back to `id` for diagrams whose IDs are already author-stable.
    +  const knownIdCounts = new Map();
    +  for (const item of items) {
    +    const placementId = item.placementId ?? item.id;
    +    knownIdCounts.set(placementId, (knownIdCounts.get(placementId) ?? 0) + 1);
    +  }
    +  for (const [key, placements] of config.placements) {
    +    const matchingItems = knownIdCounts.get(key) ?? 0;
    +    if (matchingItems === 0) {
    +      log.warn(GRID_LOG_PREFIX, `Ignoring grid placement for unknown target "${key}"`);
    +      continue;
    +    }
    +    if (placements.length > matchingItems) {
    +      log.warn(
    +        GRID_LOG_PREFIX,
    +        `Ignoring ${placements.length - matchingItems} extra grid placement occurrence(s) for target "${key}"; found ${matchingItems} matching node(s)`
    +      );
    +    }
    +  }
    +}
    +
    +function itemComparator(sourceOrder: Map) {
    +  return (a: Node, b: Node): number => {
    +    const aOrder = sourceOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER;
    +    const bOrder = sourceOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER;
    +    if (aOrder !== bOrder) {
    +      return aOrder - bOrder;
    +    }
    +    return compareCodeUnits(a.id, b.id);
    +  };
    +}
    +
    +function resolveItemPlacement(
    +  item: Node,
    +  sourceOrder: Map,
    +  config: GridLayoutConfigNormalized,
    +  occurrenceIndex: number
    +): GridResolvedPlacement {
    +  // Resolve configuration through the authored identity while retaining the generated rendering
    +  // identity for deterministic ordering and diagnostic context.
    +  const authoredPlacementId = item.placementId ?? item.id;
    +  const configPlacement = config.placements.get(authoredPlacementId)?.[occurrenceIndex] ?? {};
    +  const metadataPlacement = ownPlacementFrom(item.metadata);
    +
    +  // Node metadata is closest to the authored node, so it deliberately wins over the shared map.
    +  // Log disagreements to make an otherwise valid but surprising layout diagnosable.
    +  for (const field of ['row', 'column', 'horizontalAlign', 'verticalAlign'] as const) {
    +    if (
    +      Object.hasOwn(configPlacement, field) &&
    +      Object.hasOwn(metadataPlacement, field) &&
    +      configPlacement[field] !== metadataPlacement[field]
    +    ) {
    +      log.debug(GRID_LOG_PREFIX, 'metadata overrides placement map', {
    +        nodeId: item.id,
    +        field,
    +        metadata: metadataPlacement[field],
    +        config: configPlacement[field],
    +      });
    +    }
    +  }
    +
    +  const row = Object.hasOwn(metadataPlacement, 'row') ? metadataPlacement.row : configPlacement.row;
    +  const column = Object.hasOwn(metadataPlacement, 'column')
    +    ? metadataPlacement.column
    +    : configPlacement.column;
    +  validateGridCoordinate(authoredPlacementId, 'row', row, item.id);
    +  validateGridCoordinate(authoredPlacementId, 'column', column, item.id);
    +
    +  return {
    +    item,
    +    row: row ?? 0,
    +    column: column ?? 0,
    +    horizontalAlign:
    +      metadataPlacement.horizontalAlign ??
    +      configPlacement.horizontalAlign ??
    +      config.horizontalAlign,
    +    verticalAlign:
    +      metadataPlacement.verticalAlign ?? configPlacement.verticalAlign ?? config.verticalAlign,
    +    sourceOrder: sourceOrder.get(item.id) ?? Number.MAX_SAFE_INTEGER,
    +    explicitRow: row !== undefined,
    +    explicitColumn: column !== undefined,
    +    explicitCell: row !== undefined && column !== undefined,
    +  };
    +}
    +
    +function nextFreeColumn(row: number, occupied: Set, startColumn: number): number {
    +  let column = startColumn;
    +  while (occupied.has(`${row}:${column}`)) {
    +    column++;
    +  }
    +  return column;
    +}
    +
    +function nextFreeRow(column: number, occupied: Set, startRow: number): number {
    +  let row = startRow;
    +  while (occupied.has(`${row}:${column}`)) {
    +    row++;
    +  }
    +  return row;
    +}
    +
    +function nextAutoCell(
    +  occupied: Set,
    +  candidateColumns: number,
    +  startIndex: number
    +): {
    +  row: number;
    +  column: number;
    +  nextIndex: number;
    +} {
    +  let index = startIndex;
    +  // The occupied set is finite while the row-major coordinate space is unbounded, so a free cell
    +  // must eventually be found.
    +  for (;;) {
    +    const row = Math.floor(index / candidateColumns) + 1;
    +    const column = (index % candidateColumns) + 1;
    +    index++;
    +    if (!occupied.has(`${row}:${column}`)) {
    +      return { row, column, nextIndex: index };
    +    }
    +  }
    +}
    +
    +export function resolveGridPlacements(
    +  items: Node[],
    +  sourceOrder: Map,
    +  config: GridLayoutConfigNormalized
    +): {
    +  placements: GridResolvedPlacement[];
    +  cells: Map;
    +} {
    +  const sortedItems = [...items].sort(itemComparator(sourceOrder));
    +  const occurrenceByPlacementId = new Map();
    +  const resolved = sortedItems.map((item) => {
    +    const placementId = item.placementId ?? item.id;
    +    const occurrenceIndex = occurrenceByPlacementId.get(placementId) ?? 0;
    +    occurrenceByPlacementId.set(placementId, occurrenceIndex + 1);
    +    return resolveItemPlacement(item, sourceOrder, config, occurrenceIndex);
    +  });
    +
    +  const cells = new Map();
    +  const occupied = new Set();
    +
    +  // Reserve fully specified cells first. Auto-placement must route around authored coordinates
    +  // regardless of source order, while multiple explicit occupants intentionally form a stack.
    +  for (const placement of resolved) {
    +    if (!placement.explicitCell) {
    +      continue;
    +    }
    +    const key = `${placement.row}:${placement.column}`;
    +    occupied.add(key);
    +    const existing = cells.get(key);
    +    if (existing) {
    +      if (existing.verticalAlign !== placement.verticalAlign) {
    +        throw gridError(
    +          'GRID_CELL_ALIGNMENT_CONFLICT',
    +          `Conflicting vertical alignment in cell (${placement.row}, ${placement.column})`,
    +          {
    +            containerId: placement.item.parentId ?? 'root',
    +            row: placement.row,
    +            column: placement.column,
    +            itemIds: [...existing.items.map((item) => item.item.id), placement.item.id],
    +            values: [...existing.items.map((item) => item.verticalAlign), placement.verticalAlign],
    +          }
    +        );
    +      }
    +      existing.items.push(placement);
    +    } else {
    +      cells.set(key, {
    +        row: placement.row,
    +        column: placement.column,
    +        items: [placement],
    +        width: 0,
    +        height: 0,
    +        verticalAlign: placement.verticalAlign,
    +      });
    +    }
    +  }
    +
    +  const candidateColumns =
    +    config.columns > 0 ? config.columns : Math.max(1, Math.ceil(Math.sqrt(sortedItems.length)));
    +  let nextAutoIndex = 0;
    +  const nextColumnByRow = new Map();
    +  const nextRowByColumn = new Map();
    +
    +  // Partially specified placements scan only their missing axis. Fully automatic placements use a
    +  // compact row-major grid whose default width keeps roughly square diagrams. Each cursor only
    +  // moves forward because occupied cells are never released.
    +  for (const placement of resolved) {
    +    if (placement.explicitCell) {
    +      continue;
    +    }
    +
    +    if (placement.explicitRow) {
    +      const column = nextFreeColumn(
    +        placement.row,
    +        occupied,
    +        nextColumnByRow.get(placement.row) ?? 1
    +      );
    +      placement.column = column;
    +      nextColumnByRow.set(placement.row, column + 1);
    +    } else if (placement.explicitColumn) {
    +      const row = nextFreeRow(
    +        placement.column,
    +        occupied,
    +        nextRowByColumn.get(placement.column) ?? 1
    +      );
    +      placement.row = row;
    +      nextRowByColumn.set(placement.column, row + 1);
    +    } else {
    +      const next = nextAutoCell(occupied, candidateColumns, nextAutoIndex);
    +      placement.row = next.row;
    +      placement.column = next.column;
    +      nextAutoIndex = next.nextIndex;
    +    }
    +
    +    const key = `${placement.row}:${placement.column}`;
    +    occupied.add(key);
    +    cells.set(key, {
    +      row: placement.row,
    +      column: placement.column,
    +      items: [placement],
    +      width: 0,
    +      height: 0,
    +      verticalAlign: placement.verticalAlign,
    +    });
    +  }
    +
    +  return { placements: resolved, cells };
    +}
    diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/grid/router.spec.ts b/packages/mermaid/src/rendering-util/layout-algorithms/grid/router.spec.ts
    new file mode 100644
    index 00000000000..fa7754994b2
    --- /dev/null
    +++ b/packages/mermaid/src/rendering-util/layout-algorithms/grid/router.spec.ts
    @@ -0,0 +1,1606 @@
    +import { describe, expect, it, vi } from 'vitest';
    +import { log } from '../../../logger.js';
    +import type { Edge, LayoutData, Node } from '../../types.js';
    +import { normalizePolyline } from '../layout-utils/geometry.js';
    +import { validateLayout as validateSharedLayout } from '../layout-utils/validateLayout.js';
    +import { prepareGridLayout } from './edgeLabels.js';
    +import { runGridLayoutCore } from './layoutCore.js';
    +import { createGridRoutingInstrumentation } from './routerInstrumentation.js';
    +import { ROOT_CONTAINER_ID } from './types.js';
    +
    +const validateLayout = (data: LayoutData) =>
    +  validateSharedLayout(data, { requireEdgeLabelCenter: true });
    +
    +function leaf(
    +  id: string,
    +  width = 80,
    +  height = 40,
    +  metadata?: Record,
    +  parentId?: string
    +): Node {
    +  return {
    +    id,
    +    parentId,
    +    isGroup: false,
    +    shape: 'rect',
    +    width,
    +    height,
    +    metadata,
    +  } as Node;
    +}
    +
    +function group(
    +  id: string,
    +  label: string,
    +  metadata?: Record,
    +  parentId?: string
    +): Node {
    +  return {
    +    id,
    +    parentId,
    +    isGroup: true,
    +    shape: 'rect',
    +    label,
    +    labelBBox: { width: 70, height: 20 },
    +    metadata,
    +  } as Node;
    +}
    +
    +function edge(id: string, start: string, end: string, label?: string): Edge {
    +  return {
    +    id,
    +    start,
    +    end,
    +    arrowTypeStart: 'none',
    +    arrowTypeEnd: 'arrow_point',
    +    label,
    +    type: 'arrow_point',
    +  } as Edge;
    +}
    +
    +function baseLayout(nodes: Node[], edges: Edge[], grid: Record = {}): LayoutData {
    +  return {
    +    nodes,
    +    edges,
    +    config: {
    +      layout: 'grid',
    +      grid,
    +    } as LayoutData['config'],
    +  };
    +}
    +
    +function sizeGridLabelNodes(data: LayoutData, width = 42, height = 18): void {
    +  prepareGridLayout(data);
    +  for (const node of data.nodes) {
    +    if ((node as { isEdgeLabel?: boolean }).isEdgeLabel) {
    +      node.width = width;
    +      node.height = height;
    +    }
    +  }
    +}
    +
    +function segmentLength(points: { x: number; y: number }[]): number {
    +  if (points.length < 2) {
    +    return 0;
    +  }
    +  const [a, b] = points;
    +  return Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
    +}
    +
    +function primaryTrackCoordinate(edge: Edge): number {
    +  const primary = [...normalizePolyline(edge.points ?? []).segments].sort(
    +    (a, b) => segmentLength([b.a, b.b]) - segmentLength([a.a, a.b])
    +  )[0];
    +  if (!primary) {
    +    return Number.NaN;
    +  }
    +  return primary.orientation === 'H' ? primary.a.y : primary.a.x;
    +}
    +
    +function terminalLength(points: { x: number; y: number }[], atStart: boolean): number {
    +  const normalized = normalizePolyline(points).points;
    +  const a = atStart ? normalized[0] : normalized[normalized.length - 1];
    +  const b = atStart ? normalized[1] : normalized[normalized.length - 2];
    +  return Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
    +}
    +
    +function longestSharedNonterminalSubpath(first: Edge, second: Edge): number {
    +  const firstSegments = normalizePolyline(first.points ?? []).segments.slice(1, -1);
    +  const secondSegments = normalizePolyline(second.points ?? []).segments.slice(1, -1);
    +  let longest = 0;
    +  for (const a of firstSegments) {
    +    for (const b of secondSegments) {
    +      if (a.orientation !== b.orientation) {
    +        continue;
    +      }
    +      if (a.orientation === 'H' && a.a.y === b.a.y) {
    +        longest = Math.max(
    +          longest,
    +          0,
    +          Math.min(Math.max(a.a.x, a.b.x), Math.max(b.a.x, b.b.x)) -
    +            Math.max(Math.min(a.a.x, a.b.x), Math.min(b.a.x, b.b.x))
    +        );
    +      } else if (a.orientation === 'V' && a.a.x === b.a.x) {
    +        longest = Math.max(
    +          longest,
    +          0,
    +          Math.min(Math.max(a.a.y, a.b.y), Math.max(b.a.y, b.b.y)) -
    +            Math.max(Math.min(a.a.y, a.b.y), Math.min(b.a.y, b.b.y))
    +        );
    +      }
    +    }
    +  }
    +  return longest;
    +}
    +
    +function nodeRect(node: Node) {
    +  return {
    +    left: (node.x ?? 0) - (node.width ?? 0) / 2,
    +    right: (node.x ?? 0) + (node.width ?? 0) / 2,
    +    top: (node.y ?? 0) - (node.height ?? 0) / 2,
    +    bottom: (node.y ?? 0) + (node.height ?? 0) / 2,
    +  };
    +}
    +
    +function boundaryCrossings(points: { x: number; y: number }[], owner: Node): number {
    +  const rect = nodeRect(owner);
    +  return normalizePolyline(points).segments.reduce((count, segment) => {
    +    if (segment.orientation === 'H' && segment.a.y > rect.top && segment.a.y < rect.bottom) {
    +      const low = Math.min(segment.a.x, segment.b.x);
    +      const high = Math.max(segment.a.x, segment.b.x);
    +      return (
    +        count +
    +        Number(low < rect.left && high > rect.left) +
    +        Number(low < rect.right && high > rect.right)
    +      );
    +    }
    +    if (segment.orientation === 'V' && segment.a.x > rect.left && segment.a.x < rect.right) {
    +      const low = Math.min(segment.a.y, segment.b.y);
    +      const high = Math.max(segment.a.y, segment.b.y);
    +      return (
    +        count +
    +        Number(low < rect.top && high > rect.top) +
    +        Number(low < rect.bottom && high > rect.bottom)
    +      );
    +    }
    +    return count;
    +  }, 0);
    +}
    +
    +const invalidRoutingIssueTypes = new Set([
    +  'edge-missing-points',
    +  'edge-non-orthogonal',
    +  'edge-intersects-obstacle',
    +  'edge-endpoint-inside-node',
    +]);
    +
    +function invalidRoutingIssues(data: LayoutData) {
    +  return validateLayout(data).issues.filter(({ type }) => invalidRoutingIssueTypes.has(type));
    +}
    +
    +function expectFiniteOrthogonalRoutes(data: LayoutData): void {
    +  for (const edge of data.edges) {
    +    const points = edge.points ?? [];
    +    expect(points.length).toBeGreaterThanOrEqual(2);
    +    expect(points.every(({ x, y }) => Number.isFinite(x) && Number.isFinite(y))).toBe(true);
    +    for (let index = 1; index < points.length; index++) {
    +      expect(
    +        points[index - 1].x === points[index].x || points[index - 1].y === points[index].y
    +      ).toBe(true);
    +    }
    +  }
    +}
    +
    +describe('grid router', () => {
    +  it('keeps an aligned edge straight and routes a diagonal edge into a separate endpoint side', () => {
    +    const createLayout = (edges: Edge[]) =>
    +      baseLayout(
    +        [
    +          leaf('A', 80, 40, { row: 1, column: 1 }),
    +          leaf('C', 80, 40, { row: 2, column: 1 }),
    +          leaf('D', 80, 40, { row: 1, column: 3 }),
    +        ],
    +        edges,
    +        { columns: 3, rowGap: 45, columnGap: 55 }
    +      );
    +    const data = createLayout([edge('A-D', 'A', 'D'), edge('C-D', 'C', 'D')]);
    +    runGridLayoutCore(data);
    +
    +    const aligned = data.edges.find(({ id }) => id === 'A-D')!;
    +    const diagonal = data.edges.find(({ id }) => id === 'C-D')!;
    +    const alignedPoints = normalizePolyline(aligned.points ?? []).points;
    +    const diagonalPoints = normalizePolyline(diagonal.points ?? []).points;
    +    const destination = data.nodes.find(({ id }) => id === 'D')!;
    +    const destinationBounds = nodeRect(destination);
    +
    +    expect(alignedPoints.every(({ y }) => y === alignedPoints[0].y)).toBe(true);
    +    expect(diagonalPoints.at(-1)!.y).toBe(destinationBounds.bottom);
    +    expect(diagonalPoints.at(-2)!.y).toBeGreaterThan(destinationBounds.bottom);
    +
    +    const reversed = createLayout([edge('C-D', 'C', 'D'), edge('A-D', 'A', 'D')]);
    +    runGridLayoutCore(reversed);
    +    expect(Object.fromEntries(reversed.edges.map(({ id, points }) => [id, points]))).toEqual(
    +      Object.fromEntries(data.edges.map(({ id, points }) => [id, points]))
    +    );
    +  });
    +
    +  it.each([2, 3])(
    +    'shares the destination side of a straight outgoing edge instead of detouring (E in row %i)',
    +    (eRow) => {
    +      const data = baseLayout(
    +        [
    +          leaf('A', 80, 40, { row: 1, column: 1 }),
    +          leaf('B', 80, 40, { row: 1, column: 2 }),
    +          leaf('C', 80, 40, { row: 2, column: 1 }),
    +          leaf('D', 80, 40, { row: 1, column: 3 }),
    +          leaf('E', 80, 40, { row: eRow, column: 3 }),
    +        ],
    +        [
    +          edge('A-B', 'A', 'B'),
    +          edge('A-C', 'A', 'C'),
    +          edge('B-D', 'B', 'D'),
    +          edge('C-D', 'C', 'D'),
    +          edge('D-E', 'D', 'E'),
    +        ],
    +        { columns: 3, rowGap: 45, columnGap: 55 }
    +      );
    +      runGridLayoutCore(data);
    +
    +      const points = (id: string) =>
    +        normalizePolyline(data.edges.find((candidate) => candidate.id === id)!.points ?? []).points;
    +      const destination = nodeRect(data.nodes.find(({ id }) => id === 'D')!);
    +      const incoming = points('C-D');
    +      const outgoing = points('D-E');
    +
    +      expect(Math.min(...incoming.map(({ y }) => y))).toBeGreaterThanOrEqual(destination.top);
    +      expect(incoming.at(-1)!.y).toBe(destination.bottom);
    +      expect(outgoing[0].y).toBe(destination.bottom);
    +      expect(outgoing.every(({ x }) => x === outgoing[0].x)).toBe(true);
    +      expect(Math.abs(incoming.at(-1)!.x - outgoing[0].x)).toBeGreaterThanOrEqual(24);
    +    }
    +  );
    +
    +  it('keeps a straight edge in a gap shorter than two terminal approaches', () => {
    +    const data = baseLayout(
    +      [
    +        leaf('A', 80, 40, { row: 1, column: 1, horizontalAlign: 'left', verticalAlign: 'center' }),
    +        leaf('B', 80, 40, {
    +          row: 1,
    +          column: 1,
    +          horizontalAlign: 'center',
    +          verticalAlign: 'center',
    +        }),
    +        leaf('D', 80, 40, { row: 1, column: 2 }),
    +      ],
    +      [edge('A-B', 'A', 'B'), edge('A-D', 'A', 'D')],
    +      { cellGap: 18, rowGap: 55, columnGap: 80 }
    +    );
    +    runGridLayoutCore(data);
    +
    +    const straight = normalizePolyline(
    +      data.edges.find(({ id }) => id === 'A-B')!.points ?? []
    +    ).points;
    +    expect(straight).toHaveLength(2);
    +    expect(straight[0].x).toBe(straight[1].x);
    +  });
    +
    +  it('keeps edges that enter the same group off a shared corridor', () => {
    +    const data = baseLayout(
    +      [
    +        leaf('C', 80, 40, { row: 1, column: 1 }),
    +        group('Nested', 'Nested', { row: 2, column: 1 }),
    +        leaf('D', 80, 40, { row: 1, column: 1 }, 'Nested'),
    +        leaf('E', 80, 40, { row: 2, column: 1 }, 'Nested'),
    +      ],
    +      [edge('C-D', 'C', 'D'), edge('C-E', 'C', 'E')],
    +      { rowGap: 45, columnGap: 70 }
    +    );
    +    runGridLayoutCore(data);
    +
    +    expect(invalidRoutingIssues(data)).toEqual([]);
    +    const [first, second] = data.edges;
    +    expect(longestSharedNonterminalSubpath(first, second)).toBe(0);
    +    const nested = data.nodes.find(({ id }) => id === 'Nested')!;
    +    const nestedLeft = (nested.x ?? 0) - (nested.width ?? 0) / 2;
    +    const nestedRight = (nested.x ?? 0) + (nested.width ?? 0) / 2;
    +    expect(Math.max(...(first.points?.map(({ x }) => x) ?? []))).toBeGreaterThan(nestedRight);
    +    expect(Math.min(...(second.points?.map(({ x }) => x) ?? []))).toBeLessThan(nestedLeft);
    +    expect(first.points?.[0].x).toBeGreaterThan(second.points?.[0].x ?? Number.POSITIVE_INFINITY);
    +  });
    +
    +  it('does not let a later hierarchy route displace an earlier sparse route', () => {
    +    const data = baseLayout(
    +      [
    +        leaf('C', 80, 40, { row: 1, column: 1 }),
    +        group('Work', 'Work', { row: 1, column: 2 }),
    +        group('Nested', 'Nested', { row: 2, column: 1 }, 'Work'),
    +        leaf('D', 80, 40, { row: 1, column: 1 }, 'Nested'),
    +        leaf('E', 80, 40, { row: 2, column: 1 }, 'Nested'),
    +      ],
    +      [edge('C-D', 'C', 'D'), edge('C-E', 'C', 'E')],
    +      { rowGap: 45, columnGap: 70 }
    +    );
    +    runGridLayoutCore(data);
    +
    +    expect(invalidRoutingIssues(data)).toEqual([]);
    +    const [toD, toE] = data.edges;
    +    expect(longestSharedNonterminalSubpath(toD, toE)).toBe(0);
    +    const endpointYs = [
    +      data.nodes.find(({ id }) => id === 'C')?.y ?? 0,
    +      data.nodes.find(({ id }) => id === 'D')?.y ?? 0,
    +    ];
    +    const routeYs = toD.points?.map(({ y }) => y) ?? [];
    +    expect(Math.min(...routeYs)).toBeGreaterThanOrEqual(Math.min(...endpointYs) - 1);
    +    expect(Math.max(...routeYs)).toBeLessThanOrEqual(Math.max(...endpointYs) + 1);
    +  });
    +
    +  it.each(['LR', 'TB', 'BT'])(
    +    'reserves a strongly preferred group side before placing an ambiguous hierarchy route in %s',
    +    (direction) => {
    +      const data = baseLayout(
    +        [
    +          group('Work', 'Work', { row: 1, column: 2 }),
    +          leaf('C', 80, 40, { row: 1, column: 1 }, 'Work'),
    +          group('Nested', 'Nested', { row: 2, column: 1 }, 'Work'),
    +          leaf('D', 80, 40, { row: 1, column: 1 }, 'Nested'),
    +          leaf('E', 80, 40, { row: 2, column: 1 }, 'Nested'),
    +          group('Output', 'Output', { row: 1, column: 3 }),
    +          leaf('F', 80, 40, { row: 1, column: 1 }, 'Output'),
    +        ],
    +        [edge('C-E', 'C', 'E'), edge('D-F', 'D', 'F')],
    +        { rowGap: 45, columnGap: 70 }
    +      );
    +      data.direction = direction;
    +      runGridLayoutCore(data);
    +
    +      expect(invalidRoutingIssues(data)).toEqual([]);
    +      const nested = data.nodes.find(({ id }) => id === 'Nested')!;
    +      const toE = data.edges.find(({ id }) => id === 'C-E')!;
    +      const toF = data.edges.find(({ id }) => id === 'D-F')!;
    +      expect(Math.min(...(toE.points?.map(({ x }) => x) ?? []))).toBeLessThan(
    +        (nested.x ?? 0) - (nested.width ?? 0) / 2
    +      );
    +      const endpointYs = [
    +        data.nodes.find(({ id }) => id === 'D')?.y ?? 0,
    +        data.nodes.find(({ id }) => id === 'F')?.y ?? 0,
    +      ];
    +      const routeYs = toF.points?.map(({ y }) => y) ?? [];
    +      expect(Math.min(...routeYs)).toBeGreaterThanOrEqual(Math.min(...endpointYs) - 1);
    +      expect(Math.max(...routeYs)).toBeLessThanOrEqual(Math.max(...endpointYs) + 1);
    +    }
    +  );
    +
    +  it('reserves a horizontal side before assigning a diagonal hierarchy route vertically', () => {
    +    const data = baseLayout(
    +      [
    +        group('Work', 'Work', { row: 1, column: 1 }),
    +        leaf('A', 80, 40, { row: 1, column: 1 }, 'Work'),
    +        leaf('B', 80, 40, { row: 1, column: 2 }, 'Work'),
    +        group('Nested', 'Nested', { row: 2, column: 2 }, 'Work'),
    +        leaf('C', 80, 40, { row: 1, column: 1 }, 'Nested'),
    +      ],
    +      [edge('A-C', 'A', 'C'), edge('A-B', 'A', 'B')],
    +      { rowGap: 82, columnGap: 70 }
    +    );
    +    runGridLayoutCore(data);
    +
    +    expect(invalidRoutingIssues(data)).toEqual([]);
    +    const diagonal = data.edges.find(({ id }) => id === 'A-C')!;
    +    const horizontal = data.edges.find(({ id }) => id === 'A-B')!;
    +    expect(diagonal.points?.[0].x).toBe(diagonal.points?.[1].x);
    +    expect(horizontal.points?.[0].y).toBe(horizontal.points?.[1].y);
    +  });
    +
    +  it('lines group portals up with the item ports so edges take a single jog', () => {
    +    const data = baseLayout(
    +      [
    +        group('Work', 'Work', { row: 1, column: 2 }),
    +        group('Nested', 'Nested', { row: 2, column: 1 }, 'Work'),
    +        leaf('D', 80, 40, { row: 1, column: 1 }, 'Nested'),
    +        leaf('E', 80, 40, { row: 2, column: 1 }, 'Nested'),
    +        group('Output', 'Output', { row: 1, column: 3 }),
    +        leaf('F', 80, 40, { row: 1, column: 1 }, 'Output'),
    +      ],
    +      [edge('D-F', 'D', 'F'), edge('E-F', 'E', 'F')],
    +      { rowGap: 45, columnGap: 70 }
    +    );
    +    runGridLayoutCore(data);
    +
    +    expect(invalidRoutingIssues(data)).toEqual([]);
    +    for (const item of data.edges) {
    +      expect(normalizePolyline(item.points ?? []).bends).toBe(2);
    +    }
    +  });
    +
    +  it('routes around a blocker on the aligned straight corridor', () => {
    +    const data = baseLayout(
    +      [
    +        leaf('A', 80, 40, { row: 1, column: 1 }),
    +        leaf('C', 80, 40, { row: 2, column: 1 }),
    +        leaf('B', 80, 40, { row: 1, column: 2 }),
    +        leaf('D', 80, 40, { row: 1, column: 3 }),
    +      ],
    +      [edge('A-D', 'A', 'D'), edge('C-D', 'C', 'D')],
    +      { columns: 3, rowGap: 45, columnGap: 55 }
    +    );
    +    runGridLayoutCore(data);
    +
    +    const aligned = data.edges.find(({ id }) => id === 'A-D')!;
    +    expect(normalizePolyline(aligned.points ?? []).bends).toBeGreaterThan(0);
    +    expect(validateLayout(data)).toMatchObject({ ok: true, issues: [] });
    +  });
    +
    +  it.each([
    +    [80, 40],
    +    [100, 40],
    +    [120, 60],
    +  ])('does not overlap unrelated diagonal edges (%ix%i nodes)', (width, height) => {
    +    const data = baseLayout(
    +      [
    +        leaf('tl', width, height, { row: 1, column: 1 }),
    +        leaf('tr', width, height, { row: 1, column: 2 }),
    +        leaf('bl', width, height, { row: 2, column: 1 }),
    +        leaf('br', width, height, { row: 2, column: 2 }),
    +      ],
    +      [edge('e1', 'tl', 'br'), edge('e2', 'tr', 'bl')]
    +    );
    +    runGridLayoutCore(data);
    +    expectFiniteOrthogonalRoutes(data);
    +    expect(longestSharedNonterminalSubpath(data.edges[0], data.edges[1])).toBe(0);
    +  });
    +
    +  it('crosses unrelated diagonal edges perpendicularly instead of running them in parallel', () => {
    +    const data = baseLayout(
    +      [
    +        leaf('tl', 90, 54, { row: 1, column: 1 }),
    +        leaf('tr', 90, 54, { row: 1, column: 3 }),
    +        leaf('bl', 90, 54, { row: 3, column: 1 }),
    +        leaf('br', 90, 54, { row: 3, column: 3 }),
    +      ],
    +      [edge('e1', 'tl', 'br'), edge('e2', 'tr', 'bl')]
    +    );
    +    runGridLayoutCore(data);
    +    const [first, second] = data.edges.map((e) => normalizePolyline(e.points ?? []).segments);
    +    for (const a of first) {
    +      for (const b of second) {
    +        if (a.orientation !== b.orientation) {
    +          continue;
    +        }
    +        const horizontal = a.orientation === 'H';
    +        const overlap = horizontal
    +          ? Math.min(Math.max(a.a.x, a.b.x), Math.max(b.a.x, b.b.x)) -
    +            Math.max(Math.min(a.a.x, a.b.x), Math.min(b.a.x, b.b.x))
    +          : Math.min(Math.max(a.a.y, a.b.y), Math.max(b.a.y, b.b.y)) -
    +            Math.max(Math.min(a.a.y, a.b.y), Math.min(b.a.y, b.b.y));
    +        const separation = horizontal ? Math.abs(a.a.y - b.a.y) : Math.abs(a.a.x - b.a.x);
    +        expect(overlap > 0 && separation < 24).toBe(false);
    +      }
    +    }
    +  });
    +
    +  it('uses expanded label boundaries when separating unrelated routes', () => {
    +    const data = baseLayout(
    +      [
    +        leaf('tl', 90, 54, { row: 1, column: 1 }),
    +        leaf('tr', 90, 54, { row: 1, column: 3 }),
    +        leaf('bl', 90, 54, { row: 3, column: 1 }),
    +        leaf('br', 90, 54, { row: 3, column: 3 }),
    +      ],
    +      [edge('labelled', 'tl', 'tr', 'wide label'), edge('e1', 'tl', 'br'), edge('e2', 'tr', 'bl')]
    +    );
    +    prepareGridLayout(data);
    +    const helper = data.nodes.find((node) => node.id === data.edges[0].labelNodeId)!;
    +    helper.width = 120;
    +    helper.height = 20;
    +
    +    runGridLayoutCore(data);
    +
    +    expect(
    +      data.nodes.find((node) => node.id === 'tr')!.x! -
    +        data.nodes.find((node) => node.id === 'tl')!.x! -
    +        90
    +    ).toBe(146);
    +    const [first, second] = data.edges
    +      .slice(1)
    +      .map((item) => normalizePolyline(item.points ?? []).segments);
    +    for (const a of first) {
    +      for (const b of second) {
    +        if (a.orientation !== b.orientation) {
    +          continue;
    +        }
    +        const horizontal = a.orientation === 'H';
    +        const overlap = horizontal
    +          ? Math.min(Math.max(a.a.x, a.b.x), Math.max(b.a.x, b.b.x)) -
    +            Math.max(Math.min(a.a.x, a.b.x), Math.min(b.a.x, b.b.x))
    +          : Math.min(Math.max(a.a.y, a.b.y), Math.max(b.a.y, b.b.y)) -
    +            Math.max(Math.min(a.a.y, a.b.y), Math.min(b.a.y, b.b.y));
    +        const separation = horizontal ? Math.abs(a.a.y - b.a.y) : Math.abs(a.a.x - b.a.x);
    +        expect(overlap > 0 && separation < 24).toBe(false);
    +      }
    +    }
    +    expect(validateLayout(data).ok).toBe(true);
    +  });
    +
    +  it('uses the configured edge curve and rounded corner radius', () => {
    +    const data = baseLayout(
    +      [leaf('a', 80, 40, { row: 1, column: 1 }), leaf('b', 80, 40, { row: 2, column: 2 })],
    +      [edge('a-b', 'a', 'b')],
    +      { curve: 'rounded', edgeCornerRadius: 12 }
    +    );
    +
    +    runGridLayoutCore(data);
    +    expect(data.edges[0]).toMatchObject({ curve: 'rounded', cornerRadius: 12 });
    +    expect(normalizePolyline(data.edges[0].points ?? []).bends).toBeGreaterThan(0);
    +  });
    +
    +  it('keeps routing deterministic when edge input order changes', () => {
    +    const build = () =>
    +      baseLayout(
    +        [
    +          leaf('a', 80, 40, { row: 1, column: 1 }),
    +          leaf('b', 80, 40, { row: 1, column: 2 }),
    +          leaf('c', 80, 40, { row: 2, column: 2 }),
    +        ],
    +        [edge('horizontal', 'a', 'b'), edge('vertical', 'b', 'c')],
    +        { rowGap: 40, columnGap: 60 }
    +      );
    +    const baseline = build();
    +    runGridLayoutCore(baseline);
    +
    +    const permuted = build();
    +    permuted.edges.reverse();
    +    runGridLayoutCore(permuted);
    +    expect(new Map(permuted.edges.map((item) => [item.id, item.points]))).toEqual(
    +      new Map(baseline.edges.map((item) => [item.id, item.points]))
    +    );
    +  });
    +
    +  it('routes ordinary and labelled edges into valid orthogonal polylines', () => {
    +    const data = baseLayout(
    +      [
    +        group('g', 'Group', { row: 1, column: 1 }),
    +        leaf('a', 80, 40, { row: 1, column: 1 }, 'g'),
    +        leaf('b', 90, 40, { row: 1, column: 2 }),
    +        leaf('c', 70, 40, { row: 2, column: 2 }),
    +      ],
    +      [edge('e1', 'a', 'b'), edge('e3', 'a', 'c', 'labelled')],
    +      { rowGap: 40, columnGap: 40 }
    +    );
    +
    +    sizeGridLabelNodes(data, 60, 20);
    +    runGridLayoutCore(data);
    +
    +    expectFiniteOrthogonalRoutes(data);
    +    expect(invalidRoutingIssues(data)).toEqual([]);
    +
    +    const labelNode = data.nodes.find((node) => (node as { isEdgeLabel?: boolean }).isEdgeLabel);
    +    expect(labelNode?.x).toEqual(expect.any(Number));
    +    expect(labelNode?.y).toEqual(expect.any(Number));
    +  });
    +
    +  it('routes a leaf-to-group edge with finite orthogonal points', () => {
    +    const data = baseLayout(
    +      [
    +        group('g', 'Group', { row: 1, column: 1 }),
    +        leaf('a', 80, 40, { row: 1, column: 1 }, 'g'),
    +        leaf('b', 90, 40, { row: 1, column: 2 }),
    +      ],
    +      [edge('e2', 'b', 'g')],
    +      { rowGap: 40, columnGap: 40 }
    +    );
    +
    +    runGridLayoutCore(data);
    +
    +    expectFiniteOrthogonalRoutes(data);
    +    expect(invalidRoutingIssues(data)).toEqual([]);
    +  });
    +
    +  it.each([
    +    {
    +      name: 'group-to-member',
    +      edges: [edge('group-to-member', 'g', 'member')],
    +    },
    +    {
    +      name: 'outside-to-member',
    +      edges: [edge('outside-to-member', 'outside', 'member')],
    +    },
    +  ])('routes $name edges without containment violations', ({ edges }) => {
    +    const data = baseLayout(
    +      [
    +        group('g', 'Group', { row: 1, column: 1 }),
    +        leaf('member', 80, 40, { row: 1, column: 1 }, 'g'),
    +        leaf('outside', 90, 40, { row: 1, column: 2 }),
    +      ],
    +      edges,
    +      { rowGap: 40, columnGap: 40 }
    +    );
    +
    +    runGridLayoutCore(data);
    +    expect(validateLayout(data)).toMatchObject({ ok: true, issues: [] });
    +  });
    +
    +  it('uses centered 8px pair lanes, distinct ports, and deterministic reverse ordering', () => {
    +    const build = () =>
    +      baseLayout(
    +        [leaf('a', 80, 40, { row: 1, column: 1 }), leaf('b', 80, 40, { row: 1, column: 2 })],
    +        [
    +          edge('e1', 'a', 'b'),
    +          edge('e2', 'a', 'b'),
    +          edge('e3', 'a', 'b'),
    +          edge('e4', 'b', 'a'),
    +          edge('loop-1', 'a', 'a'),
    +          edge('loop-2', 'a', 'a'),
    +        ],
    +        { rowGap: 40, columnGap: 60 }
    +      );
    +    const data = build();
    +    runGridLayoutCore(data);
    +
    +    const byId = new Map(data.edges.map((item) => [item.id, item.points ?? []]));
    +    expect(byId.get('e1')).not.toEqual(byId.get('e2'));
    +    expect(byId.get('e2')).not.toEqual(byId.get('e3'));
    +    expect(byId.get('e4')).not.toEqual(byId.get('e1'));
    +    expect(byId.get('loop-1')).not.toEqual(byId.get('loop-2'));
    +
    +    const bundle = data.edges.filter((item) => item.id.startsWith('e'));
    +    const primaryTracks = bundle
    +      .filter((item) => item.id.startsWith('e'))
    +      .map((item) => primaryTrackCoordinate(item))
    +      .sort((a, b) => a - b);
    +    const centerY = data.nodes.find(({ id }) => id === 'a')!.y ?? 0;
    +    expect(primaryTracks.map((coordinate) => coordinate - centerY)).toEqual([-12, -4, 4, 12]);
    +    for (let index = 1; index < primaryTracks.length; index++) {
    +      expect(primaryTracks[index] - primaryTracks[index - 1]).toBeGreaterThanOrEqual(8);
    +    }
    +    expect(new Set(bundle.map((item) => JSON.stringify(item.points?.[0]))).size).toBe(
    +      bundle.length
    +    );
    +    expect(new Set(bundle.map((item) => JSON.stringify(item.points?.at(-1)))).size).toBe(
    +      bundle.length
    +    );
    +    for (let first = 0; first < bundle.length; first++) {
    +      for (let second = first + 1; second < bundle.length; second++) {
    +        expect(longestSharedNonterminalSubpath(bundle[first], bundle[second])).toBeLessThan(8);
    +      }
    +    }
    +    const rerun = build();
    +    runGridLayoutCore(rerun);
    +    expect(rerun.edges.map((item) => item.points)).toEqual(data.edges.map((item) => item.points));
    +  });
    +
    +  it('routes a four-edge bundle around a blocked lane with shared spans below 8px', () => {
    +    const data = baseLayout(
    +      [
    +        leaf('a', 80, 40, { row: 1, column: 1 }),
    +        leaf('blocker', 80, 8, { row: 1, column: 2 }),
    +        leaf('b', 80, 40, { row: 1, column: 3 }),
    +      ],
    +      [edge('e1', 'a', 'b'), edge('e2', 'a', 'b'), edge('e3', 'a', 'b'), edge('e4', 'b', 'a')],
    +      { rowGap: 40, columnGap: 60 }
    +    );
    +    runGridLayoutCore(data);
    +
    +    expect(validateLayout(data)).toMatchObject({ ok: true, issues: [] });
    +    expect(data.edges.some((item) => normalizePolyline(item.points ?? []).bends > 0)).toBe(true);
    +    for (let first = 0; first < data.edges.length; first++) {
    +      for (let second = first + 1; second < data.edges.length; second++) {
    +        expect(longestSharedNonterminalSubpath(data.edges[first], data.edges[second])).toBeLessThan(
    +          8
    +        );
    +      }
    +    }
    +  });
    +
    +  it('throws an explicit no-route error when a pair cannot allocate distinct 8px lanes', () => {
    +    const data = baseLayout(
    +      [leaf('a', 40, 24, { row: 1, column: 1 }), leaf('b', 40, 24, { row: 1, column: 2 })],
    +      Array.from({ length: 10 }, (_, index) => edge(`e${index}`, 'a', 'b')),
    +      { rowGap: 0, columnGap: 12 }
    +    );
    +    const before = structuredClone(data);
    +
    +    expect(() => runGridLayoutCore(data)).toThrowError(
    +      /GRID_ROUTE_NOT_FOUND: No distinct lane route/
    +    );
    +    expect(data).toEqual(before);
    +  });
    +
    +  it('routes self-loops around adjacent nodes when track gaps are zero', () => {
    +    const data = baseLayout(
    +      [leaf('a', 80, 40, { row: 1, column: 1 }), leaf('b', 80, 40, { row: 1, column: 2 })],
    +      [edge('loop', 'b', 'b')],
    +      { rowGap: 0, columnGap: 0 }
    +    );
    +
    +    runGridLayoutCore(data);
    +
    +    expect(validateLayout(data)).toMatchObject({ ok: true, issues: [] });
    +  });
    +
    +  it.each([
    +    ['edge id', 'owner', 'edge:loaded'],
    +    ['owner id', 'owner:part', 'loaded'],
    +  ])('accounts for occupied sides when the %s contains a colon', (_label, ownerId, edgeId) => {
    +    const data = baseLayout(
    +      [leaf('target', 80, 40, { row: 1, column: 1 }), leaf(ownerId, 80, 40, { row: 1, column: 2 })],
    +      [edge(edgeId, ownerId, 'target'), edge('loop', ownerId, ownerId)],
    +      { columnGap: 80 }
    +    );
    +
    +    runGridLayoutCore(data);
    +
    +    expect(validateLayout(data)).toMatchObject({ ok: true, issues: [] });
    +    const owner = data.nodes.find(({ id }) => id === ownerId)!;
    +    const loop = data.edges.find(({ id }) => id === 'loop')!;
    +    expect(loop.points?.[0].x).toBe((owner.x ?? 0) + (owner.width ?? 0) / 2);
    +  });
    +
    +  it('allocates distinct ports for repeated self-loops on the same node', () => {
    +    const data = baseLayout(
    +      [leaf('a', 800, 400, { row: 1, column: 1 })],
    +      Array.from({ length: 5 }, (_, index) => edge(`loop-${index}`, 'a', 'a'))
    +    );
    +
    +    runGridLayoutCore(data);
    +
    +    expect(validateLayout(data)).toMatchObject({ ok: true, issues: [] });
    +    expect(new Set(data.edges.map(({ points }) => JSON.stringify(points))).size).toBe(5);
    +  });
    +
    +  it('keeps reverse pair lanes distinct across hierarchy portals', () => {
    +    const data = baseLayout(
    +      [
    +        group('left-group', 'Left', { row: 1, column: 1 }),
    +        leaf('a', 80, 40, { row: 1, column: 1 }, 'left-group'),
    +        group('right-group', 'Right', { row: 1, column: 2 }),
    +        leaf('b', 80, 40, { row: 1, column: 1 }, 'right-group'),
    +      ],
    +      [edge('e1', 'a', 'b'), edge('e2', 'a', 'b'), edge('e3', 'b', 'a')],
    +      { rowGap: 50, columnGap: 90 }
    +    );
    +    runGridLayoutCore(data);
    +
    +    expect(validateLayout(data)).toMatchObject({ ok: true, issues: [] });
    +    expect(new Set(data.edges.map(({ points }) => JSON.stringify(points))).size).toBe(3);
    +    const a = data.nodes.find(({ id }) => id === 'a')!;
    +    const aPorts = data.edges
    +      .map((item) => (item.start === 'a' ? item.points?.[0] : item.points?.at(-1)))
    +      .map((point) => (point?.y ?? 0) - (a.y ?? 0))
    +      .sort((first, second) => first - second);
    +    expect(aPorts).toEqual([-8, 0, 8]);
    +    for (let first = 0; first < data.edges.length; first++) {
    +      for (let second = first + 1; second < data.edges.length; second++) {
    +        expect(longestSharedNonterminalSubpath(data.edges[first], data.edges[second])).toBeLessThan(
    +          8
    +        );
    +      }
    +    }
    +  });
    +
    +  it('selects a shortest straight route through unused cell space over the legacy detour', () => {
    +    const data = baseLayout(
    +      [
    +        leaf('v1', 80, 40, { row: 1, column: 1 }),
    +        leaf('v2', 80, 40, { row: 1, column: 3 }),
    +        leaf('v2p', 80, 40, { row: 2, column: 2 }),
    +      ],
    +      [edge('v1-v2', 'v1', 'v2')],
    +      { rowGap: 40, columnGap: 40 }
    +    );
    +    runGridLayoutCore(data);
    +
    +    const normalized = normalizePolyline(data.edges[0].points ?? []);
    +    expect(validateLayout(data)).toMatchObject({ ok: true, issues: [] });
    +    expect(normalized.segments).toHaveLength(1);
    +    expect(normalized.segments[0]?.orientation).toBe('H');
    +  });
    +
    +  it('does not separate edges that use opposite sides of the same node', () => {
    +    const data = baseLayout(
    +      [
    +        leaf('v1', 80, 40, { row: 1, column: 1 }),
    +        leaf('v2p', 80, 40, { row: 2, column: 2 }),
    +        leaf('v2', 80, 40, { row: 1, column: 3 }),
    +        leaf('v3', 80, 40, { row: 1, column: 4 }),
    +      ],
    +      [edge('v1-v2', 'v1', 'v2'), edge('v2-v3', 'v2', 'v3'), edge('v2p-v1', 'v2p', 'v1')]
    +    );
    +
    +    runGridLayoutCore(data);
    +
    +    expect(data.edges.find(({ id }) => id === 'v2-v3')?.points).toEqual([
    +      { x: 340, y: 20 },
    +      { x: 390, y: 20 },
    +    ]);
    +    const preview = data.nodes.find(({ id }) => id === 'v2p')!;
    +    const previewToV1 = data.edges.find(({ id }) => id === 'v2p-v1')!;
    +    expect(previewToV1.points?.[0]).toEqual({
    +      x: (preview.x ?? 0) - (preview.width ?? 0) / 2,
    +      y: preview.y,
    +    });
    +    expect(
    +      normalizePolyline(previewToV1.points ?? []).segments.every(
    +        ({ orientation }) => orientation === 'H' || orientation === 'V'
    +      )
    +    ).toBe(true);
    +    expect(validateLayout(data)).toMatchObject({ ok: true, issues: [] });
    +  });
    +
    +  it('allocates deterministic legal endpoint slots with clearance and terminal approach', () => {
    +    const build = () =>
    +      baseLayout(
    +        [
    +          leaf('source', 80, 56, { row: 1, column: 1 }),
    +          leaf('high', 60, 32, { row: 1, column: 3 }),
    +          leaf('middle', 60, 32, { row: 1, column: 3 }),
    +          leaf('low', 60, 32, { row: 1, column: 3 }),
    +        ],
    +        [
    +          edge('to-low', 'source', 'low'),
    +          edge('to-high', 'source', 'high'),
    +          edge('to-middle', 'source', 'middle'),
    +        ],
    +        { rowGap: 34, columnGap: 70 }
    +      );
    +    const data = build();
    +
    +    runGridLayoutCore(data);
    +
    +    const source = data.nodes.find(({ id }) => id === 'source')!;
    +    const bounds = nodeRect(source);
    +    const portsBySide = new Map();
    +    for (const routed of data.edges) {
    +      const port = routed.points![0];
    +      const side =
    +        port.x === bounds.left
    +          ? 'left'
    +          : port.x === bounds.right
    +            ? 'right'
    +            : port.y === bounds.top
    +              ? 'top'
    +              : port.y === bounds.bottom
    +                ? 'bottom'
    +                : undefined;
    +      expect(side).toBeDefined();
    +      const coordinate = side === 'left' || side === 'right' ? port.y : port.x;
    +      const values = portsBySide.get(side!) ?? [];
    +      values.push(coordinate);
    +      portsBySide.set(side!, values);
    +    }
    +    for (const [side, coordinates] of portsBySide) {
    +      const low = side === 'left' || side === 'right' ? bounds.top + 6 : bounds.left + 6;
    +      const high = side === 'left' || side === 'right' ? bounds.bottom - 6 : bounds.right - 6;
    +      const sorted = coordinates.sort((a, b) => a - b);
    +      expect(sorted[0]).toBeGreaterThanOrEqual(low);
    +      expect(sorted.at(-1)).toBeLessThanOrEqual(high);
    +      for (let index = 1; index < sorted.length; index++) {
    +        expect(sorted[index] - sorted[index - 1]).toBeGreaterThanOrEqual(4);
    +      }
    +    }
    +    for (const routed of data.edges) {
    +      expect(terminalLength(routed.points ?? [], true)).toBeGreaterThanOrEqual(12);
    +      expect(terminalLength(routed.points ?? [], false)).toBeGreaterThanOrEqual(12);
    +    }
    +
    +    const rerun = build();
    +    runGridLayoutCore(rerun);
    +    expect(rerun.edges.map(({ points }) => points)).toEqual(data.edges.map(({ points }) => points));
    +  });
    +
    +  it('routes around the inflated measured bounds of a nonrectangular blocker', () => {
    +    const source = leaf('source', 70, 70, { row: 1, column: 1 });
    +    source.shape = 'circle';
    +    const blocker = leaf('blocker', 80, 80, { row: 1, column: 2 });
    +    blocker.shape = 'diamond';
    +    const target = leaf('target', 70, 70, { row: 1, column: 3 });
    +    target.shape = 'stadium';
    +    const data = baseLayout(
    +      [source, blocker, target],
    +      [edge('around-blocker', 'source', 'target')],
    +      { rowGap: 40, columnGap: 50 }
    +    );
    +
    +    runGridLayoutCore(data);
    +
    +    const blockerRect = {
    +      left: (blocker.x ?? 0) - (blocker.width ?? 0) / 2 - 6,
    +      right: (blocker.x ?? 0) + (blocker.width ?? 0) / 2 + 6,
    +      top: (blocker.y ?? 0) - (blocker.height ?? 0) / 2 - 6,
    +      bottom: (blocker.y ?? 0) + (blocker.height ?? 0) / 2 + 6,
    +    };
    +    const points = data.edges[0].points ?? [];
    +    for (let index = 1; index < points.length; index++) {
    +      const a = points[index - 1];
    +      const b = points[index];
    +      const crosses =
    +        a.y === b.y
    +          ? a.y > blockerRect.top &&
    +            a.y < blockerRect.bottom &&
    +            Math.max(a.x, b.x) > blockerRect.left &&
    +            Math.min(a.x, b.x) < blockerRect.right
    +          : a.x > blockerRect.left &&
    +            a.x < blockerRect.right &&
    +            Math.max(a.y, b.y) > blockerRect.top &&
    +            Math.min(a.y, b.y) < blockerRect.bottom;
    +      expect(crosses).toBe(false);
    +    }
    +  });
    +
    +  it('treats user nodes with the edge-label prefix as routing obstacles', () => {
    +    const source = leaf('source', 80, 40, { row: 1, column: 1 });
    +    const blocker = leaf('edge-label-blocker', 80, 80, { row: 1, column: 2 });
    +    const target = leaf('target', 80, 40, { row: 1, column: 3 });
    +    const data = baseLayout(
    +      [source, blocker, target],
    +      [edge('around-prefixed-blocker', 'source', 'target')],
    +      { rowGap: 40, columnGap: 50 }
    +    );
    +
    +    runGridLayoutCore(data);
    +
    +    const blockerBounds = nodeRect(blocker);
    +    const route = normalizePolyline(data.edges[0].points ?? []);
    +    expect(route.bends).toBeGreaterThan(0);
    +    for (const segment of route.segments) {
    +      const crosses =
    +        segment.orientation === 'H'
    +          ? segment.a.y > blockerBounds.top &&
    +            segment.a.y < blockerBounds.bottom &&
    +            Math.max(segment.a.x, segment.b.x) > blockerBounds.left &&
    +            Math.min(segment.a.x, segment.b.x) < blockerBounds.right
    +          : segment.a.x > blockerBounds.left &&
    +            segment.a.x < blockerBounds.right &&
    +            Math.max(segment.a.y, segment.b.y) > blockerBounds.top &&
    +            Math.min(segment.a.y, segment.b.y) < blockerBounds.bottom;
    +      expect(crosses).toBe(false);
    +    }
    +    expect(validateLayout(data)).toMatchObject({ ok: true, issues: [] });
    +  });
    +
    +  it.each([
    +    {
    +      reason: 'vertex_cap',
    +      options: { topologyCaps: { maxVertices: 1 } },
    +    },
    +    {
    +      reason: 'adjacency_cap',
    +      options: { topologyCaps: { maxAdjacencyEntries: 1 } },
    +    },
    +    {
    +      reason: 'estimated_memory_cap',
    +      options: { topologyCaps: { maxEstimatedBytes: 1 } },
    +    },
    +    {
    +      reason: 'search_state_cap',
    +      options: { searchCaps: { maxExpandedStates: 1 } },
    +    },
    +  ] as const)(
    +    'falls back only for the fixed $reason resource-cap reason',
    +    ({ reason, options }) => {
    +      const data = baseLayout(
    +        [leaf('a', 80, 40, { row: 1, column: 1 }), leaf('b', 80, 40, { row: 1, column: 2 })],
    +        [edge('a-b', 'a', 'b')],
    +        { columnGap: 60 }
    +      );
    +      const metrics = createGridRoutingInstrumentation();
    +
    +      runGridLayoutCore(data, { metrics, routing: options });
    +
    +      expect(validateLayout(data)).toMatchObject({ ok: true, issues: [] });
    +      // These counters prove the configured cap selected a validated fallback for the expected
    +      // reason; valid geometry alone cannot distinguish fallback routing from the normal path.
    +      expect(metrics.resourceLimitFallbacks).toBe(1);
    +      expect(metrics.fallbackReasons[reason]).toBe(1);
    +      expect(metrics.fallbackValidationFailures).toBe(0);
    +      expect(metrics.routeOrder).toEqual(['a-b']);
    +      expect(metrics.routes).toHaveLength(1);
    +      expect(metrics.routesFound).toBe(1);
    +      expect(metrics.routesImpossible).toBe(0);
    +      expect(metrics.routeLength).toBe(metrics.routes[0].routeLength);
    +      expect(metrics.bendCount).toBe(metrics.routes[0].bendCount);
    +      expect(metrics.crossingCount).toBe(metrics.routes[0].crossingCount);
    +      expect(metrics.sharedLength).toBe(metrics.routes[0].sharedLength);
    +    }
    +  );
    +
    +  it('rejects an invalid resource-cap fallback instead of committing it', () => {
    +    const data = baseLayout(
    +      [leaf('a', 80, 40, { row: 1, column: 1 }), leaf('b', 80, 40, { row: 1, column: 2 })],
    +      [edge('a-b', 'a', 'b')],
    +      { columnGap: 0 }
    +    );
    +    const metrics = createGridRoutingInstrumentation();
    +    const before = structuredClone(data);
    +
    +    expect(() =>
    +      runGridLayoutCore(data, { metrics, routing: { topologyCaps: { maxVertices: 1 } } })
    +    ).toThrowError(/GRID_ROUTE_NOT_FOUND: Invalid legacy fallback/);
    +    expect(data).toEqual(before);
    +    // The counters are necessary to prove the rejected geometry came from the configured fallback
    +    // and that fallback validation, rather than an unrelated routing failure, blocked the commit.
    +    expect(metrics.resourceLimitFallbacks).toBe(1);
    +    expect(metrics.fallbackValidationFailures).toBe(1);
    +    expect(metrics.routeOrder).toEqual([]);
    +    expect(metrics.routes).toEqual([]);
    +    expect(metrics.routesFound).toBe(0);
    +    expect(metrics.routeLength).toBe(0);
    +    expect(metrics.bendCount).toBe(0);
    +    expect(metrics.crossingCount).toBe(0);
    +    expect(metrics.sharedLength).toBe(0);
    +  });
    +
    +  it('validates resource-cap fallback for every hierarchy segment', () => {
    +    const data = baseLayout(
    +      [
    +        group('left-group', 'Left', { row: 1, column: 1 }),
    +        leaf('source', 70, 36, { row: 1, column: 1 }, 'left-group'),
    +        group('right-group', 'Right', { row: 1, column: 2 }),
    +        leaf('target', 70, 36, { row: 1, column: 1 }, 'right-group'),
    +      ],
    +      [edge('cross-group', 'source', 'target')],
    +      { rowGap: 70, columnGap: 90 }
    +    );
    +    const metrics = createGridRoutingInstrumentation();
    +
    +    runGridLayoutCore(data, { metrics, routing: { topologyCaps: { maxVertices: 1 } } });
    +
    +    expect(validateLayout(data)).toMatchObject({ ok: true, issues: [] });
    +    // A cross-group route has three independently capped hierarchy segments; these counters prove
    +    // every segment used and validated the fallback rather than only the final assembled route.
    +    expect(metrics.resourceLimitFallbacks).toBe(3);
    +    expect(metrics.fallbackReasons.vertex_cap).toBe(3);
    +    expect(metrics.fallbackValidationFailures).toBe(0);
    +  });
    +
    +  it('commits an earlier validated candidate when a later candidate reaches the search cap', () => {
    +    const data = baseLayout(
    +      [
    +        leaf('a', 80, 40, { row: 1, column: 1 }),
    +        leaf('blocker', 80, 400, { row: 1, column: 2 }),
    +        leaf('b', 80, 40, { row: 1, column: 3 }),
    +      ],
    +      [edge('a-b', 'a', 'b')],
    +      { columnGap: 40 }
    +    );
    +    const metrics = createGridRoutingInstrumentation();
    +
    +    runGridLayoutCore(data, {
    +      metrics,
    +      routing: { searchCaps: { maxInvocationExpandedStates: 30 } },
    +    });
    +
    +    expect(validateLayout(data)).toMatchObject({ ok: true, issues: [] });
    +    // The exact expansion count is the configured shared invocation budget; it verifies the router
    +    // stops at the cap while retaining the earlier validated candidate.
    +    expect(metrics.expandedStates).toBe(30);
    +    expect(data.edges[0].points).toEqual([
    +      { x: 80, y: 200 },
    +      { x: 114, y: 200 },
    +      { x: 114, y: -6 },
    +      { x: 206, y: -6 },
    +      { x: 206, y: 200 },
    +      { x: 240, y: 200 },
    +    ]);
    +  });
    +
    +  it('warns once when the shared invocation search budget is exhausted', () => {
    +    const data = baseLayout(
    +      [
    +        leaf('a', 80, 40, { row: 1, column: 1 }),
    +        leaf('b', 80, 40, { row: 1, column: 2 }),
    +        leaf('c', 80, 40, { row: 1, column: 3 }),
    +      ],
    +      [edge('a-b', 'a', 'b'), edge('b-c', 'b', 'c')],
    +      { columnGap: 60 }
    +    );
    +    const warn = vi.spyOn(log, 'warn').mockImplementation(() => undefined);
    +    const metrics = createGridRoutingInstrumentation();
    +
    +    try {
    +      runGridLayoutCore(data, {
    +        metrics,
    +        routing: { searchCaps: { maxInvocationExpandedStates: 1 } },
    +      });
    +
    +      expect(invalidRoutingIssues(data)).toEqual([]);
    +      expect(warn).toHaveBeenCalledTimes(1);
    +      expect(warn).toHaveBeenCalledWith(
    +        'grid-router',
    +        'GRID_ROUTING_INVOCATION_SEARCH_BUDGET_EXHAUSTED',
    +        expect.objectContaining({
    +          edgeId: expect.any(String),
    +          containerId: ROOT_CONTAINER_ID,
    +          expandedStates: 1,
    +        })
    +      );
    +      expect(metrics.endpointOverlayBuilds).toBe(1);
    +    } finally {
    +      warn.mockRestore();
    +    }
    +  });
    +
    +  it('does not fall back when endpoint capacity makes a route impossible', () => {
    +    const data = baseLayout(
    +      [leaf('a', 10, 10, { row: 1, column: 1 }), leaf('b', 10, 10, { row: 1, column: 2 })],
    +      [edge('a-b', 'a', 'b')],
    +      { columnGap: 40 }
    +    );
    +    const before = structuredClone(data);
    +
    +    expect(() => runGridLayoutCore(data)).toThrowError(
    +      /GRID_ROUTE_NOT_FOUND: No legal endpoint candidates/
    +    );
    +    expect(data).toEqual(before);
    +  });
    +
    +  it('routes high-degree endpoints when no side can preserve minimum port spacing', () => {
    +    const data = baseLayout(
    +      [
    +        leaf('source', 80, 40, { row: 1, column: 1 }),
    +        ...Array.from({ length: 19 }, (_, index) =>
    +          leaf(`target-${index}`, 80, 40, { row: 1, column: index + 2 })
    +        ),
    +      ],
    +      Array.from({ length: 19 }, (_, index) => edge(`edge-${index}`, 'source', `target-${index}`)),
    +      { columnGap: 40 }
    +    );
    +
    +    runGridLayoutCore(data);
    +
    +    expect(data.edges.every(({ points }) => (points?.length ?? 0) >= 2)).toBe(true);
    +    expect(
    +      new Set(data.edges.map(({ points }) => JSON.stringify(points?.[0]))).size
    +    ).toBeGreaterThan(1);
    +  });
    +
    +  it('routes hierarchy bundles when strict separation cannot be preserved', () => {
    +    const makeData = (reverseEdges = false) => {
    +      const edges = Array.from({ length: 6 }, (_, index) =>
    +        edge(`edge-${index}`, 'source', 'target')
    +      );
    +      return baseLayout(
    +        [
    +          group('left-group', 'Left', { row: 1, column: 1 }),
    +          leaf('source', 80, 40, { row: 1, column: 1 }, 'left-group'),
    +          group('right-group', 'Right', { row: 1, column: 2 }),
    +          leaf('target', 80, 40, { row: 1, column: 1 }, 'right-group'),
    +        ],
    +        reverseEdges ? edges.reverse() : edges,
    +        { rowGap: 50, columnGap: 90 }
    +      );
    +    };
    +    const data = makeData();
    +    const metrics = createGridRoutingInstrumentation();
    +
    +    runGridLayoutCore(data, { metrics });
    +
    +    expect(data.edges.every(({ points }) => (points?.length ?? 0) >= 2)).toBe(true);
    +    expect(invalidRoutingIssues(data)).toEqual([]);
    +    expect(metrics).toMatchObject({
    +      hierarchyPortalPairs: 12,
    +      hierarchyPortalTransitionLength: 144,
    +      hierarchyPortalAlternativeAttempts: 8,
    +      hierarchyPortalAlternativeSelections: 4,
    +      hierarchyBoundaryTransitions: 12,
    +      routesFound: 6,
    +      routesImpossible: 0,
    +      bundleSeparationRelaxations: 2,
    +      routeOrder: ['edge-0', 'edge-1', 'edge-2', 'edge-3', 'edge-4', 'edge-5'],
    +    });
    +    expect(
    +      metrics.routes.map(({ edgeId, routeOrder, laneOffset, boundaryTransitionCount }) => ({
    +        edgeId,
    +        routeOrder,
    +        laneOffset,
    +        boundaryTransitionCount,
    +      }))
    +    ).toEqual([
    +      { edgeId: 'edge-0', routeOrder: 0, laneOffset: -20, boundaryTransitionCount: 2 },
    +      { edgeId: 'edge-1', routeOrder: 1, laneOffset: -12, boundaryTransitionCount: 2 },
    +      { edgeId: 'edge-2', routeOrder: 2, laneOffset: -4, boundaryTransitionCount: 2 },
    +      { edgeId: 'edge-3', routeOrder: 3, laneOffset: 4, boundaryTransitionCount: 2 },
    +      { edgeId: 'edge-4', routeOrder: 4, laneOffset: 12, boundaryTransitionCount: 2 },
    +      { edgeId: 'edge-5', routeOrder: 5, laneOffset: 20, boundaryTransitionCount: 2 },
    +    ]);
    +    expect(metrics.routeLength).toBeCloseTo(
    +      metrics.routes.reduce((total, route) => total + route.routeLength, 0)
    +    );
    +    expect(metrics.bendCount).toBe(
    +      metrics.routes.reduce((total, route) => total + route.bendCount, 0)
    +    );
    +    expect(metrics.crossingCount).toBe(
    +      metrics.routes.reduce((total, route) => total + route.crossingCount, 0)
    +    );
    +    expect(metrics.sharedLength).toBeCloseTo(
    +      metrics.routes.reduce((total, route) => total + route.sharedLength, 0)
    +    );
    +
    +    const reversed = makeData(true);
    +    const reversedMetrics = createGridRoutingInstrumentation();
    +    runGridLayoutCore(reversed, { metrics: reversedMetrics });
    +
    +    expect(
    +      Object.fromEntries(data.edges.map(({ id, points }) => [id, JSON.stringify(points)]))
    +    ).toEqual(
    +      Object.fromEntries(reversed.edges.map(({ id, points }) => [id, JSON.stringify(points)]))
    +    );
    +    expect(reversedMetrics.routeOrder).toEqual(metrics.routeOrder);
    +    expect(reversedMetrics.routes).toEqual(metrics.routes);
    +  });
    +
    +  it('excludes group titles and corners from same-container endpoint slots', () => {
    +    const data = baseLayout(
    +      [
    +        leaf('above', 60, 30, { row: 1, column: 1 }),
    +        group('g', 'Titled group', { row: 2, column: 1 }),
    +        leaf('member', 80, 40, { row: 1, column: 1 }, 'g'),
    +      ],
    +      [edge('g-above', 'g', 'above')],
    +      { rowGap: 70 }
    +    );
    +
    +    runGridLayoutCore(data);
    +
    +    const owner = data.nodes.find(({ id }) => id === 'g')!;
    +    const port = data.edges[0].points![0];
    +    const ownerRect = {
    +      left: (owner.x ?? 0) - (owner.width ?? 0) / 2,
    +      right: (owner.x ?? 0) + (owner.width ?? 0) / 2,
    +      top: (owner.y ?? 0) - (owner.height ?? 0) / 2,
    +      bottom: (owner.y ?? 0) + (owner.height ?? 0) / 2,
    +    };
    +    expect(port.y).not.toBe(ownerRect.top);
    +    if (port.x === ownerRect.left || port.x === ownerRect.right) {
    +      expect(port.y).toBeGreaterThanOrEqual((owner.groupTitleRect?.bottom ?? ownerRect.top) + 6);
    +    }
    +    expect(port.y).toBeLessThanOrEqual(ownerRect.bottom - 6);
    +  });
    +
    +  it('routes same-cell stack endpoints around measured intervening items', () => {
    +    const data = baseLayout(
    +      [
    +        leaf('top', 70, 32, { row: 1, column: 1 }),
    +        leaf('middle', 90, 42, { row: 1, column: 1 }),
    +        leaf('bottom', 70, 32, { row: 1, column: 1 }),
    +      ],
    +      [edge('stack-edge', 'top', 'bottom')],
    +      { cellGap: 20 }
    +    );
    +
    +    runGridLayoutCore(data);
    +
    +    expect(validateLayout(data)).toMatchObject({ ok: true, issues: [] });
    +    expect(normalizePolyline(data.edges[0].points ?? []).bends).toBeGreaterThanOrEqual(2);
    +  });
    +
    +  it('routes hierarchy edges to a root sibling in the same stacked cell', () => {
    +    const data = baseLayout(
    +      [
    +        group('G', 'Group One', { row: 1, column: 1 }),
    +        leaf('A', 80, 40, { row: 1, column: 1 }, 'G'),
    +        leaf('B', 80, 40, { row: 2, column: 1 }, 'G'),
    +        leaf('C', 80, 40, { row: 1, column: 1 }),
    +      ],
    +      [edge('A-to-C', 'A', 'C'), edge('B-to-C', 'B', 'C')]
    +    );
    +
    +    runGridLayoutCore(data);
    +
    +    expect(data.edges.every(({ points }) => (points?.length ?? 0) >= 2)).toBe(true);
    +    expect(invalidRoutingIssues(data)).toEqual([]);
    +  });
    +
    +  it('routes a mixed-demand hierarchy edge around intervening stacked items', () => {
    +    const data = baseLayout(
    +      [
    +        group('group', 'Group', { row: 1, column: 1 }),
    +        leaf('top', 70, 32, { row: 1, column: 1 }, 'group'),
    +        leaf('middle', 90, 42, { row: 1, column: 1 }, 'group'),
    +        leaf('bottom', 70, 32, { row: 1, column: 1 }, 'group'),
    +        leaf('outside', 70, 32, { row: 1, column: 2 }),
    +      ],
    +      [edge('hierarchy-edge', 'top', 'outside'), edge('incident-edge', 'top', 'middle')],
    +      { cellGap: 20, columnGap: 80 }
    +    );
    +    runGridLayoutCore(data);
    +
    +    expect(validateLayout(data)).toMatchObject({ ok: true, issues: [] });
    +    expect(normalizePolyline(data.edges[0].points ?? []).bends).toBeGreaterThanOrEqual(2);
    +  });
    +
    +  it('routes a vertical mixed-demand stack without invalid geometry', () => {
    +    const data = baseLayout(
    +      [
    +        group('group', 'Group', { row: 1, column: 1 }),
    +        leaf('top', 70, 32, { row: 1, column: 1 }, 'group'),
    +        leaf('middle', 90, 42, { row: 1, column: 1 }, 'group'),
    +        leaf('bottom', 70, 32, { row: 1, column: 1 }, 'group'),
    +        leaf('outside', 70, 32, { row: 2, column: 1 }),
    +      ],
    +      [edge('hierarchy-edge', 'top', 'outside'), edge('incident-edge', 'top', 'middle')],
    +      { cellGap: 20, rowGap: 80 }
    +    );
    +    runGridLayoutCore(data);
    +
    +    expect(data.edges.every(({ points }) => (points?.length ?? 0) >= 2)).toBe(true);
    +    expect(invalidRoutingIssues(data)).toEqual([]);
    +  });
    +
    +  it.each([
    +    {
    +      name: 'nested siblings',
    +      nodes: [
    +        group('outer', 'Outer', { row: 1, column: 1 }),
    +        group('inner', 'Inner', { row: 1, column: 1 }, 'outer'),
    +        leaf('source', 70, 36, { row: 1, column: 1 }, 'inner'),
    +        leaf('target', 70, 36, { row: 1, column: 2 }, 'inner'),
    +      ],
    +      route: edge('nested-siblings', 'source', 'target'),
    +      boundaries: [],
    +    },
    +    {
    +      name: 'group/member',
    +      nodes: [
    +        group('outer', 'Outer', { row: 1, column: 1 }),
    +        leaf('source', 70, 36, { row: 1, column: 1 }, 'outer'),
    +      ],
    +      route: edge('group-member', 'outer', 'source'),
    +      boundaries: ['outer'],
    +    },
    +    {
    +      name: 'ancestor/member',
    +      nodes: [
    +        group('outer', 'Outer', { row: 1, column: 1 }),
    +        group('inner', 'Inner', { row: 1, column: 1 }, 'outer'),
    +        leaf('source', 70, 36, { row: 1, column: 1 }, 'inner'),
    +      ],
    +      route: edge('ancestor-member', 'outer', 'source'),
    +      boundaries: ['outer', 'inner'],
    +    },
    +    {
    +      name: 'cross-group members',
    +      nodes: [
    +        group('left-group', 'Left', { row: 1, column: 1 }),
    +        leaf('source', 70, 36, { row: 1, column: 1 }, 'left-group'),
    +        group('right-group', 'Right', { row: 1, column: 2 }),
    +        leaf('target', 70, 36, { row: 1, column: 1 }, 'right-group'),
    +      ],
    +      route: edge('cross-group', 'source', 'target'),
    +      boundaries: ['left-group', 'right-group'],
    +    },
    +    {
    +      name: 'outside/group endpoint',
    +      nodes: [
    +        leaf('source', 70, 36, { row: 1, column: 1 }),
    +        group('target-group', 'Target', { row: 1, column: 2 }),
    +        leaf('member', 70, 36, { row: 1, column: 1 }, 'target-group'),
    +      ],
    +      route: edge('group-endpoint', 'source', 'target-group'),
    +      boundaries: [],
    +    },
    +  ])(
    +    'routes $name container-by-container with only required boundary transitions',
    +    ({ nodes, route, boundaries }) => {
    +      const build = () =>
    +        baseLayout(
    +          nodes.map((node) => ({ ...node })),
    +          [{ ...route }],
    +          {
    +            rowGap: 70,
    +            columnGap: 90,
    +          }
    +        );
    +      const data = build();
    +      runGridLayoutCore(data);
    +
    +      expect(validateLayout(data)).toMatchObject({ ok: true, issues: [] });
    +      const routed = data.edges[0];
    +      const groups = data.nodes.filter((node) => node.isGroup);
    +      const actual = groups.reduce(
    +        (total, owner) => total + boundaryCrossings(routed.points ?? [], owner),
    +        0
    +      );
    +      expect(actual).toBe(boundaries.length);
    +      for (const owner of groups) {
    +        expect(boundaryCrossings(routed.points ?? [], owner)).toBe(
    +          boundaries.includes(owner.id) ? 1 : 0
    +        );
    +      }
    +      const rerun = build();
    +      runGridLayoutCore(rerun);
    +      expect(rerun.edges[0].points).toEqual(routed.points);
    +    }
    +  );
    +
    +  it('validates assembled hierarchy routes across deterministic placement variations', () => {
    +    for (let caseIndex = 0; caseIndex < 64; caseIndex++) {
    +      const leftRow = 1 + (caseIndex % 3);
    +      const rightRow = 1 + ((caseIndex >> 2) % 3);
    +      const leftColumn = 1 + ((caseIndex >> 4) % 2);
    +      const rightColumn = leftColumn + 1 + ((caseIndex >> 5) % 2);
    +      const data = baseLayout(
    +        [
    +          group('left-group', 'Left', { row: leftRow, column: leftColumn }),
    +          leaf('source', 60, 32, { row: 1, column: 1 }, 'left-group'),
    +          leaf('left-blocker', 72, 36, { row: 2, column: 2 }, 'left-group'),
    +          group('right-group', 'Right', { row: rightRow, column: rightColumn }),
    +          leaf('target', 60, 32, { row: 1, column: 1 }, 'right-group'),
    +          leaf('right-blocker', 72, 36, { row: 2, column: 2 }, 'right-group'),
    +        ],
    +        [edge(`cross-group-${caseIndex}`, 'source', 'target')],
    +        {
    +          rowGap: 48 + (caseIndex % 3) * 8,
    +          columnGap: 56 + (caseIndex % 4) * 8,
    +        }
    +      );
    +
    +      runGridLayoutCore(data);
    +
    +      expect(validateLayout(data), `case ${caseIndex}`).toMatchObject({ ok: true, issues: [] });
    +      const route = data.edges[0].points ?? [];
    +      const leftGroup = data.nodes.find(({ id }) => id === 'left-group')!;
    +      const rightGroup = data.nodes.find(({ id }) => id === 'right-group')!;
    +      expect(boundaryCrossings(route, leftGroup), `left boundary case ${caseIndex}`).toBe(1);
    +      expect(boundaryCrossings(route, rightGroup), `right boundary case ${caseIndex}`).toBe(1);
    +    }
    +  });
    +
    +  it('uses 12px paired portals outside title and corner exclusions', () => {
    +    const data = baseLayout(
    +      [
    +        group('titled', 'A deliberately wide title', { row: 1, column: 1 }),
    +        leaf('member', 64, 32, { row: 1, column: 1 }, 'titled'),
    +        leaf('outside', 64, 32, { row: 1, column: 2 }),
    +      ],
    +      [edge('title-adjacent', 'member', 'outside')],
    +      { rowGap: 60, columnGap: 80 }
    +    );
    +    const metrics = createGridRoutingInstrumentation();
    +
    +    runGridLayoutCore(data, { metrics });
    +
    +    expect(validateLayout(data)).toMatchObject({ ok: true, issues: [] });
    +    const owner = data.nodes.find(({ id }) => id === 'titled')!;
    +    const rect = nodeRect(owner);
    +    const crossing = normalizePolyline(data.edges[0].points ?? []).segments.find((segment) => {
    +      if (segment.orientation === 'H') {
    +        return (
    +          segment.a.y > rect.top &&
    +          segment.a.y < rect.bottom &&
    +          Math.min(segment.a.x, segment.b.x) < rect.right &&
    +          Math.max(segment.a.x, segment.b.x) > rect.right
    +        );
    +      }
    +      return (
    +        segment.a.x > rect.left &&
    +        segment.a.x < rect.right &&
    +        Math.min(segment.a.y, segment.b.y) < rect.bottom &&
    +        Math.max(segment.a.y, segment.b.y) > rect.bottom
    +      );
    +    });
    +    expect(crossing).toBeDefined();
    +    const tangential = crossing?.orientation === 'H' ? crossing.a.y : crossing?.a.x;
    +    const low =
    +      crossing?.orientation === 'H'
    +        ? Math.max(rect.top + 6, (owner.groupTitleRect?.bottom ?? rect.top) + 6)
    +        : rect.left + 6;
    +    const high = crossing?.orientation === 'H' ? rect.bottom - 6 : rect.right - 6;
    +    expect(tangential).toBeGreaterThanOrEqual(low);
    +    expect(tangential).toBeLessThanOrEqual(high);
    +    // The portal counters express the functional paired-transition contract that is lost when the
    +    // final route is normalized: one boundary crossing must use one 12px portal pair.
    +    expect(metrics.routes[0]?.boundaryTransitionCount).toBe(1);
    +    expect(metrics.hierarchyPortalPairs).toBe(1);
    +    expect(metrics.hierarchyPortalTransitionLength).toBe(12);
    +  });
    +
    +  it('keeps independent hierarchy portals near their preferred coordinates', () => {
    +    const data = baseLayout(
    +      [
    +        group('group', 'Stacked cell', { row: 1, column: 1 }),
    +        leaf(
    +          'a',
    +          80,
    +          40,
    +          { row: 1, column: 1, horizontalAlign: 'left', verticalAlign: 'bottom' },
    +          'group'
    +        ),
    +        leaf(
    +          'b',
    +          80,
    +          40,
    +          { row: 2, column: 1, horizontalAlign: 'right', verticalAlign: 'bottom' },
    +          'group'
    +        ),
    +        leaf('c', 80, 40, { row: 1, column: 2 }),
    +      ],
    +      [edge('a-c', 'a', 'c'), edge('b-c', 'b', 'c')],
    +      { cellGap: 16 }
    +    );
    +
    +    runGridLayoutCore(data);
    +
    +    expect(validateLayout(data)).toMatchObject({ ok: true, issues: [] });
    +    const owner = data.nodes.find(({ id }) => id === 'group')!;
    +    const ownerRect = nodeRect(owner);
    +    const crossingCoordinate = (route: Edge): number => {
    +      const crossing = normalizePolyline(route.points ?? []).segments.find((segment) => {
    +        if (segment.orientation !== 'H') {
    +          return false;
    +        }
    +        const low = Math.min(segment.a.x, segment.b.x);
    +        const high = Math.max(segment.a.x, segment.b.x);
    +        return low < ownerRect.right && high > ownerRect.right;
    +      });
    +      expect(crossing).toBeDefined();
    +      return crossing!.a.y;
    +    };
    +
    +    for (const route of data.edges) {
    +      const source = data.nodes.find(({ id }) => id === route.start)!;
    +      expect(Math.abs(crossingCoordinate(route) - (source.y ?? 0))).toBeLessThanOrEqual(1);
    +    }
    +  });
    +
    +  it('keeps a single hierarchy portal near its endpoint instead of centering it', () => {
    +    const data = baseLayout(
    +      [
    +        group('group', 'Group', { row: 1, column: 1 }),
    +        leaf('a', 80, 40, { row: 1, column: 1 }, 'group'),
    +        leaf('c', 80, 40, { row: 1, column: 2 }),
    +      ],
    +      [edge('a-c', 'a', 'c')],
    +      { cellGap: 16 }
    +    );
    +
    +    runGridLayoutCore(data);
    +
    +    expect(validateLayout(data)).toMatchObject({ ok: true, issues: [] });
    +    const owner = data.nodes.find(({ id }) => id === 'group')!;
    +    const source = data.nodes.find(({ id }) => id === 'a')!;
    +    const ownerRect = nodeRect(owner);
    +    const crossing = normalizePolyline(data.edges[0].points ?? []).segments.find((segment) => {
    +      if (segment.orientation !== 'H') {
    +        return false;
    +      }
    +      const low = Math.min(segment.a.x, segment.b.x);
    +      const high = Math.max(segment.a.x, segment.b.x);
    +      return low < ownerRect.right && high > ownerRect.right;
    +    });
    +
    +    expect(crossing).toBeDefined();
    +    expect(Math.abs(crossing!.a.y - (source.y ?? 0))).toBeLessThanOrEqual(1);
    +    const endpointYs = [source.y ?? 0, data.nodes.find(({ id }) => id === 'c')?.y ?? 0];
    +    const routeYs = data.edges[0].points?.map(({ y }) => y) ?? [];
    +    expect(Math.min(...routeYs)).toBeGreaterThanOrEqual(Math.min(...endpointYs) - 1);
    +    expect(Math.max(...routeYs)).toBeLessThanOrEqual(Math.max(...endpointYs) + 1);
    +  });
    +
    +  it('reports test-only sparse and legacy route comparison without changing selection', () => {
    +    const data = baseLayout(
    +      [
    +        leaf('v1', 80, 40, { row: 1, column: 1 }),
    +        leaf('v2', 80, 40, { row: 1, column: 3 }),
    +        leaf('v2p', 80, 40, { row: 2, column: 2 }),
    +      ],
    +      [edge('v1-v2', 'v1', 'v2')],
    +      { rowGap: 40, columnGap: 40 }
    +    );
    +    const comparisons: unknown[] = [];
    +
    +    runGridLayoutCore(data, {
    +      routing: {
    +        onDualRouteComparison: (comparison) => comparisons.push(comparison),
    +      },
    +    });
    +
    +    expect(comparisons).toEqual([
    +      expect.objectContaining({
    +        edgeId: 'v1-v2',
    +        sparse: expect.objectContaining({ valid: true, bends: 0 }),
    +        legacy: expect.objectContaining({ valid: true, bends: 4 }),
    +      }),
    +    ]);
    +    expect(normalizePolyline(data.edges[0].points ?? []).segments).toHaveLength(1);
    +  });
    +});
    diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/grid/router.ts b/packages/mermaid/src/rendering-util/layout-algorithms/grid/router.ts
    new file mode 100644
    index 00000000000..61f37129774
    --- /dev/null
    +++ b/packages/mermaid/src/rendering-util/layout-algorithms/grid/router.ts
    @@ -0,0 +1,48 @@
    +import type { LayoutData } from '../../types.js';
    +import type { GridRoutingInstrumentation } from './routerInstrumentation.js';
    +import type { RouterSearchCaps } from './routerSearch.js';
    +import { GridEdgeRoutingSession } from './routerSession.js';
    +import type { TopologyResourceCaps } from './routerTopology.js';
    +import type { GridLayoutResult } from './types.js';
    +
    +// Public routing facade. The implementation is split by phase so callers only depend on the
    +// stable options and compatibility exports that predate the module extraction.
    +export {
    +  areExactlyAxisAligned,
    +  boundedAlternativePortalCoordinates,
    +} from './routerCompatibility.js';
    +export { validateSameContainerRoute } from './routerConstraint.js';
    +export { assignCompactPortalCoordinates } from './routerPlanning.js';
    +
    +export interface GridRoutingDualRouteComparison {
    +  edgeId: string;
    +  sparse: { valid: boolean; length: number; bends: number };
    +  legacy: { valid: boolean; length: number; bends: number };
    +}
    +
    +export interface GridRoutingOptions {
    +  // Caps are also test seams, so enabling them deliberately bypasses compatibility fast paths.
    +  topologyCaps?: TopologyResourceCaps;
    +  searchCaps?: RouterSearchCaps;
    +}
    +
    +/** @internal */
    +export interface GridRoutingTestOptions extends GridRoutingOptions {
    +  onDualRouteComparison?: (comparison: GridRoutingDualRouteComparison) => void;
    +}
    +
    +export function routeGridEdges(
    +  layout: LayoutData,
    +  result: GridLayoutResult,
    +  metrics?: GridRoutingInstrumentation,
    +  options?: GridRoutingOptions
    +): void;
    +export function routeGridEdges(
    +  layout: LayoutData,
    +  result: GridLayoutResult,
    +  metrics?: GridRoutingInstrumentation,
    +  options: GridRoutingTestOptions = {}
    +): void {
    +  // A fresh session owns every mutable routing structure and commits routes in deterministic order.
    +  new GridEdgeRoutingSession(layout, result, metrics, options).route();
    +}
    diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/grid/routerCompatibility.spec.ts b/packages/mermaid/src/rendering-util/layout-algorithms/grid/routerCompatibility.spec.ts
    new file mode 100644
    index 00000000000..de328f40d17
    --- /dev/null
    +++ b/packages/mermaid/src/rendering-util/layout-algorithms/grid/routerCompatibility.spec.ts
    @@ -0,0 +1,163 @@
    +import { describe, expect, it } from 'vitest';
    +import type { Node } from '../../types.js';
    +import {
    +  areExactlyAxisAligned,
    +  boundedAlternativePortalCoordinates,
    +  combinePointChains,
    +  labelAwareSelfLoopCandidates,
    +  orderedSelfLoopSides,
    +  reversePoints,
    +} from './routerCompatibility.js';
    +import { ownerSideKey } from './routerPlanning.js';
    +import type { GridSide } from './types.js';
    +
    +const point = (x: number, y: number): { x: number; y: number } => ({ x, y });
    +
    +describe('grid router compatibility', () => {
    +  it('bounds alternative portal coordinates and prefers nearby routing corridors', () => {
    +    expect(boundedAlternativePortalCoordinates(50, 10, 90, [80, 20, 60, 50, 100])).toEqual([
    +      60, 20, 80,
    +    ]);
    +    expect(boundedAlternativePortalCoordinates(50, 10, 90, [], 4)).toEqual([10, 90]);
    +  });
    +
    +  it('requires exact axis alignment for the direct route shortcut', () => {
    +    expect(areExactlyAxisAligned({ x: 0, y: 0 }, { x: 0.5, y: 10 })).toBe(false);
    +    expect(areExactlyAxisAligned({ x: 0, y: 0 }, { x: 0, y: 10 })).toBe(true);
    +  });
    +
    +  it('expands a labelled loop as one U when the owner side can hold the ports', () => {
    +    const owner = {
    +      id: 'owner',
    +      isGroup: false,
    +      x: 40,
    +      y: 20,
    +      width: 80,
    +      height: 40,
    +    } as Node;
    +
    +    expect(
    +      labelAwareSelfLoopCandidates(owner, 'left', 0, 20, {
    +        along: 90,
    +        cross: 20,
    +        startInset: 12,
    +        endInset: 12,
    +        requiredAlongSpan: 116,
    +      })
    +    ).toEqual([[point(0, 4), point(-51, 4), point(-51, 36), point(0, 36)]]);
    +  });
    +
    +  it('uses one symmetric shoulder when the label span exceeds the owner side', () => {
    +    const owner = {
    +      id: 'owner',
    +      isGroup: false,
    +      x: 40,
    +      y: 20,
    +      width: 80,
    +      height: 40,
    +    } as Node;
    +
    +    const [candidate] = labelAwareSelfLoopCandidates(owner, 'top', 0, 40, {
    +      along: 90,
    +      cross: 20,
    +      startInset: 12,
    +      endInset: 12,
    +      requiredAlongSpan: 116,
    +    });
    +
    +    expect(candidate).toEqual([
    +      point(31, 0),
    +      point(31, -14),
    +      point(-11, -14),
    +      point(-11, -32),
    +      point(91, -32),
    +      point(91, -14),
    +      point(49, -14),
    +      point(49, 0),
    +    ]);
    +  });
    +
    +  it.each([
    +    {
    +      name: 'empty chains',
    +      chains: [],
    +      expected: [],
    +    },
    +    {
    +      name: 'duplicate chain junctions',
    +      chains: [
    +        [point(0, 0), point(10, 0)],
    +        [point(10, 0), point(10, 10)],
    +      ],
    +      expected: [point(0, 0), point(10, 0), point(10, 10)],
    +    },
    +    {
    +      name: 'collinear spans across chains',
    +      chains: [
    +        [point(0, 0), point(10, 0)],
    +        [point(10, 0), point(20, 0)],
    +      ],
    +      expected: [point(0, 0), point(20, 0)],
    +    },
    +  ])('combines $name', ({ chains, expected }) => {
    +    expect(combinePointChains(chains)).toEqual(expected);
    +  });
    +
    +  it.each([
    +    { name: 'an empty chain', points: [], expected: [] },
    +    {
    +      name: 'a populated chain',
    +      points: [point(0, 0), point(10, 0), point(10, 10)],
    +      expected: [point(10, 10), point(10, 0), point(0, 0)],
    +    },
    +  ])('reverses $name without mutating the input', ({ points, expected }) => {
    +    const before = structuredClone(points);
    +
    +    expect(reversePoints(points)).toEqual(expected);
    +    expect(points).toEqual(before);
    +  });
    +
    +  it.each([
    +    {
    +      name: 'preserves the default side order when demand is tied',
    +      titled: false,
    +      counts: {},
    +      selfLoopCounts: {},
    +      expected: ['left', 'right', 'top', 'bottom'],
    +    },
    +    {
    +      name: 'combines ordinary and self-loop demand',
    +      titled: false,
    +      counts: { left: 2, top: 4, bottom: 1 },
    +      selfLoopCounts: { right: 3, bottom: 1 },
    +      expected: ['left', 'bottom', 'right', 'top'],
    +    },
    +    {
    +      name: 'excludes the title side for titled groups',
    +      titled: true,
    +      counts: { left: 2, right: 1, top: 0, bottom: 3 },
    +      selfLoopCounts: {},
    +      expected: ['right', 'left', 'bottom'],
    +    },
    +  ] as const)(
    +    '$name',
    +    ({ titled, counts: countBySide, selfLoopCounts: selfLoopCountBySide, expected }) => {
    +      const node = {
    +        id: 'owner:1',
    +        isGroup: titled,
    +        groupTitleRect: titled ? { left: -10, right: 10, top: -10, bottom: 0 } : undefined,
    +      } as Node;
    +      const counts = new Map(
    +        Object.entries(countBySide).map(([side, count]) => [
    +          ownerSideKey(node.id, side as GridSide),
    +          count,
    +        ])
    +      );
    +      const selfLoopCounts = new Map(
    +        Object.entries(selfLoopCountBySide).map(([side, count]) => [`${node.id}:${side}`, count])
    +      );
    +
    +      expect(orderedSelfLoopSides(node, counts, selfLoopCounts)).toEqual(expected);
    +    }
    +  );
    +});
    diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/grid/routerCompatibility.ts b/packages/mermaid/src/rendering-util/layout-algorithms/grid/routerCompatibility.ts
    new file mode 100644
    index 00000000000..c43de06c16a
    --- /dev/null
    +++ b/packages/mermaid/src/rendering-util/layout-algorithms/grid/routerCompatibility.ts
    @@ -0,0 +1,1224 @@
    +import type { Point } from '../../../types.js';
    +import type { Node } from '../../types.js';
    +import { normalizePolyline } from '../layout-utils/geometry.js';
    +import { manhattanLength, polylineIntersectsRect, rectForNode } from '../layout-utils/helpers.js';
    +import type { Rect } from '../layout-utils/types.js';
    +import { isAncestorGroup } from './groups.js';
    +import { GRID_LABEL_CLEARANCE, type GridLabelSpanRequirement } from './labelGeometry.js';
    +import type { GridRoutingTestOptions } from './router.js';
    +import {
    +  routerRect,
    +  validateContainerSegment,
    +  validateSameContainerRoute,
    +} from './routerConstraint.js';
    +import { RouteOccupancyIndex } from './routerOccupancy.js';
    +import {
    +  LANE_SEPARATION_PX,
    +  TERMINAL_APPROACH_PX,
    +  ownerGroupTitle,
    +  ownerSideKey,
    +  type EdgeEndpointPlan,
    +  type EdgeRoutePlan,
    +  type PreparedEdgeRoutes,
    +} from './routerPlanning.js';
    +import {
    +  GridRoutingResourceLimitError,
    +  type GridRoutingInstrumentation,
    +} from './routerInstrumentation.js';
    +import {
    +  buildContainerRoutingTopology,
    +  DEFAULT_MAX_ROUTING_ESTIMATED_BYTES,
    +  derivePortalRanges,
    +  ROUTE_CLEARANCE_PX,
    +} from './routerTopology.js';
    +import type {
    +  GridAttachment,
    +  GridContainerId,
    +  GridLayoutResult,
    +  GridRoutingContext,
    +  GridSide,
    +  PairedPortal,
    +  PortalRange,
    +  RouterRect,
    +} from './types.js';
    +import { ROOT_CONTAINER_ID, gridError, isEdgeLabelNode } from './types.js';
    +
    +// Deterministic corridor routing remains the validated fast path and the resource-limit fallback
    +// while sparse visibility routing is incrementally adopted for harder routes.
    +const ROOT_OUTER_MARGIN = 24;
    +
    +export const SELF_LOOP_PORT_GAP = 18;
    +
    +const SELF_LOOP_APPROACH = 14;
    +
    +const SELF_LOOP_TRACK_GAP = 18;
    +
    +export const SELF_LOOP_PORT_OFFSET_STEP = 4;
    +
    +export interface PreparedRoutingModes {
    +  compatibilityFastRoutes: Map;
    +  sparseHierarchyIds: Set;
    +  sparseLcaIds: Set;
    +  routedContainerIds: GridContainerId[];
    +  routeOccupancy: Map;
    +}
    +
    +const MAX_PLANS_FOR_OVERLAP_DEMOTION = 64;
    +const MAX_FAST_ROUTE_SEPARATION_STEPS = 4;
    +
    +/**
    + * Moves one interior segment of a minimal corridor route sideways until it no longer shares a
    + * corridor with another edge's route. Shifting an interior segment keeps both terminal stubs and
    + * the Manhattan length, so the result is accepted only if it still passes the caller's validation.
    + */
    +function separateRouteFromOccupied(
    +  route: readonly Point[],
    +  occupancy: RouteOccupancyIndex,
    +  pairKey: string,
    +  isAcceptable: (candidate: Point[]) => boolean
    +): Point[] | undefined {
    +  if (!occupancy.conflictsWithRoute(route, pairKey)) {
    +    return [...route];
    +  }
    +  const points = normalizePolyline([...route]).points;
    +  for (let step = 1; step <= MAX_FAST_ROUTE_SEPARATION_STEPS; step++) {
    +    for (const sign of [1, -1]) {
    +      const delta = sign * step * LANE_SEPARATION_PX;
    +      for (let index = 1; index < points.length - 2; index++) {
    +        const vertical = points[index].x === points[index + 1].x;
    +        const shifted = points.map((point, pointIndex) =>
    +          pointIndex === index || pointIndex === index + 1
    +            ? vertical
    +              ? { x: point.x + delta, y: point.y }
    +              : { x: point.x, y: point.y + delta }
    +            : point
    +        );
    +        if (!occupancy.conflictsWithRoute(shifted, pairKey) && isAcceptable(shifted)) {
    +          return shifted;
    +        }
    +      }
    +    }
    +  }
    +  return undefined;
    +}
    +
    +function terminalStubsAreLongEnough(points: readonly Point[]): boolean {
    +  const stub = (a: Point, b: Point) => Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
    +  return (
    +    stub(points[0], points[1]) >= TERMINAL_APPROACH_PX &&
    +    stub(points.at(-1)!, points.at(-2)!) >= TERMINAL_APPROACH_PX
    +  );
    +}
    +
    +export function boundedAlternativePortalCoordinates(
    +  selected: number,
    +  low: number,
    +  high: number,
    +  corridors: readonly number[],
    +  limit = 3
    +): number[] {
    +  // Retry the nearest established corridors first, then the range's canonical midpoint and ends.
    +  // The hard limit bounds hierarchy retry fan-out and keeps output independent of map iteration.
    +  const candidates = [
    +    ...corridors
    +      .filter((coordinate) => coordinate >= low && coordinate <= high)
    +      .sort((a, b) => Math.abs(a - selected) - Math.abs(b - selected) || a - b),
    +    (low + high) / 2,
    +    low,
    +    high,
    +  ];
    +  const seen = new Set([selected]);
    +  const alternatives: number[] = [];
    +  for (const coordinate of candidates) {
    +    if (seen.has(coordinate)) {
    +      continue;
    +    }
    +    seen.add(coordinate);
    +    alternatives.push(coordinate);
    +    if (alternatives.length === limit) {
    +      break;
    +    }
    +  }
    +  return alternatives;
    +}
    +
    +function equalPoint(a: Point, b: Point): boolean {
    +  return Math.abs(a.x - b.x) <= 1e-6 && Math.abs(a.y - b.y) <= 1e-6;
    +}
    +
    +export function areExactlyAxisAligned(a: Point, b: Point): boolean {
    +  return a.x === b.x || a.y === b.y;
    +}
    +
    +function chooseIntermediateCoordinate(
    +  values: number[],
    +  start: number,
    +  end: number,
    +  variant: number
    +): number {
    +  const sorted = [...values].sort(
    +    (a, b) =>
    +      Math.abs(a - start) + Math.abs(a - end) - (Math.abs(b - start) + Math.abs(b - end)) || a - b
    +  );
    +  return sorted[Math.min(variant, Math.max(0, sorted.length - 1))] ?? (start + end) / 2;
    +}
    +
    +function dedupeConsecutive(points: Point[]): Point[] {
    +  const out: Point[] = [];
    +  for (const point of points) {
    +    if (out.length === 0 || !equalPoint(out[out.length - 1], point)) {
    +      out.push(point);
    +    }
    +  }
    +  return out;
    +}
    +
    +export function routeWithinContainer(
    +  containerId: GridContainerId,
    +  result: GridLayoutResult,
    +  start: GridAttachment,
    +  end: GridAttachment,
    +  variant: number
    +): Point[] {
    +  // This compatibility router only chooses among measured grid corridors. `variant` selects the
    +  // next deterministic dogleg when a bundle retry needs a different lane.
    +  const container = result.containers.get(containerId);
    +  if (!container) {
    +    throw gridError('GRID_ROUTE_NOT_FOUND', `Missing container layout "${containerId}"`);
    +  }
    +
    +  const points: Point[] = [start.port, start.connect];
    +  if (start.orientation === 'V' && end.orientation === 'V') {
    +    if (
    +      !equalPoint(start.connect, end.connect) &&
    +      Math.abs(start.connect.x - end.connect.x) > 1e-6
    +    ) {
    +      const y = chooseIntermediateCoordinate(
    +        container.horizontalCorridors,
    +        start.connect.y,
    +        end.connect.y,
    +        variant
    +      );
    +      points.push({ x: start.connect.x, y }, { x: end.connect.x, y });
    +    } else if (
    +      !equalPoint(start.connect, end.connect) &&
    +      Math.abs(start.connect.y - end.connect.y) > 1e-6 &&
    +      variant > 0
    +    ) {
    +      const alternateCorridors = container.verticalCorridors.filter(
    +        (value) => Math.abs(value - start.connect.x) > 1e-6
    +      );
    +      const x = chooseIntermediateCoordinate(
    +        alternateCorridors.length > 0 ? alternateCorridors : container.verticalCorridors,
    +        start.connect.x,
    +        end.connect.x,
    +        variant - 1
    +      );
    +      points.push({ x, y: start.connect.y }, { x, y: end.connect.y });
    +    }
    +  } else if (start.orientation === 'H' && end.orientation === 'H') {
    +    if (
    +      !equalPoint(start.connect, end.connect) &&
    +      Math.abs(start.connect.y - end.connect.y) > 1e-6
    +    ) {
    +      const x = chooseIntermediateCoordinate(
    +        container.verticalCorridors,
    +        start.connect.x,
    +        end.connect.x,
    +        variant
    +      );
    +      points.push({ x, y: start.connect.y }, { x, y: end.connect.y });
    +    }
    +  } else if (start.orientation === 'V' && end.orientation === 'H') {
    +    points.push({ x: start.connect.x, y: end.connect.y });
    +  } else {
    +    points.push({ x: end.connect.x, y: start.connect.y });
    +  }
    +  points.push(end.connect, end.port);
    +  return normalizePolyline(dedupeConsecutive(points)).points;
    +}
    +
    +export function itemAttachment(
    +  ownerId: string,
    +  side: GridSide,
    +  demandKey: string,
    +  containerId: GridContainerId,
    +  result: GridLayoutResult,
    +  demandCoords: Map,
    +  coordinate?: number
    +): GridAttachment {
    +  const owner = result.forest.nodeById.get(ownerId);
    +  const item = result.itemMeta.get(ownerId);
    +  if (!owner || !item || item.containerId !== containerId) {
    +    throw gridError(
    +      'GRID_ROUTE_NOT_FOUND',
    +      `Missing item metadata for "${ownerId}" in "${containerId}"`
    +    );
    +  }
    +
    +  const rect = rectForNode(owner);
    +  const coord =
    +    coordinate ??
    +    demandCoords.get(demandKey) ??
    +    (side === 'left' || side === 'right' ? rect.cy : rect.cx);
    +  switch (side) {
    +    case 'left':
    +      return {
    +        port: { x: rect.left, y: coord },
    +        connect: { x: item.leftCorridorX, y: coord },
    +        orientation: 'V',
    +        side,
    +      };
    +    case 'right':
    +      return {
    +        port: { x: rect.right, y: coord },
    +        connect: { x: item.rightCorridorX, y: coord },
    +        orientation: 'V',
    +        side,
    +      };
    +    case 'top':
    +      return {
    +        port: { x: coord, y: rect.top },
    +        connect: { x: coord, y: item.topCorridorY },
    +        orientation: 'H',
    +        side,
    +      };
    +    case 'bottom':
    +      return {
    +        port: { x: coord, y: rect.bottom },
    +        connect: { x: coord, y: item.bottomCorridorY },
    +        orientation: 'H',
    +        side,
    +      };
    +  }
    +}
    +
    +export function boundaryAttachment(
    +  containerId: GridContainerId,
    +  side: GridSide,
    +  demandKey: string,
    +  result: GridLayoutResult,
    +  demandCoords: Map
    +): GridAttachment {
    +  const owner = result.forest.nodeById.get(containerId);
    +  const container = result.containers.get(containerId);
    +  if (!owner || !container) {
    +    throw gridError('GRID_ROUTE_NOT_FOUND', `Missing boundary metadata for "${containerId}"`);
    +  }
    +  const rect = rectForNode(owner);
    +  const coord =
    +    demandCoords.get(demandKey) ?? (side === 'left' || side === 'right' ? rect.cy : rect.cx);
    +  switch (side) {
    +    case 'left':
    +      return {
    +        port: { x: rect.left, y: coord },
    +        connect: { x: container.verticalCorridors[0], y: coord },
    +        orientation: 'V',
    +        side,
    +      };
    +    case 'right':
    +      return {
    +        port: { x: rect.right, y: coord },
    +        connect: {
    +          x: container.verticalCorridors[container.verticalCorridors.length - 1],
    +          y: coord,
    +        },
    +        orientation: 'V',
    +        side,
    +      };
    +    case 'top':
    +      return {
    +        port: { x: coord, y: rect.top },
    +        connect: { x: coord, y: container.horizontalCorridors[0] },
    +        orientation: 'H',
    +        side,
    +      };
    +    case 'bottom':
    +      return {
    +        port: { x: coord, y: rect.bottom },
    +        connect: {
    +          x: coord,
    +          y: container.horizontalCorridors[container.horizontalCorridors.length - 1],
    +        },
    +        orientation: 'H',
    +        side,
    +      };
    +  }
    +}
    +
    +export interface SegmentAttachment extends GridAttachment {
    +  ownerId: string;
    +}
    +
    +export interface SegmentAttachmentAlternative {
    +  attachment: SegmentAttachment;
    +  select: () => void;
    +}
    +
    +function portalBoundaryPoint(portal: PairedPortal): Point {
    +  return {
    +    x: (portal.interior.x + portal.exterior.x) / 2,
    +    y: (portal.interior.y + portal.exterior.y) / 2,
    +  };
    +}
    +
    +export function portalAttachment(portal: PairedPortal, interior: boolean): SegmentAttachment {
    +  // The visible port is the group boundary midpoint; search starts on one side of the clearance
    +  // transition so each container topology remains independent.
    +  return {
    +    ownerId: portal.ownerId,
    +    port: portalBoundaryPoint(portal),
    +    connect: interior ? portal.interior : portal.exterior,
    +    orientation: portal.side === 'left' || portal.side === 'right' ? 'V' : 'H',
    +    side: portal.side,
    +  };
    +}
    +
    +export function groupBoundaryEndpointAttachment(
    +  ownerId: string,
    +  side: GridSide,
    +  demandKey: string,
    +  result: GridLayoutResult,
    +  demandCoords: Map
    +): SegmentAttachment {
    +  const owner = result.forest.nodeById.get(ownerId);
    +  if (!owner?.isGroup) {
    +    throw gridError('GRID_ROUTE_NOT_FOUND', `Missing group endpoint "${ownerId}"`);
    +  }
    +  const rect = rectForNode(owner);
    +  const coordinate =
    +    demandCoords.get(demandKey) ?? (side === 'left' || side === 'right' ? rect.cy : rect.cx);
    +  const port =
    +    side === 'left' || side === 'right'
    +      ? { x: side === 'left' ? rect.left : rect.right, y: coordinate }
    +      : { x: coordinate, y: side === 'top' ? rect.top : rect.bottom };
    +  return {
    +    ownerId,
    +    port,
    +    connect: {
    +      x:
    +        port.x +
    +        (side === 'left' ? TERMINAL_APPROACH_PX : side === 'right' ? -TERMINAL_APPROACH_PX : 0),
    +      y:
    +        port.y +
    +        (side === 'top' ? TERMINAL_APPROACH_PX : side === 'bottom' ? -TERMINAL_APPROACH_PX : 0),
    +    },
    +    orientation: side === 'left' || side === 'right' ? 'V' : 'H',
    +    side,
    +  };
    +}
    +
    +export function reversePoints(points: Point[]): Point[] {
    +  return [...points].reverse();
    +}
    +
    +export function combinePointChains(chains: Point[][]): Point[] {
    +  const points: Point[] = [];
    +  for (const chain of chains) {
    +    for (const point of chain) {
    +      if (points.length === 0 || !equalPoint(points[points.length - 1], point)) {
    +        points.push(point);
    +      }
    +    }
    +  }
    +  return normalizePolyline(points).points;
    +}
    +
    +function routeSelfLoop(owner: Node, side: GridSide, index: number, demandCoord: number): Point[] {
    +  const rect = rectForNode(owner);
    +  const depth = SELF_LOOP_APPROACH + (index + 1) * SELF_LOOP_TRACK_GAP;
    +  const portOffset = index * SELF_LOOP_PORT_OFFSET_STEP;
    +  switch (side) {
    +    case 'right': {
    +      const startY = demandCoord - SELF_LOOP_PORT_GAP / 2 - portOffset;
    +      const endY = demandCoord + SELF_LOOP_PORT_GAP / 2 + portOffset;
    +      return normalizePolyline([
    +        { x: rect.right, y: startY },
    +        { x: rect.right + SELF_LOOP_APPROACH, y: startY },
    +        { x: rect.right + depth, y: startY },
    +        { x: rect.right + depth, y: endY },
    +        { x: rect.right + SELF_LOOP_APPROACH, y: endY },
    +        { x: rect.right, y: endY },
    +      ]).points;
    +    }
    +    case 'left': {
    +      const startY = demandCoord - SELF_LOOP_PORT_GAP / 2 - portOffset;
    +      const endY = demandCoord + SELF_LOOP_PORT_GAP / 2 + portOffset;
    +      return normalizePolyline([
    +        { x: rect.left, y: startY },
    +        { x: rect.left - SELF_LOOP_APPROACH, y: startY },
    +        { x: rect.left - depth, y: startY },
    +        { x: rect.left - depth, y: endY },
    +        { x: rect.left - SELF_LOOP_APPROACH, y: endY },
    +        { x: rect.left, y: endY },
    +      ]).points;
    +    }
    +    case 'top': {
    +      const startX = demandCoord - SELF_LOOP_PORT_GAP / 2 - portOffset;
    +      const endX = demandCoord + SELF_LOOP_PORT_GAP / 2 + portOffset;
    +      return normalizePolyline([
    +        { x: startX, y: rect.top },
    +        { x: startX, y: rect.top - SELF_LOOP_APPROACH },
    +        { x: startX, y: rect.top - depth },
    +        { x: endX, y: rect.top - depth },
    +        { x: endX, y: rect.top - SELF_LOOP_APPROACH },
    +        { x: endX, y: rect.top },
    +      ]).points;
    +    }
    +    case 'bottom': {
    +      const startX = demandCoord - SELF_LOOP_PORT_GAP / 2 - portOffset;
    +      const endX = demandCoord + SELF_LOOP_PORT_GAP / 2 + portOffset;
    +      return normalizePolyline([
    +        { x: startX, y: rect.bottom },
    +        { x: startX, y: rect.bottom + SELF_LOOP_APPROACH },
    +        { x: startX, y: rect.bottom + depth },
    +        { x: endX, y: rect.bottom + depth },
    +        { x: endX, y: rect.bottom + SELF_LOOP_APPROACH },
    +        { x: endX, y: rect.bottom },
    +      ]).points;
    +    }
    +  }
    +}
    +
    +export function labelAwareSelfLoopCandidates(
    +  owner: Node,
    +  side: GridSide,
    +  index: number,
    +  demandCoord: number,
    +  requirement: GridLabelSpanRequirement
    +): Point[][] {
    +  const rect = rectForNode(owner);
    +  const baseDepth = SELF_LOOP_APPROACH + (index + 1) * SELF_LOOP_TRACK_GAP;
    +  const portOffset = index * SELF_LOOP_PORT_OFFSET_STEP;
    +  const baseSpan = SELF_LOOP_PORT_GAP + portOffset * 2;
    +  const verticalSide = side === 'left' || side === 'right';
    +  const span = Math.max(
    +    baseSpan,
    +    (verticalSide ? requirement.cross : requirement.along) + GRID_LABEL_CLEARANCE * 2
    +  );
    +  const depth = Math.max(
    +    baseDepth,
    +    (verticalSide ? requirement.along : requirement.cross) / 2 + GRID_LABEL_CLEARANCE
    +  );
    +  const interval = verticalSide ? [rect.top, rect.bottom] : [rect.left, rect.right];
    +  const directStart = demandCoord - span / 2;
    +  const directEnd = demandCoord + span / 2;
    +
    +  if (directStart >= interval[0] && directEnd <= interval[1]) {
    +    switch (side) {
    +      case 'right':
    +        return [
    +          normalizePolyline([
    +            { x: rect.right, y: directStart },
    +            { x: rect.right + depth, y: directStart },
    +            { x: rect.right + depth, y: directEnd },
    +            { x: rect.right, y: directEnd },
    +          ]).points,
    +        ];
    +      case 'left':
    +        return [
    +          normalizePolyline([
    +            { x: rect.left, y: directStart },
    +            { x: rect.left - depth, y: directStart },
    +            { x: rect.left - depth, y: directEnd },
    +            { x: rect.left, y: directEnd },
    +          ]).points,
    +        ];
    +      case 'top':
    +        return [
    +          normalizePolyline([
    +            { x: directStart, y: rect.top },
    +            { x: directStart, y: rect.top - depth },
    +            { x: directEnd, y: rect.top - depth },
    +            { x: directEnd, y: rect.top },
    +          ]).points,
    +        ];
    +      case 'bottom':
    +        return [
    +          normalizePolyline([
    +            { x: directStart, y: rect.bottom },
    +            { x: directStart, y: rect.bottom + depth },
    +            { x: directEnd, y: rect.bottom + depth },
    +            { x: directEnd, y: rect.bottom },
    +          ]).points,
    +        ];
    +    }
    +  }
    +
    +  const portStart = demandCoord - SELF_LOOP_PORT_GAP / 2 - portOffset;
    +  const portEnd = demandCoord + SELF_LOOP_PORT_GAP / 2 + portOffset;
    +  if (portStart < interval[0] || portEnd > interval[1]) {
    +    return [];
    +  }
    +  const outerStart = demandCoord - span / 2;
    +  const outerEnd = demandCoord + span / 2;
    +  switch (side) {
    +    case 'right':
    +      return [
    +        normalizePolyline([
    +          { x: rect.right, y: portStart },
    +          { x: rect.right + SELF_LOOP_APPROACH, y: portStart },
    +          { x: rect.right + SELF_LOOP_APPROACH, y: outerStart },
    +          { x: rect.right + depth, y: outerStart },
    +          { x: rect.right + depth, y: outerEnd },
    +          { x: rect.right + SELF_LOOP_APPROACH, y: outerEnd },
    +          { x: rect.right + SELF_LOOP_APPROACH, y: portEnd },
    +          { x: rect.right, y: portEnd },
    +        ]).points,
    +      ];
    +    case 'left':
    +      return [
    +        normalizePolyline([
    +          { x: rect.left, y: portStart },
    +          { x: rect.left - SELF_LOOP_APPROACH, y: portStart },
    +          { x: rect.left - SELF_LOOP_APPROACH, y: outerStart },
    +          { x: rect.left - depth, y: outerStart },
    +          { x: rect.left - depth, y: outerEnd },
    +          { x: rect.left - SELF_LOOP_APPROACH, y: outerEnd },
    +          { x: rect.left - SELF_LOOP_APPROACH, y: portEnd },
    +          { x: rect.left, y: portEnd },
    +        ]).points,
    +      ];
    +    case 'top':
    +      return [
    +        normalizePolyline([
    +          { x: portStart, y: rect.top },
    +          { x: portStart, y: rect.top - SELF_LOOP_APPROACH },
    +          { x: outerStart, y: rect.top - SELF_LOOP_APPROACH },
    +          { x: outerStart, y: rect.top - depth },
    +          { x: outerEnd, y: rect.top - depth },
    +          { x: outerEnd, y: rect.top - SELF_LOOP_APPROACH },
    +          { x: portEnd, y: rect.top - SELF_LOOP_APPROACH },
    +          { x: portEnd, y: rect.top },
    +        ]).points,
    +      ];
    +    case 'bottom':
    +      return [
    +        normalizePolyline([
    +          { x: portStart, y: rect.bottom },
    +          { x: portStart, y: rect.bottom + SELF_LOOP_APPROACH },
    +          { x: outerStart, y: rect.bottom + SELF_LOOP_APPROACH },
    +          { x: outerStart, y: rect.bottom + depth },
    +          { x: outerEnd, y: rect.bottom + depth },
    +          { x: outerEnd, y: rect.bottom + SELF_LOOP_APPROACH },
    +          { x: portEnd, y: rect.bottom + SELF_LOOP_APPROACH },
    +          { x: portEnd, y: rect.bottom },
    +        ]).points,
    +      ];
    +  }
    +}
    +
    +export function orderedSelfLoopSides(
    +  node: Node,
    +  counts: Map,
    +  selfLoopCounts: Map
    +): GridSide[] {
    +  const sides: GridSide[] =
    +    node.isGroup && ownerGroupTitle(node)
    +      ? ['left', 'right', 'bottom']
    +      : ['left', 'right', 'top', 'bottom'];
    +  return sides.sort((a, b) => {
    +    const aCount =
    +      (counts.get(ownerSideKey(node.id, a)) ?? 0) + (selfLoopCounts.get(`${node.id}:${a}`) ?? 0);
    +    const bCount =
    +      (counts.get(ownerSideKey(node.id, b)) ?? 0) + (selfLoopCounts.get(`${node.id}:${b}`) ?? 0);
    +    return aCount - bCount;
    +  });
    +}
    +
    +function selfLoopObstacles(owner: Node, result: GridLayoutResult): Rect[] {
    +  const obstacles: Rect[] = [];
    +  for (const node of result.forest.nodeById.values()) {
    +    if (node.id !== owner.id && !node.isGroup && node.x !== undefined && node.y !== undefined) {
    +      obstacles.push(rectForNode(node));
    +    }
    +    if (node.id !== owner.id && node.groupTitleRect) {
    +      const { left, right, top, bottom } = node.groupTitleRect;
    +      obstacles.push({
    +        cx: (left + right) / 2,
    +        cy: (top + bottom) / 2,
    +        left,
    +        right,
    +        top,
    +        bottom,
    +      });
    +    }
    +  }
    +  return obstacles;
    +}
    +
    +export function routeObstacleClearSelfLoop(
    +  owner: Node,
    +  ownerSideCounts: Map,
    +  selfLoopCounts: Map,
    +  result: GridLayoutResult,
    +  labelRequirement?: GridLabelSpanRequirement,
    +  metrics?: GridRoutingInstrumentation
    +): { points: Point[]; side: GridSide; index: number } {
    +  // Side load determines preference, but fixed side ordering breaks ties deterministically.
    +  const rect = rectForNode(owner);
    +  const obstacles = selfLoopObstacles(owner, result);
    +  for (const side of orderedSelfLoopSides(owner, ownerSideCounts, selfLoopCounts)) {
    +    const countKey = `${owner.id}:${side}`;
    +    const index = selfLoopCounts.get(countKey) ?? 0;
    +    const demandCoord = side === 'left' || side === 'right' ? rect.cy : rect.cx;
    +    const candidates = labelRequirement
    +      ? labelAwareSelfLoopCandidates(owner, side, index, demandCoord, labelRequirement)
    +      : [routeSelfLoop(owner, side, index, demandCoord)];
    +    if (labelRequirement && metrics) {
    +      metrics.labelAwareSelfLoopCandidates += candidates.length;
    +    }
    +    for (const points of candidates) {
    +      if (!obstacles.some((obstacle) => polylineIntersectsRect(points, obstacle))) {
    +        return { points, side, index };
    +      }
    +    }
    +  }
    +  throw gridError('GRID_ROUTE_NOT_FOUND', `No obstacle-clear self-loop route for "${owner.id}"`, {
    +    nodeId: owner.id,
    +  });
    +}
    +
    +export function rootContainerMeta(result: GridLayoutResult): void {
    +  if (result.containers.has(ROOT_CONTAINER_ID)) {
    +    return;
    +  }
    +  let minLeft = 0;
    +  let maxRight = 0;
    +  let minTop = 0;
    +  let maxBottom = 0;
    +  let first = true;
    +  for (const item of result.itemMeta.values()) {
    +    if (item.containerId !== ROOT_CONTAINER_ID) {
    +      continue;
    +    }
    +    if (first) {
    +      minLeft = item.cellLeft;
    +      maxRight = item.cellLeft + item.cellWidth;
    +      minTop = item.cellTop;
    +      maxBottom = item.cellTop + item.cellHeight;
    +      first = false;
    +    } else {
    +      minLeft = Math.min(minLeft, item.cellLeft);
    +      maxRight = Math.max(maxRight, item.cellLeft + item.cellWidth);
    +      minTop = Math.min(minTop, item.cellTop);
    +      maxBottom = Math.max(maxBottom, item.cellTop + item.cellHeight);
    +    }
    +  }
    +
    +  const horizontalCorridors = new Set([
    +    minTop - ROOT_OUTER_MARGIN,
    +    maxBottom + ROOT_OUTER_MARGIN,
    +  ]);
    +  // The synthetic root has no node bounds, so derive its routable domain from root cells and keep
    +  // an outer track available for routes that must pass around the whole diagram.
    +  const verticalCorridors = new Set([
    +    minLeft - ROOT_OUTER_MARGIN,
    +    maxRight + ROOT_OUTER_MARGIN,
    +  ]);
    +  for (const item of result.itemMeta.values()) {
    +    if (item.containerId !== ROOT_CONTAINER_ID) {
    +      continue;
    +    }
    +    horizontalCorridors.add(item.topCorridorY);
    +    horizontalCorridors.add(item.bottomCorridorY);
    +    verticalCorridors.add(item.leftCorridorX);
    +    verticalCorridors.add(item.rightCorridorX);
    +  }
    +  result.containers.set(ROOT_CONTAINER_ID, {
    +    id: ROOT_CONTAINER_ID,
    +    left: minLeft - ROOT_OUTER_MARGIN,
    +    top: minTop - ROOT_OUTER_MARGIN,
    +    width: maxRight - minLeft + ROOT_OUTER_MARGIN * 2,
    +    height: maxBottom - minTop + ROOT_OUTER_MARGIN * 2,
    +    contentLeft: minLeft,
    +    contentTop: minTop,
    +    contentRight: maxRight,
    +    contentBottom: maxBottom,
    +    verticalCorridors: [...verticalCorridors].sort((a, b) => a - b),
    +    horizontalCorridors: [...horizontalCorridors].sort((a, b) => a - b),
    +  });
    +}
    +
    +function containerBounds(containerId: GridContainerId, result: GridLayoutResult): RouterRect {
    +  const container = result.containers.get(containerId);
    +  if (!container) {
    +    throw gridError('GRID_ROUTE_NOT_FOUND', `Missing container layout "${containerId}"`);
    +  }
    +  if (containerId === ROOT_CONTAINER_ID) {
    +    const rootItems = [...result.itemMeta.values()].filter(
    +      ({ containerId: ownerContainerId }) => ownerContainerId === ROOT_CONTAINER_ID
    +    );
    +    if (rootItems.length > 0) {
    +      return {
    +        left: Math.min(...rootItems.map(({ cellLeft }) => cellLeft)) - ROOT_OUTER_MARGIN,
    +        right:
    +          Math.max(...rootItems.map(({ cellLeft, cellWidth }) => cellLeft + cellWidth)) +
    +          ROOT_OUTER_MARGIN,
    +        top: Math.min(...rootItems.map(({ cellTop }) => cellTop)) - ROOT_OUTER_MARGIN,
    +        bottom:
    +          Math.max(...rootItems.map(({ cellTop, cellHeight }) => cellTop + cellHeight)) +
    +          ROOT_OUTER_MARGIN,
    +      };
    +    }
    +    return {
    +      left: container.left,
    +      right: container.left + container.width,
    +      top: container.top,
    +      bottom: container.top + container.height,
    +    };
    +  }
    +  const owner = result.forest.nodeById.get(containerId);
    +  if (!owner) {
    +    throw gridError('GRID_ROUTE_NOT_FOUND', `Missing container owner "${containerId}"`);
    +  }
    +  const ownerBounds = routerRect(owner);
    +  return {
    +    left: ownerBounds.left + ROUTE_CLEARANCE_PX,
    +    right: ownerBounds.right - ROUTE_CLEARANCE_PX,
    +    top: ownerBounds.top + ROUTE_CLEARANCE_PX,
    +    bottom: ownerBounds.bottom - ROUTE_CLEARANCE_PX,
    +  };
    +}
    +
    +function portalCoordinate(bounds: RouterRect, side: GridSide, interior: boolean): number {
    +  const boundary =
    +    side === 'left'
    +      ? bounds.left
    +      : side === 'right'
    +        ? bounds.right
    +        : side === 'top'
    +          ? bounds.top
    +          : bounds.bottom;
    +  const towardInterior = side === 'left' || side === 'top' ? 1 : -1;
    +  return boundary + towardInterior * ROUTE_CLEARANCE_PX * (interior ? 1 : -1);
    +}
    +
    +function containerPortalRanges(
    +  containerId: GridContainerId,
    +  result: GridLayoutResult
    +): PortalRange[] {
    +  const ranges: PortalRange[] = [];
    +  if (containerId !== ROOT_CONTAINER_ID) {
    +    const owner = result.forest.nodeById.get(containerId);
    +    if (owner) {
    +      const bounds = routerRect(owner);
    +      ranges.push(
    +        ...derivePortalRanges(containerId, bounds, owner.groupTitleRect).map((range) => ({
    +          ...range,
    +          coordinate: portalCoordinate(bounds, range.side, true),
    +        }))
    +      );
    +    }
    +  }
    +  for (const child of result.forest.childrenByParent.get(containerId) ?? []) {
    +    if (!child.isGroup) {
    +      continue;
    +    }
    +    const bounds = routerRect(child);
    +    ranges.push(
    +      ...derivePortalRanges(child.id, bounds, child.groupTitleRect).map((range) => ({
    +        ...range,
    +        coordinate: portalCoordinate(bounds, range.side, false),
    +      }))
    +    );
    +  }
    +  return ranges;
    +}
    +
    +export function buildRoutingContext(
    +  containerIds: readonly GridContainerId[],
    +  result: GridLayoutResult,
    +  metrics: GridRoutingInstrumentation | undefined,
    +  options: GridRoutingTestOptions
    +): GridRoutingContext {
    +  // Build each immutable container topology once. Per-edge endpoints are added later as cheap
    +  // overlays, avoiding repeated obstacle sweeps for large diagrams.
    +  const context: GridRoutingContext = {
    +    topologies: new Map(),
    +    fallbackContainers: new Map(),
    +    searchBudget: { expandedStates: 0 },
    +    searchBudgetWarningEmitted: false,
    +    baseEstimatedBytes: 0,
    +    metrics,
    +  };
    +  let estimatedBytes = 0;
    +  for (const containerId of [...new Set(containerIds)].sort()) {
    +    const children = (result.forest.childrenByParent.get(containerId) ?? []).filter(
    +      (node) => !isEdgeLabelNode(node)
    +    );
    +    const title = result.forest.nodeById.get(containerId)?.groupTitleRect;
    +    try {
    +      const topology = buildContainerRoutingTopology(
    +        {
    +          containerId,
    +          bounds: containerBounds(containerId, result),
    +          obstacles: children.map((node) => ({ id: node.id, bounds: routerRect(node) })),
    +          titleExclusions: title
    +            ? [{ id: `${containerId}:title`, bounds: { ...title } }]
    +            : undefined,
    +          portalRanges: containerPortalRanges(containerId, result),
    +        },
    +        { caps: options.topologyCaps }
    +      );
    +      const invocationMemoryCap =
    +        options.topologyCaps?.maxEstimatedBytes ?? DEFAULT_MAX_ROUTING_ESTIMATED_BYTES;
    +      if (estimatedBytes + topology.estimatedBytes > invocationMemoryCap) {
    +        // Keep the invocation-wide cap honest: an individually valid topology may still exceed the
    +        // aggregate budget when combined with topologies already retained by this routing session.
    +        context.fallbackContainers.set(containerId, 'estimated_memory_cap');
    +        continue;
    +      }
    +      estimatedBytes += topology.estimatedBytes;
    +      context.baseEstimatedBytes = estimatedBytes;
    +      context.topologies.set(containerId, topology);
    +      if (metrics) {
    +        metrics.containersBuilt++;
    +        metrics.baseTopologyBuilds++;
    +        metrics.baseVertices += topology.vertices.length;
    +        metrics.baseAdjacencyEntries += topology.adjacencyEntries;
    +        metrics.buildSweepEvents += topology.seedCount * 4 + topology.obstacles.length * 4;
    +        metrics.estimatedBytes = estimatedBytes;
    +      }
    +    } catch (error) {
    +      if (!(error instanceof GridRoutingResourceLimitError)) {
    +        throw error;
    +      }
    +      context.fallbackContainers.set(containerId, error.reason);
    +    }
    +  }
    +  return context;
    +}
    +
    +export function validatedCompatibilitySegment(
    +  edgeId: string,
    +  containerId: GridContainerId,
    +  start: SegmentAttachment,
    +  end: SegmentAttachment,
    +  route: () => Point[],
    +  result: GridLayoutResult,
    +  metrics?: GridRoutingInstrumentation
    +): Point[] {
    +  const points = route();
    +  if (metrics) {
    +    metrics.compatibilitySegments++;
    +  }
    +  if (validateContainerSegment(points, start.ownerId, end.ownerId, containerId, result)) {
    +    return points;
    +  }
    +  if (metrics) {
    +    metrics.compatibilityValidationFailures++;
    +    metrics.routesImpossible++;
    +  }
    +  throw gridError('GRID_ROUTE_NOT_FOUND', `Invalid compatibility route for "${edgeId}"`, {
    +    edgeId,
    +    containerId,
    +  });
    +}
    +
    +export function compatibilityLcaAttachments(
    +  plan: EdgeRoutePlan,
    +  result: GridLayoutResult,
    +  demandCoords: Map
    +): { start: SegmentAttachment; end: SegmentAttachment } {
    +  const sourceFinal = plan.source.chain.at(-1)!;
    +  const targetFinal = plan.target.chain.at(-1)!;
    +  const finalAttachment = (
    +    endpoint: typeof plan.source,
    +    final: (typeof plan.source.chain)[number]
    +  ): SegmentAttachment => ({
    +    ownerId: final.ownerId,
    +    ...(endpoint.finalKind === 'boundary'
    +      ? boundaryAttachment(plan.lcaContainerId, final.side, final.demandKey, result, demandCoords)
    +      : itemAttachment(
    +          final.ownerId,
    +          final.side,
    +          final.demandKey,
    +          plan.lcaContainerId,
    +          result,
    +          demandCoords
    +        )),
    +  });
    +  return {
    +    start: finalAttachment(plan.source, sourceFinal),
    +    end: finalAttachment(plan.target, targetFinal),
    +  };
    +}
    +
    +export interface CompatibilitySegmentAttachments {
    +  containerId: GridContainerId;
    +  start: SegmentAttachment;
    +  end: SegmentAttachment;
    +}
    +
    +export function compatibilityHierarchyAttachments(
    +  endpoint: EdgeEndpointPlan,
    +  result: GridLayoutResult,
    +  demandCoords: Map
    +): CompatibilitySegmentAttachments[] {
    +  return endpoint.chain.slice(0, -1).map((from, index) => {
    +    const to = endpoint.chain[index + 1];
    +    return {
    +      containerId: to.ownerId,
    +      start: {
    +        ownerId: from.ownerId,
    +        ...itemAttachment(
    +          from.ownerId,
    +          from.side,
    +          from.demandKey,
    +          to.ownerId,
    +          result,
    +          demandCoords
    +        ),
    +      },
    +      end: {
    +        ownerId: to.ownerId,
    +        ...boundaryAttachment(to.ownerId, to.side, to.demandKey, result, demandCoords),
    +      },
    +    };
    +  });
    +}
    +
    +function compatibilityPlanIsValid(
    +  plan: EdgeRoutePlan,
    +  result: GridLayoutResult,
    +  demandCoords: Map
    +): boolean {
    +  const segmentIsValid = (
    +    containerId: GridContainerId,
    +    start: SegmentAttachment,
    +    end: SegmentAttachment
    +  ): boolean =>
    +    validateContainerSegment(
    +      routeWithinContainer(containerId, result, start, end, plan.laneIndex),
    +      start.ownerId,
    +      end.ownerId,
    +      containerId,
    +      result
    +    );
    +  for (const endpoint of [plan.source, plan.target]) {
    +    for (const { containerId, start, end } of compatibilityHierarchyAttachments(
    +      endpoint,
    +      result,
    +      demandCoords
    +    )) {
    +      if (!segmentIsValid(containerId, start, end)) {
    +        return false;
    +      }
    +    }
    +  }
    +  const attachments = compatibilityLcaAttachments(plan, result, demandCoords);
    +  return segmentIsValid(plan.lcaContainerId, attachments.start, attachments.end);
    +}
    +
    +export /*
    + * Grid routing currently uses two algorithms:
    + *
    + * - `routeWithinContainer()` is the deterministic corridor router. During normal rendering, an
    + *   ordinary unbundled same-container edge uses it as a fast path when its endpoint ports are
    + *   legal, the complete route passes measured-geometry validation, and its length equals the
    + *   Manhattan lower bound. This avoids building a visibility topology and running A* for
    + *   routes that sparse search cannot improve.
    + * - The sparse visibility router handles self-loops, bundles, ordinary routes that fail the
    + *   fast path, and hierarchy routes that need alternative portals or obstacle-aware search.
    + *
    + * Test-only topology/search caps and dual-route comparison disable the fast path intentionally:
    + * those modes must exercise sparse topology, search, and fallback behavior. Normal router and
    + * performance tests cover the production fast path separately.
    + *
    + * Hierarchy routing is still partially migrated. Bundles, routes whose two endpoints have no
    + * unrelated incident edges, and plans whose corridor route fails validation use sparse routing
    + * for every hierarchy-chain segment. Other single hierarchy edges may retain validated
    + * corridor segments when at least one endpoint is shared with another edge.
    + * Group-to-descendant routes always use sparse routing for their LCA segment, even when their
    + * ascent or descent chain still uses corridor routing. Defined sparse resource-limit failures
    + * also use the corridor route as a fallback, but only after that route passes the same geometry
    + * and pair-separation validation.
    + *
    + * Finishing the migration requires sparse routing to handle the remaining mixed-demand
    + * hierarchy segments without introducing shared subpaths or crossings, and to meet the large
    + * graph performance target without the common-edge fast path. It also requires generated
    + * nested hierarchy, label, loop, bundle, and fallback coverage plus release-level validation.
    + * Only then can `routeWithinContainer()`, corridor metadata, compatibility/fallback
    + * instrumentation, and their obsolete tests be removed.
    + */
    +function prepareRoutingModes(
    +  prepared: PreparedEdgeRoutes,
    +  result: GridLayoutResult,
    +  metrics: GridRoutingInstrumentation | undefined,
    +  options: GridRoutingTestOptions
    +): PreparedRoutingModes {
    +  // Classify every plan before building topologies: proven minimal corridor routes avoid sparse
    +  // setup, while only containers needed by sparse LCA, hierarchy, or loop segments are materialized.
    +  const {
    +    plans,
    +    eligiblePlans,
    +    eligibleIds,
    +    demandCoords,
    +    endpointCandidatesByEdge,
    +    endpointIncidentCounts,
    +    coordinatedEndpointIds,
    +  } = prepared;
    +  const hasIsolatedEndpoints = (plan: EdgeRoutePlan): boolean =>
    +    (endpointIncidentCounts.get(plan.edge.start!) ?? 0) === plan.bundleSize &&
    +    (endpointIncidentCounts.get(plan.edge.end!) ?? 0) === plan.bundleSize;
    +  const hasAncestorEndpoint = (plan: EdgeRoutePlan): boolean => {
    +    const source = result.forest.nodeById.get(plan.edge.start!);
    +    const target = result.forest.nodeById.get(plan.edge.end!);
    +    return Boolean(
    +      source &&
    +        target &&
    +        ((source.isGroup && isAncestorGroup(source.id, target, result.forest.nodeById)) ||
    +          (target.isGroup && isAncestorGroup(target.id, source, result.forest.nodeById)))
    +    );
    +  };
    +  const compatibilityFastRoutes = new Map();
    +  const routeOccupancy = new Map();
    +  const compatibilityFastPathEnabled =
    +    options.topologyCaps === undefined &&
    +    options.searchCaps === undefined &&
    +    options.onDualRouteComparison === undefined;
    +  for (const plan of compatibilityFastPathEnabled ? eligiblePlans : []) {
    +    if (coordinatedEndpointIds.has(plan.edge.id)) {
    +      continue;
    +    }
    +    const candidates = endpointCandidatesByEdge.get(plan.edge.id);
    +    if (plan.bundleSize !== 1 || !candidates?.sources.length || !candidates.targets.length) {
    +      continue;
    +    }
    +    const source = result.forest.nodeById.get(plan.edge.start!);
    +    const target = result.forest.nodeById.get(plan.edge.end!);
    +    if (!source || !target) {
    +      continue;
    +    }
    +    const attachments = compatibilityLcaAttachments(plan, result, demandCoords);
    +    const route = routeWithinContainer(
    +      plan.lcaContainerId,
    +      result,
    +      attachments.start,
    +      attachments.end,
    +      plan.laneIndex
    +    );
    +    const normalized = normalizePolyline(route);
    +    const first = normalized.points[0];
    +    const last = normalized.points.at(-1);
    +    if (!first || !last) {
    +      continue;
    +    }
    +    const sourceCandidate = candidates.sources.find(
    +      ({ port }) => port.x === first.x && port.y === first.y
    +    );
    +    const targetCandidate = candidates.targets.find(
    +      ({ port }) => port.x === last.x && port.y === last.y
    +    );
    +    if (!sourceCandidate || !targetCandidate) {
    +      continue;
    +    }
    +    if (metrics) {
    +      metrics.compatibilityFastPathAttempts++;
    +    }
    +    const lowerBoundLength = Math.abs(first.x - last.x) + Math.abs(first.y - last.y);
    +    if (!validateSameContainerRoute(route, source, target, plan.lcaContainerId, result)) {
    +      if (metrics) {
    +        metrics.compatibilityFastPathValidationFailures++;
    +      }
    +      continue;
    +    }
    +    if (manhattanLength(normalized.points) !== lowerBoundLength) {
    +      if (metrics) {
    +        metrics.compatibilityFastPathNonMinimalRoutes++;
    +      }
    +      continue;
    +    }
    +    // Small layouts demote a route that runs close and parallel to an earlier edge to sparse
    +    // routing. Large layouts keep the fast path to stay search-free within the performance
    +    // contract, nudging the route sideways when possible.
    +    let occupancy = routeOccupancy.get(plan.lcaContainerId);
    +    if (!occupancy) {
    +      occupancy = new RouteOccupancyIndex();
    +      routeOccupancy.set(plan.lcaContainerId, occupancy);
    +    }
    +    const conflicting = occupancy.conflictsWithRoute(normalized.points, plan.pairKey);
    +    if (conflicting && eligiblePlans.length <= MAX_PLANS_FOR_OVERLAP_DEMOTION) {
    +      // The sparse router can choose other ports and corridors, which reads better than a nudge.
    +      continue;
    +    }
    +    const separated = separateRouteFromOccupied(
    +      normalized.points,
    +      occupancy,
    +      plan.pairKey,
    +      (candidate) =>
    +        terminalStubsAreLongEnough(candidate) &&
    +        manhattanLength(candidate) === lowerBoundLength &&
    +        validateSameContainerRoute(candidate, source, target, plan.lcaContainerId, result)
    +    );
    +    const finalRoute = separated ?? route;
    +    occupancy.add(finalRoute, plan.pairKey);
    +    compatibilityFastRoutes.set(plan.edge.id, finalRoute);
    +  }
    +  const sparseCandidate = (plan: EdgeRoutePlan): boolean =>
    +    !eligibleIds.has(plan.edge.id) &&
    +    plan.edge.start !== plan.edge.end &&
    +    (plan.bundleSize > 1 ||
    +      hasIsolatedEndpoints(plan) ||
    +      !compatibilityPlanIsValid(plan, result, demandCoords));
    +  const planningOccupancy = new Map();
    +  const overlappingHierarchyIds = new Set();
    +  if (plans.length <= MAX_PLANS_FOR_OVERLAP_DEMOTION) {
    +    for (const plan of prepared.orderedPlans) {
    +      if (
    +        eligibleIds.has(plan.edge.id) ||
    +        plan.edge.start === plan.edge.end ||
    +        hasAncestorEndpoint(plan) ||
    +        sparseCandidate(plan)
    +      ) {
    +        continue;
    +      }
    +      const attachments = compatibilityLcaAttachments(plan, result, demandCoords);
    +      const route = routeWithinContainer(
    +        plan.lcaContainerId,
    +        result,
    +        attachments.start,
    +        attachments.end,
    +        plan.laneIndex
    +      );
    +      let occupancy = planningOccupancy.get(plan.lcaContainerId);
    +      if (!occupancy) {
    +        occupancy = new RouteOccupancyIndex();
    +        planningOccupancy.set(plan.lcaContainerId, occupancy);
    +      }
    +      if (occupancy.conflictsWithRoute(route, plan.pairKey)) {
    +        overlappingHierarchyIds.add(plan.edge.id);
    +      } else {
    +        occupancy.add(route, plan.pairKey);
    +      }
    +    }
    +  }
    +  const sparseHierarchyIds = new Set([
    +    ...plans.filter(sparseCandidate).map(({ edge }) => edge.id),
    +    ...overlappingHierarchyIds,
    +  ]);
    +  const sparseLcaIds = new Set([
    +    ...sparseHierarchyIds,
    +    ...plans.filter(hasAncestorEndpoint).map(({ edge }) => edge.id),
    +  ]);
    +  const routedContainerIds = plans
    +    .filter(
    +      (plan) =>
    +        (eligibleIds.has(plan.edge.id) && !compatibilityFastRoutes.has(plan.edge.id)) ||
    +        sparseLcaIds.has(plan.edge.id) ||
    +        plan.edge.start === plan.edge.end
    +    )
    +    .flatMap((plan) => [
    +      plan.lcaContainerId,
    +      ...plan.source.chain.slice(1).map(({ ownerId }) => ownerId),
    +      ...plan.target.chain.slice(1).map(({ ownerId }) => ownerId),
    +    ]);
    +  return {
    +    compatibilityFastRoutes,
    +    sparseHierarchyIds,
    +    sparseLcaIds,
    +    routedContainerIds,
    +    routeOccupancy,
    +  };
    +}
    diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/grid/routerConstraint.spec.ts b/packages/mermaid/src/rendering-util/layout-algorithms/grid/routerConstraint.spec.ts
    new file mode 100644
    index 00000000000..5b721ccf0fa
    --- /dev/null
    +++ b/packages/mermaid/src/rendering-util/layout-algorithms/grid/routerConstraint.spec.ts
    @@ -0,0 +1,227 @@
    +import { describe, expect, it } from 'vitest';
    +import type { Node } from '../../types.js';
    +import { endpointPairLowerBound, validateSameContainerRoute } from './routerConstraint.js';
    +import type { EndpointCandidate } from './routerPlanning.js';
    +import type { GridLayoutResult, GridSide } from './types.js';
    +import { ROOT_CONTAINER_ID } from './types.js';
    +
    +function endpoint(side: GridSide, x: number, y: number): EndpointCandidate {
    +  return {
    +    ownerId: side,
    +    side,
    +    port: { x, y },
    +    connect: { x, y },
    +    rank: 0,
    +  };
    +}
    +
    +function routingFixture(): {
    +  source: Node;
    +  target: Node;
    +  result: GridLayoutResult;
    +} {
    +  const source = {
    +    id: 'source',
    +    isGroup: false,
    +    shape: 'rect',
    +    x: 0,
    +    y: 0,
    +    width: 40,
    +    height: 40,
    +  } as Node;
    +  const target = {
    +    id: 'target',
    +    isGroup: false,
    +    shape: 'rect',
    +    x: 100,
    +    y: 0,
    +    width: 40,
    +    height: 40,
    +  } as Node;
    +  const result = {
    +    forest: {
    +      childrenByParent: new Map([[ROOT_CONTAINER_ID, [source, target]]]),
    +    },
    +  } as GridLayoutResult;
    +  return { source, target, result };
    +}
    +
    +describe('grid router constraints', () => {
    +  it.each([
    +    {
    +      name: 'coincident horizontal endpoints',
    +      source: endpoint('right', 0, 0),
    +      target: endpoint('left', 0, 0),
    +      expected: [40, 0],
    +    },
    +    {
    +      name: 'coincident vertical endpoints',
    +      source: endpoint('bottom', 0, 0),
    +      target: endpoint('top', 0, 0),
    +      expected: [40, 0],
    +    },
    +    {
    +      name: 'coincident mixed-orientation endpoints',
    +      source: endpoint('right', 0, 0),
    +      target: endpoint('top', 0, 0),
    +      expected: [40, 1],
    +    },
    +    {
    +      name: 'horizontal displacement with horizontal endpoints',
    +      source: endpoint('right', 0, 0),
    +      target: endpoint('left', 10, 0),
    +      expected: [50, 0],
    +    },
    +    {
    +      name: 'vertical displacement with horizontal endpoints',
    +      source: endpoint('right', 0, 0),
    +      target: endpoint('left', 0, 10),
    +      expected: [50, 2],
    +    },
    +    {
    +      name: 'two-axis displacement with matching turn orientations',
    +      source: endpoint('right', 0, 0),
    +      target: endpoint('top', 10, 10),
    +      expected: [60, 1],
    +    },
    +  ] as const)('computes an admissible lower bound for $name', ({ source, target, expected }) => {
    +    expect(endpointPairLowerBound(source, target)).toEqual(expected);
    +  });
    +
    +  it('rejects a terminal segment that passes through its destination node', () => {
    +    const { source, target, result } = routingFixture();
    +
    +    expect(
    +      validateSameContainerRoute(
    +        [
    +          { x: 20, y: 0 },
    +          { x: 20, y: -40 },
    +          { x: 120, y: -40 },
    +          { x: 120, y: 0 },
    +          { x: 80, y: 0 },
    +        ],
    +        source,
    +        target,
    +        ROOT_CONTAINER_ID,
    +        result
    +      )
    +    ).toBe(false);
    +  });
    +
    +  it('snaps near-axis interior segments before validating obstacles', () => {
    +    const { source, target, result } = routingFixture();
    +
    +    expect(
    +      validateSameContainerRoute(
    +        [
    +          { x: 20, y: 0 },
    +          { x: 40, y: 0 },
    +          { x: 40.5, y: -40 },
    +          { x: 80, y: -39.5 },
    +          { x: 80, y: 0 },
    +        ],
    +        source,
    +        target,
    +        ROOT_CONTAINER_ID,
    +        result
    +      )
    +    ).toBe(true);
    +  });
    +
    +  it('accepts exact orthogonal segments with fractional coordinates', () => {
    +    const { source, target, result } = routingFixture();
    +
    +    expect(
    +      validateSameContainerRoute(
    +        [
    +          { x: 20, y: 0 },
    +          { x: 40.25, y: 0 },
    +          { x: 40.25, y: -40.5 },
    +          { x: 80, y: -40.5 },
    +          { x: 80, y: 0 },
    +        ],
    +        source,
    +        target,
    +        ROOT_CONTAINER_ID,
    +        result
    +      )
    +    ).toBe(true);
    +  });
    +
    +  it('rejects segments that remain diagonal beyond the pixel tolerance', () => {
    +    const { source, target, result } = routingFixture();
    +
    +    expect(
    +      validateSameContainerRoute(
    +        [
    +          { x: 20, y: 0 },
    +          { x: 40, y: -20 },
    +          { x: 80, y: -20 },
    +          { x: 80, y: 0 },
    +        ],
    +        source,
    +        target,
    +        ROOT_CONTAINER_ID,
    +        result
    +      )
    +    ).toBe(false);
    +  });
    +
    +  it('does not snap a route endpoint to make a segment orthogonal', () => {
    +    const { source, target, result } = routingFixture();
    +
    +    expect(
    +      validateSameContainerRoute(
    +        [
    +          { x: 20, y: 0 },
    +          { x: 80, y: 0.5 },
    +        ],
    +        source,
    +        target,
    +        ROOT_CONTAINER_ID,
    +        result
    +      )
    +    ).toBe(false);
    +  });
    +
    +  it('preserves an endpoint when collapsing a trailing near-duplicate', () => {
    +    const { source, target, result } = routingFixture();
    +
    +    expect(
    +      validateSameContainerRoute(
    +        [
    +          { x: 20, y: 0 },
    +          { x: 40, y: 0 },
    +          { x: 40, y: -40 },
    +          { x: 80, y: -40 },
    +          { x: 80, y: -0.5 },
    +          { x: 80, y: 0 },
    +        ],
    +        source,
    +        target,
    +        ROOT_CONTAINER_ID,
    +        result
    +      )
    +    ).toBe(true);
    +  });
    +
    +  it('rejects accumulated drift beyond the pixel tolerance', () => {
    +    const { source, target, result } = routingFixture();
    +
    +    expect(
    +      validateSameContainerRoute(
    +        [
    +          { x: 20, y: 0 },
    +          { x: 40, y: 0.8 },
    +          { x: 60, y: 1.6 },
    +          { x: 80, y: 1.6 },
    +          { x: 80, y: 0 },
    +        ],
    +        source,
    +        target,
    +        ROOT_CONTAINER_ID,
    +        result
    +      )
    +    ).toBe(false);
    +  });
    +});
    diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/grid/routerConstraint.ts b/packages/mermaid/src/rendering-util/layout-algorithms/grid/routerConstraint.ts
    new file mode 100644
    index 00000000000..d1b4cfc66c0
    --- /dev/null
    +++ b/packages/mermaid/src/rendering-util/layout-algorithms/grid/routerConstraint.ts
    @@ -0,0 +1,410 @@
    +import type { Point } from '../../../types.js';
    +import type { Node } from '../../types.js';
    +import { normalizePolyline, PIXEL_EPSILON } from '../layout-utils/geometry.js';
    +import { rectForNode } from '../layout-utils/helpers.js';
    +import {
    +  LANE_SEPARATION_PX,
    +  TERMINAL_APPROACH_PX,
    +  type EndpointCandidate,
    +} from './routerPlanning.js';
    +import { ROUTE_CLEARANCE_PX } from './routerTopology.js';
    +import type {
    +  GridContainerId,
    +  GridLayoutResult,
    +  GridOrientation,
    +  RouterPoint,
    +  RouterRect,
    +} from './types.js';
    +import { isEdgeLabelNode } from './types.js';
    +
    +// Centralizes post-route validity checks so sparse routes, compatibility fallbacks, and bundle
    +// retries all enforce the same obstacle-clearance and lane-separation contract.
    +export function isPairSeparationError(error: unknown): boolean {
    +  if (!(error instanceof Error) || !('details' in error)) {
    +    return false;
    +  }
    +  const details = error.details;
    +  return (
    +    typeof details === 'object' &&
    +    details !== null &&
    +    'reason' in details &&
    +    details.reason === 'pair-separation'
    +  );
    +}
    +
    +export function routerRect(node: Node): RouterRect {
    +  const rect = rectForNode(node);
    +  return { left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom };
    +}
    +
    +function canonicalizeOrthogonalPolyline(points: readonly Point[]): Point[] | undefined {
    +  if (points.length < 2 || points.some(({ x, y }) => !Number.isFinite(x) || !Number.isFinite(y))) {
    +    return undefined;
    +  }
    +
    +  const collapsed: Point[] = [{ ...points[0] }];
    +  for (let index = 1; index < points.length; index++) {
    +    const point = points[index];
    +    const previous = collapsed.at(-1)!;
    +    const isNearDuplicate =
    +      Math.abs(point.x - previous.x) <= PIXEL_EPSILON &&
    +      Math.abs(point.y - previous.y) <= PIXEL_EPSILON;
    +    if (!isNearDuplicate) {
    +      collapsed.push({ ...point });
    +      continue;
    +    }
    +    if (index !== points.length - 1) {
    +      continue;
    +    }
    +    while (collapsed.length > 1) {
    +      const beforePrevious = collapsed.at(-2)!;
    +      if (
    +        Math.abs(point.x - beforePrevious.x) > PIXEL_EPSILON ||
    +        Math.abs(point.y - beforePrevious.y) > PIXEL_EPSILON
    +      ) {
    +        break;
    +      }
    +      collapsed.pop();
    +    }
    +    if (collapsed.length === 1) {
    +      return undefined;
    +    }
    +    collapsed[collapsed.length - 1] = { ...point };
    +  }
    +  if (collapsed.length < 2) {
    +    return undefined;
    +  }
    +
    +  const orientations: GridOrientation[] = [];
    +  for (let index = 0; index < collapsed.length - 1; index++) {
    +    const a = collapsed[index];
    +    const b = collapsed[index + 1];
    +    const dx = Math.abs(b.x - a.x);
    +    const dy = Math.abs(b.y - a.y);
    +    if (dy <= PIXEL_EPSILON) {
    +      orientations.push('H');
    +    } else if (dx <= PIXEL_EPSILON) {
    +      orientations.push('V');
    +    } else {
    +      return undefined;
    +    }
    +  }
    +
    +  const canonical = collapsed.map((point) => ({ ...point }));
    +  for (let start = 0; start < orientations.length; ) {
    +    const orientation = orientations[start];
    +    let end = start + 1;
    +    while (end < orientations.length && orientations[end] === orientation) {
    +      end++;
    +    }
    +    const axis = orientation === 'H' ? 'y' : 'x';
    +    if (
    +      start === 0 &&
    +      end === collapsed.length - 1 &&
    +      collapsed[start][axis] !== collapsed[end][axis]
    +    ) {
    +      return undefined;
    +    }
    +    const coordinate = end === collapsed.length - 1 ? collapsed[end][axis] : collapsed[start][axis];
    +    for (let pointIndex = start; pointIndex <= end; pointIndex++) {
    +      if (Math.abs(collapsed[pointIndex][axis] - coordinate) > PIXEL_EPSILON) {
    +        return undefined;
    +      }
    +      canonical[pointIndex][axis] = coordinate;
    +    }
    +    start = end;
    +  }
    +  return canonical;
    +}
    +
    +function segmentEntersRect(a: Point, b: Point, rect: RouterRect): boolean {
    +  if (a.y === b.y) {
    +    return (
    +      a.y > rect.top &&
    +      a.y < rect.bottom &&
    +      Math.max(a.x, b.x) > rect.left &&
    +      Math.min(a.x, b.x) < rect.right
    +    );
    +  }
    +  if (a.x === b.x) {
    +    return (
    +      a.x > rect.left &&
    +      a.x < rect.right &&
    +      Math.max(a.y, b.y) > rect.top &&
    +      Math.min(a.y, b.y) < rect.bottom
    +    );
    +  }
    +  return true;
    +}
    +
    +function inflatedRect(node: Node): RouterRect {
    +  const rect = routerRect(node);
    +  return {
    +    left: rect.left - ROUTE_CLEARANCE_PX,
    +    right: rect.right + ROUTE_CLEARANCE_PX,
    +    top: rect.top - ROUTE_CLEARANCE_PX,
    +    bottom: rect.bottom + ROUTE_CLEARANCE_PX,
    +  };
    +}
    +
    +export function validateSameContainerRoute(
    +  points: readonly Point[],
    +  source: Node,
    +  target: Node,
    +  containerId: GridContainerId,
    +  result: GridLayoutResult
    +): boolean {
    +  // Terminal segments may touch their own endpoint rectangles, but must leave them directly and
    +  // every other segment must remain outside clearance-inflated child geometry.
    +  const canonical = canonicalizeOrthogonalPolyline(points);
    +  if (!canonical) {
    +    return false;
    +  }
    +  const normalized = normalizePolyline(canonical);
    +  if (normalized.points.length < 2) {
    +    return false;
    +  }
    +  if (
    +    normalized.segments.length > 1 &&
    +    (Math.abs(normalized.segments[0].a.x - normalized.segments[0].b.x) +
    +      Math.abs(normalized.segments[0].a.y - normalized.segments[0].b.y) <
    +      TERMINAL_APPROACH_PX ||
    +      Math.abs(normalized.segments.at(-1)!.a.x - normalized.segments.at(-1)!.b.x) +
    +        Math.abs(normalized.segments.at(-1)!.a.y - normalized.segments.at(-1)!.b.y) <
    +        TERMINAL_APPROACH_PX)
    +  ) {
    +    return false;
    +  }
    +  const children = (result.forest.childrenByParent.get(containerId) ?? []).filter(
    +    (node) => !isEdgeLabelNode(node)
    +  );
    +  for (const obstacle of children) {
    +    const rect = inflatedRect(obstacle);
    +    for (let index = 0; index < normalized.points.length - 1; index++) {
    +      const terminalObstacle =
    +        (obstacle.id === source.id && index === 0) ||
    +        (obstacle.id === target.id && index === normalized.points.length - 2);
    +      if (terminalObstacle) {
    +        if (
    +          segmentEntersRect(
    +            normalized.points[index],
    +            normalized.points[index + 1],
    +            routerRect(obstacle)
    +          )
    +        ) {
    +          return false;
    +        }
    +        continue;
    +      }
    +      if (segmentEntersRect(normalized.points[index], normalized.points[index + 1], rect)) {
    +        return false;
    +      }
    +    }
    +  }
    +  return true;
    +}
    +
    +export function validateContainerSegment(
    +  points: readonly Point[],
    +  startOwnerId: string,
    +  endOwnerId: string,
    +  containerId: GridContainerId,
    +  result: GridLayoutResult
    +): boolean {
    +  const canonical = canonicalizeOrthogonalPolyline(points);
    +  if (!canonical) {
    +    return false;
    +  }
    +  const normalized = normalizePolyline(canonical);
    +  if (normalized.points.length < 2) {
    +    return false;
    +  }
    +  const children = (result.forest.childrenByParent.get(containerId) ?? []).filter(
    +    (node) => !isEdgeLabelNode(node)
    +  );
    +  for (const obstacle of children) {
    +    const rect = inflatedRect(obstacle);
    +    for (let index = 0; index < normalized.points.length - 1; index++) {
    +      const terminalObstacle =
    +        (obstacle.id === startOwnerId && index === 0) ||
    +        (obstacle.id === endOwnerId && index === normalized.points.length - 2);
    +      if (terminalObstacle) {
    +        if (
    +          segmentEntersRect(
    +            normalized.points[index],
    +            normalized.points[index + 1],
    +            routerRect(obstacle)
    +          )
    +        ) {
    +          return false;
    +        }
    +        continue;
    +      }
    +      if (segmentEntersRect(normalized.points[index], normalized.points[index + 1], rect)) {
    +        return false;
    +      }
    +    }
    +  }
    +  return true;
    +}
    +
    +export interface OrthogonalSegment {
    +  a: Point;
    +  b: Point;
    +  orientation: GridOrientation;
    +}
    +
    +function segmentSpan(segment: OrthogonalSegment): { low: number; high: number } {
    +  return segment.orientation === 'H'
    +    ? { low: Math.min(segment.a.x, segment.b.x), high: Math.max(segment.a.x, segment.b.x) }
    +    : { low: Math.min(segment.a.y, segment.b.y), high: Math.max(segment.a.y, segment.b.y) };
    +}
    +
    +export function nonterminalSegments(points: readonly Point[]): OrthogonalSegment[] {
    +  const segments = normalizePolyline([...points])
    +    .segments.filter((segment) => segment.orientation !== 'Z')
    +    .map((segment) => ({
    +      a: { ...segment.a },
    +      b: { ...segment.b },
    +      orientation: segment.orientation as GridOrientation,
    +    }));
    +  if (segments.length === 0) {
    +    return [];
    +  }
    +  if (
    +    segments.length === 1 &&
    +    Math.abs(segments[0].a.x - segments[0].b.x) + Math.abs(segments[0].a.y - segments[0].b.y) <=
    +      TERMINAL_APPROACH_PX * 2
    +  ) {
    +    return [];
    +  }
    +  // Pair lanes may share the short attachment stubs at nodes. Trim those stubs before measuring
    +  // corridor overlap so only the independently routable interior is constrained.
    +  const trim = (segment: OrthogonalSegment, atStart: boolean): void => {
    +    if (segment.orientation === 'H') {
    +      const direction = Math.sign(segment.b.x - segment.a.x);
    +      if (atStart) {
    +        segment.a.x += direction * TERMINAL_APPROACH_PX;
    +      } else {
    +        segment.b.x -= direction * TERMINAL_APPROACH_PX;
    +      }
    +    } else {
    +      const direction = Math.sign(segment.b.y - segment.a.y);
    +      if (atStart) {
    +        segment.a.y += direction * TERMINAL_APPROACH_PX;
    +      } else {
    +        segment.b.y -= direction * TERMINAL_APPROACH_PX;
    +      }
    +    }
    +  };
    +  trim(segments[0], true);
    +  trim(segments[segments.length - 1], false);
    +  return segments.filter(({ a, b }) => a.x !== b.x || a.y !== b.y);
    +}
    +
    +export function pairSegmentsConflict(
    +  candidate: OrthogonalSegment,
    +  committed: OrthogonalSegment
    +): boolean {
    +  if (candidate.orientation !== committed.orientation) {
    +    return false;
    +  }
    +  const first = segmentSpan(candidate);
    +  const second = segmentSpan(committed);
    +  const overlap = Math.max(0, Math.min(first.high, second.high) - Math.max(first.low, second.low));
    +  if (overlap <= 0) {
    +    return false;
    +  }
    +  const separation =
    +    candidate.orientation === 'H'
    +      ? Math.abs(candidate.a.y - committed.a.y)
    +      : Math.abs(candidate.a.x - committed.a.x);
    +  // Collinear overlap is illegal when lanes coincide for a meaningful distance or are closer than
    +  // the configured lane spacing. Endpoint-only contact has zero overlap and remains legal.
    +  return (
    +    (separation === 0 && overlap >= LANE_SEPARATION_PX) ||
    +    (separation > 0 && separation < LANE_SEPARATION_PX)
    +  );
    +}
    +
    +export function routeSatisfiesPairConstraints(
    +  points: readonly Point[],
    +  pairRoutes: readonly (readonly Point[])[]
    +): boolean {
    +  const normalized = normalizePolyline([...points]).points;
    +  const candidatePorts = [normalized[0], normalized.at(-1)!];
    +  const candidateSegments = nonterminalSegments(normalized);
    +  return pairRoutes.every((route) => {
    +    const committed = normalizePolyline([...route]).points;
    +    const committedPorts = [committed[0], committed.at(-1)!];
    +    if (
    +      candidatePorts.some((port) =>
    +        committedPorts.some((other) => port.x === other.x && port.y === other.y)
    +      )
    +    ) {
    +      return false;
    +    }
    +    return candidateSegments.every((candidate) =>
    +      nonterminalSegments(committed).every(
    +        (committedSegment) => !pairSegmentsConflict(candidate, committedSegment)
    +      )
    +    );
    +  });
    +}
    +
    +export function routeHasDistinctPairPorts(
    +  points: readonly Point[],
    +  pairRoutes: readonly (readonly Point[])[]
    +): boolean {
    +  const normalized = normalizePolyline([...points]).points;
    +  const candidatePorts = [normalized[0], normalized.at(-1)!];
    +  return pairRoutes.every((route) => {
    +    const committed = normalizePolyline([...route]).points;
    +    const committedPorts = [committed[0], committed.at(-1)!];
    +    return !candidatePorts.some((port) =>
    +      committedPorts.some((other) => port.x === other.x && port.y === other.y)
    +    );
    +  });
    +}
    +
    +export function pairArcAllowed(
    +  from: RouterPoint,
    +  to: RouterPoint,
    +  pairRoutes: readonly (readonly Point[])[]
    +): boolean {
    +  const orientation: GridOrientation = from.y === to.y ? 'H' : 'V';
    +  const candidate = { a: from, b: to, orientation };
    +  return pairRoutes.every((route) =>
    +    nonterminalSegments(route).every(
    +      (committedSegment) => !pairSegmentsConflict(candidate, committedSegment)
    +    )
    +  );
    +}
    +
    +export function endpointPairLowerBound(
    +  source: EndpointCandidate,
    +  target: EndpointCandidate
    +): readonly [length: number, bends: number] {
    +  // This admissible lower bound includes both terminal stubs and the minimum bend count implied by
    +  // endpoint orientations; candidate-pair search can safely prune against it.
    +  const sourceOrientation: GridOrientation =
    +    source.side === 'left' || source.side === 'right' ? 'H' : 'V';
    +  const targetOrientation: GridOrientation =
    +    target.side === 'left' || target.side === 'right' ? 'H' : 'V';
    +  const dx = Math.abs(source.connect.x - target.connect.x);
    +  const dy = Math.abs(source.connect.y - target.connect.y);
    +  let bends: number;
    +  if (dx === 0 && dy === 0) {
    +    bends = Number(sourceOrientation !== targetOrientation);
    +  } else if (dx === 0 || dy === 0) {
    +    const pathOrientation: GridOrientation = dx === 0 ? 'V' : 'H';
    +    bends =
    +      Number(sourceOrientation !== pathOrientation) + Number(targetOrientation !== pathOrientation);
    +  } else {
    +    bends = Math.min(
    +      Number(sourceOrientation !== 'H') + 1 + Number(targetOrientation !== 'V'),
    +      Number(sourceOrientation !== 'V') + 1 + Number(targetOrientation !== 'H')
    +    );
    +  }
    +  return [TERMINAL_APPROACH_PX * 2 + dx + dy, bends];
    +}
    diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/grid/routerInstrumentation.spec.ts b/packages/mermaid/src/rendering-util/layout-algorithms/grid/routerInstrumentation.spec.ts
    new file mode 100644
    index 00000000000..dca1621e2df
    --- /dev/null
    +++ b/packages/mermaid/src/rendering-util/layout-algorithms/grid/routerInstrumentation.spec.ts
    @@ -0,0 +1,78 @@
    +import { describe, expect, it } from 'vitest';
    +import {
    +  createGridRoutingInstrumentation,
    +  createGridRoutingInstrumentationCheckpoint,
    +  gridRoutingMetricDisposition,
    +  restoreGridRoutingInstrumentationCheckpoint,
    +  type GridRouteInstrumentation,
    +  type GridRoutingInstrumentation,
    +} from './routerInstrumentation.js';
    +
    +const route = (edgeId: string): GridRouteInstrumentation => ({
    +  edgeId,
    +  routeOrder: 0,
    +  laneOffset: 0,
    +  routeLength: 10,
    +  bendCount: 1,
    +  crossingCount: 0,
    +  sharedLength: 0,
    +  boundaryTransitionCount: 0,
    +  routeSignature: edgeId,
    +});
    +
    +describe('grid routing instrumentation checkpoints', () => {
    +  it('classifies every metric as transactional or cumulative', () => {
    +    expect(Object.keys(gridRoutingMetricDisposition).sort()).toEqual(
    +      Object.keys(createGridRoutingInstrumentation()).sort()
    +    );
    +    expect(gridRoutingMetricDisposition.compatibilityFastPaths).toBe('cumulative');
    +    expect(gridRoutingMetricDisposition.labelOverlayBuilds).toBe('cumulative');
    +    expect(gridRoutingMetricDisposition.labelOverlayVertices).toBe('cumulative');
    +
    +    const checkpoint = createGridRoutingInstrumentationCheckpoint(
    +      createGridRoutingInstrumentation()
    +    );
    +    const transactionalValueMetrics = Object.entries(gridRoutingMetricDisposition)
    +      .filter(
    +        ([key, disposition]) =>
    +          disposition === 'transactional' && key !== 'routeOrder' && key !== 'routes'
    +      )
    +      .map(([key]) => key)
    +      .sort();
    +    expect(Object.keys(checkpoint.values).sort()).toEqual(transactionalValueMetrics);
    +  });
    +
    +  it('restores committed output while preserving work performed', () => {
    +    const metrics = createGridRoutingInstrumentation();
    +    metrics.routeOrder.push('committed');
    +    metrics.routes.push(route('committed'));
    +    const checkpoint = createGridRoutingInstrumentationCheckpoint(metrics);
    +    const beforeAttempt = structuredClone(metrics);
    +    const mutableMetrics = metrics as unknown as Record;
    +
    +    for (const key of Object.keys(metrics) as (keyof GridRoutingInstrumentation)[]) {
    +      const value = metrics[key];
    +      if (typeof value === 'number') {
    +        mutableMetrics[key] = value + 7;
    +      }
    +    }
    +    for (const reason of Object.keys(
    +      metrics.fallbackReasons
    +    ) as (keyof typeof metrics.fallbackReasons)[]) {
    +      metrics.fallbackReasons[reason] += 7;
    +    }
    +    metrics.routeOrder.push('failed-attempt');
    +    metrics.routes.push(route('failed-attempt'));
    +    const afterAttempt = structuredClone(metrics);
    +
    +    restoreGridRoutingInstrumentationCheckpoint(metrics, checkpoint);
    +
    +    for (const key of Object.keys(metrics) as (keyof GridRoutingInstrumentation)[]) {
    +      if (gridRoutingMetricDisposition[key] === 'transactional') {
    +        expect(metrics[key], key).toEqual(beforeAttempt[key]);
    +      } else {
    +        expect(metrics[key], key).toEqual(afterAttempt[key]);
    +      }
    +    }
    +  });
    +});
    diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/grid/routerInstrumentation.ts b/packages/mermaid/src/rendering-util/layout-algorithms/grid/routerInstrumentation.ts
    new file mode 100644
    index 00000000000..073f8a9d96b
    --- /dev/null
    +++ b/packages/mermaid/src/rendering-util/layout-algorithms/grid/routerInstrumentation.ts
    @@ -0,0 +1,318 @@
    +import type { Point } from '../../../types.js';
    +import { normalizePolyline, segmentsCross } from '../layout-utils/geometry.js';
    +import { manhattanLength } from '../layout-utils/helpers.js';
    +
    +// Instrumentation doubles as observable behavior for retries. Work counters are cumulative, while
    +// committed-output counters must roll back with a failed bundle attempt.
    +export type GridRoutingFallbackReason =
    +  | 'vertex_cap'
    +  | 'adjacency_cap'
    +  | 'estimated_memory_cap'
    +  | 'search_state_cap'
    +  | 'label_index_work_cap';
    +
    +export class GridRoutingResourceLimitError extends Error {
    +  constructor(
    +    public readonly reason: GridRoutingFallbackReason,
    +    message: string,
    +    public readonly searchStateScope?: 'edge' | 'invocation'
    +  ) {
    +    super(message);
    +    this.name = 'GridRoutingResourceLimitError';
    +  }
    +}
    +
    +export interface GridRouteInstrumentation {
    +  edgeId: string;
    +  routeOrder: number;
    +  laneOffset: number;
    +  routeLength: number;
    +  bendCount: number;
    +  crossingCount: number;
    +  sharedLength: number;
    +  boundaryTransitionCount: number;
    +  routeSignature: string;
    +}
    +
    +export interface GridRoutingInstrumentation {
    +  containersBuilt: number;
    +  baseTopologyBuilds: number;
    +  baseVertices: number;
    +  baseAdjacencyEntries: number;
    +  buildSweepEvents: number;
    +  endpointOverlayBuilds: number;
    +  endpointOverlayVertices: number;
    +  hierarchyPortalPairs: number;
    +  hierarchyPortalTransitionLength: number;
    +  hierarchyPortalAlternativeAttempts: number;
    +  hierarchyPortalAlternativeSelections: number;
    +  hierarchyBoundaryTransitions: number;
    +  labelOverlayBuilds: number;
    +  labelOverlayVertices: number;
    +  labelSpacingEdgesExamined: number;
    +  labelSpacingEligibleEdges: number;
    +  labelSpacingBoundariesExpanded: number;
    +  labelSpacingPixelsAdded: number;
    +  labelAwareSelfLoopCandidates: number;
    +  labelAwareSelfLoopsCommitted: number;
    +  labelAwareSelfLoopFallbacks: number;
    +  searches: number;
    +  expandedStates: number;
    +  maxOpenSet: number;
    +  routesFound: number;
    +  routesImpossible: number;
    +  resourceLimitFallbacks: number;
    +  fallbackReasons: Record;
    +  fallbackValidationFailures: number;
    +  compatibilitySegments: number;
    +  compatibilityFastPathAttempts: number;
    +  compatibilityFastPaths: number;
    +  compatibilityFastPathValidationFailures: number;
    +  compatibilityFastPathNonMinimalRoutes: number;
    +  compatibilityRecoveries: number;
    +  compatibilityValidationFailures: number;
    +  bundleSeparationRelaxations: number;
    +  routeLength: number;
    +  bendCount: number;
    +  crossingCount: number;
    +  sharedLength: number;
    +  estimatedBytes: number;
    +  searchWorkspaceBytes: number;
    +  routeOrder: string[];
    +  routes: GridRouteInstrumentation[];
    +}
    +
    +export const gridRoutingMetricDisposition = {
    +  containersBuilt: 'cumulative',
    +  baseTopologyBuilds: 'cumulative',
    +  baseVertices: 'cumulative',
    +  baseAdjacencyEntries: 'cumulative',
    +  buildSweepEvents: 'cumulative',
    +  endpointOverlayBuilds: 'cumulative',
    +  endpointOverlayVertices: 'cumulative',
    +  hierarchyPortalPairs: 'transactional',
    +  hierarchyPortalTransitionLength: 'transactional',
    +  hierarchyPortalAlternativeAttempts: 'cumulative',
    +  hierarchyPortalAlternativeSelections: 'transactional',
    +  hierarchyBoundaryTransitions: 'transactional',
    +  labelOverlayBuilds: 'cumulative',
    +  labelOverlayVertices: 'cumulative',
    +  labelSpacingEdgesExamined: 'cumulative',
    +  labelSpacingEligibleEdges: 'cumulative',
    +  labelSpacingBoundariesExpanded: 'cumulative',
    +  labelSpacingPixelsAdded: 'cumulative',
    +  labelAwareSelfLoopCandidates: 'cumulative',
    +  labelAwareSelfLoopsCommitted: 'transactional',
    +  labelAwareSelfLoopFallbacks: 'cumulative',
    +  searches: 'cumulative',
    +  expandedStates: 'cumulative',
    +  maxOpenSet: 'cumulative',
    +  routesFound: 'transactional',
    +  routesImpossible: 'transactional',
    +  resourceLimitFallbacks: 'cumulative',
    +  fallbackReasons: 'cumulative',
    +  fallbackValidationFailures: 'cumulative',
    +  compatibilitySegments: 'transactional',
    +  compatibilityFastPathAttempts: 'cumulative',
    +  compatibilityFastPaths: 'cumulative',
    +  compatibilityFastPathValidationFailures: 'cumulative',
    +  compatibilityFastPathNonMinimalRoutes: 'cumulative',
    +  compatibilityRecoveries: 'transactional',
    +  compatibilityValidationFailures: 'cumulative',
    +  bundleSeparationRelaxations: 'transactional',
    +  routeLength: 'transactional',
    +  bendCount: 'transactional',
    +  crossingCount: 'transactional',
    +  sharedLength: 'transactional',
    +  estimatedBytes: 'cumulative',
    +  searchWorkspaceBytes: 'cumulative',
    +  routeOrder: 'transactional',
    +  routes: 'transactional',
    +} as const satisfies Record;
    +
    +// Deriving checkpoint keys from the exhaustive disposition map forces every new metric to declare
    +// whether retries preserve the work or restore the committed result.
    +type TransactionalGridRoutingMetric = {
    +  [K in keyof typeof gridRoutingMetricDisposition]: (typeof gridRoutingMetricDisposition)[K] extends 'transactional'
    +    ? K
    +    : never;
    +}[keyof typeof gridRoutingMetricDisposition];
    +
    +type TransactionalGridRoutingValueMetric = Exclude<
    +  TransactionalGridRoutingMetric,
    +  'routeOrder' | 'routes'
    +>;
    +
    +const transactionalGridRoutingValueMetrics = (
    +  Object.keys(gridRoutingMetricDisposition) as (keyof GridRoutingInstrumentation)[]
    +).filter(
    +  (key): key is TransactionalGridRoutingValueMetric =>
    +    gridRoutingMetricDisposition[key] === 'transactional' &&
    +    key !== 'routeOrder' &&
    +    key !== 'routes'
    +);
    +
    +export interface GridRoutingInstrumentationCheckpoint {
    +  routeOrderLength: number;
    +  routesLength: number;
    +  values: Pick;
    +}
    +
    +export function createGridRoutingInstrumentationCheckpoint(
    +  metrics: GridRoutingInstrumentation
    +): GridRoutingInstrumentationCheckpoint {
    +  // Route arrays are append-only during an attempt, so lengths are sufficient and avoid cloning
    +  // potentially large per-edge records on every bundle retry.
    +  return {
    +    routeOrderLength: metrics.routeOrder.length,
    +    routesLength: metrics.routes.length,
    +    values: Object.fromEntries(
    +      transactionalGridRoutingValueMetrics.map((key) => [key, metrics[key]])
    +    ) as GridRoutingInstrumentationCheckpoint['values'],
    +  };
    +}
    +
    +export function restoreGridRoutingInstrumentationCheckpoint(
    +  metrics: GridRoutingInstrumentation,
    +  checkpoint: GridRoutingInstrumentationCheckpoint
    +): void {
    +  metrics.routeOrder.length = checkpoint.routeOrderLength;
    +  metrics.routes.length = checkpoint.routesLength;
    +  Object.assign(metrics, checkpoint.values);
    +}
    +
    +export function createGridRoutingInstrumentation(): GridRoutingInstrumentation {
    +  return {
    +    containersBuilt: 0,
    +    baseTopologyBuilds: 0,
    +    baseVertices: 0,
    +    baseAdjacencyEntries: 0,
    +    buildSweepEvents: 0,
    +    endpointOverlayBuilds: 0,
    +    endpointOverlayVertices: 0,
    +    hierarchyPortalPairs: 0,
    +    hierarchyPortalTransitionLength: 0,
    +    hierarchyPortalAlternativeAttempts: 0,
    +    hierarchyPortalAlternativeSelections: 0,
    +    hierarchyBoundaryTransitions: 0,
    +    labelOverlayBuilds: 0,
    +    labelOverlayVertices: 0,
    +    labelSpacingEdgesExamined: 0,
    +    labelSpacingEligibleEdges: 0,
    +    labelSpacingBoundariesExpanded: 0,
    +    labelSpacingPixelsAdded: 0,
    +    labelAwareSelfLoopCandidates: 0,
    +    labelAwareSelfLoopsCommitted: 0,
    +    labelAwareSelfLoopFallbacks: 0,
    +    searches: 0,
    +    expandedStates: 0,
    +    maxOpenSet: 0,
    +    routesFound: 0,
    +    routesImpossible: 0,
    +    resourceLimitFallbacks: 0,
    +    fallbackReasons: {
    +      vertex_cap: 0,
    +      adjacency_cap: 0,
    +      estimated_memory_cap: 0,
    +      search_state_cap: 0,
    +      label_index_work_cap: 0,
    +    },
    +    fallbackValidationFailures: 0,
    +    compatibilitySegments: 0,
    +    compatibilityFastPathAttempts: 0,
    +    compatibilityFastPaths: 0,
    +    compatibilityFastPathValidationFailures: 0,
    +    compatibilityFastPathNonMinimalRoutes: 0,
    +    compatibilityRecoveries: 0,
    +    compatibilityValidationFailures: 0,
    +    bundleSeparationRelaxations: 0,
    +    routeLength: 0,
    +    bendCount: 0,
    +    crossingCount: 0,
    +    sharedLength: 0,
    +    estimatedBytes: 0,
    +    searchWorkspaceBytes: 0,
    +    routeOrder: [],
    +    routes: [],
    +  };
    +}
    +
    +function intervalOverlap(
    +  firstStart: number,
    +  firstEnd: number,
    +  secondStart: number,
    +  secondEnd: number
    +): number {
    +  return Math.max(
    +    0,
    +    Math.min(Math.max(firstStart, firstEnd), Math.max(secondStart, secondEnd)) -
    +      Math.max(Math.min(firstStart, firstEnd), Math.min(secondStart, secondEnd))
    +  );
    +}
    +
    +function sharedSegmentLength(first: Point[], second: Point[]): number {
    +  const firstSegments = normalizePolyline(first).segments;
    +  const secondSegments = normalizePolyline(second).segments;
    +  let total = 0;
    +
    +  for (const a of firstSegments) {
    +    for (const b of secondSegments) {
    +      if (a.orientation !== b.orientation) {
    +        continue;
    +      }
    +      if (a.orientation === 'H' && a.a.y === b.a.y) {
    +        total += intervalOverlap(a.a.x, a.b.x, b.a.x, b.b.x);
    +      } else if (a.orientation === 'V' && a.a.x === b.a.x) {
    +        total += intervalOverlap(a.a.y, a.b.y, b.a.y, b.b.y);
    +      }
    +    }
    +  }
    +  return total;
    +}
    +
    +export function recordGridRoute(
    +  metrics: GridRoutingInstrumentation,
    +  edgeId: string,
    +  points: Point[],
    +  previousRoutes: readonly Point[][],
    +  boundaryTransitionCount = 0,
    +  laneOffset = 0
    +): void {
    +  // Quality metrics compare only against previously committed routes, matching visible route order
    +  // and avoiding double-counting pairs.
    +  const normalized = normalizePolyline(points);
    +  let crossingCount = 0;
    +  let sharedLength = 0;
    +
    +  for (const previous of previousRoutes) {
    +    const previousSegments = normalizePolyline(previous).segments;
    +    for (const segment of normalized.segments) {
    +      for (const previousSegment of previousSegments) {
    +        if (segmentsCross(segment, previousSegment)) {
    +          crossingCount++;
    +        }
    +      }
    +    }
    +    sharedLength += sharedSegmentLength(normalized.points, previous);
    +  }
    +
    +  const route: GridRouteInstrumentation = {
    +    edgeId,
    +    routeOrder: metrics.routes.length,
    +    laneOffset,
    +    routeLength: manhattanLength(normalized.points),
    +    bendCount: normalized.bends,
    +    crossingCount,
    +    sharedLength,
    +    boundaryTransitionCount,
    +    routeSignature: JSON.stringify(normalized.points),
    +  };
    +  metrics.routeOrder.push(edgeId);
    +  metrics.routes.push(route);
    +  metrics.routesFound++;
    +  metrics.routeLength += route.routeLength;
    +  metrics.bendCount += route.bendCount;
    +  metrics.crossingCount += route.crossingCount;
    +  metrics.sharedLength += route.sharedLength;
    +  metrics.hierarchyBoundaryTransitions += boundaryTransitionCount;
    +}
    diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/grid/routerOccupancy.ts b/packages/mermaid/src/rendering-util/layout-algorithms/grid/routerOccupancy.ts
    new file mode 100644
    index 00000000000..4793fd8467d
    --- /dev/null
    +++ b/packages/mermaid/src/rendering-util/layout-algorithms/grid/routerOccupancy.ts
    @@ -0,0 +1,127 @@
    +import type { Point } from '../../../types.js';
    +import { nonterminalSegments, type OrthogonalSegment } from './routerConstraint.js';
    +import { EDGE_CLEARANCE_PX } from './routerTopology.js';
    +import type { GridOrientation, RouterPoint } from './types.js';
    +
    +function segmentsTooClose(candidate: OrthogonalSegment, committed: OrthogonalSegment): boolean {
    +  if (candidate.orientation !== committed.orientation) {
    +    return false;
    +  }
    +  const horizontal = candidate.orientation === 'H';
    +  const lowOf = (segment: OrthogonalSegment) =>
    +    horizontal ? Math.min(segment.a.x, segment.b.x) : Math.min(segment.a.y, segment.b.y);
    +  const highOf = (segment: OrthogonalSegment) =>
    +    horizontal ? Math.max(segment.a.x, segment.b.x) : Math.max(segment.a.y, segment.b.y);
    +  const overlap =
    +    Math.min(highOf(candidate), highOf(committed)) - Math.max(lowOf(candidate), lowOf(committed));
    +  const separation = horizontal
    +    ? Math.abs(candidate.a.y - committed.a.y)
    +    : Math.abs(candidate.a.x - committed.a.x);
    +  return overlap > 0 && separation < EDGE_CLEARANCE_PX;
    +}
    +
    +interface OccupiedSegment {
    +  segment: OrthogonalSegment;
    +  pairKey: string;
    +}
    +
    +interface OccupancyLogEntry {
    +  orientation: GridOrientation;
    +  coordinate: number;
    +}
    +
    +/**
    + * Container-scoped index of committed route segments.
    + *
    + * Routes from different edges must not share a corridor, so every routed edge registers its
    + * nonterminal segments here and later candidates query it with the clearance rule
    + * (`segmentsTooClose`). Segments are bucketed by their fixed coordinate so a query
    + * only inspects lines within the clearance instead of every committed segment.
    + *
    + * Routes are append-only and can be truncated back to an earlier size, which is how bundle retries
    + * roll back state.
    + */
    +export class RouteOccupancyIndex {
    +  private readonly lines: Record<'H' | 'V', Map> = {
    +    H: new Map(),
    +    V: new Map(),
    +  };
    +  private readonly sortedCoordinates: Record<'H' | 'V', number[] | undefined> = {
    +    H: undefined,
    +    V: undefined,
    +  };
    +  private readonly log: OccupancyLogEntry[] = [];
    +
    +  get size(): number {
    +    return this.log.length;
    +  }
    +
    +  add(route: readonly Point[], pairKey: string): void {
    +    for (const segment of nonterminalSegments(route)) {
    +      const orientation = segment.orientation as 'H' | 'V';
    +      const coordinate = orientation === 'H' ? segment.a.y : segment.a.x;
    +      const bucket = this.lines[orientation].get(coordinate);
    +      if (bucket) {
    +        bucket.push({ segment, pairKey });
    +      } else {
    +        this.lines[orientation].set(coordinate, [{ segment, pairKey }]);
    +        this.sortedCoordinates[orientation] = undefined;
    +      }
    +      this.log.push({ orientation, coordinate });
    +    }
    +  }
    +
    +  truncate(size: number): void {
    +    while (this.log.length > size) {
    +      const { orientation, coordinate } = this.log.pop()!;
    +      this.lines[orientation].get(coordinate)?.pop();
    +    }
    +  }
    +
    +  /** Reports whether a route shares a corridor with a committed route of another edge pair. */
    +  conflictsWithRoute(route: readonly Point[], pairKey: string): boolean {
    +    return nonterminalSegments(route).some((segment) => this.conflicts(segment, pairKey));
    +  }
    +
    +  conflictsWithArc(from: RouterPoint, to: RouterPoint, pairKey: string): boolean {
    +    const orientation: GridOrientation = from.y === to.y ? 'H' : 'V';
    +    return this.conflicts({ a: from, b: to, orientation }, pairKey);
    +  }
    +
    +  private conflicts(candidate: OrthogonalSegment, pairKey: string): boolean {
    +    const orientation = candidate.orientation as string;
    +    if (orientation !== 'H' && orientation !== 'V') {
    +      return false;
    +    }
    +    const coordinate = orientation === 'H' ? candidate.a.y : candidate.a.x;
    +    const coordinates = this.coordinatesFor(orientation);
    +    let low = 0;
    +    let high = coordinates.length;
    +    while (low < high) {
    +      const middle = (low + high) >> 1;
    +      if (coordinates[middle] <= coordinate - EDGE_CLEARANCE_PX) {
    +        low = middle + 1;
    +      } else {
    +        high = middle;
    +      }
    +    }
    +    for (let index = low; index < coordinates.length; index++) {
    +      const key = coordinates[index];
    +      if (key >= coordinate + EDGE_CLEARANCE_PX) {
    +        break;
    +      }
    +      for (const occupied of this.lines[orientation].get(key)!) {
    +        if (occupied.pairKey !== pairKey && segmentsTooClose(candidate, occupied.segment)) {
    +          return true;
    +        }
    +      }
    +    }
    +    return false;
    +  }
    +
    +  private coordinatesFor(orientation: 'H' | 'V'): number[] {
    +    return (this.sortedCoordinates[orientation] ??= [...this.lines[orientation].keys()].sort(
    +      (a, b) => a - b
    +    ));
    +  }
    +}
    diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/grid/routerPlanning.spec.ts b/packages/mermaid/src/rendering-util/layout-algorithms/grid/routerPlanning.spec.ts
    new file mode 100644
    index 00000000000..d57c5034679
    --- /dev/null
    +++ b/packages/mermaid/src/rendering-util/layout-algorithms/grid/routerPlanning.spec.ts
    @@ -0,0 +1,122 @@
    +import { describe, expect, it } from 'vitest';
    +import type { Edge, LayoutData, Node } from '../../types.js';
    +import { assignCompactPortalCoordinates, prepareEdgeRoutes } from './routerPlanning.js';
    +import { derivePortalRanges } from './routerTopology.js';
    +import type { GridLayoutResult } from './types.js';
    +
    +function node(id: string, x: number, y: number): Node {
    +  return {
    +    id,
    +    isGroup: false,
    +    shape: 'rect',
    +    width: 40,
    +    height: 40,
    +    x,
    +    y,
    +  } as Node;
    +}
    +
    +function edge(id: string, start: string, end: string): Edge {
    +  return { id, start, end } as Edge;
    +}
    +
    +describe('grid router planning', () => {
    +  it('keeps compact portal coordinates within their available interval', () => {
    +    expect(assignCompactPortalCoordinates([0, 10, 0], 0, 10)).toEqual([0, 6, 10]);
    +  });
    +
    +  it('keeps demand identities distinct when edge and owner ids contain colons', () => {
    +    const nodes = [
    +      node('b', 0, 0),
    +      node('target-1', 100, 0),
    +      node('source:b', 0, 100),
    +      node('target-2', 100, 100),
    +    ];
    +    const edges = [edge('a:source', 'b', 'target-1'), edge('a', 'source:b', 'target-2')];
    +    const layout = { nodes, edges } as LayoutData;
    +    const result = {
    +      forest: { nodeById: new Map(nodes.map((entry) => [entry.id, entry])) },
    +      containers: new Map(),
    +    } as GridLayoutResult;
    +
    +    const prepared = prepareEdgeRoutes(layout, result);
    +    const demandKeys = prepared.plans.flatMap((plan) =>
    +      [...plan.source.chain, ...plan.target.chain].map((entry) => entry.demandKey)
    +    );
    +
    +    expect(new Set(demandKeys).size).toBe(4);
    +    expect(prepared.demandCoords.size).toBe(4);
    +    expect(demandKeys.every((key) => prepared.demandCoords.has(key))).toBe(true);
    +  });
    +
    +  it('keeps clustered endpoint coordinates within the preferred side interval', () => {
    +    const nodes = [
    +      node('owner', 0, 0),
    +      node('upper', 100, -100),
    +      node('lower-1', 100, 100),
    +      node('lower-2', 100, 100),
    +    ];
    +    const edges = [
    +      edge('upper-edge', 'owner', 'upper'),
    +      edge('lower-edge-1', 'owner', 'lower-1'),
    +      edge('lower-edge-2', 'owner', 'lower-2'),
    +    ];
    +    const layout = { nodes, edges } as LayoutData;
    +    const result = {
    +      forest: { nodeById: new Map(nodes.map((entry) => [entry.id, entry])) },
    +      containers: new Map(),
    +    } as GridLayoutResult;
    +
    +    const prepared = prepareEdgeRoutes(layout, result);
    +    const sourceCandidates = edges.map(
    +      ({ id }) => prepared.endpointCandidatesByEdge.get(id)!.sources[0]
    +    );
    +
    +    expect(sourceCandidates.map(({ side }) => side)).toEqual(['right', 'right', 'right']);
    +    expect(sourceCandidates.map(({ port }) => port.y).sort((a, b) => a - b)).toEqual([-14, 10, 14]);
    +  });
    +
    +  it('keeps clustered hierarchy portals separated inside the topology range', () => {
    +    const owner = {
    +      id: 'owner',
    +      isGroup: true,
    +      shape: 'rect',
    +      label: 'Owner',
    +      x: 0,
    +      y: 0,
    +      width: 40,
    +      height: 40,
    +    } as Node;
    +    const nodes = [
    +      owner,
    +      { ...node('source-1', 0, -15), parentId: owner.id },
    +      { ...node('source-2', 0, -15), parentId: owner.id },
    +      node('target-1', 100, -15),
    +      node('target-2', 100, -15),
    +    ];
    +    const edges = [edge('edge-1', 'source-1', 'target-1'), edge('edge-2', 'source-2', 'target-2')];
    +    const layout = { nodes, edges } as LayoutData;
    +    const result = {
    +      forest: { nodeById: new Map(nodes.map((entry) => [entry.id, entry])) },
    +      containers: new Map(),
    +    } as GridLayoutResult;
    +
    +    const prepared = prepareEdgeRoutes(layout, result);
    +    const coordinates = prepared.plans
    +      .map((plan) => plan.source.chain.find(({ ownerId }) => ownerId === owner.id))
    +      .map((entry) => prepared.demandCoords.get(entry!.demandKey)!)
    +      .sort((a, b) => a - b);
    +    const range = derivePortalRanges(owner.id, {
    +      left: -20,
    +      right: 20,
    +      top: -20,
    +      bottom: 20,
    +    }).find(({ side }) => side === 'right')!;
    +
    +    expect(coordinates).toEqual([-14, -10]);
    +    expect(
    +      coordinates.every((coordinate) => coordinate >= range.low && coordinate <= range.high)
    +    ).toBe(true);
    +    expect(coordinates[1] - coordinates[0]).toBeGreaterThanOrEqual(4);
    +  });
    +});
    diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/grid/routerPlanning.ts b/packages/mermaid/src/rendering-util/layout-algorithms/grid/routerPlanning.ts
    new file mode 100644
    index 00000000000..f44c4cab36d
    --- /dev/null
    +++ b/packages/mermaid/src/rendering-util/layout-algorithms/grid/routerPlanning.ts
    @@ -0,0 +1,1171 @@
    +import type { Point } from '../../../types.js';
    +import type { Edge, LayoutData, Node } from '../../types.js';
    +import { PIXEL_EPSILON } from '../layout-utils/geometry.js';
    +import { clamp, compareCodeUnits, rectForNode } from '../layout-utils/helpers.js';
    +import { isAncestorGroup } from './groups.js';
    +import { EDGE_CLEARANCE_PX, ROUTE_CLEARANCE_PX, routingPointKey } from './routerTopology.js';
    +import type {
    +  GridAttachmentDemand,
    +  GridContainerId,
    +  GridLayoutResult,
    +  GridSide,
    +  RouterPoint,
    +} from './types.js';
    +import { ROOT_CONTAINER_ID, gridError } from './types.js';
    +
    +// Planning is deterministic and side-effect free: it resolves hierarchy chains, bundle lanes,
    +// endpoint demand, and route order before the session creates any routing topology.
    +const MIN_PORT_SEPARATION_PX = 4;
    +export const TERMINAL_APPROACH_PX = 20;
    +export const LANE_SEPARATION_PX = 8;
    +
    +export interface EdgeEndpointEntry {
    +  ownerId: string;
    +  side: GridSide;
    +  demandKey: string;
    +  oppositeCoord: number;
    +  preferredCoord: number;
    +  compactPortal: boolean;
    +  ambiguousSides?: readonly GridSide[];
    +  inner?: { demandKey: string; side: GridSide };
    +}
    +
    +export interface EdgeEndpointPlan {
    +  // Entries walk from the endpoint outward toward the least common container. Each adjacent pair
    +  // represents one hierarchy boundary that the final route must cross through a paired portal.
    +  chain: EdgeEndpointEntry[];
    +  finalKind: 'item' | 'boundary';
    +}
    +
    +export interface EdgeRoutePlan {
    +  edge: Edge;
    +  lcaContainerId: GridContainerId;
    +  source: EdgeEndpointPlan;
    +  target: EdgeEndpointPlan;
    +  laneIndex: number;
    +  laneOffset: number;
    +  bundleSize: number;
    +  pairKey: string;
    +}
    +
    +export interface EdgeRouteCandidates {
    +  sources: EndpointCandidate[];
    +  targets: EndpointCandidate[];
    +}
    +
    +export interface PreparedEdgeRoutes {
    +  plans: EdgeRoutePlan[];
    +  eligiblePlans: EdgeRoutePlan[];
    +  eligibleIds: Set;
    +  orderedPlans: EdgeRoutePlan[];
    +  demandCoords: Map;
    +  endpointCandidatesByEdge: Map;
    +  endpointIncidentCounts: Map;
    +  coordinatedEndpointIds: Set;
    +}
    +
    +export function ownerGroupTitle(node: Node): boolean {
    +  return Boolean(node.groupTitleRect);
    +}
    +
    +export function ownerSideKey(ownerId: string, side: GridSide): string {
    +  return JSON.stringify([ownerId, side]);
    +}
    +
    +function preferredSide(node: Node, toward: Point): GridSide {
    +  const rect = rectForNode(node);
    +  const dx = toward.x - rect.cx;
    +  const dy = toward.y - rect.cy;
    +  const avoidTop = node.isGroup && ownerGroupTitle(node);
    +
    +  if (Math.abs(dx) >= Math.abs(dy)) {
    +    return dx >= 0 ? 'right' : 'left';
    +  }
    +  if (dy >= 0) {
    +    return 'bottom';
    +  }
    +  if (avoidTop) {
    +    return dx >= 0 ? 'right' : 'left';
    +  }
    +  return 'top';
    +}
    +
    +function containerChain(node: Node, nodeById: Map): GridContainerId[] {
    +  const chain: GridContainerId[] = [];
    +  let currentParent = node.parentId ?? ROOT_CONTAINER_ID;
    +  const seen = new Set();
    +  while (true) {
    +    chain.push(currentParent);
    +    if (currentParent === ROOT_CONTAINER_ID) {
    +      break;
    +    }
    +    if (seen.has(currentParent)) {
    +      break;
    +    }
    +    seen.add(currentParent);
    +    currentParent = nodeById.get(currentParent)?.parentId ?? ROOT_CONTAINER_ID;
    +  }
    +  return chain;
    +}
    +
    +function commonContainerId(
    +  source: Node,
    +  target: Node,
    +  nodeById: Map
    +): GridContainerId {
    +  if (source.isGroup && isAncestorGroup(source.id, target, nodeById)) {
    +    return source.parentId ?? ROOT_CONTAINER_ID;
    +  }
    +  if (target.isGroup && isAncestorGroup(target.id, source, nodeById)) {
    +    return target.parentId ?? ROOT_CONTAINER_ID;
    +  }
    +  const targetContainers = new Set(containerChain(target, nodeById));
    +  for (const containerId of containerChain(source, nodeById)) {
    +    if (targetContainers.has(containerId)) {
    +      return containerId;
    +    }
    +  }
    +  return ROOT_CONTAINER_ID;
    +}
    +
    +function oppositeCoordFor(rect: ReturnType, side: GridSide): number {
    +  return side === 'left' || side === 'right' ? rect.cy : rect.cx;
    +}
    +
    +function sideMidpoint(rect: ReturnType, side: GridSide): Point {
    +  return side === 'left' || side === 'right'
    +    ? { x: side === 'left' ? rect.left : rect.right, y: rect.cy }
    +    : { x: rect.cx, y: side === 'top' ? rect.top : rect.bottom };
    +}
    +
    +function tangentialCoordinate(point: Point, side: GridSide): number {
    +  return side === 'left' || side === 'right' ? point.y : point.x;
    +}
    +
    +function buildEndpointPlan(
    +  endpoint: Node,
    +  other: Node,
    +  lcaContainerId: GridContainerId,
    +  nodeById: Map,
    +  edgeId: string,
    +  role: 'source' | 'target'
    +): EdgeEndpointPlan {
    +  const chain: EdgeEndpointEntry[] = [];
    +  const endpointRect = rectForNode(endpoint);
    +  const otherRect = rectForNode(other);
    +  const otherCenter = { x: other.x ?? 0, y: other.y ?? 0 };
    +  let current = endpoint;
    +
    +  for (;;) {
    +    const parentId = current.parentId ?? ROOT_CONTAINER_ID;
    +    const parent = nodeById.get(parentId);
    +    const currentCenter = { x: current.x ?? 0, y: current.y ?? 0 };
    +    const dx = otherCenter.x - currentCenter.x;
    +    const dy = otherCenter.y - currentCenter.y;
    +    const titleAvoidanceTie =
    +      dy < 0 &&
    +      Math.abs(dx) <= PIXEL_EPSILON &&
    +      Boolean((current.isGroup ? current.groupTitleRect : undefined) ?? parent?.groupTitleRect);
    +    const directSideCandidates: GridSide[] = [];
    +    if (Math.abs(Math.abs(dx) - Math.abs(dy)) <= PIXEL_EPSILON && Math.abs(dx) > PIXEL_EPSILON) {
    +      directSideCandidates.push(dx >= 0 ? 'right' : 'left', dy >= 0 ? 'bottom' : 'top');
    +    }
    +    const topBlocked = Boolean(
    +      (current.isGroup ? current.groupTitleRect : undefined) ?? parent?.groupTitleRect
    +    );
    +    const ambiguousSides = (
    +      titleAvoidanceTie ? (['left', 'right'] as GridSide[]) : directSideCandidates
    +    ).filter((candidate) => candidate !== 'top' || !topBlocked);
    +    let side = preferredSide(current, otherCenter);
    +    if (side === 'top' && parent?.groupTitleRect) {
    +      side = dx >= 0 ? 'right' : 'left';
    +    }
    +    chain.push({
    +      ownerId: current.id,
    +      side,
    +      demandKey: JSON.stringify([edgeId, role, current.id, side]),
    +      oppositeCoord: oppositeCoordFor(otherRect, side),
    +      preferredCoord: oppositeCoordFor(endpointRect, side),
    +      compactPortal: current.id !== endpoint.id,
    +      ambiguousSides: ambiguousSides.length > 1 ? ambiguousSides : undefined,
    +      inner: chain.length
    +        ? { demandKey: chain[chain.length - 1].demandKey, side: chain[chain.length - 1].side }
    +        : undefined,
    +    });
    +
    +    if (
    +      current.isGroup &&
    +      current.id === lcaContainerId &&
    +      isAncestorGroup(current.id, other, nodeById)
    +    ) {
    +      return { chain, finalKind: 'boundary' };
    +    }
    +    if (parentId === lcaContainerId) {
    +      return { chain, finalKind: 'item' };
    +    }
    +
    +    if (!parent?.isGroup) {
    +      throw gridError('GRID_INVALID_CONTAINMENT', `Missing parent group "${parentId}"`, {
    +        traversedIds: chain.map((entry) => entry.ownerId),
    +      });
    +    }
    +    current = parent;
    +  }
    +}
    +
    +interface PairLane {
    +  index: number;
    +  size: number;
    +  offset: number;
    +  pairKey: string;
    +}
    +
    +function buildPairLanes(edges: Edge[]): Map {
    +  const byPair = new Map();
    +  for (const edge of edges) {
    +    if (!edge.start || !edge.end) {
    +      continue;
    +    }
    +    const pairKey =
    +      edge.start < edge.end ? `${edge.start}|${edge.end}` : `${edge.end}|${edge.start}`;
    +    if (!byPair.has(pairKey)) {
    +      byPair.set(pairKey, []);
    +    }
    +    byPair.get(pairKey)!.push(edge);
    +  }
    +
    +  const out = new Map();
    +  for (const [pairKey, entries] of byPair) {
    +    // Directed endpoint order and edge id make lane assignment stable even if input edges arrive in
    +    // a different order.
    +    entries.sort(
    +      (a, b) =>
    +        compareCodeUnits(`${a.start}|${a.end}`, `${b.start}|${b.end}`) ||
    +        compareCodeUnits(a.id, b.id)
    +    );
    +    entries.forEach((edge, index) =>
    +      out.set(edge.id, {
    +        index,
    +        size: entries.length,
    +        offset: (index - (entries.length - 1) / 2) * LANE_SEPARATION_PX,
    +        pairKey,
    +      })
    +    );
    +  }
    +  return out;
    +}
    +
    +function resolveAmbiguousEndpointSides(plans: EdgeRoutePlan[], result: GridLayoutResult): void {
    +  // Reserve geometrically preferred sides first. Equal-cost endpoint choices then use the legal
    +  // side with the fewest strong reservations, preserving the original preference as a stable tie.
    +  const reserved = new Map();
    +  const reservationKey = (ownerId: string, side: GridSide) => ownerSideKey(ownerId, side);
    +  for (const plan of plans) {
    +    for (const entry of [...plan.source.chain, ...plan.target.chain]) {
    +      if (!entry.ambiguousSides) {
    +        const key = reservationKey(entry.ownerId, entry.side);
    +        reserved.set(key, (reserved.get(key) ?? 0) + 1);
    +      }
    +    }
    +  }
    +
    +  const resolveEndpoint = (
    +    plan: EdgeRoutePlan,
    +    endpoint: EdgeEndpointPlan,
    +    role: 'source' | 'target'
    +  ): void => {
    +    const ambiguous = endpoint.chain.filter(({ ambiguousSides }) => ambiguousSides);
    +    if (ambiguous.length === 0) {
    +      return;
    +    }
    +    const legalSides = (['right', 'bottom', 'left', 'top'] as const).filter(
    +      (side) =>
    +        ambiguous.every((entry) => entry.ambiguousSides?.includes(side)) &&
    +        ambiguous.every((entry) => {
    +          const owner = result.forest.nodeById.get(entry.ownerId);
    +          return owner !== undefined && sideInterval(owner, side) !== undefined;
    +        })
    +    );
    +    const selected = legalSides.sort(
    +      (a, b) =>
    +        ambiguous.reduce(
    +          (count, entry) => count + (reserved.get(reservationKey(entry.ownerId, a)) ?? 0),
    +          0
    +        ) -
    +          ambiguous.reduce(
    +            (count, entry) => count + (reserved.get(reservationKey(entry.ownerId, b)) ?? 0),
    +            0
    +          ) || Number(a !== ambiguous[0].side) - Number(b !== ambiguous[0].side)
    +    )[0];
    +    if (!selected) {
    +      return;
    +    }
    +    for (const entry of ambiguous) {
    +      entry.side = selected;
    +      const key = reservationKey(entry.ownerId, selected);
    +      reserved.set(key, (reserved.get(key) ?? 0) + 1);
    +      const owner = result.forest.nodeById.get(entry.ownerId);
    +      const oppositeId = role === 'source' ? plan.edge.end : plan.edge.start;
    +      const opposite = oppositeId ? result.forest.nodeById.get(oppositeId) : undefined;
    +      if (owner && opposite) {
    +        entry.oppositeCoord = oppositeCoordFor(rectForNode(opposite), selected);
    +        entry.preferredCoord = oppositeCoordFor(rectForNode(owner), selected);
    +      }
    +    }
    +    for (const [index, entry] of endpoint.chain.entries()) {
    +      entry.demandKey = JSON.stringify([plan.edge.id, role, entry.ownerId, entry.side]);
    +      entry.inner =
    +        index > 0
    +          ? {
    +              demandKey: endpoint.chain[index - 1].demandKey,
    +              side: endpoint.chain[index - 1].side,
    +            }
    +          : undefined;
    +    }
    +  };
    +
    +  const endpoints = plans
    +    .flatMap((plan) => [
    +      { plan, endpoint: plan.source, role: 'source' as const },
    +      { plan, endpoint: plan.target, role: 'target' as const },
    +    ])
    +    .sort((a, b) =>
    +      compareCodeUnits(
    +        `${a.plan.edge.start ?? ''}|${a.plan.edge.end ?? ''}|${a.plan.edge.id}|${a.role}`,
    +        `${b.plan.edge.start ?? ''}|${b.plan.edge.end ?? ''}|${b.plan.edge.id}|${b.role}`
    +      )
    +    );
    +  for (const { plan, endpoint, role } of endpoints) {
    +    resolveEndpoint(plan, endpoint, role);
    +  }
    +
    +  for (const plan of plans) {
    +    const sourceFinal = plan.source.chain.at(-1);
    +    const targetFinal = plan.target.chain.at(-1);
    +    const sourceOwner = sourceFinal ? result.forest.nodeById.get(sourceFinal.ownerId) : undefined;
    +    const targetOwner = targetFinal ? result.forest.nodeById.get(targetFinal.ownerId) : undefined;
    +    if (!sourceFinal || !targetFinal || !sourceOwner || !targetOwner) {
    +      continue;
    +    }
    +    const sourcePoint = sideMidpoint(rectForNode(sourceOwner), sourceFinal.side);
    +    const targetPoint = sideMidpoint(rectForNode(targetOwner), targetFinal.side);
    +    for (const entry of plan.source.chain) {
    +      entry.oppositeCoord = tangentialCoordinate(targetPoint, entry.side);
    +    }
    +    for (const entry of plan.target.chain) {
    +      entry.oppositeCoord = tangentialCoordinate(sourcePoint, entry.side);
    +    }
    +  }
    +}
    +
    +function collectRoutePlans(layout: LayoutData, result: GridLayoutResult): EdgeRoutePlan[] {
    +  const nodeById = result.forest.nodeById;
    +  const pairLanes = buildPairLanes(layout.edges);
    +
    +  const plans = layout.edges.map((edge) => {
    +    const source = edge.start ? nodeById.get(edge.start) : undefined;
    +    const target = edge.end ? nodeById.get(edge.end) : undefined;
    +    if (!source || !target) {
    +      throw gridError('GRID_MISSING_ENDPOINT', `Missing endpoint for edge "${edge.id}"`, {
    +        edgeId: edge.id,
    +        start: edge.start,
    +        end: edge.end,
    +      });
    +    }
    +    const lcaContainerId = commonContainerId(source, target, nodeById);
    +    const pairLane = pairLanes.get(edge.id) ?? {
    +      index: 0,
    +      size: 1,
    +      offset: 0,
    +      pairKey: endpointPairKey(edge),
    +    };
    +    return {
    +      edge,
    +      lcaContainerId,
    +      source: buildEndpointPlan(source, target, lcaContainerId, nodeById, edge.id, 'source'),
    +      target: buildEndpointPlan(target, source, lcaContainerId, nodeById, edge.id, 'target'),
    +      laneIndex: pairLane.index,
    +      laneOffset: pairLane.offset,
    +      bundleSize: pairLane.size,
    +      pairKey: pairLane.pairKey,
    +    };
    +  });
    +  resolveAmbiguousEndpointSides(plans, result);
    +  return plans;
    +}
    +
    +export function assignCompactPortalCoordinates(
    +  desired: readonly number[],
    +  low: number,
    +  high: number
    +): number[] {
    +  // Forward/backward constraint passes enforce minimum spacing; the final common shift preserves
    +  // the requested center of mass as far as the legal interval permits.
    +  const coordinates: number[] = [];
    +  for (const [index, coordinate] of desired.entries()) {
    +    coordinates.push(
    +      index === 0
    +        ? coordinate
    +        : Math.max(coordinate, coordinates[index - 1] + MIN_PORT_SEPARATION_PX)
    +    );
    +  }
    +  for (let index = coordinates.length - 1; index >= 0; index--) {
    +    coordinates[index] = Math.min(
    +      coordinates[index],
    +      index === coordinates.length - 1 ? high : coordinates[index + 1] - MIN_PORT_SEPARATION_PX
    +    );
    +  }
    +  const averageDesired = desired.reduce((sum, coordinate) => sum + coordinate, 0) / desired.length;
    +  const averageAssigned =
    +    coordinates.reduce((sum, coordinate) => sum + coordinate, 0) / coordinates.length;
    +  const minimumShift = low - coordinates[0];
    +  const maximumShift = high - coordinates[coordinates.length - 1];
    +  const shift = clamp(averageDesired - averageAssigned, minimumShift, maximumShift);
    +  return coordinates.map((coordinate) => coordinate + shift);
    +}
    +
    +function assignDemandCoordinates(
    +  plans: EdgeRoutePlan[],
    +  result: GridLayoutResult,
    +  endpointIncidentCounts: ReadonlyMap
    +): Map {
    +  // Allocate all demands for an owner side together. This prevents independently routed edges from
    +  // selecting the same port and lets bundles retain symmetric lane offsets when capacity allows.
    +  const nodeById = result.forest.nodeById;
    +  const planByEdgeId = new Map(plans.map((plan) => [plan.edge.id, plan]));
    +  const demandByKey = new Map();
    +  for (const plan of plans) {
    +    if (plan.edge.start === plan.edge.end) {
    +      continue;
    +    }
    +    for (const entry of [...plan.source.chain, ...plan.target.chain]) {
    +      if (!demandByKey.has(entry.demandKey)) {
    +        demandByKey.set(entry.demandKey, {
    +          ownerId: entry.ownerId,
    +          side: entry.side,
    +          edgeId: plan.edge.id,
    +          demandKey: entry.demandKey,
    +          oppositeCoord: entry.oppositeCoord,
    +          preferredCoord: entry.preferredCoord,
    +          compactPortal: entry.compactPortal,
    +          inner: entry.inner,
    +        });
    +      }
    +    }
    +  }
    +
    +  const byOwnerSide = new Map<
    +    string,
    +    { ownerId: string; side: GridSide; demands: GridAttachmentDemand[] }
    +  >();
    +  for (const demand of demandByKey.values()) {
    +    const key = ownerSideKey(demand.ownerId, demand.side);
    +    const group = byOwnerSide.get(key) ?? {
    +      ownerId: demand.ownerId,
    +      side: demand.side,
    +      demands: [],
    +    };
    +    group.demands.push(demand);
    +    byOwnerSide.set(key, group);
    +  }
    +
    +  // Ports are placed before the compact portals that line up with them, innermost group first.
    +  const depthOf = (ownerId: string): number => {
    +    const owner = nodeById.get(ownerId);
    +    return owner ? containerChain(owner, nodeById).length : 0;
    +  };
    +  const orderedOwnerSides = [...byOwnerSide.values()].sort(
    +    (a, b) =>
    +      Number(a.demands.every((d) => d.compactPortal)) -
    +        Number(b.demands.every((d) => d.compactPortal)) || depthOf(b.ownerId) - depthOf(a.ownerId)
    +  );
    +  const assigned = new Map();
    +  for (const { ownerId, side, demands } of orderedOwnerSides) {
    +    const owner = nodeById.get(ownerId);
    +    if (!owner) {
    +      continue;
    +    }
    +    const rect = rectForNode(owner);
    +    let low =
    +      side === 'left' || side === 'right'
    +        ? rect.top + ROUTE_CLEARANCE_PX
    +        : rect.left + ROUTE_CLEARANCE_PX;
    +    const high =
    +      side === 'left' || side === 'right'
    +        ? rect.bottom - ROUTE_CLEARANCE_PX
    +        : rect.right - ROUTE_CLEARANCE_PX;
    +    if ((side === 'left' || side === 'right') && owner.groupTitleRect) {
    +      low = Math.max(low, owner.groupTitleRect.bottom + ROUTE_CLEARANCE_PX);
    +    }
    +    const span = Math.max(0, high - low);
    +    demands.sort(
    +      (a, b) => a.oppositeCoord - b.oppositeCoord || compareCodeUnits(a.edgeId, b.edgeId)
    +    );
    +    const demandPlans = demands.map((demand) => planByEdgeId.get(demand.edgeId));
    +    const pairKeys = new Set(demandPlans.map((plan) => plan?.pairKey));
    +    const compactHierarchyPortals =
    +      owner.isGroup && demands.every(({ compactPortal }) => compactPortal);
    +    if (
    +      !(compactHierarchyPortals && demands.length === 1) &&
    +      pairKeys.size === 1 &&
    +      demandPlans.every(
    +        (plan) =>
    +          plan &&
    +          plan.bundleSize === demands.length &&
    +          endpointIncidentCounts.get(plan.edge.start!) === plan.bundleSize &&
    +          endpointIncidentCounts.get(plan.edge.end!) === plan.bundleSize
    +      )
    +    ) {
    +      const center = (low + high) / 2;
    +      const coordinates = demandPlans.map((plan) => center + plan!.laneOffset);
    +      if (coordinates.every((coordinate) => coordinate >= low && coordinate <= high)) {
    +        demands.forEach((demand, index) => assigned.set(demand.demandKey, coordinates[index]));
    +        continue;
    +      }
    +    }
    +    if (compactHierarchyPortals) {
    +      const container = result.containers.get(owner.id);
    +      const corridorCoordinates =
    +        side === 'left' || side === 'right'
    +          ? container?.horizontalCorridors
    +          : container?.verticalCorridors;
    +      const preferredCoordinate = (demand: GridAttachmentDemand): number => {
    +        const innerCoord = demand.inner ? assigned.get(demand.inner.demandKey) : undefined;
    +        const alignedWithInner =
    +          demand.inner !== undefined &&
    +          innerCoord !== undefined &&
    +          (demand.inner.side === 'left' || demand.inner.side === 'right') ===
    +            (side === 'left' || side === 'right');
    +        const clamped = clamp(alignedWithInner ? innerCoord : demand.preferredCoord, low, high);
    +        const nearest = corridorCoordinates?.reduce(
    +          (best, coordinate) =>
    +            Math.abs(coordinate - clamped) < Math.abs(best - clamped) ? coordinate : best,
    +          corridorCoordinates[0]
    +        );
    +        return nearest !== undefined && Math.abs(nearest - clamped) <= PIXEL_EPSILON
    +          ? nearest
    +          : clamped;
    +      };
    +      if (demands.length === 1) {
    +        assigned.set(demands[0].demandKey, preferredCoordinate(demands[0]));
    +        continue;
    +      }
    +      if (span >= MIN_PORT_SEPARATION_PX * (demands.length - 1)) {
    +        const desired = demands.map(preferredCoordinate);
    +        const coordinates = assignCompactPortalCoordinates(desired, low, high);
    +        demands.forEach((demand, index) => assigned.set(demand.demandKey, coordinates[index]));
    +        continue;
    +      }
    +    }
    +    if (demands.length === 1 || span <= 0) {
    +      assigned.set(demands[0].demandKey, (low + high) / 2);
    +      continue;
    +    }
    +    const step = span / (demands.length - 1);
    +    demands.forEach((demand, index) => {
    +      assigned.set(demand.demandKey, low + step * index);
    +    });
    +  }
    +
    +  // Keep a nearly aligned hierarchy route on one exact coordinate. Sub-pixel differences between
    +  // independently allocated endpoint and portal slots are otherwise normalized away as a diagonal,
    +  // which unnecessarily demotes the route to sparse search.
    +  for (const plan of plans) {
    +    if (plan.bundleSize !== 1) {
    +      continue;
    +    }
    +    const sourceFinal = plan.source.chain.at(-1);
    +    const targetFinal = plan.target.chain.at(-1);
    +    if (!sourceFinal || !targetFinal) {
    +      continue;
    +    }
    +    const verticalSide = (side: GridSide) => side === 'left' || side === 'right';
    +    if (verticalSide(sourceFinal.side) !== verticalSide(targetFinal.side)) {
    +      continue;
    +    }
    +    const sourceCoord = assigned.get(sourceFinal.demandKey);
    +    const targetCoord = assigned.get(targetFinal.demandKey);
    +    if (
    +      sourceCoord === undefined ||
    +      targetCoord === undefined ||
    +      Math.abs(sourceCoord - targetCoord) > PIXEL_EPSILON
    +    ) {
    +      continue;
    +    }
    +    const entries = [...plan.source.chain, ...plan.target.chain];
    +    if (entries.some((entry) => verticalSide(entry.side) !== verticalSide(sourceFinal.side))) {
    +      continue;
    +    }
    +    const alignedCoord = [sourceCoord, targetCoord].find((coordinate) =>
    +      entries.every((entry) => {
    +        const owner = nodeById.get(entry.ownerId);
    +        const interval = owner ? sideInterval(owner, entry.side) : undefined;
    +        return (
    +          interval !== undefined &&
    +          coordinate >= interval.low - PIXEL_EPSILON &&
    +          coordinate <= interval.high + PIXEL_EPSILON
    +        );
    +      })
    +    );
    +    if (alignedCoord !== undefined) {
    +      entries.forEach((entry) => assigned.set(entry.demandKey, alignedCoord));
    +    }
    +  }
    +  return assigned;
    +}
    +
    +export interface EndpointCandidate {
    +  ownerId: string;
    +  side: GridSide;
    +  port: RouterPoint;
    +  connect: RouterPoint;
    +  rank: number;
    +  // Set only at owners that mix aligned and unaligned edges: whether the candidate uses the side the
    +  // allocator assigned. The sparse search keeps those owners on their assigned side when it can.
    +  onAssignedSide?: boolean;
    +}
    +
    +interface EndpointDemandEntry {
    +  plan: EdgeRoutePlan;
    +  role: 'source' | 'target';
    +  opposite: Node;
    +}
    +
    +interface EndpointSlotAssignment {
    +  side: GridSide;
    +  coordinate: number;
    +}
    +
    +export function sideOrder(side: GridSide): number {
    +  // Endpoint preference order is independent of topology's canonical record ordering.
    +  return side === 'right' ? 0 : side === 'bottom' ? 1 : side === 'left' ? 2 : 3;
    +}
    +
    +export function sideInterval(
    +  owner: Node,
    +  side: GridSide
    +): { low: number; high: number } | undefined {
    +  const rect = rectForNode(owner);
    +  if (side === 'top' && owner.groupTitleRect) {
    +    return undefined;
    +  }
    +  let low =
    +    side === 'left' || side === 'right'
    +      ? rect.top + ROUTE_CLEARANCE_PX
    +      : rect.left + ROUTE_CLEARANCE_PX;
    +  const high =
    +    side === 'left' || side === 'right'
    +      ? rect.bottom - ROUTE_CLEARANCE_PX
    +      : rect.right - ROUTE_CLEARANCE_PX;
    +  if ((side === 'left' || side === 'right') && owner.groupTitleRect) {
    +    low = Math.max(low, owner.groupTitleRect.bottom + ROUTE_CLEARANCE_PX);
    +  }
    +  return low <= high ? { low, high } : undefined;
    +}
    +
    +function endpointPairKey(edge: Edge): string {
    +  const start = edge.start ?? '';
    +  const end = edge.end ?? '';
    +  return start < end ? `${start}|${end}` : `${end}|${start}`;
    +}
    +
    +function compareEndpointDemands(
    +  a: EndpointDemandEntry,
    +  b: EndpointDemandEntry,
    +  side: GridSide
    +): number {
    +  const aRect = rectForNode(a.opposite);
    +  const bRect = rectForNode(b.opposite);
    +  const aCoord = side === 'left' || side === 'right' ? aRect.cy : aRect.cx;
    +  const bCoord = side === 'left' || side === 'right' ? bRect.cy : bRect.cx;
    +  return (
    +    aCoord - bCoord ||
    +    compareCodeUnits(endpointPairKey(a.plan.edge), endpointPairKey(b.plan.edge)) ||
    +    compareCodeUnits(
    +      `${a.plan.edge.start}|${a.plan.edge.end}`,
    +      `${b.plan.edge.start}|${b.plan.edge.end}`
    +    ) ||
    +    compareCodeUnits(a.plan.edge.id, b.plan.edge.id) ||
    +    compareCodeUnits(a.role, b.role)
    +  );
    +}
    +
    +function endpointDemandKey(demand: EndpointDemandEntry): string {
    +  return `${demand.plan.edge.id}:${demand.role}`;
    +}
    +
    +function compareEndpointDemandIdentity(a: EndpointDemandEntry, b: EndpointDemandEntry): number {
    +  return (
    +    compareCodeUnits(endpointPairKey(a.plan.edge), endpointPairKey(b.plan.edge)) ||
    +    compareCodeUnits(
    +      `${a.plan.edge.start}|${a.plan.edge.end}`,
    +      `${b.plan.edge.start}|${b.plan.edge.end}`
    +    ) ||
    +    compareCodeUnits(a.plan.edge.id, b.plan.edge.id) ||
    +    compareCodeUnits(a.role, b.role)
    +  );
    +}
    +
    +function preferredEndpointCoordinates(
    +  owner: Node,
    +  side: GridSide,
    +  demands: readonly EndpointDemandEntry[]
    +): ReadonlyMap {
    +  const interval = sideInterval(owner, side);
    +  if (!interval || demands.length === 0) {
    +    return new Map();
    +  }
    +  const sorted = [...demands].sort((a, b) => compareEndpointDemands(a, b, side));
    +  if (sorted.length === 1) {
    +    const rect = rectForNode(owner);
    +    const center = side === 'left' || side === 'right' ? rect.cy : rect.cx;
    +    return new Map([[sorted[0], clamp(center, interval.low, interval.high)]]);
    +  }
    +  if ((interval.high - interval.low) / (sorted.length - 1) < MIN_PORT_SEPARATION_PX) {
    +    return new Map(
    +      sorted.map((demand, index) => [
    +        demand,
    +        interval.low + (index * (interval.high - interval.low)) / (sorted.length - 1),
    +      ])
    +    );
    +  }
    +  const desired = sorted.map(({ opposite }) => {
    +    const rect = rectForNode(opposite);
    +    const coordinate = side === 'left' || side === 'right' ? rect.cy : rect.cx;
    +    return clamp(coordinate, interval.low, interval.high);
    +  });
    +  const coordinates = assignCompactPortalCoordinates(desired, interval.low, interval.high);
    +  return new Map(sorted.map((demand, index) => [demand, coordinates[index]]));
    +}
    +
    +function endpointSideCapacity(owner: Node, side: GridSide): number {
    +  const interval = sideInterval(owner, side);
    +  if (!interval) {
    +    return 0;
    +  }
    +  return 1 + Math.floor((interval.high - interval.low) / MIN_PORT_SEPARATION_PX);
    +}
    +
    +function endpointSidePreferences(owner: Node, demand: EndpointDemandEntry): GridSide[] {
    +  const oppositeRect = rectForNode(demand.opposite);
    +  const preferred = preferredSide(owner, { x: oppositeRect.cx, y: oppositeRect.cy });
    +  return (['right', 'bottom', 'left', 'top'] as const)
    +    .filter((side) => endpointSideCapacity(owner, side) > 0)
    +    .sort((a, b) => {
    +      if (a === preferred) {
    +        return -1;
    +      }
    +      if (b === preferred) {
    +        return 1;
    +      }
    +      const ownerRect = rectForNode(owner);
    +      const aPoint = sideMidpoint(ownerRect, a);
    +      const bPoint = sideMidpoint(ownerRect, b);
    +      const aDistance = Math.abs(aPoint.x - oppositeRect.cx) + Math.abs(aPoint.y - oppositeRect.cy);
    +      const bDistance = Math.abs(bPoint.x - oppositeRect.cx) + Math.abs(bPoint.y - oppositeRect.cy);
    +      return aDistance - bDistance || sideOrder(a) - sideOrder(b);
    +    });
    +}
    +
    +function isAxisAlignedDemand(owner: Node, demand: EndpointDemandEntry): boolean {
    +  const ownerRect = rectForNode(owner);
    +  const oppositeRect = rectForNode(demand.opposite);
    +  return (
    +    Math.abs(ownerRect.cx - oppositeRect.cx) <= PIXEL_EPSILON ||
    +    Math.abs(ownerRect.cy - oppositeRect.cy) <= PIXEL_EPSILON
    +  );
    +}
    +
    +// Places unaligned ports on a side that already carries fixed (aligned) ports. Each port takes the
    +// legal coordinate nearest its desired one that keeps EDGE_CLEARANCE_PX from every other port, so the
    +// aligned ports stay on their straight lines. Returns undefined when the side cannot fit them all.
    +function placePortsBesideFixed(
    +  owner: Node,
    +  side: GridSide,
    +  fixed: readonly number[],
    +  movable: readonly EndpointDemandEntry[]
    +): Map | undefined {
    +  const interval = sideInterval(owner, side);
    +  if (!interval) {
    +    return undefined;
    +  }
    +  const placed = [...fixed];
    +  const placements = new Map();
    +  for (const demand of movable) {
    +    const opposite = rectForNode(demand.opposite);
    +    const desired = clamp(
    +      side === 'left' || side === 'right' ? opposite.cy : opposite.cx,
    +      interval.low,
    +      interval.high
    +    );
    +    const candidates = [
    +      desired,
    +      interval.low,
    +      interval.high,
    +      ...placed.flatMap((coordinate) => [
    +        coordinate - EDGE_CLEARANCE_PX,
    +        coordinate + EDGE_CLEARANCE_PX,
    +      ]),
    +    ]
    +      .filter(
    +        (coordinate) =>
    +          coordinate >= interval.low - PIXEL_EPSILON &&
    +          coordinate <= interval.high + PIXEL_EPSILON &&
    +          placed.every((other) => Math.abs(coordinate - other) >= EDGE_CLEARANCE_PX - PIXEL_EPSILON)
    +      )
    +      .sort((a, b) => Math.abs(a - desired) - Math.abs(b - desired) || a - b);
    +    if (candidates.length === 0) {
    +      return undefined;
    +    }
    +    placements.set(demand, candidates[0]);
    +    placed.push(candidates[0]);
    +  }
    +  return placements;
    +}
    +
    +function allocateEndpointSlots(
    +  owner: Node,
    +  demands: readonly EndpointDemandEntry[]
    +): ReadonlyMap {
    +  // Aligned edges get first choice of their direct side and keep their straight port. Other edges
    +  // prefer an unreserved side, or share a reserved side when their port can sit a full clearance
    +  // away from the aligned ports. This stops a diagonal approach from displacing a straight one.
    +  const assignedBySide = new Map();
    +  const capacities = new Map(
    +    (['right', 'bottom', 'left', 'top'] as const).map((side) => [
    +      side,
    +      endpointSideCapacity(owner, side),
    +    ])
    +  );
    +
    +  const orderedDemands = [...demands].sort(compareEndpointDemandIdentity);
    +  const alignedDemands = orderedDemands.filter((demand) => isAxisAlignedDemand(owner, demand));
    +  const unalignedDemands = orderedDemands.filter((demand) => !isAxisAlignedDemand(owner, demand));
    +  const reservedSides = new Set();
    +  const alignedCoordinates = new Map();
    +  const sharedPlacements = new Map>();
    +  const hasCapacity = (candidate: GridSide) =>
    +    (assignedBySide.get(candidate)?.length ?? 0) < (capacities.get(candidate) ?? 0);
    +  const record = (demand: EndpointDemandEntry, side: GridSide) => {
    +    const assigned = assignedBySide.get(side) ?? [];
    +    assigned.push(demand);
    +    assignedBySide.set(side, assigned);
    +  };
    +  const assignAligned = (demand: EndpointDemandEntry): void => {
    +    const side = endpointSidePreferences(owner, demand).find(hasCapacity);
    +    if (side) {
    +      record(demand, side);
    +      reservedSides.add(side);
    +    }
    +  };
    +  const tryShareReservedSide = (demand: EndpointDemandEntry, side: GridSide): boolean => {
    +    const shared = sharedPlacements.get(side) ?? new Map();
    +    const placements = placePortsBesideFixed(
    +      owner,
    +      side,
    +      [...(alignedCoordinates.get(side) ?? []), ...shared.values()],
    +      [demand]
    +    );
    +    if (!placements) {
    +      return false;
    +    }
    +    shared.set(demand, placements.get(demand)!);
    +    sharedPlacements.set(side, shared);
    +    record(demand, side);
    +    return true;
    +  };
    +  const assignUnaligned = (demand: EndpointDemandEntry): void => {
    +    const preferences = endpointSidePreferences(owner, demand).filter(hasCapacity);
    +    const unreserved = preferences.find((candidate) => !reservedSides.has(candidate));
    +    const sharable = preferences.find(
    +      (candidate) =>
    +        (unreserved === undefined ||
    +          preferences.indexOf(candidate) < preferences.indexOf(unreserved)) &&
    +        reservedSides.has(candidate) &&
    +        tryShareReservedSide(demand, candidate)
    +    );
    +    if (sharable) {
    +      return;
    +    }
    +    const side = unreserved ?? preferences[0];
    +    if (side) {
    +      record(demand, side);
    +    }
    +  };
    +
    +  for (const demand of alignedDemands) {
    +    assignAligned(demand);
    +  }
    +  for (const [side, assigned] of assignedBySide) {
    +    alignedCoordinates.set(side, [...preferredEndpointCoordinates(owner, side, assigned).values()]);
    +  }
    +  for (const demand of unalignedDemands) {
    +    assignUnaligned(demand);
    +  }
    +
    +  const assignments = new Map();
    +  for (const [side, assigned] of assignedBySide) {
    +    const interval = sideInterval(owner, side);
    +    if (!interval) {
    +      continue;
    +    }
    +    const shared = sharedPlacements.get(side);
    +    const fixedDemands = shared ? assigned.filter((demand) => !shared.has(demand)) : assigned;
    +    const preferredCoordinates = new Map([
    +      ...preferredEndpointCoordinates(owner, side, fixedDemands),
    +      ...(shared ?? []),
    +    ]);
    +    const sorted = [...assigned].sort((a, b) => compareEndpointDemands(a, b, side));
    +    for (const [index, demand] of sorted.entries()) {
    +      const coordinate =
    +        preferredCoordinates.get(demand) ??
    +        (sorted.length === 1
    +          ? (interval.low + interval.high) / 2
    +          : interval.low + (index * (interval.high - interval.low)) / (sorted.length - 1));
    +      assignments.set(endpointDemandKey(demand), { side, coordinate });
    +    }
    +  }
    +  return assignments;
    +}
    +
    +function endpointCandidates(
    +  plan: EdgeRoutePlan,
    +  role: 'source' | 'target',
    +  demandsByOwner: ReadonlyMap,
    +  assignmentsByOwner: ReadonlyMap>,
    +  coordinatedOwnerIds: ReadonlySet,
    +  result: GridLayoutResult
    +): EndpointCandidate[] {
    +  const ownerId = role === 'source' ? plan.edge.start! : plan.edge.end!;
    +  const oppositeId = role === 'source' ? plan.edge.end! : plan.edge.start!;
    +  const owner = result.forest.nodeById.get(ownerId);
    +  const opposite = result.forest.nodeById.get(oppositeId);
    +  if (!owner || !opposite) {
    +    throw gridError('GRID_MISSING_ENDPOINT', `Missing endpoint for edge "${plan.edge.id}"`);
    +  }
    +  const oppositeRect = rectForNode(opposite);
    +  const candidates: EndpointCandidate[] = [];
    +  const candidateKeys = new Set();
    +
    +  const assignment = assignmentsByOwner.get(ownerId)?.get(`${plan.edge.id}:${role}`);
    +  const trackAssignedSide = coordinatedOwnerIds.has(ownerId) && assignment !== undefined;
    +  const addCandidate = (side: GridSide, coordinate: number, rank: number): void => {
    +    const interval = sideInterval(owner, side);
    +    if (!interval || coordinate < interval.low || coordinate > interval.high) {
    +      return;
    +    }
    +    const rect = rectForNode(owner);
    +    const port =
    +      side === 'left' || side === 'right'
    +        ? { x: side === 'left' ? rect.left : rect.right, y: coordinate }
    +        : { x: coordinate, y: side === 'top' ? rect.top : rect.bottom };
    +    const key = `${side}:${routingPointKey(port)}`;
    +    if (candidateKeys.has(key)) {
    +      return;
    +    }
    +    candidateKeys.add(key);
    +    candidates.push({
    +      ownerId,
    +      side,
    +      port,
    +      connect: {
    +        x:
    +          port.x +
    +          (side === 'left' ? -TERMINAL_APPROACH_PX : side === 'right' ? TERMINAL_APPROACH_PX : 0),
    +        y:
    +          port.y +
    +          (side === 'top' ? -TERMINAL_APPROACH_PX : side === 'bottom' ? TERMINAL_APPROACH_PX : 0),
    +      },
    +      rank,
    +      onAssignedSide: trackAssignedSide ? side === assignment.side : undefined,
    +    });
    +  };
    +
    +  // Negative ranks reserve precedence for the globally allocated slot and the bundle lane. The
    +  // remaining geometric alternatives are re-ranked after deterministic sorting.
    +  if (assignment && plan.bundleSize === 1) {
    +    addCandidate(assignment.side, assignment.coordinate, -2);
    +  }
    +
    +  if (plan.bundleSize > 1) {
    +    const side = preferredSide(owner, { x: oppositeRect.cx, y: oppositeRect.cy });
    +    const ownerRect = rectForNode(owner);
    +    const center = side === 'left' || side === 'right' ? ownerRect.cy : ownerRect.cx;
    +    addCandidate(side, center + plan.laneOffset, -1);
    +  }
    +
    +  const ownerDemands = [...(demandsByOwner.get(ownerId) ?? [])];
    +  for (const side of ['right', 'bottom', 'left', 'top'] as const) {
    +    const interval = sideInterval(owner, side);
    +    if (!interval) {
    +      continue;
    +    }
    +    const coordinates = preferredEndpointCoordinates(owner, side, ownerDemands);
    +    const demand = ownerDemands.find((entry) => entry.plan === plan && entry.role === role);
    +    const coordinate = demand ? coordinates.get(demand) : undefined;
    +    if (coordinate === undefined) {
    +      continue;
    +    }
    +    const rect = rectForNode(owner);
    +    const port =
    +      side === 'left' || side === 'right'
    +        ? { x: side === 'left' ? rect.left : rect.right, y: coordinate }
    +        : { x: coordinate, y: side === 'top' ? rect.top : rect.bottom };
    +    const distance = Math.abs(port.x - oppositeRect.cx) + Math.abs(port.y - oppositeRect.cy);
    +    addCandidate(side, coordinate, distance);
    +  }
    +
    +  candidates.sort((a, b) => a.rank - b.rank || sideOrder(a.side) - sideOrder(b.side));
    +  candidates.forEach((candidate, index) => {
    +    candidate.rank = index;
    +  });
    +  return candidates;
    +}
    +
    +export function prepareEdgeRoutes(
    +  layout: LayoutData,
    +  result: GridLayoutResult
    +): PreparedEdgeRoutes {
    +  // Route order is a correctness input because committed routes constrain later bundle siblings.
    +  // Harder hierarchy and loop routes go first, followed by fewer candidate combinations and stable
    +  // lexical tie-breakers.
    +  const plans = collectRoutePlans(layout, result);
    +  const pairCounts = new Map();
    +  const endpointIncidentCounts = new Map();
    +  for (const plan of plans) {
    +    const key = endpointPairKey(plan.edge);
    +    pairCounts.set(key, (pairCounts.get(key) ?? 0) + 1);
    +    if (plan.edge.start) {
    +      endpointIncidentCounts.set(
    +        plan.edge.start,
    +        (endpointIncidentCounts.get(plan.edge.start) ?? 0) + 1
    +      );
    +    }
    +    if (plan.edge.end && plan.edge.end !== plan.edge.start) {
    +      endpointIncidentCounts.set(
    +        plan.edge.end,
    +        (endpointIncidentCounts.get(plan.edge.end) ?? 0) + 1
    +      );
    +    }
    +  }
    +  const eligiblePlans = plans.filter(
    +    (plan) =>
    +      plan.edge.start !== plan.edge.end &&
    +      plan.source.chain.length === 1 &&
    +      plan.target.chain.length === 1 &&
    +      plan.source.finalKind === 'item' &&
    +      plan.target.finalKind === 'item'
    +  );
    +  const endpointDemandsByOwner = new Map();
    +  for (const plan of eligiblePlans) {
    +    const source = result.forest.nodeById.get(plan.edge.start!);
    +    const target = result.forest.nodeById.get(plan.edge.end!);
    +    if (!source || !target) {
    +      continue;
    +    }
    +    const sourceDemands = endpointDemandsByOwner.get(source.id) ?? [];
    +    sourceDemands.push({ plan, role: 'source', opposite: target });
    +    endpointDemandsByOwner.set(source.id, sourceDemands);
    +    const targetDemands = endpointDemandsByOwner.get(target.id) ?? [];
    +    targetDemands.push({ plan, role: 'target', opposite: source });
    +    endpointDemandsByOwner.set(target.id, targetDemands);
    +  }
    +  const endpointAssignmentsByOwner = new Map(
    +    [...endpointDemandsByOwner].map(([ownerId, demands]) => {
    +      const owner = result.forest.nodeById.get(ownerId);
    +      return [ownerId, owner ? allocateEndpointSlots(owner, demands) : new Map()] as const;
    +    })
    +  );
    +  const coordinatedEndpointIds = new Set();
    +  const coordinatedOwnerIds = new Set();
    +  for (const [ownerId, demands] of endpointDemandsByOwner) {
    +    const owner = result.forest.nodeById.get(ownerId);
    +    if (!owner) {
    +      continue;
    +    }
    +    const hasAligned = demands.some((demand) => isAxisAlignedDemand(owner, demand));
    +    const hasUnaligned = demands.some((demand) => !isAxisAlignedDemand(owner, demand));
    +    if (hasAligned && hasUnaligned) {
    +      coordinatedOwnerIds.add(ownerId);
    +      // The aligned edge already has its straight route, so only the others need the sparse search.
    +      for (const demand of demands) {
    +        if (!isAxisAlignedDemand(owner, demand)) {
    +          coordinatedEndpointIds.add(demand.plan.edge.id);
    +        }
    +      }
    +    }
    +  }
    +  const endpointCandidatesByEdge = new Map(
    +    eligiblePlans.map((plan) => [
    +      plan.edge.id,
    +      {
    +        sources: endpointCandidates(
    +          plan,
    +          'source',
    +          endpointDemandsByOwner,
    +          endpointAssignmentsByOwner,
    +          coordinatedOwnerIds,
    +          result
    +        ),
    +        targets: endpointCandidates(
    +          plan,
    +          'target',
    +          endpointDemandsByOwner,
    +          endpointAssignmentsByOwner,
    +          coordinatedOwnerIds,
    +          result
    +        ),
    +      },
    +    ])
    +  );
    +  const endpointCandidateProducts = new Map(
    +    [...endpointCandidatesByEdge].map(([edgeId, candidates]) => [
    +      edgeId,
    +      candidates.sources.length * candidates.targets.length,
    +    ])
    +  );
    +  const routeClass = (plan: EdgeRoutePlan): number => {
    +    const source = result.forest.nodeById.get(plan.edge.start!);
    +    const target = result.forest.nodeById.get(plan.edge.end!);
    +    if (plan.edge.start === plan.edge.end) {
    +      return 0;
    +    }
    +    if (
    +      (source?.isGroup && target && isAncestorGroup(source.id, target, result.forest.nodeById)) ||
    +      (target?.isGroup && source && isAncestorGroup(target.id, source, result.forest.nodeById))
    +    ) {
    +      return 1;
    +    }
    +    if ((source?.parentId ?? ROOT_CONTAINER_ID) !== (target?.parentId ?? ROOT_CONTAINER_ID)) {
    +      return 2;
    +    }
    +    return pairCounts.get(endpointPairKey(plan.edge))! > 1 ? 3 : 4;
    +  };
    +  const orderedPlans = [...plans].sort((a, b) => {
    +    const aUnordered = endpointPairKey(a.edge);
    +    const bUnordered = endpointPairKey(b.edge);
    +    const aDirected = `${a.edge.start ?? ''}|${a.edge.end ?? ''}`;
    +    const bDirected = `${b.edge.start ?? ''}|${b.edge.end ?? ''}`;
    +    const aBoundaryCount = a.source.chain.length + a.target.chain.length - 2;
    +    const bBoundaryCount = b.source.chain.length + b.target.chain.length - 2;
    +    return (
    +      routeClass(a) - routeClass(b) ||
    +      bBoundaryCount - aBoundaryCount ||
    +      (endpointCandidateProducts.get(a.edge.id) ?? 0) -
    +        (endpointCandidateProducts.get(b.edge.id) ?? 0) ||
    +      compareCodeUnits(aUnordered, bUnordered) ||
    +      compareCodeUnits(aDirected, bDirected) ||
    +      compareCodeUnits(a.edge.id, b.edge.id)
    +    );
    +  });
    +  return {
    +    plans,
    +    eligiblePlans,
    +    eligibleIds: new Set(eligiblePlans.map(({ edge }) => edge.id)),
    +    orderedPlans,
    +    demandCoords: assignDemandCoordinates(orderedPlans, result, endpointIncidentCounts),
    +    endpointCandidatesByEdge,
    +    endpointIncidentCounts,
    +    coordinatedEndpointIds,
    +  };
    +}
    diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/grid/routerSearch.spec.ts b/packages/mermaid/src/rendering-util/layout-algorithms/grid/routerSearch.spec.ts
    new file mode 100644
    index 00000000000..f2b3e582def
    --- /dev/null
    +++ b/packages/mermaid/src/rendering-util/layout-algorithms/grid/routerSearch.spec.ts
    @@ -0,0 +1,336 @@
    +import { describe, expect, it, vi } from 'vitest';
    +import { createGridRoutingInstrumentation } from './routerInstrumentation.js';
    +import { compareTupleCost, findShortestRoute, RouterSearchWorkspace } from './routerSearch.js';
    +import { findDenseOracleRoute } from './routerSearch.testUtils.js';
    +import {
    +  EndpointOverlayScratch,
    +  buildContainerRoutingTopology,
    +  buildEndpointRoutingOverlay,
    +} from './routerTopology.js';
    +import type { ContainerRoutingTopology, RouterPoint } from './types.js';
    +
    +function build(
    +  obstacles: { id: string; left: number; right: number; top: number; bottom: number }[] = []
    +): ContainerRoutingTopology {
    +  return buildContainerRoutingTopology({
    +    containerId: '__grid_root__',
    +    bounds: { left: 0, right: 100, top: 0, bottom: 100 },
    +    obstacles: obstacles.map(({ id, ...bounds }) => ({ id, bounds })),
    +  });
    +}
    +
    +function vertexAt(topology: ContainerRoutingTopology, point: RouterPoint): number {
    +  const vertex = topology.vertices.find(
    +    (candidate) => candidate.point.x === point.x && candidate.point.y === point.y
    +  );
    +  if (!vertex) {
    +    throw new Error(`Missing topology vertex at ${point.x},${point.y}`);
    +  }
    +  return vertex.id;
    +}
    +
    +describe('grid router search', () => {
    +  it('compares tuple costs lexicographically without epsilon', () => {
    +    expect(compareTupleCost([10, 2, 0, 0], [10, 3, 0, 0])).toBeLessThan(0);
    +    expect(compareTupleCost([10.0000000001, 1, 0, 0], [10, 99, 0, 0])).toBeGreaterThan(0);
    +  });
    +
    +  it('finds a deterministic shortest path with exact bend accounting', () => {
    +    const topology = build();
    +    const source = vertexAt(topology, { x: 0, y: 0 });
    +    const target = vertexAt(topology, { x: 100, y: 100 });
    +    const metrics = createGridRoutingInstrumentation();
    +
    +    const route = findShortestRoute(topology, source, target, { metrics });
    +
    +    expect(route?.cost.slice(0, 2)).toEqual([200, 1]);
    +    expect(route?.points).toEqual([
    +      { x: 0, y: 0 },
    +      { x: 0, y: 100 },
    +      { x: 100, y: 100 },
    +    ]);
    +    expect(metrics.searches).toBe(1);
    +    expect(metrics.routesFound).toBe(1);
    +    expect(metrics.expandedStates).toBeGreaterThan(0);
    +    expect(metrics.maxOpenSet).toBeGreaterThan(0);
    +  });
    +
    +  it('matches the dense oracle for a shortest route along an obstacle boundary', () => {
    +    const topology = build([{ id: 'center', left: 40, right: 60, top: 30, bottom: 70 }]);
    +    // The obstacle inflates to y=24. Its boundary is routable; only its interior is blocked.
    +    const source = vertexAt(topology, { x: 0, y: 24 });
    +    const target = vertexAt(topology, { x: 100, y: 24 });
    +
    +    const reduced = findShortestRoute(topology, source, target);
    +    const dense = findDenseOracleRoute({
    +      bounds: topology.bounds,
    +      obstacles: topology.obstacles,
    +      source: topology.vertices[source].point,
    +      target: topology.vertices[target].point,
    +    });
    +
    +    expect(reduced?.cost.slice(0, 2)).toEqual(dense?.cost.slice(0, 2));
    +    expect(reduced?.cost.slice(0, 2)).toEqual([100, 0]);
    +  });
    +
    +  it('keeps endpoint overlays bounded and outside the base topology', () => {
    +    const topology = build([{ id: 'center', left: 40, right: 60, top: 30, bottom: 70 }]);
    +    const metrics = createGridRoutingInstrumentation();
    +    const overlay = buildEndpointRoutingOverlay(
    +      topology,
    +      { x: 10, y: 10 },
    +      { x: 90, y: 90 },
    +      metrics
    +    );
    +
    +    expect(overlay.vertices.length - topology.vertices.length).toBeLessThanOrEqual(32);
    +    expect(overlay.adjacencyEntries - topology.adjacencyEntries).toBeLessThanOrEqual(64);
    +    expect(metrics.endpointOverlayBuilds).toBe(1);
    +    expect(metrics.endpointOverlayVertices).toBe(
    +      overlay.vertices.length - topology.vertices.length
    +    );
    +    expect(topology.vertices.some(({ kind }) => kind === 'endpoint')).toBe(false);
    +  });
    +
    +  it('reuses endpoint overlay scratch without changing the immutable base topology', () => {
    +    const topology = build([{ id: 'center', left: 40, right: 60, top: 30, bottom: 70 }]);
    +    const baseSnapshot = structuredClone({
    +      vertices: topology.vertices,
    +      adjacency: [...topology.adjacency],
    +    });
    +    const scratch = new EndpointOverlayScratch(topology);
    +    const first = buildEndpointRoutingOverlay(
    +      topology,
    +      { x: 10, y: 10 },
    +      { x: 90, y: 90 },
    +      undefined,
    +      scratch
    +    );
    +    const firstCounts = {
    +      vertices: first.vertices.length - topology.vertices.length,
    +      arcs: first.adjacencyEntries - topology.adjacencyEntries,
    +    };
    +    const second = buildEndpointRoutingOverlay(
    +      topology,
    +      { x: 10, y: 90 },
    +      { x: 90, y: 10 },
    +      undefined,
    +      scratch
    +    );
    +
    +    expect(firstCounts.vertices).toBeLessThanOrEqual(32);
    +    expect(firstCounts.arcs).toBeLessThanOrEqual(64);
    +    expect(second.vertices.length - topology.vertices.length).toBeLessThanOrEqual(32);
    +    expect(second.adjacencyEntries - topology.adjacencyEntries).toBeLessThanOrEqual(64);
    +    expect(scratch.resetCount).toBe(2);
    +    expect({
    +      vertices: topology.vertices,
    +      adjacency: [...topology.adjacency],
    +    }).toEqual(baseSnapshot);
    +  });
    +
    +  it('returns the same canonical route with zero heuristic and perturbed queue order', () => {
    +    const topology = build([
    +      { id: 'center', left: 40, right: 60, top: 30, bottom: 70 },
    +      { id: 'upper-left', left: 15, right: 25, top: 10, bottom: 25 },
    +    ]);
    +    const overlay = buildEndpointRoutingOverlay(topology, { x: 0, y: 50 }, { x: 100, y: 50 });
    +    const source = vertexAt(overlay, { x: 0, y: 50 });
    +    const target = vertexAt(overlay, { x: 100, y: 50 });
    +    const canonical = findShortestRoute(overlay, source, target);
    +    const zero = findShortestRoute(overlay, source, target, { heuristic: 'zero' });
    +    const perturbed = findShortestRoute(overlay, source, target, {
    +      queueOrder: 'reverse',
    +    });
    +
    +    expect(zero).toEqual(canonical);
    +    expect(perturbed).toEqual(canonical);
    +  });
    +
    +  it('returns an already-found route when search-state caps interrupt tie resolution', () => {
    +    const topology = build();
    +    const source = vertexAt(topology, { x: 0, y: 0 });
    +    const target = vertexAt(topology, { x: 100, y: 100 });
    +    const canonical = findShortestRoute(topology, source, target);
    +    const edgeMetrics = createGridRoutingInstrumentation();
    +    const invocationMetrics = createGridRoutingInstrumentation();
    +    const budget = { expandedStates: 0 };
    +
    +    const edgeCapped = findShortestRoute(topology, source, target, {
    +      metrics: edgeMetrics,
    +      caps: { maxExpandedStates: 3 },
    +      queueOrder: 'reverse',
    +    });
    +    const invocationCapped = findShortestRoute(topology, source, target, {
    +      metrics: invocationMetrics,
    +      budget,
    +      caps: { maxInvocationExpandedStates: 3 },
    +      queueOrder: 'reverse',
    +    });
    +
    +    expect(edgeCapped?.cost).toEqual(canonical?.cost);
    +    expect(invocationCapped?.cost).toEqual(canonical?.cost);
    +    expect(edgeCapped?.points).toEqual([
    +      { x: 0, y: 0 },
    +      { x: 100, y: 0 },
    +      { x: 100, y: 100 },
    +    ]);
    +    expect(invocationCapped?.points).toEqual(edgeCapped?.points);
    +    expect(edgeMetrics).toMatchObject({ expandedStates: 3, routesFound: 1 });
    +    expect(invocationMetrics).toMatchObject({ expandedStates: 3, routesFound: 1 });
    +    expect(budget.expandedStates).toBe(3);
    +  });
    +
    +  it('accounts for reusable search workspace memory and enforces its cap', () => {
    +    const topology = build();
    +    const source = vertexAt(topology, { x: 0, y: 0 });
    +    const target = vertexAt(topology, { x: 100, y: 100 });
    +    const metrics = createGridRoutingInstrumentation();
    +
    +    findShortestRoute(topology, source, target, { metrics });
    +    expect(metrics.searchWorkspaceBytes).toBeGreaterThan(0);
    +    expect(() =>
    +      findShortestRoute(topology, source, target, {
    +        caps: { maxEstimatedBytes: 1 },
    +      })
    +    ).toThrowError(expect.objectContaining({ reason: 'estimated_memory_cap' }));
    +  });
    +
    +  it('reports deterministic search-state cap exhaustion', () => {
    +    const topology = build([{ id: 'center', left: 40, right: 60, top: 30, bottom: 70 }]);
    +    const source = vertexAt(topology, { x: 0, y: 24 });
    +    const target = vertexAt(topology, { x: 100, y: 76 });
    +    const metrics = createGridRoutingInstrumentation();
    +
    +    expect(() =>
    +      findShortestRoute(topology, source, target, {
    +        metrics,
    +        caps: { maxExpandedStates: 1 },
    +      })
    +    ).toThrowError(
    +      expect.objectContaining({
    +        reason: 'search_state_cap',
    +        searchStateScope: 'edge',
    +      })
    +    );
    +    expect(metrics.expandedStates).toBe(1);
    +  });
    +
    +  it('reuses a workspace after capped searches without clearing untouched states', () => {
    +    const topology = build([{ id: 'center', left: 40, right: 60, top: 30, bottom: 70 }]);
    +    const source = vertexAt(topology, { x: 0, y: 24 });
    +    const target = vertexAt(topology, { x: 100, y: 76 });
    +    const workspace = new RouterSearchWorkspace();
    +
    +    expect(() =>
    +      findShortestRoute(topology, source, target, {
    +        workspace,
    +        caps: { maxExpandedStates: 1 },
    +      })
    +    ).toThrowError(expect.objectContaining({ reason: 'search_state_cap' }));
    +
    +    expect(findShortestRoute(topology, source, target, { workspace })).toBeDefined();
    +  });
    +
    +  it('distinguishes invocation search-state exhaustion', () => {
    +    const topology = build([{ id: 'center', left: 40, right: 60, top: 30, bottom: 70 }]);
    +    const source = vertexAt(topology, { x: 0, y: 24 });
    +    const target = vertexAt(topology, { x: 100, y: 76 });
    +    const workspace = new RouterSearchWorkspace();
    +    const reset = vi.spyOn(workspace, 'reset');
    +
    +    expect(() =>
    +      findShortestRoute(topology, source, target, {
    +        workspace,
    +        budget: { expandedStates: 1 },
    +        caps: { maxInvocationExpandedStates: 1 },
    +      })
    +    ).toThrowError(
    +      expect.objectContaining({
    +        reason: 'search_state_cap',
    +        searchStateScope: 'invocation',
    +      })
    +    );
    +    expect(reset).not.toHaveBeenCalled();
    +  });
    +
    +  it('rejects malformed graph invariants without treating them as no-route', () => {
    +    const topology = build();
    +    const source = vertexAt(topology, { x: 0, y: 0 });
    +    const target = vertexAt(topology, { x: 100, y: 100 });
    +    const adjacency = new Map(topology.adjacency);
    +    adjacency.set(source, [
    +      {
    +        from: source,
    +        to: target,
    +        orientation: 'H',
    +        length: Number.NaN,
    +        kind: 'visibility',
    +        intervalStart: 0,
    +        intervalEnd: 100,
    +      },
    +    ]);
    +
    +    expect(() =>
    +      findShortestRoute({ vertices: topology.vertices, adjacency }, source, target)
    +    ).toThrowError('Malformed grid routing arc');
    +  });
    +
    +  it('matches dense Dijkstra reachability, shortest length, and bends for 10,000 cases', () => {
    +    let state = 0x5eed1234;
    +    const random = () => {
    +      state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
    +      return state / 0x1_0000_0000;
    +    };
    +
    +    for (let caseIndex = 0; caseIndex < 10_000; caseIndex++) {
    +      const obstacleCount = 1 + Math.floor(random() * 3);
    +      const obstacles = Array.from({ length: obstacleCount }, (_, index) => {
    +        const column = index % 2;
    +        const row = Math.floor(index / 2);
    +        const left = 16 + column * 44 + Math.floor(random() * 8);
    +        const top = 16 + row * 44 + Math.floor(random() * 8);
    +        return {
    +          id: `obstacle-${index}`,
    +          left,
    +          right: left + 12 + Math.floor(random() * 10),
    +          top,
    +          bottom: top + 12 + Math.floor(random() * 10),
    +        };
    +      });
    +      const topology = build(obstacles);
    +      const seeds = topology.vertices.filter(({ kind }) => kind !== 'projection');
    +      const source = seeds[caseIndex % seeds.length];
    +      const target = seeds[(caseIndex * 17 + 7) % seeds.length];
    +      const overlay = buildEndpointRoutingOverlay(topology, source.point, target.point);
    +      const reduced = findShortestRoute(
    +        overlay,
    +        vertexAt(overlay, source.point),
    +        vertexAt(overlay, target.point)
    +      );
    +      const zero = findShortestRoute(
    +        overlay,
    +        vertexAt(overlay, source.point),
    +        vertexAt(overlay, target.point),
    +        { heuristic: 'zero' }
    +      );
    +      const perturbed = findShortestRoute(
    +        overlay,
    +        vertexAt(overlay, source.point),
    +        vertexAt(overlay, target.point),
    +        { queueOrder: 'reverse' }
    +      );
    +      const dense = findDenseOracleRoute({
    +        bounds: topology.bounds,
    +        obstacles: topology.obstacles,
    +        source: source.point,
    +        target: target.point,
    +      });
    +
    +      expect(Boolean(reduced), `reachability case ${caseIndex}`).toBe(Boolean(dense));
    +      expect(reduced?.cost.slice(0, 2), `cost case ${caseIndex}`).toEqual(dense?.cost.slice(0, 2));
    +      expect(zero, `zero heuristic canonical case ${caseIndex}`).toEqual(reduced);
    +      expect(perturbed, `queue perturbation canonical case ${caseIndex}`).toEqual(reduced);
    +    }
    +  }, 60_000);
    +});
    diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/grid/routerSearch.testUtils.ts b/packages/mermaid/src/rendering-util/layout-algorithms/grid/routerSearch.testUtils.ts
    new file mode 100644
    index 00000000000..72747434b7e
    --- /dev/null
    +++ b/packages/mermaid/src/rendering-util/layout-algorithms/grid/routerSearch.testUtils.ts
    @@ -0,0 +1,132 @@
    +import { findShortestRoute, type RouterSearchResult } from './routerSearch.js';
    +import type {
    +  GridOrientation,
    +  RouterArc,
    +  RouterObstacle,
    +  RouterPoint,
    +  RouterRect,
    +  RouterVertex,
    +} from './types.js';
    +
    +export interface DenseOracleInput {
    +  bounds: RouterRect;
    +  obstacles: readonly RouterObstacle[];
    +  source: RouterPoint;
    +  target: RouterPoint;
    +}
    +
    +function isBlockedPoint(point: RouterPoint, obstacles: readonly RouterObstacle[]): boolean {
    +  return obstacles.some(
    +    (obstacle) =>
    +      point.x > obstacle.left &&
    +      point.x < obstacle.right &&
    +      point.y > obstacle.top &&
    +      point.y < obstacle.bottom
    +  );
    +}
    +
    +function segmentBlocked(
    +  orientation: GridOrientation,
    +  fixed: number,
    +  start: number,
    +  end: number,
    +  obstacles: readonly RouterObstacle[]
    +): boolean {
    +  const low = Math.min(start, end);
    +  const high = Math.max(start, end);
    +  return obstacles.some((obstacle) =>
    +    orientation === 'H'
    +      ? fixed > obstacle.top &&
    +        fixed < obstacle.bottom &&
    +        high > obstacle.left &&
    +        low < obstacle.right
    +      : fixed > obstacle.left &&
    +        fixed < obstacle.right &&
    +        high > obstacle.top &&
    +        low < obstacle.bottom
    +  );
    +}
    +
    +export function findDenseOracleRoute(input: DenseOracleInput): RouterSearchResult | undefined {
    +  const xs = [
    +    ...new Set([
    +      input.bounds.left,
    +      input.bounds.right,
    +      input.source.x,
    +      input.target.x,
    +      ...input.obstacles.flatMap(({ left, right }) => [left, right]),
    +    ]),
    +  ].sort((a, b) => a - b);
    +  const ys = [
    +    ...new Set([
    +      input.bounds.top,
    +      input.bounds.bottom,
    +      input.source.y,
    +      input.target.y,
    +      ...input.obstacles.flatMap(({ top, bottom }) => [top, bottom]),
    +    ]),
    +  ].sort((a, b) => a - b);
    +  const vertices: RouterVertex[] = [];
    +  const byPoint = new Map();
    +  for (const y of ys) {
    +    for (const x of xs) {
    +      const point = { x, y };
    +      if (!isBlockedPoint(point, input.obstacles)) {
    +        const id = vertices.length;
    +        vertices.push({ id, point, kind: 'projection' });
    +        byPoint.set(`${x}:${y}`, id);
    +      }
    +    }
    +  }
    +  const adjacency = new Map(
    +    vertices.map((vertex) => [vertex.id, [] as RouterArc[]])
    +  );
    +  const connectLines = (orientation: GridOrientation) => {
    +    const coordinates = orientation === 'H' ? ys : xs;
    +    const varying = orientation === 'H' ? xs : ys;
    +    for (const fixed of coordinates) {
    +      let previous: RouterVertex | undefined;
    +      for (const value of varying) {
    +        const id = byPoint.get(orientation === 'H' ? `${value}:${fixed}` : `${fixed}:${value}`);
    +        if (id === undefined) {
    +          continue;
    +        }
    +        const current = vertices[id];
    +        if (previous) {
    +          const start = orientation === 'H' ? previous.point.x : previous.point.y;
    +          const end = orientation === 'H' ? current.point.x : current.point.y;
    +          if (!segmentBlocked(orientation, fixed, start, end, input.obstacles)) {
    +            const length = end - start;
    +            adjacency.get(previous.id)!.push({
    +              from: previous.id,
    +              to: current.id,
    +              orientation,
    +              length,
    +              kind: 'visibility',
    +              intervalStart: start,
    +              intervalEnd: end,
    +            });
    +            adjacency.get(current.id)!.push({
    +              from: current.id,
    +              to: previous.id,
    +              orientation,
    +              length,
    +              kind: 'visibility',
    +              intervalStart: start,
    +              intervalEnd: end,
    +            });
    +          }
    +        }
    +        previous = current;
    +      }
    +    }
    +  };
    +  connectLines('H');
    +  connectLines('V');
    +  const sourceId = byPoint.get(`${input.source.x}:${input.source.y}`);
    +  const targetId = byPoint.get(`${input.target.x}:${input.target.y}`);
    +  if (sourceId === undefined || targetId === undefined) {
    +    return undefined;
    +  }
    +  return findShortestRoute({ vertices, adjacency }, sourceId, targetId, { heuristic: 'zero' });
    +}
    diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/grid/routerSearch.ts b/packages/mermaid/src/rendering-util/layout-algorithms/grid/routerSearch.ts
    new file mode 100644
    index 00000000000..4557b7317f9
    --- /dev/null
    +++ b/packages/mermaid/src/rendering-util/layout-algorithms/grid/routerSearch.ts
    @@ -0,0 +1,744 @@
    +import {
    +  GridRoutingResourceLimitError,
    +  type GridRoutingInstrumentation,
    +} from './routerInstrumentation.js';
    +import type {
    +  ContainerRoutingTopology,
    +  GridOrientation,
    +  GridSide,
    +  RouterPoint,
    +  RouterVertex,
    +} from './types.js';
    +
    +// A* minimizes this tuple lexicographically. Length and bends dominate hierarchy transitions and
    +// endpoint preference, which are deterministic tie-breakers rather than weighted approximations.
    +export type RouterTupleCost = [
    +  length: number,
    +  bends: number,
    +  boundaryTransitions: number,
    +  endpointCandidateRank: number,
    +];
    +
    +export interface RouterSearchResult {
    +  points: RouterPoint[];
    +  vertexIds: number[];
    +  cost: RouterTupleCost;
    +}
    +
    +export interface RouterSearchCaps {
    +  maxExpandedStates?: number;
    +  maxInvocationExpandedStates?: number;
    +  maxEstimatedBytes?: number;
    +}
    +
    +export interface RouterSearchBudget {
    +  expandedStates: number;
    +}
    +
    +export interface RouterSearchOptions {
    +  metrics?: GridRoutingInstrumentation;
    +  caps?: RouterSearchCaps;
    +  endpointCandidateRank?: number;
    +  recordOutcome?: boolean;
    +  budget?: RouterSearchBudget;
    +  initialOrientation?: GridOrientation;
    +  initialSide?: GridSide;
    +  initialLength?: number;
    +  targetOrientation?: GridOrientation;
    +  targetSide?: GridSide;
    +  targetLength?: number;
    +  topologyValidated?: boolean;
    +  workspace?: RouterSearchWorkspace;
    +  heuristic?: 'bend-aware' | 'zero';
    +  queueOrder?: 'canonical' | 'reverse';
    +  estimatedBytesBase?: number;
    +  arcAllowed?: (from: RouterPoint, to: RouterPoint) => boolean;
    +}
    +
    +const DEFAULT_MAX_EDGE_STATES = 100_000;
    +const DEFAULT_MAX_INVOCATION_STATES = 2_000_000;
    +
    +export class RouterSearchWorkspace {
    +  // Typed arrays are retained across searches to avoid allocating object-heavy state graphs for
    +  // every endpoint candidate pair. State capacity grows geometrically and never shrinks mid-session.
    +  stateVertices = new Int32Array(256);
    +  orientations = new Uint8Array(256);
    +  gLengths = new Float64Array(256);
    +  gBends = new Float64Array(256);
    +  gBoundaryTransitions = new Float64Array(256);
    +  fLengths = new Float64Array(256);
    +  fBends = new Float64Array(256);
    +  predecessors = new Int32Array(256);
    +  depths = new Int32Array(256);
    +  ancestor4 = new Int32Array(256);
    +  best = new Int32Array(256);
    +  heapChildren = new Int32Array(256);
    +  heapSiblings = new Int32Array(256);
    +  heapPairs: number[] = [];
    +  touchedBestIndices: number[] = [];
    +
    +  constructor() {
    +    this.best.fill(-1);
    +  }
    +
    +  reset(vertexCount: number): void {
    +    const bestLength = vertexCount * 3;
    +    if (this.best.length < bestLength) {
    +      this.best = new Int32Array(bestLength);
    +      this.best.fill(-1);
    +    } else {
    +      for (const index of this.touchedBestIndices) {
    +        this.best[index] = -1;
    +      }
    +    }
    +    this.touchedBestIndices.length = 0;
    +    this.heapPairs.length = 0;
    +  }
    +
    +  setBest(index: number, state: number): void {
    +    if (this.best[index] < 0) {
    +      this.touchedBestIndices.push(index);
    +    }
    +    this.best[index] = state;
    +  }
    +
    +  ensureStateCapacity(required: number): void {
    +    if (this.stateVertices.length >= required) {
    +      return;
    +    }
    +    const capacity = Math.max(required, this.stateVertices.length * 2);
    +    this.stateVertices = growTypedArray(this.stateVertices, capacity);
    +    this.orientations = growTypedArray(this.orientations, capacity);
    +    this.gLengths = growTypedArray(this.gLengths, capacity);
    +    this.gBends = growTypedArray(this.gBends, capacity);
    +    this.gBoundaryTransitions = growTypedArray(this.gBoundaryTransitions, capacity);
    +    this.fLengths = growTypedArray(this.fLengths, capacity);
    +    this.fBends = growTypedArray(this.fBends, capacity);
    +    this.predecessors = growTypedArray(this.predecessors, capacity);
    +    this.depths = growTypedArray(this.depths, capacity);
    +    this.ancestor4 = growTypedArray(this.ancestor4, capacity);
    +    this.heapChildren = growTypedArray(this.heapChildren, capacity);
    +    this.heapSiblings = growTypedArray(this.heapSiblings, capacity);
    +  }
    +
    +  estimatedBytes(): number {
    +    return (
    +      this.stateVertices.byteLength +
    +      this.orientations.byteLength +
    +      this.gLengths.byteLength +
    +      this.gBends.byteLength +
    +      this.gBoundaryTransitions.byteLength +
    +      this.fLengths.byteLength +
    +      this.fBends.byteLength +
    +      this.predecessors.byteLength +
    +      this.depths.byteLength +
    +      this.ancestor4.byteLength +
    +      this.best.byteLength +
    +      this.heapChildren.byteLength +
    +      this.heapSiblings.byteLength +
    +      (this.heapPairs.length + this.touchedBestIndices.length) * 8
    +    );
    +  }
    +}
    +
    +export function assertSearchBudgetAvailable(
    +  options: Pick
    +): void {
    +  const edgeCap = options.caps?.maxExpandedStates ?? DEFAULT_MAX_EDGE_STATES;
    +  const invocationCap = options.caps?.maxInvocationExpandedStates ?? DEFAULT_MAX_INVOCATION_STATES;
    +  const invocationStart = options.budget?.expandedStates ?? options.metrics?.expandedStates ?? 0;
    +  if (edgeCap > 0 && invocationStart < invocationCap) {
    +    return;
    +  }
    +  throw new GridRoutingResourceLimitError(
    +    'search_state_cap',
    +    'Grid routing search-state cap exceeded',
    +    invocationStart >= invocationCap ? 'invocation' : 'edge'
    +  );
    +}
    +
    +function growTypedArray(
    +  values: T,
    +  capacity: number
    +): T {
    +  const grown = new (values.constructor as new (length: number) => T)(capacity);
    +  grown.set(values);
    +  return grown;
    +}
    +export function compareTupleCost(a: RouterTupleCost, b: RouterTupleCost): number {
    +  for (let index = 0; index < 4; index++) {
    +    if (a[index] < b[index]) {
    +      return -1;
    +    }
    +    if (a[index] > b[index]) {
    +      return 1;
    +    }
    +  }
    +  return 0;
    +}
    +
    +function minimumBends(
    +  dx: number,
    +  dy: number,
    +  incomingOrientation?: GridOrientation,
    +  targetOrientation?: GridOrientation
    +): number {
    +  if (dx === 0 && dy === 0) {
    +    return incomingOrientation && targetOrientation
    +      ? Number(incomingOrientation !== targetOrientation)
    +      : 0;
    +  }
    +  if (dx === 0) {
    +    return (
    +      (incomingOrientation ? Number(incomingOrientation !== 'V') : 0) +
    +      (targetOrientation ? Number(targetOrientation !== 'V') : 0)
    +    );
    +  }
    +  if (dy === 0) {
    +    return (
    +      (incomingOrientation ? Number(incomingOrientation !== 'H') : 0) +
    +      (targetOrientation ? Number(targetOrientation !== 'H') : 0)
    +    );
    +  }
    +  return Math.min(
    +    (incomingOrientation ? Number(incomingOrientation !== 'H') : 0) +
    +      1 +
    +      (targetOrientation ? Number(targetOrientation !== 'V') : 0),
    +    (incomingOrientation ? Number(incomingOrientation !== 'V') : 0) +
    +      1 +
    +      (targetOrientation ? Number(targetOrientation !== 'H') : 0)
    +  );
    +}
    +
    +type OrientationOrdinal = 0 | 1 | 2;
    +
    +function orientationOrdinal(orientation: GridOrientation | undefined): OrientationOrdinal {
    +  return orientation === undefined ? 0 : orientation === 'H' ? 1 : 2;
    +}
    +
    +function movesTowardOwner(from: RouterPoint, to: RouterPoint, side: GridSide): boolean {
    +  return side === 'left'
    +    ? from.y === to.y && to.x > from.x
    +    : side === 'right'
    +      ? from.y === to.y && to.x < from.x
    +      : side === 'top'
    +        ? from.x === to.x && to.y > from.y
    +        : from.x === to.x && to.y < from.y;
    +}
    +
    +function stateIndex(vertexId: number, orientation: GridOrientation | undefined): number {
    +  return vertexId * 3 + orientationOrdinal(orientation);
    +}
    +
    +function normalizePoints(points: readonly RouterPoint[]): RouterPoint[] {
    +  const result: RouterPoint[] = [];
    +  for (const point of points) {
    +    const previous = result.at(-1);
    +    if (previous && previous.x === point.x && previous.y === point.y) {
    +      continue;
    +    }
    +    const beforePrevious = result.at(-2);
    +    if (
    +      beforePrevious &&
    +      previous &&
    +      ((beforePrevious.x === previous.x && previous.x === point.x) ||
    +        (beforePrevious.y === previous.y && previous.y === point.y))
    +    ) {
    +      result[result.length - 1] = { ...point };
    +    } else {
    +      result.push({ ...point });
    +    }
    +  }
    +  return result;
    +}
    +
    +function reconstruct(
    +  stateId: number,
    +  stateVertices: ArrayLike,
    +  predecessors: ArrayLike,
    +  cost: RouterTupleCost,
    +  vertexAt: (id: number) => RouterVertex
    +): RouterSearchResult {
    +  const vertexIds: number[] = [];
    +  let current = stateId;
    +  while (current >= 0) {
    +    vertexIds.push(stateVertices[current]);
    +    current = predecessors[current];
    +  }
    +  vertexIds.reverse();
    +  return {
    +    points: normalizePoints(vertexIds.map((id) => vertexAt(id).point)),
    +    vertexIds,
    +    cost,
    +  };
    +}
    +
    +function validateSearchTopology(
    +  topology: Pick
    +): void {
    +  for (const vertex of topology.vertices) {
    +    if (
    +      vertex.id < 0 ||
    +      topology.vertices[vertex.id] !== vertex ||
    +      !Number.isFinite(vertex.point.x) ||
    +      !Number.isFinite(vertex.point.y)
    +    ) {
    +      throw new Error('Malformed grid routing vertex');
    +    }
    +    for (const arc of topology.adjacency.get(vertex.id) ?? []) {
    +      const target = topology.vertices[arc.to];
    +      const aligned =
    +        target &&
    +        (arc.orientation === 'H'
    +          ? vertex.point.y === target.point.y
    +          : vertex.point.x === target.point.x);
    +      const length = target
    +        ? Math.abs(vertex.point.x - target.point.x) + Math.abs(vertex.point.y - target.point.y)
    +        : Number.NaN;
    +      if (
    +        arc.from !== vertex.id ||
    +        !aligned ||
    +        !Number.isFinite(arc.length) ||
    +        arc.length <= 0 ||
    +        arc.length !== length
    +      ) {
    +        throw new Error('Malformed grid routing arc');
    +      }
    +    }
    +  }
    +}
    +
    +function search(
    +  topology: Pick &
    +    Partial<
    +      Pick<
    +        ContainerRoutingTopology,
    +        | 'adjacencyByVertex'
    +        | 'searchAdjacencyByVertex'
    +        | 'vertexCount'
    +        | 'getVertex'
    +        | 'getSearchArcs'
    +      >
    +    >,
    +  sourceId: number,
    +  targetId: number,
    +  options: RouterSearchOptions
    +): RouterSearchResult | undefined {
    +  const vertexAt = (id: number): RouterVertex => {
    +    const vertex = topology.getVertex?.(id) ?? topology.vertices[id];
    +    if (!vertex) {
    +      throw new Error(`Missing grid routing vertex ${id}`);
    +    }
    +    return vertex;
    +  };
    +  const source = vertexAt(sourceId);
    +  const target = vertexAt(targetId);
    +  if (!options.topologyValidated) {
    +    validateSearchTopology(topology);
    +  }
    +
    +  const metrics = options.metrics;
    +  if (metrics) {
    +    metrics.searches++;
    +  }
    +  assertSearchBudgetAvailable(options);
    +  const workspace = options.workspace ?? new RouterSearchWorkspace();
    +  workspace.reset(topology.vertexCount ?? topology.vertices.length);
    +  let stateVertices = workspace.stateVertices;
    +  let orientations = workspace.orientations;
    +  let gLengths = workspace.gLengths;
    +  let gBends = workspace.gBends;
    +  let gBoundaryTransitions = workspace.gBoundaryTransitions;
    +  let fLengths = workspace.fLengths;
    +  let fBends = workspace.fBends;
    +  let predecessors = workspace.predecessors;
    +  let depths = workspace.depths;
    +  let ancestor4 = workspace.ancestor4;
    +  let heapChildren = workspace.heapChildren;
    +  let heapSiblings = workspace.heapSiblings;
    +  const best = workspace.best;
    +  const heapPairs = workspace.heapPairs;
    +  let stateCount = 1;
    +  const endpointCandidateRank = options.endpointCandidateRank ?? 0;
    +  const useBendHeuristic = options.heuristic !== 'zero';
    +  function nonCostOrder(a: number, b: number): number {
    +    return (
    +      stateVertices[a] - stateVertices[b] ||
    +      orientations[a] - orientations[b] ||
    +      predecessors[a] - predecessors[b] ||
    +      a - b
    +    );
    +  }
    +  function compareStates(a: number, b: number): number {
    +    return (
    +      fLengths[a] - fLengths[b] ||
    +      fBends[a] - fBends[b] ||
    +      gBoundaryTransitions[a] - gBoundaryTransitions[b] ||
    +      (options.queueOrder === 'reverse' ? -nonCostOrder(a, b) : nonCostOrder(a, b))
    +    );
    +  }
    +  function compareChains(a: number, b: number): number {
    +    // Equal-cost states choose the lexicographically smaller predecessor chain. Four-level jump
    +    // pointers reduce the common-prefix walk without retaining full paths per state.
    +    if (a === b) {
    +      return 0;
    +    }
    +    if (a < 0) {
    +      return -1;
    +    }
    +    if (b < 0) {
    +      return 1;
    +    }
    +    const aDepth = depths[a];
    +    const bDepth = depths[b];
    +    let alignedA = a;
    +    let alignedB = b;
    +    while (depths[alignedA] > depths[alignedB]) {
    +      alignedA = predecessors[alignedA];
    +    }
    +    while (depths[alignedB] > depths[alignedA]) {
    +      alignedB = predecessors[alignedB];
    +    }
    +    if (alignedA === alignedB) {
    +      return aDepth - bDepth;
    +    }
    +    while (
    +      ancestor4[alignedA] >= 0 &&
    +      ancestor4[alignedB] >= 0 &&
    +      ancestor4[alignedA] !== ancestor4[alignedB]
    +    ) {
    +      alignedA = ancestor4[alignedA];
    +      alignedB = ancestor4[alignedB];
    +    }
    +    while (predecessors[alignedA] !== predecessors[alignedB]) {
    +      alignedA = predecessors[alignedA];
    +      alignedB = predecessors[alignedB];
    +    }
    +    return (
    +      stateVertices[alignedA] - stateVertices[alignedB] ||
    +      orientations[alignedA] - orientations[alignedB]
    +    );
    +  }
    +  let heapRoot = -1;
    +  let heapSize = 0;
    +  // A pairing heap supports cheap melds in flat arrays, preserving workspace reuse and avoiding one
    +  // allocation per queue node.
    +  function heapMeld(first: number, second: number): number {
    +    if (first < 0) {
    +      return second;
    +    }
    +    if (second < 0) {
    +      return first;
    +    }
    +    if (compareStates(first, second) <= 0) {
    +      heapSiblings[second] = heapChildren[first];
    +      heapChildren[first] = second;
    +      return first;
    +    }
    +    heapSiblings[first] = heapChildren[second];
    +    heapChildren[second] = first;
    +    return second;
    +  }
    +  const heapPush = (value: number): void => {
    +    heapChildren[value] = -1;
    +    heapSiblings[value] = -1;
    +    heapRoot = heapMeld(heapRoot, value);
    +    heapSize++;
    +  };
    +  const heapPop = (): number | undefined => {
    +    if (heapRoot < 0) {
    +      return undefined;
    +    }
    +    const first = heapRoot;
    +    heapPairs.length = 0;
    +    let child = heapChildren[first];
    +    while (child >= 0) {
    +      const next = heapSiblings[child];
    +      heapSiblings[child] = -1;
    +      heapPairs.push(child);
    +      child = next;
    +    }
    +    for (let index = 0; index + 1 < heapPairs.length; index += 2) {
    +      heapPairs[index >>> 1] = heapMeld(heapPairs[index], heapPairs[index + 1]);
    +    }
    +    let pairCount = Math.ceil(heapPairs.length / 2);
    +    if (heapPairs.length % 2 !== 0) {
    +      heapPairs[pairCount - 1] = heapPairs[heapPairs.length - 1];
    +    }
    +    heapRoot = -1;
    +    while (pairCount > 0) {
    +      heapRoot = heapMeld(heapPairs[--pairCount], heapRoot);
    +    }
    +    heapSize--;
    +    return first;
    +  };
    +  let expandedThisSearch = 0;
    +  const edgeCap = options.caps?.maxExpandedStates ?? DEFAULT_MAX_EDGE_STATES;
    +  const invocationCap = options.caps?.maxInvocationExpandedStates ?? DEFAULT_MAX_INVOCATION_STATES;
    +  const invocationStart = options.budget?.expandedStates ?? metrics?.expandedStates ?? 0;
    +  const recordWorkspaceBytes = (): void => {
    +    const bytes = workspace.estimatedBytes();
    +    const totalBytes = (options.estimatedBytesBase ?? 0) + bytes;
    +    if (metrics) {
    +      metrics.searchWorkspaceBytes = Math.max(metrics.searchWorkspaceBytes, bytes);
    +      metrics.estimatedBytes = Math.max(metrics.estimatedBytes, totalBytes);
    +    }
    +    if (totalBytes > (options.caps?.maxEstimatedBytes ?? Number.POSITIVE_INFINITY)) {
    +      throw new GridRoutingResourceLimitError(
    +        'estimated_memory_cap',
    +        'Grid routing search workspace memory cap exceeded'
    +      );
    +    }
    +  };
    +  recordWorkspaceBytes();
    +  const commitExpandedStates = (): void => {
    +    // Charge states once on every exit path, including cap exceptions, so the invocation-wide
    +    // budget cannot be reset by trying another candidate pair.
    +    if (options.budget) {
    +      options.budget.expandedStates += expandedThisSearch;
    +    }
    +    if (metrics) {
    +      metrics.expandedStates += expandedThisSearch;
    +    }
    +  };
    +  const initialLength = options.initialLength ?? 0;
    +  stateVertices[0] = sourceId;
    +  orientations[0] = orientationOrdinal(options.initialOrientation);
    +  gLengths[0] = initialLength;
    +  gBends[0] = 0;
    +  gBoundaryTransitions[0] = 0;
    +  const initialDx = Math.abs(source.point.x - target.point.x);
    +  const initialDy = Math.abs(source.point.y - target.point.y);
    +  fLengths[0] = initialLength + (useBendHeuristic ? initialDx + initialDy : 0);
    +  fBends[0] = useBendHeuristic
    +    ? minimumBends(initialDx, initialDy, options.initialOrientation, options.targetOrientation)
    +    : 0;
    +  predecessors[0] = -1;
    +  depths[0] = 1;
    +  ancestor4[0] = -1;
    +  workspace.setBest(stateIndex(sourceId, options.initialOrientation), 0);
    +  heapPush(0);
    +  if (metrics) {
    +    metrics.maxOpenSet = Math.max(metrics.maxOpenSet, heapSize);
    +  }
    +  let bestGoalState = -1;
    +  let bestGoalCost: RouterTupleCost | undefined;
    +  const considerGoal = (state: number): void => {
    +    const completedCost: RouterTupleCost = [
    +      gLengths[state] + (options.targetLength ?? 0),
    +      gBends[state] +
    +        (options.targetOrientation !== undefined &&
    +        orientations[state] !== 0 &&
    +        orientationOrdinal(options.targetOrientation) !== orientations[state]
    +          ? 1
    +          : 0),
    +      gBoundaryTransitions[state],
    +      endpointCandidateRank,
    +    ];
    +    if (
    +      !bestGoalCost ||
    +      compareTupleCost(completedCost, bestGoalCost) < 0 ||
    +      (compareTupleCost(completedCost, bestGoalCost) === 0 &&
    +        compareChains(state, bestGoalState) < 0)
    +    ) {
    +      bestGoalState = state;
    +      bestGoalCost = completedCost;
    +    }
    +  };
    +  const relaxArc = (
    +    current: number,
    +    currentPoint: RouterPoint,
    +    to: number,
    +    orientation: OrientationOrdinal,
    +    arcLength: number,
    +    boundaryTransitions: number
    +  ): void => {
    +    const nextPoint = vertexAt(to).point;
    +    if (options.arcAllowed && !options.arcAllowed(currentPoint, nextPoint)) {
    +      return;
    +    }
    +    if (
    +      options.initialSide &&
    +      predecessors[current] < 0 &&
    +      movesTowardOwner(currentPoint, nextPoint, options.initialSide)
    +    ) {
    +      return;
    +    }
    +    if (
    +      options.targetSide &&
    +      to === targetId &&
    +      movesTowardOwner(target.point, currentPoint, options.targetSide)
    +    ) {
    +      return;
    +    }
    +    const index = to * 3 + orientation;
    +    const previous = best[index];
    +    const length = gLengths[current] + arcLength;
    +    const bends =
    +      gBends[current] +
    +      (orientations[current] !== 0 && orientations[current] !== orientation ? 1 : 0);
    +    const nextBoundaryTransitions = gBoundaryTransitions[current] + boundaryTransitions;
    +    const previousComparison =
    +      previous >= 0
    +        ? length - gLengths[previous] ||
    +          bends - gBends[previous] ||
    +          nextBoundaryTransitions - gBoundaryTransitions[previous]
    +        : -1;
    +    if (
    +      previous >= 0 &&
    +      (previousComparison > 0 ||
    +        (previousComparison === 0 && compareChains(current, predecessors[previous]) >= 0))
    +    ) {
    +      return;
    +    }
    +    const candidate = stateCount++;
    +    if (stateCount > stateVertices.length) {
    +      workspace.ensureStateCapacity(stateCount);
    +      stateVertices = workspace.stateVertices;
    +      orientations = workspace.orientations;
    +      gLengths = workspace.gLengths;
    +      gBends = workspace.gBends;
    +      gBoundaryTransitions = workspace.gBoundaryTransitions;
    +      fLengths = workspace.fLengths;
    +      fBends = workspace.fBends;
    +      predecessors = workspace.predecessors;
    +      depths = workspace.depths;
    +      ancestor4 = workspace.ancestor4;
    +      heapChildren = workspace.heapChildren;
    +      heapSiblings = workspace.heapSiblings;
    +      recordWorkspaceBytes();
    +    }
    +    stateVertices[candidate] = to;
    +    orientations[candidate] = orientation;
    +    gLengths[candidate] = length;
    +    gBends[candidate] = bends;
    +    gBoundaryTransitions[candidate] = nextBoundaryTransitions;
    +    const candidateDx = Math.abs(nextPoint.x - target.point.x);
    +    const candidateDy = Math.abs(nextPoint.y - target.point.y);
    +    fLengths[candidate] = length + (useBendHeuristic ? candidateDx + candidateDy : 0);
    +    fBends[candidate] =
    +      bends +
    +      (useBendHeuristic
    +        ? minimumBends(
    +            candidateDx,
    +            candidateDy,
    +            orientation === 1 ? 'H' : 'V',
    +            options.targetOrientation
    +          )
    +        : 0);
    +    predecessors[candidate] = current;
    +    depths[candidate] = depths[current] + 1;
    +    const parent2 = predecessors[current];
    +    const parent3 = parent2 >= 0 ? predecessors[parent2] : -1;
    +    ancestor4[candidate] = parent3 >= 0 ? predecessors[parent3] : -1;
    +    workspace.setBest(index, candidate);
    +    heapPush(candidate);
    +  };
    +
    +  while (heapSize > 0) {
    +    const next = heapRoot;
    +    // The queue is ordered by the same admissible tuple prefix as the goal. Once its best state is
    +    // worse than the best goal, no remaining state can improve the result.
    +    if (
    +      bestGoalCost &&
    +      (fLengths[next] - bestGoalCost[0] ||
    +        fBends[next] - bestGoalCost[1] ||
    +        gBoundaryTransitions[next] - bestGoalCost[2] ||
    +        endpointCandidateRank - bestGoalCost[3]) > 0
    +    ) {
    +      break;
    +    }
    +    const current = heapPop()!;
    +    if (best[stateVertices[current] * 3 + orientations[current]] !== current) {
    +      continue;
    +    }
    +    if (expandedThisSearch >= edgeCap || invocationStart + expandedThisSearch >= invocationCap) {
    +      if (bestGoalState >= 0 && bestGoalCost) {
    +        if (metrics && options.recordOutcome !== false) {
    +          metrics.routesFound++;
    +        }
    +        commitExpandedStates();
    +        return reconstruct(bestGoalState, stateVertices, predecessors, bestGoalCost, vertexAt);
    +      }
    +      commitExpandedStates();
    +      const searchStateScope =
    +        invocationStart + expandedThisSearch >= invocationCap ? 'invocation' : 'edge';
    +      throw new GridRoutingResourceLimitError(
    +        'search_state_cap',
    +        'Grid routing search-state cap exceeded',
    +        searchStateScope
    +      );
    +    }
    +    expandedThisSearch++;
    +
    +    if (stateVertices[current] === targetId) {
    +      considerGoal(current);
    +      continue;
    +    }
    +
    +    const vertexId = stateVertices[current];
    +    const currentPoint = vertexAt(vertexId).point;
    +    const compactArcs =
    +      topology.getSearchArcs?.(vertexId) ?? topology.searchAdjacencyByVertex?.[vertexId];
    +    if (compactArcs) {
    +      for (const arc of compactArcs) {
    +        relaxArc(
    +          current,
    +          currentPoint,
    +          arc.to,
    +          arc.orientationOrdinal,
    +          arc.length,
    +          arc.boundaryTransitions
    +        );
    +      }
    +    } else {
    +      for (const arc of topology.adjacencyByVertex?.[vertexId] ??
    +        topology.adjacency.get(vertexId) ??
    +        []) {
    +        relaxArc(
    +          current,
    +          currentPoint,
    +          arc.to,
    +          orientationOrdinal(arc.orientation),
    +          arc.length,
    +          arc.kind === 'portal' ? 1 : 0
    +        );
    +      }
    +    }
    +    if (metrics) {
    +      metrics.maxOpenSet = Math.max(metrics.maxOpenSet, heapSize);
    +    }
    +  }
    +
    +  if (bestGoalState < 0 || !bestGoalCost) {
    +    commitExpandedStates();
    +    if (metrics && options.recordOutcome !== false) {
    +      metrics.routesImpossible++;
    +    }
    +    return undefined;
    +  }
    +  if (metrics && options.recordOutcome !== false) {
    +    metrics.routesFound++;
    +  }
    +  commitExpandedStates();
    +  return reconstruct(bestGoalState, stateVertices, predecessors, bestGoalCost, vertexAt);
    +}
    +
    +export function findShortestRoute(
    +  topology: Pick &
    +    Partial<
    +      Pick<
    +        ContainerRoutingTopology,
    +        | 'adjacencyByVertex'
    +        | 'searchAdjacencyByVertex'
    +        | 'vertexCount'
    +        | 'getVertex'
    +        | 'getSearchArcs'
    +      >
    +    >,
    +  sourceId: number,
    +  targetId: number,
    +  options: RouterSearchOptions = {}
    +): RouterSearchResult | undefined {
    +  return search(topology, sourceId, targetId, options);
    +}
    diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/grid/routerSession.ts b/packages/mermaid/src/rendering-util/layout-algorithms/grid/routerSession.ts
    new file mode 100644
    index 00000000000..0993f13597e
    --- /dev/null
    +++ b/packages/mermaid/src/rendering-util/layout-algorithms/grid/routerSession.ts
    @@ -0,0 +1,734 @@
    +import type { Point } from '../../../types.js';
    +import type { Edge, LayoutData } from '../../types.js';
    +import { clamp, rectForNode } from '../layout-utils/helpers.js';
    +import type { GridRoutingTestOptions } from './router.js';
    +import {
    +  boundedAlternativePortalCoordinates,
    +  buildRoutingContext,
    +  combinePointChains,
    +  compatibilityHierarchyAttachments,
    +  compatibilityLcaAttachments,
    +  groupBoundaryEndpointAttachment,
    +  itemAttachment,
    +  portalAttachment,
    +  prepareRoutingModes,
    +  reversePoints,
    +  rootContainerMeta,
    +  routeWithinContainer,
    +  validatedCompatibilitySegment,
    +  type PreparedRoutingModes,
    +  type SegmentAttachment,
    +  type SegmentAttachmentAlternative,
    +} from './routerCompatibility.js';
    +import {
    +  isPairSeparationError,
    +  routeHasDistinctPairPorts,
    +  routeSatisfiesPairConstraints,
    +  routerRect,
    +} from './routerConstraint.js';
    +import {
    +  ownerGroupTitle,
    +  ownerSideKey,
    +  prepareEdgeRoutes,
    +  sideInterval,
    +  type EdgeEndpointEntry,
    +  type EdgeEndpointPlan,
    +  type EdgeRoutePlan,
    +  type PreparedEdgeRoutes,
    +} from './routerPlanning.js';
    +import {
    +  createGridRoutingInstrumentationCheckpoint,
    +  recordGridRoute,
    +  restoreGridRoutingInstrumentationCheckpoint,
    +  type GridRoutingInstrumentation,
    +  type GridRoutingInstrumentationCheckpoint,
    +} from './routerInstrumentation.js';
    +import { RouteOccupancyIndex } from './routerOccupancy.js';
    +import { RouterSearchWorkspace } from './routerSearch.js';
    +import {
    +  sparseContainerSegment,
    +  sparseSameContainerRoute,
    +  sparseSelfLoopRoute,
    +} from './routerSparse.js';
    +import { buildPairedPortal, derivePortalRanges, EndpointOverlayScratch } from './routerTopology.js';
    +import type {
    +  GridContainerId,
    +  GridLayoutResult,
    +  GridRoutingContext,
    +  PairedPortal,
    +} from './types.js';
    +import { ROOT_CONTAINER_ID, gridError } from './types.js';
    +
    +// The session is the transaction boundary for one routing invocation. It owns mutable reservations,
    +// reusable search memory, and retry state while the base layout geometry remains read-only.
    +interface BundleEdgeCheckpoint {
    +  edge: Edge;
    +  // Geometry and its rendering contract commit together; retries must not leak either half.
    +  points: Edge['points'];
    +  curve: Edge['curve'];
    +  cornerRadius: Edge['cornerRadius'];
    +}
    +
    +interface BundleCheckpoint {
    +  pairKey: string;
    +  occupancy: { index: RouteOccupancyIndex; size: number } | undefined;
    +  pairRoutes: Point[][] | undefined;
    +  demandKeys: string[];
    +  pairedPortals: Map;
    +  demandCoords: Map;
    +  edges: BundleEdgeCheckpoint[];
    +  instrumentedRoutesLength: number | undefined;
    +  metrics: GridRoutingInstrumentationCheckpoint | undefined;
    +}
    +
    +function createBundleCheckpoint(
    +  pairPlans: readonly EdgeRoutePlan[],
    +  pairRoutes: ReadonlyMap,
    +  pairedPortals: ReadonlyMap,
    +  demandCoords: ReadonlyMap,
    +  instrumentedRoutes: readonly Point[][] | undefined,
    +  metrics: GridRoutingInstrumentation | undefined,
    +  routeOccupancy: ReadonlyMap
    +): BundleCheckpoint {
    +  // Snapshot only state a failed bundle can mutate. Topologies and prepared plans are immutable and
    +  // intentionally shared across attempts.
    +  const pairKey = pairPlans[0].pairKey;
    +  const committedPairRoutes = pairRoutes.get(pairKey);
    +  const demandKeys = [
    +    ...new Set(
    +      pairPlans.flatMap((plan) =>
    +        [...plan.source.chain, ...plan.target.chain].map(({ demandKey }) => demandKey)
    +      )
    +    ),
    +  ];
    +  const bundlePairedPortals = new Map();
    +  const bundleDemandCoords = new Map();
    +  for (const demandKey of demandKeys) {
    +    const portal = pairedPortals.get(demandKey);
    +    if (portal) {
    +      bundlePairedPortals.set(demandKey, portal);
    +    }
    +    const coordinate = demandCoords.get(demandKey);
    +    if (coordinate !== undefined) {
    +      bundleDemandCoords.set(demandKey, coordinate);
    +    }
    +  }
    +  // Pair members share endpoints, so they share one LCA container and one occupancy index.
    +  const occupancyIndex = routeOccupancy.get(pairPlans[0].lcaContainerId);
    +  return {
    +    pairKey,
    +    occupancy: occupancyIndex ? { index: occupancyIndex, size: occupancyIndex.size } : undefined,
    +    pairRoutes: committedPairRoutes ? [...committedPairRoutes] : undefined,
    +    demandKeys,
    +    pairedPortals: bundlePairedPortals,
    +    demandCoords: bundleDemandCoords,
    +    edges: pairPlans.map(({ edge }) => ({
    +      edge,
    +      points: edge.points,
    +      curve: edge.curve,
    +      cornerRadius: edge.cornerRadius,
    +    })),
    +    instrumentedRoutesLength: instrumentedRoutes?.length,
    +    metrics: metrics ? createGridRoutingInstrumentationCheckpoint(metrics) : undefined,
    +  };
    +}
    +
    +function restoreBundleCheckpoint(
    +  checkpoint: BundleCheckpoint,
    +  pairRoutes: Map,
    +  pairedPortals: Map,
    +  demandCoords: Map,
    +  instrumentedRoutes: Point[][] | undefined,
    +  metrics: GridRoutingInstrumentation | undefined
    +): void {
    +  checkpoint.occupancy?.index.truncate(checkpoint.occupancy.size);
    +  if (checkpoint.pairRoutes) {
    +    pairRoutes.set(checkpoint.pairKey, [...checkpoint.pairRoutes]);
    +  } else {
    +    pairRoutes.delete(checkpoint.pairKey);
    +  }
    +  for (const demandKey of checkpoint.demandKeys) {
    +    const portal = checkpoint.pairedPortals.get(demandKey);
    +    if (portal) {
    +      pairedPortals.set(demandKey, portal);
    +    } else {
    +      pairedPortals.delete(demandKey);
    +    }
    +    const coordinate = checkpoint.demandCoords.get(demandKey);
    +    if (coordinate !== undefined) {
    +      demandCoords.set(demandKey, coordinate);
    +    } else {
    +      demandCoords.delete(demandKey);
    +    }
    +  }
    +  for (const edgeCheckpoint of checkpoint.edges) {
    +    edgeCheckpoint.edge.points = edgeCheckpoint.points;
    +    edgeCheckpoint.edge.curve = edgeCheckpoint.curve;
    +    edgeCheckpoint.edge.cornerRadius = edgeCheckpoint.cornerRadius;
    +  }
    +  if (instrumentedRoutes && checkpoint.instrumentedRoutesLength !== undefined) {
    +    instrumentedRoutes.length = checkpoint.instrumentedRoutesLength;
    +  }
    +  if (metrics && checkpoint.metrics) {
    +    restoreGridRoutingInstrumentationCheckpoint(metrics, checkpoint.metrics);
    +  }
    +}
    +
    +export class GridEdgeRoutingSession {
    +  private prepared!: PreparedEdgeRoutes;
    +  private modes!: PreparedRoutingModes;
    +  private routingContext!: GridRoutingContext;
    +  private readonly searchWorkspace = new RouterSearchWorkspace();
    +  private endpointOverlayScratch!: Map;
    +  private readonly pairedPortals = new Map();
    +  private readonly ownerSideCounts = new Map();
    +  private instrumentedRoutes!: Point[][] | undefined;
    +  private readonly selfLoopCounts = new Map();
    +  private readonly pairRoutes = new Map();
    +  private routed = false;
    +
    +  constructor(
    +    private readonly layout: LayoutData,
    +    private readonly result: GridLayoutResult,
    +    private readonly metrics: GridRoutingInstrumentation | undefined,
    +    private readonly options: GridRoutingTestOptions
    +  ) {}
    +
    +  route(): void {
    +    if (this.routed) {
    +      throw new Error('Grid edge routing session can only be run once');
    +    }
    +    this.routed = true;
    +    this.prepare();
    +
    +    // Pair insertion order follows the already canonical plan order; Map preserves that order when
    +    // bundles are routed as indivisible retry units.
    +    const plansByPair = new Map();
    +    for (const plan of this.prepared.orderedPlans) {
    +      const pairPlans = plansByPair.get(plan.pairKey) ?? [];
    +      pairPlans.push(plan);
    +      plansByPair.set(plan.pairKey, pairPlans);
    +    }
    +    for (const pairPlans of plansByPair.values()) {
    +      this.routeBundle(pairPlans);
    +    }
    +  }
    +
    +  private prepare(): void {
    +    // Preparation is front-loaded so routing attempts never observe partially built planning or
    +    // topology state.
    +    rootContainerMeta(this.result);
    +    this.prepared = prepareEdgeRoutes(this.layout, this.result);
    +
    +    this.modes = prepareRoutingModes(this.prepared, this.result, this.metrics, this.options);
    +    this.routingContext = buildRoutingContext(
    +      this.modes.routedContainerIds,
    +      this.result,
    +      this.metrics,
    +      this.options
    +    );
    +    this.endpointOverlayScratch = new Map(
    +      [...this.routingContext.topologies].map(([containerId, topology]) => [
    +        containerId,
    +        new EndpointOverlayScratch(topology),
    +      ])
    +    );
    +    this.instrumentedRoutes = this.metrics ? [] : undefined;
    +    const countedDemandKeys = new Set();
    +    for (const plan of this.prepared.orderedPlans) {
    +      if (plan.edge.start === plan.edge.end) {
    +        continue;
    +      }
    +      for (const entry of [...plan.source.chain, ...plan.target.chain]) {
    +        if (countedDemandKeys.has(entry.demandKey)) {
    +          continue;
    +        }
    +        countedDemandKeys.add(entry.demandKey);
    +        const key = ownerSideKey(entry.ownerId, entry.side);
    +        this.ownerSideCounts.set(key, (this.ownerSideCounts.get(key) ?? 0) + 1);
    +      }
    +    }
    +  }
    +
    +  private pairedPortal(entry: EdgeEndpointEntry): PairedPortal {
    +    const existing = this.pairedPortals.get(entry.demandKey);
    +    if (existing) {
    +      return existing;
    +    }
    +    const owner = this.result.forest.nodeById.get(entry.ownerId);
    +    if (!owner?.isGroup) {
    +      throw gridError('GRID_ROUTE_NOT_FOUND', `Missing portal owner "${entry.ownerId}"`);
    +    }
    +    const rect = routerRect(owner);
    +    const coordinate =
    +      this.prepared.demandCoords.get(entry.demandKey) ??
    +      (entry.side === 'left' || entry.side === 'right'
    +        ? (rect.top + rect.bottom) / 2
    +        : (rect.left + rect.right) / 2);
    +    const range = derivePortalRanges(entry.ownerId, rect, owner.groupTitleRect).find(
    +      ({ side }) => side === entry.side
    +    );
    +    if (!range) {
    +      throw gridError(
    +        'GRID_ROUTE_NOT_FOUND',
    +        `No legal ${entry.side} portal for "${entry.ownerId}"`
    +      );
    +    }
    +    const portal = buildPairedPortal(
    +      entry.ownerId,
    +      rect,
    +      owner.groupTitleRect ? { ...owner.groupTitleRect } : undefined,
    +      entry.side,
    +      clamp(coordinate, range.low, range.high)
    +    );
    +    this.pairedPortals.set(entry.demandKey, portal);
    +    if (this.metrics) {
    +      this.metrics.hierarchyPortalPairs++;
    +      this.metrics.hierarchyPortalTransitionLength += portal.transition.length;
    +    }
    +    return portal;
    +  }
    +
    +  private alternativePairedPortals(
    +    entry: EdgeEndpointEntry,
    +    interior = true
    +  ): SegmentAttachmentAlternative[] {
    +    const selected = this.pairedPortal(entry);
    +    const owner = this.result.forest.nodeById.get(entry.ownerId);
    +    if (!owner?.isGroup) {
    +      return [];
    +    }
    +    const rect = routerRect(owner);
    +    const range = derivePortalRanges(entry.ownerId, rect, owner.groupTitleRect).find(
    +      ({ side }) => side === entry.side
    +    );
    +    if (!range) {
    +      return [];
    +    }
    +    const container = this.result.containers.get(owner.id);
    +    const corridors =
    +      entry.side === 'left' || entry.side === 'right'
    +        ? container?.horizontalCorridors
    +        : container?.verticalCorridors;
    +    // Keep retries bounded and deterministic: nearby established corridors are preferred, with
    +    // range endpoints available when the originally selected portal blocks a hierarchy segment.
    +    return boundedAlternativePortalCoordinates(
    +      selected.tangentialCoordinate,
    +      range.low,
    +      range.high,
    +      corridors ?? []
    +    ).map((coordinate) => {
    +      const portal = buildPairedPortal(
    +        entry.ownerId,
    +        rect,
    +        owner.groupTitleRect ? { ...owner.groupTitleRect } : undefined,
    +        entry.side,
    +        coordinate
    +      );
    +      return {
    +        attachment: portalAttachment(portal, interior),
    +        select: () => {
    +          this.pairedPortals.set(entry.demandKey, portal);
    +          this.prepared.demandCoords.set(entry.demandKey, coordinate);
    +        },
    +      };
    +    });
    +  }
    +
    +  private itemSegmentAttachment(
    +    entry: EdgeEndpointEntry,
    +    containerId: GridContainerId
    +  ): SegmentAttachment {
    +    return {
    +      ownerId: entry.ownerId,
    +      ...itemAttachment(
    +        entry.ownerId,
    +        entry.side,
    +        entry.demandKey,
    +        containerId,
    +        this.result,
    +        this.prepared.demandCoords
    +      ),
    +    };
    +  }
    +
    +  private alternativeItemAttachments(
    +    plan: EdgeRoutePlan,
    +    entry: EdgeEndpointEntry,
    +    containerId: GridContainerId
    +  ): SegmentAttachmentAlternative[] {
    +    const current = this.itemSegmentAttachment(entry, containerId);
    +    const owner = this.result.forest.nodeById.get(entry.ownerId);
    +    if (!owner) {
    +      return [];
    +    }
    +    return (['right', 'bottom', 'left', 'top'] as const)
    +      .filter((side) => side !== current.side && !(side === 'top' && ownerGroupTitle(owner)))
    +      .map((side) => {
    +        const interval = sideInterval(owner, side);
    +        if (!interval) {
    +          return undefined;
    +        }
    +        const rect = rectForNode(owner);
    +        const center = side === 'left' || side === 'right' ? rect.cy : rect.cx;
    +        const coordinate = Math.max(
    +          interval.low,
    +          Math.min(interval.high, center + plan.laneOffset)
    +        );
    +        return {
    +          ownerId: entry.ownerId,
    +          ...itemAttachment(
    +            entry.ownerId,
    +            side,
    +            entry.demandKey,
    +            containerId,
    +            this.result,
    +            this.prepared.demandCoords,
    +            coordinate
    +          ),
    +        };
    +      })
    +      .filter((attachment): attachment is SegmentAttachment => attachment !== undefined)
    +      .slice(0, 3)
    +      .map((attachment) => ({
    +        attachment,
    +        select: () => undefined,
    +      }));
    +  }
    +
    +  private routePlan(plan: EdgeRoutePlan, allowHierarchyRelaxation = true): void {
    +    // A plan is routed in three phases: source ascent, one LCA-container segment, and reversed
    +    // target descent. The chains are committed only after the combined route satisfies pair rules.
    +    const result = this.result;
    +    const routingContext = this.routingContext;
    +    const searchWorkspace = this.searchWorkspace;
    +    const endpointOverlayScratch = this.endpointOverlayScratch;
    +    const demandCoords = this.prepared.demandCoords;
    +    const ownerSideCounts = this.ownerSideCounts;
    +    const selfLoopCounts = this.selfLoopCounts;
    +    const pairRoutes = this.pairRoutes;
    +    const instrumentedRoutes = this.instrumentedRoutes;
    +    const metrics = this.metrics;
    +    const options = this.options;
    +    const eligibleIds = this.prepared.eligibleIds;
    +    const endpointCandidatesByEdge = this.prepared.endpointCandidatesByEdge;
    +    const compatibilityFastRoutes = this.modes.compatibilityFastRoutes;
    +    const sparseHierarchyIds = this.modes.sparseHierarchyIds;
    +    const sparseLcaIds = this.modes.sparseLcaIds;
    +    const edge = plan.edge;
    +    const sourceNode = edge.start ? result.forest.nodeById.get(edge.start) : undefined;
    +    const targetNode = edge.end ? result.forest.nodeById.get(edge.end) : undefined;
    +    if (!sourceNode || !targetNode) {
    +      throw gridError('GRID_MISSING_ENDPOINT', `Missing endpoint for edge "${edge.id}"`, {
    +        edgeId: edge.id,
    +      });
    +    }
    +
    +    if (sourceNode.id === targetNode.id) {
    +      const committedPairRoutes = pairRoutes.get(plan.pairKey) ?? [];
    +      const { points, side, index } = sparseSelfLoopRoute(
    +        plan,
    +        sourceNode,
    +        result,
    +        routingContext,
    +        searchWorkspace,
    +        endpointOverlayScratch.get(sourceNode.parentId ?? ROOT_CONTAINER_ID),
    +        ownerSideCounts,
    +        selfLoopCounts,
    +        committedPairRoutes,
    +        options
    +      );
    +      const countKey = `${sourceNode.id}:${side}`;
    +      selfLoopCounts.set(countKey, index + 1);
    +      edge.points = points;
    +      // The router owns the polyline; attach its curve contract at the same commit boundary.
    +      edge.curve = result.config.curve;
    +      edge.cornerRadius = result.config.edgeCornerRadius;
    +      committedPairRoutes.push(points);
    +      pairRoutes.set(plan.pairKey, committedPairRoutes);
    +      if (metrics && instrumentedRoutes) {
    +        recordGridRoute(metrics, edge.id, points, instrumentedRoutes, 0, plan.laneOffset);
    +        instrumentedRoutes.push(points);
    +      }
    +      return;
    +    }
    +
    +    const sourceFinal = plan.source.chain[plan.source.chain.length - 1];
    +    const targetFinal = plan.target.chain[plan.target.chain.length - 1];
    +    const useSparseLca = sparseLcaIds.has(edge.id);
    +    const compatibilityLca = useSparseLca
    +      ? undefined
    +      : compatibilityLcaAttachments(plan, result, demandCoords);
    +    const lcaStart: SegmentAttachment = compatibilityLca
    +      ? compatibilityLca.start
    +      : plan.source.finalKind === 'boundary'
    +        ? groupBoundaryEndpointAttachment(
    +            sourceFinal.ownerId,
    +            sourceFinal.side,
    +            sourceFinal.demandKey,
    +            result,
    +            demandCoords
    +          )
    +        : plan.source.chain.length > 1
    +          ? portalAttachment(this.pairedPortal(sourceFinal), false)
    +          : this.itemSegmentAttachment(sourceFinal, plan.lcaContainerId);
    +    const lcaEnd: SegmentAttachment = compatibilityLca
    +      ? compatibilityLca.end
    +      : plan.target.finalKind === 'boundary'
    +        ? groupBoundaryEndpointAttachment(
    +            targetFinal.ownerId,
    +            targetFinal.side,
    +            targetFinal.demandKey,
    +            result,
    +            demandCoords
    +          )
    +        : plan.target.chain.length > 1
    +          ? portalAttachment(this.pairedPortal(targetFinal), false)
    +          : this.itemSegmentAttachment(targetFinal, plan.lcaContainerId);
    +    let legacyLcaPoints: Point[] | undefined;
    +    const legacyRoute = () =>
    +      (legacyLcaPoints ??= routeWithinContainer(
    +        plan.lcaContainerId,
    +        result,
    +        lcaStart,
    +        lcaEnd,
    +        plan.laneIndex
    +      ));
    +    const compatibilityFastRoute = compatibilityFastRoutes.get(edge.id);
    +    const lcaPoints = compatibilityFastRoute
    +      ? (() => {
    +          if (metrics) {
    +            metrics.compatibilityFastPaths++;
    +          }
    +          return compatibilityFastRoute;
    +        })()
    +      : eligibleIds.has(edge.id)
    +        ? sparseSameContainerRoute(
    +            plan,
    +            endpointCandidatesByEdge.get(edge.id)!.sources,
    +            endpointCandidatesByEdge.get(edge.id)!.targets,
    +            sourceNode,
    +            targetNode,
    +            legacyRoute,
    +            result,
    +            routingContext,
    +            searchWorkspace,
    +            endpointOverlayScratch.get(plan.lcaContainerId)!,
    +            options,
    +            pairRoutes.get(plan.pairKey) ?? [],
    +            {
    +              index: this.occupancyFor(plan.lcaContainerId),
    +              pairKey: plan.pairKey,
    +              prioritizeEndpointCandidates: this.prepared.coordinatedEndpointIds.has(edge.id),
    +            }
    +          )
    +        : useSparseLca
    +          ? sparseContainerSegment(
    +              edge.id,
    +              plan.lcaContainerId,
    +              lcaStart,
    +              lcaEnd,
    +              legacyRoute,
    +              result,
    +              routingContext,
    +              searchWorkspace,
    +              endpointOverlayScratch.get(plan.lcaContainerId),
    +              options,
    +              pairRoutes.get(plan.pairKey) ?? [],
    +              () =>
    +                plan.target.finalKind === 'item' && plan.target.chain.length > 1
    +                  ? this.alternativePairedPortals(targetFinal, false)
    +                  : [],
    +              () =>
    +                plan.source.finalKind === 'item' && plan.source.chain.length > 1
    +                  ? this.alternativePairedPortals(sourceFinal, false)
    +                  : [],
    +              plan.bundleSize > 1,
    +              { index: this.occupancyFor(plan.lcaContainerId), pairKey: plan.pairKey }
    +            )
    +          : validatedCompatibilitySegment(
    +              edge.id,
    +              plan.lcaContainerId,
    +              lcaStart,
    +              lcaEnd,
    +              legacyRoute,
    +              result,
    +              metrics
    +            );
    +
    +    const routeHierarchyChain = (endpoint: EdgeEndpointPlan, chains: Point[][]): void => {
    +      if (!sparseHierarchyIds.has(edge.id)) {
    +        for (const { containerId, start, end } of compatibilityHierarchyAttachments(
    +          endpoint,
    +          result,
    +          demandCoords
    +        )) {
    +          chains.push(
    +            validatedCompatibilitySegment(
    +              edge.id,
    +              containerId,
    +              start,
    +              end,
    +              () => routeWithinContainer(containerId, result, start, end, plan.laneIndex),
    +              result,
    +              metrics
    +            )
    +          );
    +        }
    +        return;
    +      }
    +      for (let index = 0; index < endpoint.chain.length - 1; index++) {
    +        const from = endpoint.chain[index];
    +        const to = endpoint.chain[index + 1];
    +        const start =
    +          index === 0
    +            ? this.itemSegmentAttachment(from, to.ownerId)
    +            : portalAttachment(this.pairedPortal(from), false);
    +        const end = portalAttachment(this.pairedPortal(to), true);
    +        chains.push(
    +          sparseContainerSegment(
    +            edge.id,
    +            to.ownerId,
    +            start,
    +            end,
    +            () => routeWithinContainer(to.ownerId, result, start, end, plan.laneIndex),
    +            result,
    +            routingContext,
    +            searchWorkspace,
    +            endpointOverlayScratch.get(to.ownerId),
    +            options,
    +            pairRoutes.get(plan.pairKey) ?? [],
    +            () => this.alternativePairedPortals(to),
    +            () => (index === 0 ? this.alternativeItemAttachments(plan, from, to.ownerId) : []),
    +            plan.bundleSize > 1
    +          )
    +        );
    +      }
    +    };
    +    const sourceChains: Point[][] = [];
    +    const targetChains: Point[][] = [];
    +    routeHierarchyChain(plan.source, sourceChains);
    +    routeHierarchyChain(plan.target, targetChains);
    +
    +    const points = combinePointChains([
    +      ...sourceChains,
    +      lcaPoints,
    +      ...targetChains.reverse().map((chain) => reversePoints(chain)),
    +    ]);
    +    const committedPairRoutes = pairRoutes.get(plan.pairKey) ?? [];
    +    const satisfiesPairConstraints = routeSatisfiesPairConstraints(points, committedPairRoutes);
    +    const relaxHierarchySeparation =
    +      !satisfiesPairConstraints &&
    +      plan.bundleSize > 1 &&
    +      plan.source.chain.length + plan.target.chain.length > 2 &&
    +      routeHasDistinctPairPorts(points, committedPairRoutes);
    +    if (
    +      plan.bundleSize > 1 &&
    +      !satisfiesPairConstraints &&
    +      (!relaxHierarchySeparation || !allowHierarchyRelaxation)
    +    ) {
    +      if (metrics) {
    +        metrics.routesImpossible++;
    +      }
    +      throw gridError('GRID_ROUTE_NOT_FOUND', `No distinct lane route for "${edge.id}"`, {
    +        edgeId: edge.id,
    +        reason: relaxHierarchySeparation ? 'pair-separation' : 'no-distinct-route',
    +      });
    +    }
    +    if (relaxHierarchySeparation && metrics) {
    +      metrics.bundleSeparationRelaxations++;
    +    }
    +    edge.points = points;
    +    // Publish style only after the route satisfies terminal and pair-separation invariants.
    +    edge.curve = result.config.curve;
    +    edge.cornerRadius = result.config.edgeCornerRadius;
    +    committedPairRoutes.push(points);
    +    pairRoutes.set(plan.pairKey, committedPairRoutes);
    +    // Fast routes were registered while routing modes were prepared.
    +    if (!compatibilityFastRoute) {
    +      this.occupancyFor(plan.lcaContainerId).add(lcaPoints, plan.pairKey);
    +    }
    +    if (metrics && instrumentedRoutes) {
    +      const boundaryTransitionCount = plan.source.chain.length + plan.target.chain.length - 2;
    +      recordGridRoute(
    +        metrics,
    +        edge.id,
    +        points,
    +        instrumentedRoutes,
    +        boundaryTransitionCount,
    +        plan.laneOffset
    +      );
    +      instrumentedRoutes.push(points);
    +    }
    +  }
    +
    +  private occupancyFor(containerId: GridContainerId): RouteOccupancyIndex {
    +    let occupancy = this.modes.routeOccupancy.get(containerId);
    +    if (!occupancy) {
    +      occupancy = new RouteOccupancyIndex();
    +      this.modes.routeOccupancy.set(containerId, occupancy);
    +    }
    +    return occupancy;
    +  }
    +
    +  private createBundleCheckpoint(pairPlans: readonly EdgeRoutePlan[]): BundleCheckpoint {
    +    this.occupancyFor(pairPlans[0].lcaContainerId);
    +    return createBundleCheckpoint(
    +      pairPlans,
    +      this.pairRoutes,
    +      this.pairedPortals,
    +      this.prepared.demandCoords,
    +      this.instrumentedRoutes,
    +      this.metrics,
    +      this.modes.routeOccupancy
    +    );
    +  }
    +
    +  private restoreBundleCheckpoint(checkpoint: BundleCheckpoint): void {
    +    restoreBundleCheckpoint(
    +      checkpoint,
    +      this.pairRoutes,
    +      this.pairedPortals,
    +      this.prepared.demandCoords,
    +      this.instrumentedRoutes,
    +      this.metrics
    +    );
    +  }
    +
    +  private routeBundle(pairPlans: EdgeRoutePlan[]): void {
    +    // Small bundles first require strict pair separation. If that fails only because hierarchy
    +    // interiors overlap, restore the whole bundle and retry with that separation relaxed while
    +    // preserving distinct endpoint ports.
    +    const canAttemptStrictSeparation =
    +      pairPlans.length > 1 &&
    +      pairPlans.length <= 8 &&
    +      pairPlans.every(({ edge }) => edge.start !== edge.end);
    +    if (!canAttemptStrictSeparation) {
    +      for (const plan of pairPlans) {
    +        this.routePlan(plan);
    +      }
    +      return;
    +    }
    +    // A bundle is the retry unit because earlier siblings reserve pair corridors and portals for
    +    // later ones. Restore every transactional structure before relaxing separation. The cumulative
    +    // search budget intentionally retains work performed by the discarded attempt.
    +    const checkpoint = this.createBundleCheckpoint(pairPlans);
    +    let initialError: unknown;
    +    try {
    +      for (const plan of pairPlans) {
    +        this.routePlan(plan, false);
    +      }
    +      return;
    +    } catch (error) {
    +      initialError = error;
    +      this.restoreBundleCheckpoint(checkpoint);
    +    }
    +
    +    if (!isPairSeparationError(initialError)) {
    +      throw initialError;
    +    }
    +    for (const plan of pairPlans) {
    +      this.routePlan(plan);
    +    }
    +  }
    +}
    diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/grid/routerSparse.ts b/packages/mermaid/src/rendering-util/layout-algorithms/grid/routerSparse.ts
    new file mode 100644
    index 00000000000..92a55cd0f7e
    --- /dev/null
    +++ b/packages/mermaid/src/rendering-util/layout-algorithms/grid/routerSparse.ts
    @@ -0,0 +1,897 @@
    +import { log } from '../../../logger.js';
    +import type { Point } from '../../../types.js';
    +import type { Node } from '../../types.js';
    +import { normalizePolyline } from '../layout-utils/geometry.js';
    +import { manhattanLength, rectForNode } from '../layout-utils/helpers.js';
    +import type { GridRoutingTestOptions } from './router.js';
    +import type { RouteOccupancyIndex } from './routerOccupancy.js';
    +import {
    +  SELF_LOOP_PORT_GAP,
    +  SELF_LOOP_PORT_OFFSET_STEP,
    +  areExactlyAxisAligned,
    +  labelAwareSelfLoopCandidates,
    +  orderedSelfLoopSides,
    +  routeObstacleClearSelfLoop,
    +  type SegmentAttachment,
    +  type SegmentAttachmentAlternative,
    +} from './routerCompatibility.js';
    +import {
    +  endpointPairLowerBound,
    +  pairArcAllowed,
    +  routeHasDistinctPairPorts,
    +  routeSatisfiesPairConstraints,
    +  validateContainerSegment,
    +  validateSameContainerRoute,
    +} from './routerConstraint.js';
    +import {
    +  TERMINAL_APPROACH_PX,
    +  sideInterval,
    +  sideOrder,
    +  type EdgeRoutePlan,
    +  type EndpointCandidate,
    +} from './routerPlanning.js';
    +import {
    +  GridRoutingResourceLimitError,
    +  type GridRoutingFallbackReason,
    +} from './routerInstrumentation.js';
    +import {
    +  assertSearchBudgetAvailable,
    +  compareTupleCost,
    +  findShortestRoute,
    +  type RouterSearchResult,
    +  type RouterSearchWorkspace,
    +  type RouterTupleCost,
    +} from './routerSearch.js';
    +import {
    +  buildEndpointRoutingOverlay,
    +  DEFAULT_MAX_ROUTING_ESTIMATED_BYTES,
    +  routingPointKey,
    +  type EndpointOverlayScratch,
    +} from './routerTopology.js';
    +import type {
    +  GridContainerId,
    +  GridLayoutResult,
    +  GridOrientation,
    +  GridRoutingContext,
    +  GridSide,
    +  RouterPoint,
    +} from './types.js';
    +import { ROOT_CONTAINER_ID, gridError } from './types.js';
    +
    +// Sparse routing overlays edge-specific endpoints onto immutable container visibility graphs, then
    +// validates every result before it can replace the deterministic corridor route.
    +const ROUTER_DEBUG_KEY = 'grid-router';
    +
    +function recordFallback(
    +  context: GridRoutingContext,
    +  reason: GridRoutingFallbackReason,
    +  edgeId: string,
    +  containerId: GridContainerId,
    +  searchStateScope?: GridRoutingResourceLimitError['searchStateScope']
    +): void {
    +  if (context.metrics) {
    +    context.metrics.resourceLimitFallbacks++;
    +    context.metrics.fallbackReasons[reason]++;
    +  }
    +  log.debug(ROUTER_DEBUG_KEY, 'GRID_ROUTING_RESOURCE_FALLBACK', {
    +    edgeId,
    +    containerId,
    +    reason,
    +  });
    +  if (
    +    reason === 'search_state_cap' &&
    +    searchStateScope === 'invocation' &&
    +    !context.searchBudgetWarningEmitted
    +  ) {
    +    context.searchBudgetWarningEmitted = true;
    +    log.warn(ROUTER_DEBUG_KEY, 'GRID_ROUTING_INVOCATION_SEARCH_BUDGET_EXHAUSTED', {
    +      edgeId,
    +      containerId,
    +      expandedStates: context.searchBudget.expandedStates,
    +    });
    +  }
    +}
    +
    +interface ValidatedFallbackOptions {
    +  context: GridRoutingContext;
    +  reason: GridRoutingFallbackReason;
    +  edgeId: string;
    +  containerId: GridContainerId;
    +  searchStateScope?: GridRoutingResourceLimitError['searchStateScope'];
    +  route: () => T;
    +  isValid: (fallback: T) => boolean;
    +  includeContainerIdInError?: boolean;
    +}
    +
    +function runValidatedFallback({
    +  context,
    +  reason,
    +  edgeId,
    +  containerId,
    +  searchStateScope,
    +  route,
    +  isValid,
    +  includeContainerIdInError,
    +}: ValidatedFallbackOptions): T {
    +  const fallback = route();
    +  recordFallback(context, reason, edgeId, containerId, searchStateScope);
    +  if (isValid(fallback)) {
    +    return fallback;
    +  }
    +  if (context.metrics) {
    +    context.metrics.fallbackValidationFailures++;
    +  }
    +  throw gridError('GRID_ROUTE_NOT_FOUND', `Invalid legacy fallback for "${edgeId}"`, {
    +    edgeId,
    +    ...(includeContainerIdInError ? { containerId } : {}),
    +    reason,
    +  });
    +}
    +
    +/*
    + * TODO: Consider an opt-in `grid.routingPolicy: 'best-effort'` mode while keeping validated
    + * routing as the default.
    + *
    + * A minimal implementation could catch recoverable per-edge routing exhaustion after both the
    + * sparse router and validated compatibility route fail, then construct a deterministic orthogonal
    + * dogleg between the measured source and target bounds. Self-loops would need a similarly
    + * deterministic rectangular loop. The fallback could cross protected geometry or share bundle
    + * lanes, but it must still use finite coordinates, preserve valid endpoints, avoid partially
    + * committed state, and emit a warning containing the edge id and original failure reason.
    + *
    + * A production-quality implementation would need:
    + * - Explicit classification of recoverable geometry exhaustion versus malformed input and internal
    + *   invariants. Invalid coordinates, containment, measurements, endpoints, and topology state must
    + *   remain errors in every mode.
    + * - Transactional per-edge fallback that updates pair-route, portal, instrumentation, and retry
    + *   state consistently.
    + * - Documented constraints that best-effort routing may relax, with deterministic output and
    + *   observable warnings or metrics for every degraded edge.
    + * - A matching label policy that restores provisional label-routing state before placing a label
    + *   at a deterministic fallback position or omitting it.
    + * - Tests for both policies using the same production paths, including hierarchy routes,
    + *   self-loops, bundles, resource-limit fallbacks, and label-placement exhaustion.
    + */
    +export function sparseSameContainerRoute(
    +  plan: EdgeRoutePlan,
    +  sources: readonly EndpointCandidate[],
    +  targets: readonly EndpointCandidate[],
    +  source: Node,
    +  target: Node,
    +  legacyRoute: () => Point[],
    +  result: GridLayoutResult,
    +  context: GridRoutingContext,
    +  searchWorkspace: RouterSearchWorkspace,
    +  overlayScratch: EndpointOverlayScratch,
    +  options: GridRoutingTestOptions,
    +  pairRoutes: readonly (readonly Point[])[],
    +  occupied?: {
    +    index: RouteOccupancyIndex;
    +    pairKey: string;
    +    prioritizeEndpointCandidates?: boolean;
    +  }
    +): Point[] {
    +  const fallbackReason = context.fallbackContainers.get(plan.lcaContainerId);
    +  if (fallbackReason) {
    +    return runValidatedFallback({
    +      context,
    +      reason: fallbackReason,
    +      edgeId: plan.edge.id,
    +      containerId: plan.lcaContainerId,
    +      route: legacyRoute,
    +      isValid: (legacy) =>
    +        validateSameContainerRoute(legacy, source, target, plan.lcaContainerId, result) &&
    +        (plan.bundleSize <= 1 || routeSatisfiesPairConstraints(legacy, pairRoutes)),
    +    });
    +  }
    +  const topology = context.topologies.get(plan.lcaContainerId);
    +  if (!topology) {
    +    throw gridError('GRID_ROUTE_NOT_FOUND', `Missing routing topology "${plan.lcaContainerId}"`);
    +  }
    +  if (sources.length === 0 || targets.length === 0) {
    +    if (context.metrics) {
    +      context.metrics.routesImpossible++;
    +    }
    +    throw gridError(
    +      'GRID_ROUTE_NOT_FOUND',
    +      plan.bundleSize > 1
    +        ? `No distinct lane route for "${plan.edge.id}"`
    +        : `No legal endpoint candidates for "${plan.edge.id}"`,
    +      { edgeId: plan.edge.id }
    +    );
    +  }
    +
    +  let best: { result: RouterSearchResult; points: Point[] } | undefined;
    +  let searchCap: GridRoutingResourceLimitError | undefined;
    +  const pairs = sources
    +    .flatMap((sourceCandidate) =>
    +      targets.map((targetCandidate) => {
    +        const pairRank = sourceCandidate.rank * targets.length + targetCandidate.rank;
    +        const [length, bends] = endpointPairLowerBound(sourceCandidate, targetCandidate);
    +        const lowerCost: RouterTupleCost = [length, bends, 0, pairRank];
    +        return { sourceCandidate, targetCandidate, pairRank, lowerCost };
    +      })
    +    )
    +    .sort(
    +      (a, b) =>
    +        compareTupleCost(a.lowerCost, b.lowerCost) ||
    +        sideOrder(a.sourceCandidate.side) - sideOrder(b.sourceCandidate.side) ||
    +        sideOrder(a.targetCandidate.side) - sideOrder(b.targetCandidate.side)
    +    );
    +
    +  // Candidate pairs are ordered by an admissible lower bound. Once a completed route is no worse
    +  // than that bound, neither this pair nor any remaining pair can improve the current best.
    +  const searchPairs = (
    +    avoidOccupied: boolean,
    +    mode: 'all' | 'preferred' | 'assigned-side' = 'all'
    +  ): void => {
    +    const arcAllowed: ((from: RouterPoint, to: RouterPoint) => boolean) | undefined =
    +      plan.bundleSize > 1 || avoidOccupied
    +        ? (from, to) =>
    +            (plan.bundleSize <= 1 || pairArcAllowed(from, to, pairRoutes)) &&
    +            !(avoidOccupied && occupied!.index.conflictsWithArc(from, to, occupied!.pairKey))
    +        : undefined;
    +    for (const { sourceCandidate, targetCandidate, pairRank, lowerCost } of pairs) {
    +      if (mode === 'preferred' && pairRank !== 0) {
    +        continue;
    +      }
    +      if (
    +        mode === 'assigned-side' &&
    +        (sourceCandidate.onAssignedSide === false || targetCandidate.onAssignedSide === false)
    +      ) {
    +        continue;
    +      }
    +      if (best && compareTupleCost(best.result.cost, lowerCost) <= 0) {
    +        break;
    +      }
    +      try {
    +        assertSearchBudgetAvailable({
    +          metrics: context.metrics,
    +          caps: options.searchCaps,
    +          budget: context.searchBudget,
    +        });
    +        const overlay = buildEndpointRoutingOverlay(
    +          topology,
    +          sourceCandidate.connect,
    +          targetCandidate.connect,
    +          context.metrics,
    +          overlayScratch,
    +          plan.bundleSize > 1 &&
    +            (sourceCandidate.connect.x === targetCandidate.connect.x ||
    +              sourceCandidate.connect.y === targetCandidate.connect.y)
    +            ? [
    +                {
    +                  x: (sourceCandidate.connect.x + targetCandidate.connect.x) / 2,
    +                  y: (sourceCandidate.connect.y + targetCandidate.connect.y) / 2,
    +                },
    +              ]
    +            : []
    +        );
    +        const liveEstimatedBytes =
    +          context.baseEstimatedBytes + overlay.estimatedBytes - topology.estimatedBytes;
    +        if (
    +          liveEstimatedBytes >
    +          (options.topologyCaps?.maxEstimatedBytes ?? DEFAULT_MAX_ROUTING_ESTIMATED_BYTES)
    +        ) {
    +          throw new GridRoutingResourceLimitError(
    +            'estimated_memory_cap',
    +            'Grid routing estimated_memory_cap exceeded'
    +          );
    +        }
    +        if (context.metrics) {
    +          context.metrics.estimatedBytes = Math.max(
    +            context.metrics.estimatedBytes,
    +            liveEstimatedBytes
    +          );
    +        }
    +        const sourceId = overlay.pointVertexIds.get(routingPointKey(sourceCandidate.connect));
    +        const targetId = overlay.pointVertexIds.get(routingPointKey(targetCandidate.connect));
    +        if (sourceId === undefined || targetId === undefined) {
    +          throw gridError(
    +            'GRID_ROUTE_NOT_FOUND',
    +            `Endpoint projection is missing from the routing overlay for "${plan.edge.id}"`,
    +            {
    +              edgeId: plan.edge.id,
    +              containerId: plan.lcaContainerId,
    +              source: sourceCandidate.connect,
    +              target: targetCandidate.connect,
    +            }
    +          );
    +        }
    +        const sourceOrientation: GridOrientation =
    +          sourceCandidate.side === 'left' || sourceCandidate.side === 'right' ? 'H' : 'V';
    +        const targetOrientation: GridOrientation =
    +          targetCandidate.side === 'left' || targetCandidate.side === 'right' ? 'H' : 'V';
    +        const resultForPair = findShortestRoute(overlay, sourceId, targetId, {
    +          metrics: context.metrics,
    +          caps: {
    +            ...options.searchCaps,
    +            maxEstimatedBytes:
    +              options.searchCaps?.maxEstimatedBytes ??
    +              options.topologyCaps?.maxEstimatedBytes ??
    +              DEFAULT_MAX_ROUTING_ESTIMATED_BYTES,
    +          },
    +          endpointCandidateRank: pairRank,
    +          recordOutcome: false,
    +          budget: context.searchBudget,
    +          initialOrientation: sourceOrientation,
    +          initialLength: TERMINAL_APPROACH_PX,
    +          targetOrientation,
    +          targetLength: TERMINAL_APPROACH_PX,
    +          topologyValidated: true,
    +          workspace: searchWorkspace,
    +          estimatedBytesBase: liveEstimatedBytes,
    +          arcAllowed,
    +        });
    +        if (!resultForPair) {
    +          continue;
    +        }
    +        const points = normalizePolyline([
    +          sourceCandidate.port,
    +          ...resultForPair.points,
    +          targetCandidate.port,
    +        ]).points;
    +        if (!validateSameContainerRoute(points, source, target, plan.lcaContainerId, result)) {
    +          continue;
    +        }
    +        if (plan.bundleSize > 1 && !routeSatisfiesPairConstraints(points, pairRoutes)) {
    +          continue;
    +        }
    +        if (avoidOccupied && occupied!.index.conflictsWithRoute(points, occupied!.pairKey)) {
    +          continue;
    +        }
    +        if (!best || compareTupleCost(resultForPair.cost, best.result.cost) < 0) {
    +          best = { result: resultForPair, points };
    +        }
    +      } catch (error) {
    +        if (!(error instanceof GridRoutingResourceLimitError)) {
    +          throw error;
    +        }
    +        if (best) {
    +          break;
    +        }
    +        searchCap = error;
    +        break;
    +      }
    +    }
    +  };
    +  // Prefer a route that does not share a corridor with another edge. Sharing is the last resort,
    +  // so a failed constrained search retries without it instead of reporting no route.
    +  const avoidOccupied = occupied !== undefined && occupied.index.size > 0;
    +  if (occupied?.prioritizeEndpointCandidates) {
    +    // The assigned ports win unless another pair on the same sides at coordinated owners is cheaper,
    +    // which avoids accepting a legal but contorted route for the assigned ports.
    +    searchPairs(avoidOccupied, 'preferred');
    +    searchPairs(avoidOccupied, 'assigned-side');
    +  }
    +  if (!best) {
    +    searchPairs(avoidOccupied);
    +  }
    +  if (!best && !searchCap && occupied !== undefined && occupied.index.size > 0) {
    +    searchPairs(false);
    +  }
    +  if (searchCap) {
    +    // Resource caps select the compatibility route only after it passes the same geometry and bundle
    +    // constraints; caps never weaken correctness.
    +    return runValidatedFallback({
    +      context,
    +      reason: searchCap.reason,
    +      edgeId: plan.edge.id,
    +      containerId: plan.lcaContainerId,
    +      searchStateScope: searchCap.searchStateScope,
    +      route: legacyRoute,
    +      isValid: (legacy) =>
    +        validateSameContainerRoute(legacy, source, target, plan.lcaContainerId, result) &&
    +        (plan.bundleSize <= 1 || routeSatisfiesPairConstraints(legacy, pairRoutes)),
    +    });
    +  }
    +  if (!best) {
    +    if (context.metrics) {
    +      context.metrics.routesImpossible++;
    +    }
    +    throw gridError(
    +      'GRID_ROUTE_NOT_FOUND',
    +      plan.bundleSize > 1
    +        ? `No distinct lane route for "${plan.edge.id}"`
    +        : `No cell-aware route for "${plan.edge.id}"`,
    +      {
    +        edgeId: plan.edge.id,
    +      }
    +    );
    +  }
    +  if (options.onDualRouteComparison) {
    +    const legacy = legacyRoute();
    +    const sparseNormalized = normalizePolyline(best.points);
    +    const legacyNormalized = normalizePolyline(legacy);
    +    options.onDualRouteComparison({
    +      edgeId: plan.edge.id,
    +      sparse: {
    +        valid: true,
    +        length: manhattanLength(sparseNormalized.points),
    +        bends: sparseNormalized.bends,
    +      },
    +      legacy: {
    +        valid: validateSameContainerRoute(legacy, source, target, plan.lcaContainerId, result),
    +        length: manhattanLength(legacyNormalized.points),
    +        bends: legacyNormalized.bends,
    +      },
    +    });
    +  }
    +  return best.points;
    +}
    +
    +function selfLoopAttachments(
    +  owner: Node,
    +  side: GridSide,
    +  index: number
    +): { start: EndpointCandidate; target: EndpointCandidate } | undefined {
    +  const interval = sideInterval(owner, side);
    +  if (!interval || interval.high - interval.low < SELF_LOOP_PORT_GAP) {
    +    return undefined;
    +  }
    +  const rect = rectForNode(owner);
    +  const center = (interval.low + interval.high) / 2;
    +  const portOffset = index * SELF_LOOP_PORT_OFFSET_STEP;
    +  const coordinates = [
    +    center - SELF_LOOP_PORT_GAP / 2 - portOffset,
    +    center + SELF_LOOP_PORT_GAP / 2 + portOffset,
    +  ];
    +  if (coordinates[0] < interval.low || coordinates[1] > interval.high) {
    +    return undefined;
    +  }
    +  const candidate = (coordinate: number): EndpointCandidate => {
    +    const port =
    +      side === 'left' || side === 'right'
    +        ? { x: side === 'left' ? rect.left : rect.right, y: coordinate }
    +        : { x: coordinate, y: side === 'top' ? rect.top : rect.bottom };
    +    return {
    +      ownerId: owner.id,
    +      side,
    +      port,
    +      connect: {
    +        x:
    +          port.x +
    +          (side === 'left' ? -TERMINAL_APPROACH_PX : side === 'right' ? TERMINAL_APPROACH_PX : 0),
    +        y:
    +          port.y +
    +          (side === 'top' ? -TERMINAL_APPROACH_PX : side === 'bottom' ? TERMINAL_APPROACH_PX : 0),
    +      },
    +      rank: 0,
    +    };
    +  };
    +  return { start: candidate(coordinates[0]), target: candidate(coordinates[1]) };
    +}
    +
    +export function sparseSelfLoopRoute(
    +  plan: EdgeRoutePlan,
    +  owner: Node,
    +  result: GridLayoutResult,
    +  context: GridRoutingContext,
    +  searchWorkspace: RouterSearchWorkspace,
    +  overlayScratch: EndpointOverlayScratch | undefined,
    +  ownerSideCounts: Map,
    +  selfLoopCounts: Map,
    +  pairRoutes: readonly (readonly Point[])[],
    +  options: GridRoutingTestOptions
    +): { points: Point[]; side: GridSide; index: number } {
    +  // Try sides in load order and reserve progressively wider port pairs for repeated loops on the
    +  // same side.
    +  const containerId = owner.parentId ?? ROOT_CONTAINER_ID;
    +  const labelRequirement = result.labelRequirements.selfLoops.get(plan.edge.id);
    +  const fallbackReason = context.fallbackContainers.get(containerId);
    +  const legacyRoute = (): { points: Point[]; side: GridSide; index: number } =>
    +    routeObstacleClearSelfLoop(
    +      owner,
    +      ownerSideCounts,
    +      selfLoopCounts,
    +      result,
    +      labelRequirement,
    +      context.metrics
    +    );
    +  if (fallbackReason) {
    +    const fallback = runValidatedFallback({
    +      context,
    +      reason: fallbackReason,
    +      edgeId: plan.edge.id,
    +      containerId,
    +      route: legacyRoute,
    +      isValid: (legacy) =>
    +        validateSameContainerRoute(legacy.points, owner, owner, containerId, result) &&
    +        routeSatisfiesPairConstraints(legacy.points, pairRoutes),
    +    });
    +    if (labelRequirement && context.metrics) {
    +      context.metrics.labelAwareSelfLoopsCommitted++;
    +    }
    +    return fallback;
    +  }
    +  const topology = context.topologies.get(containerId);
    +  if (!topology || !overlayScratch) {
    +    throw gridError('GRID_ROUTE_NOT_FOUND', `Missing routing topology "${containerId}"`);
    +  }
    +  const orderedSides = orderedSelfLoopSides(owner, ownerSideCounts, selfLoopCounts);
    +  if (labelRequirement) {
    +    for (const side of orderedSides) {
    +      const index = selfLoopCounts.get(`${owner.id}:${side}`) ?? 0;
    +      const rect = rectForNode(owner);
    +      const demandCoord = side === 'left' || side === 'right' ? rect.cy : rect.cx;
    +      const candidates = labelAwareSelfLoopCandidates(
    +        owner,
    +        side,
    +        index,
    +        demandCoord,
    +        labelRequirement
    +      );
    +      if (context.metrics) {
    +        context.metrics.labelAwareSelfLoopCandidates += candidates.length;
    +      }
    +      for (const points of candidates) {
    +        if (
    +          validateSameContainerRoute(points, owner, owner, containerId, result) &&
    +          routeSatisfiesPairConstraints(points, pairRoutes)
    +        ) {
    +          if (context.metrics) {
    +            context.metrics.labelAwareSelfLoopsCommitted++;
    +          }
    +          return { points, side, index };
    +        }
    +      }
    +    }
    +    if (context.metrics) {
    +      context.metrics.labelAwareSelfLoopFallbacks++;
    +    }
    +  }
    +  for (const side of orderedSides) {
    +    const index = selfLoopCounts.get(`${owner.id}:${side}`) ?? 0;
    +    const attachments = selfLoopAttachments(owner, side, index);
    +    if (!attachments) {
    +      continue;
    +    }
    +    const { start, target } = attachments;
    +    try {
    +      assertSearchBudgetAvailable({
    +        metrics: context.metrics,
    +        caps: options.searchCaps,
    +        budget: context.searchBudget,
    +      });
    +      const overlay = buildEndpointRoutingOverlay(
    +        topology,
    +        start.connect,
    +        target.connect,
    +        context.metrics,
    +        overlayScratch,
    +        [
    +          {
    +            x: (start.connect.x + target.connect.x) / 2,
    +            y: (start.connect.y + target.connect.y) / 2,
    +          },
    +        ]
    +      );
    +      const sourceId = overlay.pointVertexIds.get(routingPointKey(start.connect));
    +      const targetId = overlay.pointVertexIds.get(routingPointKey(target.connect));
    +      if (sourceId === undefined || targetId === undefined) {
    +        throw gridError(
    +          'GRID_ROUTE_NOT_FOUND',
    +          `Self-loop endpoint projection is missing from the routing overlay for "${plan.edge.id}"`,
    +          {
    +            edgeId: plan.edge.id,
    +            containerId,
    +            source: start.connect,
    +            target: target.connect,
    +            side,
    +          }
    +        );
    +      }
    +      const orientation: GridOrientation = side === 'left' || side === 'right' ? 'H' : 'V';
    +      const route = findShortestRoute(overlay, sourceId, targetId, {
    +        metrics: context.metrics,
    +        caps: {
    +          ...options.searchCaps,
    +          maxEstimatedBytes:
    +            options.searchCaps?.maxEstimatedBytes ??
    +            options.topologyCaps?.maxEstimatedBytes ??
    +            DEFAULT_MAX_ROUTING_ESTIMATED_BYTES,
    +        },
    +        recordOutcome: false,
    +        budget: context.searchBudget,
    +        initialOrientation: orientation,
    +        initialSide: side,
    +        initialLength: TERMINAL_APPROACH_PX,
    +        targetOrientation: orientation,
    +        targetSide: side,
    +        targetLength: TERMINAL_APPROACH_PX,
    +        topologyValidated: true,
    +        workspace: searchWorkspace,
    +        estimatedBytesBase:
    +          context.baseEstimatedBytes + overlay.estimatedBytes - topology.estimatedBytes,
    +        arcAllowed: (from, to) => pairArcAllowed(from, to, pairRoutes),
    +      });
    +      if (!route) {
    +        continue;
    +      }
    +      const points = normalizePolyline([start.port, ...route.points, target.port]).points;
    +      if (
    +        validateSameContainerRoute(points, owner, owner, containerId, result) &&
    +        routeSatisfiesPairConstraints(points, pairRoutes)
    +      ) {
    +        return {
    +          points,
    +          side,
    +          index,
    +        };
    +      }
    +    } catch (error) {
    +      if (!(error instanceof GridRoutingResourceLimitError)) {
    +        throw error;
    +      }
    +      const fallback = runValidatedFallback({
    +        context,
    +        reason: error.reason,
    +        edgeId: plan.edge.id,
    +        containerId,
    +        searchStateScope: error.searchStateScope,
    +        route: legacyRoute,
    +        isValid: (legacy) =>
    +          validateSameContainerRoute(legacy.points, owner, owner, containerId, result) &&
    +          routeSatisfiesPairConstraints(legacy.points, pairRoutes),
    +      });
    +      if (labelRequirement && context.metrics) {
    +        context.metrics.labelAwareSelfLoopsCommitted++;
    +      }
    +      return fallback;
    +    }
    +  }
    +  if (context.metrics) {
    +    context.metrics.routesImpossible++;
    +  }
    +  throw gridError('GRID_ROUTE_NOT_FOUND', `No distinct self-loop route for "${plan.edge.id}"`, {
    +    edgeId: plan.edge.id,
    +    nodeId: owner.id,
    +  });
    +}
    +
    +export function sparseContainerSegment(
    +  edgeId: string,
    +  containerId: GridContainerId,
    +  start: SegmentAttachment,
    +  end: SegmentAttachment,
    +  legacyRoute: () => Point[],
    +  result: GridLayoutResult,
    +  context: GridRoutingContext,
    +  searchWorkspace: RouterSearchWorkspace,
    +  overlayScratch: EndpointOverlayScratch | undefined,
    +  options: GridRoutingTestOptions,
    +  pairRoutes: readonly (readonly Point[])[] = [],
    +  endAlternatives: () => readonly SegmentAttachmentAlternative[] = () => [],
    +  startAlternatives: () => readonly SegmentAttachmentAlternative[] = () => [],
    +  allowSharedPairCorridors = false,
    +  occupied?: { index: RouteOccupancyIndex; pairKey: string }
    +): Point[] {
    +  const fallbackReason = context.fallbackContainers.get(containerId);
    +  if (fallbackReason) {
    +    return runValidatedFallback({
    +      context,
    +      reason: fallbackReason,
    +      edgeId,
    +      containerId,
    +      route: legacyRoute,
    +      isValid: (legacy) =>
    +        validateContainerSegment(legacy, start.ownerId, end.ownerId, containerId, result) &&
    +        routeSatisfiesPairConstraints(legacy, pairRoutes),
    +      includeContainerIdInError: true,
    +    });
    +  }
    +  const topology = context.topologies.get(containerId);
    +  if (!topology || !overlayScratch) {
    +    throw gridError('GRID_ROUTE_NOT_FOUND', `Missing routing topology "${containerId}"`);
    +  }
    +  const attempts: {
    +    start: SegmentAttachment;
    +    end: SegmentAttachment;
    +    select: readonly (() => void)[];
    +  }[] = [{ start, end, select: [] }];
    +  // Alternatives are generated lazily only after the selected attachments fail. Their `select`
    +  // callbacks commit portal-coordinate changes after a complete route validates.
    +  let alternativesAdded = false;
    +  const appendAlternatives = (): void => {
    +    if (alternativesAdded) {
    +      return;
    +    }
    +    alternativesAdded = true;
    +    const starts = startAlternatives();
    +    const ends = endAlternatives();
    +    attempts.push(
    +      ...ends.map((alternative) => ({
    +        start,
    +        end: alternative.attachment,
    +        select: [alternative.select],
    +      })),
    +      ...starts.map((alternative) => ({
    +        start: alternative.attachment,
    +        end,
    +        select: [alternative.select],
    +      })),
    +      ...starts.flatMap((startAlternative) =>
    +        ends.map((endAlternative) => ({
    +          start: startAlternative.attachment,
    +          end: endAlternative.attachment,
    +          select: [startAlternative.select, endAlternative.select],
    +        }))
    +      )
    +    );
    +  };
    +  // Prefer attachments and corridors that do not run along another edge. Sharing is the last
    +  // resort, so a failed constrained pass retries without it.
    +  const passes = occupied !== undefined && occupied.index.size > 0 ? [true, false] : [false];
    +  try {
    +    for (const avoidOccupied of passes) {
    +      for (const [attemptIndex, attempt] of attempts.entries()) {
    +        const attemptedStart = attempt.start;
    +        const attemptedEnd = attempt.end;
    +        if (attemptIndex > 0 && context.metrics) {
    +          context.metrics.hierarchyPortalAlternativeAttempts++;
    +        }
    +        const aligned = areExactlyAxisAligned(attemptedStart.connect, attemptedEnd.connect);
    +        if (aligned) {
    +          const direct = normalizePolyline([
    +            attemptedStart.port,
    +            attemptedStart.connect,
    +            attemptedEnd.connect,
    +            attemptedEnd.port,
    +          ]).points;
    +          if (
    +            validateContainerSegment(
    +              direct,
    +              attemptedStart.ownerId,
    +              attemptedEnd.ownerId,
    +              containerId,
    +              result
    +            ) &&
    +            routeSatisfiesPairConstraints(direct, pairRoutes) &&
    +            !(avoidOccupied && occupied!.index.conflictsWithRoute(direct, occupied!.pairKey))
    +          ) {
    +            for (const select of attempt.select) {
    +              select();
    +            }
    +            if (attemptIndex > 0 && context.metrics) {
    +              context.metrics.hierarchyPortalAlternativeSelections++;
    +            }
    +            return direct;
    +          }
    +        }
    +        assertSearchBudgetAvailable({
    +          metrics: context.metrics,
    +          caps: options.searchCaps,
    +          budget: context.searchBudget,
    +        });
    +        const overlay = buildEndpointRoutingOverlay(
    +          topology,
    +          attemptedStart.connect,
    +          attemptedEnd.connect,
    +          context.metrics,
    +          overlayScratch
    +        );
    +        const liveEstimatedBytes =
    +          context.baseEstimatedBytes + overlay.estimatedBytes - topology.estimatedBytes;
    +        if (
    +          liveEstimatedBytes >
    +          (options.topologyCaps?.maxEstimatedBytes ?? DEFAULT_MAX_ROUTING_ESTIMATED_BYTES)
    +        ) {
    +          throw new GridRoutingResourceLimitError(
    +            'estimated_memory_cap',
    +            'Grid routing estimated_memory_cap exceeded'
    +          );
    +        }
    +        const sourceId = overlay.pointVertexIds.get(routingPointKey(attemptedStart.connect));
    +        const targetId = overlay.pointVertexIds.get(routingPointKey(attemptedEnd.connect));
    +        if (sourceId === undefined || targetId === undefined) {
    +          if (attemptIndex === 0) {
    +            appendAlternatives();
    +          }
    +          continue;
    +        }
    +        const route = findShortestRoute(overlay, sourceId, targetId, {
    +          metrics: context.metrics,
    +          caps: {
    +            ...options.searchCaps,
    +            maxEstimatedBytes:
    +              options.searchCaps?.maxEstimatedBytes ??
    +              options.topologyCaps?.maxEstimatedBytes ??
    +              DEFAULT_MAX_ROUTING_ESTIMATED_BYTES,
    +          },
    +          recordOutcome: false,
    +          budget: context.searchBudget,
    +          initialOrientation:
    +            attemptedStart.side === 'left' || attemptedStart.side === 'right' ? 'H' : 'V',
    +          initialSide: attemptedStart.side,
    +          initialLength:
    +            Math.abs(attemptedStart.port.x - attemptedStart.connect.x) +
    +            Math.abs(attemptedStart.port.y - attemptedStart.connect.y),
    +          targetOrientation:
    +            attemptedEnd.side === 'left' || attemptedEnd.side === 'right' ? 'H' : 'V',
    +          targetSide: attemptedEnd.side,
    +          targetLength:
    +            Math.abs(attemptedEnd.port.x - attemptedEnd.connect.x) +
    +            Math.abs(attemptedEnd.port.y - attemptedEnd.connect.y),
    +          topologyValidated: true,
    +          workspace: searchWorkspace,
    +          estimatedBytesBase: liveEstimatedBytes,
    +          arcAllowed:
    +            pairRoutes.length > 0 || avoidOccupied
    +              ? (from, to) =>
    +                  (pairRoutes.length === 0 || pairArcAllowed(from, to, pairRoutes)) &&
    +                  !(avoidOccupied && occupied!.index.conflictsWithArc(from, to, occupied!.pairKey))
    +              : undefined,
    +        });
    +        if (!route) {
    +          if (attemptIndex === 0) {
    +            appendAlternatives();
    +          }
    +          continue;
    +        }
    +        const points = normalizePolyline([
    +          attemptedStart.port,
    +          ...route.points,
    +          attemptedEnd.port,
    +        ]).points;
    +        if (
    +          !validateContainerSegment(
    +            points,
    +            attemptedStart.ownerId,
    +            attemptedEnd.ownerId,
    +            containerId,
    +            result
    +          ) ||
    +          !routeSatisfiesPairConstraints(points, pairRoutes) ||
    +          (avoidOccupied && occupied!.index.conflictsWithRoute(points, occupied!.pairKey))
    +        ) {
    +          if (attemptIndex === 0) {
    +            appendAlternatives();
    +          }
    +          continue;
    +        }
    +        for (const select of attempt.select) {
    +          select();
    +        }
    +        if (attemptIndex > 0 && context.metrics) {
    +          context.metrics.hierarchyPortalAlternativeSelections++;
    +        }
    +        return points;
    +      }
    +    }
    +  } catch (error) {
    +    if (!(error instanceof GridRoutingResourceLimitError)) {
    +      throw error;
    +    }
    +    return runValidatedFallback({
    +      context,
    +      reason: error.reason,
    +      edgeId,
    +      containerId,
    +      searchStateScope: error.searchStateScope,
    +      route: legacyRoute,
    +      isValid: (legacy) =>
    +        validateContainerSegment(legacy, start.ownerId, end.ownerId, containerId, result) &&
    +        routeSatisfiesPairConstraints(legacy, pairRoutes),
    +      includeContainerIdInError: true,
    +    });
    +  }
    +  const compatibility = legacyRoute();
    +  // Exhausted sparse geometry may recover through the corridor router. Bundled hierarchy segments
    +  // can share interior corridors only when their endpoint ports remain distinct.
    +  if (
    +    validateContainerSegment(compatibility, start.ownerId, end.ownerId, containerId, result) &&
    +    (routeSatisfiesPairConstraints(compatibility, pairRoutes) ||
    +      (allowSharedPairCorridors && routeHasDistinctPairPorts(compatibility, pairRoutes)))
    +  ) {
    +    if (context.metrics) {
    +      context.metrics.compatibilitySegments++;
    +      context.metrics.compatibilityRecoveries++;
    +    }
    +    return compatibility;
    +  }
    +  throw gridError(
    +    'GRID_ROUTE_NOT_FOUND',
    +    `No hierarchy route for "${edgeId}" in "${containerId}" from ${routingPointKey(start.connect)} to ${routingPointKey(end.connect)}`,
    +    { edgeId, containerId }
    +  );
    +}
    diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/grid/routerTopology.spec.ts b/packages/mermaid/src/rendering-util/layout-algorithms/grid/routerTopology.spec.ts
    new file mode 100644
    index 00000000000..8af7a660bdc
    --- /dev/null
    +++ b/packages/mermaid/src/rendering-util/layout-algorithms/grid/routerTopology.spec.ts
    @@ -0,0 +1,314 @@
    +import { describe, expect, it } from 'vitest';
    +import { compareCodeUnits } from '../layout-utils/helpers.js';
    +import { createGridRoutingInstrumentation } from './routerInstrumentation.js';
    +import {
    +  buildContainerRoutingTopology,
    +  buildPairedPortal,
    +  derivePortalRanges,
    +  routingPointKey,
    +} from './routerTopology.js';
    +import type { RouterPoint } from './types.js';
    +
    +function point(x: number, y: number): RouterPoint {
    +  return { x, y };
    +}
    +
    +function topology(
    +  obstacles: { id: string; left: number; right: number; top: number; bottom: number }[] = []
    +) {
    +  return buildContainerRoutingTopology({
    +    containerId: '__grid_root__',
    +    bounds: { left: 0, right: 100, top: 0, bottom: 100 },
    +    obstacles: obstacles.map(({ id, ...bounds }) => ({ id, bounds })),
    +  });
    +}
    +
    +describe('grid router topology', () => {
    +  it('normalizes negative zero in routing point keys', () => {
    +    expect(routingPointKey(point(-0, -0))).toBe('0:0');
    +  });
    +
    +  it('matches a brute-force obstacle union and interval oracle on randomized cases', () => {
    +    let state = 0x71ab_19e3;
    +    const random = () => {
    +      state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
    +      return state / 0x1_0000_0000;
    +    };
    +
    +    for (let caseIndex = 0; caseIndex < 500; caseIndex++) {
    +      const obstacles = Array.from({ length: 1 + Math.floor(random() * 12) }, (_, index) => {
    +        const left = Math.floor(random() * 80);
    +        const top = Math.floor(random() * 80);
    +        return {
    +          id: `o-${index}`,
    +          left,
    +          right: left + 1 + Math.floor(random() * 20),
    +          top,
    +          bottom: top + 1 + Math.floor(random() * 20),
    +        };
    +      });
    +      const result = topology(obstacles);
    +      const inflated = obstacles.map((obstacle) => ({
    +        left: Math.max(0, obstacle.left - 6),
    +        right: Math.min(100, obstacle.right + 6),
    +        top: Math.max(0, obstacle.top - 6),
    +        bottom: Math.min(100, obstacle.bottom + 6),
    +      }));
    +
    +      for (let query = 0; query < 50; query++) {
    +        const coordinate = Math.floor(random() * 101);
    +        const start = Math.floor(random() * 101);
    +        const end = Math.floor(random() * 101);
    +        const low = Math.min(start, end);
    +        const high = Math.max(start, end);
    +        const blocked = (orientation: 'H' | 'V') => {
    +          // A point probe on an outline is legal. A span is blocked only where the obstacle union
    +          // has interior on both sides of the sweep line.
    +          const isBoundary = inflated.some((obstacle) =>
    +            orientation === 'H'
    +              ? obstacle.top === coordinate || obstacle.bottom === coordinate
    +              : obstacle.left === coordinate || obstacle.right === coordinate
    +          );
    +          if (isBoundary && low === high) {
    +            return false;
    +          }
    +          const before = inflated.filter((obstacle) =>
    +            orientation === 'H'
    +              ? obstacle.top < coordinate && obstacle.bottom >= coordinate
    +              : obstacle.left < coordinate && obstacle.right >= coordinate
    +          );
    +          const after = inflated.filter((obstacle) =>
    +            orientation === 'H'
    +              ? obstacle.top <= coordinate && obstacle.bottom > coordinate
    +              : obstacle.left <= coordinate && obstacle.right > coordinate
    +          );
    +          return before.some((first) =>
    +            after.some((second) => {
    +              const firstLow = orientation === 'H' ? first.left : first.top;
    +              const firstHigh = orientation === 'H' ? first.right : first.bottom;
    +              const secondLow = orientation === 'H' ? second.left : second.top;
    +              const secondHigh = orientation === 'H' ? second.right : second.bottom;
    +              if (low === high) {
    +                return low > firstLow && low < firstHigh && low > secondLow && low < secondHigh;
    +              }
    +              return Math.max(low, firstLow, secondLow) < Math.min(high, firstHigh, secondHigh);
    +            })
    +          );
    +        };
    +        const horizontal = blocked('H');
    +        const vertical = blocked('V');
    +        expect(
    +          result.horizontalIntervals.intersects(coordinate, start, end),
    +          `horizontal case=${caseIndex} coordinate=${coordinate} range=${start}:${end}`
    +        ).toBe(horizontal);
    +        expect(
    +          result.verticalIntervals.intersects(coordinate, start, end),
    +          `vertical case=${caseIndex} coordinate=${coordinate} range=${start}:${end}`
    +        ).toBe(vertical);
    +      }
    +
    +      const rebuilt = topology([...obstacles].reverse());
    +      expect(rebuilt.obstacles).toEqual(result.obstacles);
    +      expect(rebuilt.vertices).toEqual(result.vertices);
    +      expect([...rebuilt.adjacency]).toEqual([...result.adjacency]);
    +    }
    +  }, 10_000);
    +
    +  it('uses inflated measured geometry and title exclusions as obstacles', () => {
    +    const result = buildContainerRoutingTopology({
    +      containerId: 'group',
    +      bounds: { left: 0, right: 120, top: 0, bottom: 100 },
    +      obstacles: [{ id: 'leaf', bounds: { left: 20, right: 40, top: 30, bottom: 50 } }],
    +      titleExclusions: [{ id: 'group-title', bounds: { left: 50, right: 90, top: 4, bottom: 14 } }],
    +    });
    +
    +    expect(result.obstacles).toEqual([
    +      { id: 'group-title', left: 44, right: 96, top: 0, bottom: 20 },
    +      { id: 'leaf', left: 14, right: 46, top: 24, bottom: 56 },
    +    ]);
    +    expect(result.horizontalIntervals.intersects(40, 15, 45)).toBe(true);
    +    expect(result.horizontalIntervals.intersects(70, 15, 45)).toBe(false);
    +    expect(result.verticalIntervals.intersects(30, 25, 55)).toBe(true);
    +  });
    +
    +  it('normalizes overlapping inflated obstacles into disjoint rectangles', () => {
    +    const result = topology([
    +      { id: 'b', left: 30, right: 60, top: 30, bottom: 60 },
    +      { id: 'a', left: 50, right: 75, top: 40, bottom: 70 },
    +    ]);
    +
    +    for (let first = 0; first < result.obstacles.length; first++) {
    +      for (let second = first + 1; second < result.obstacles.length; second++) {
    +        const a = result.obstacles[first];
    +        const b = result.obstacles[second];
    +        const overlapWidth = Math.min(a.right, b.right) - Math.max(a.left, b.left);
    +        const overlapHeight = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
    +        expect(overlapWidth > 0 && overlapHeight > 0).toBe(false);
    +      }
    +    }
    +  });
    +
    +  it('marks a shared boundary inside the obstacle union as blocked', () => {
    +    const result = topology([
    +      { id: 'left', left: 20, right: 44, top: 30, bottom: 70 },
    +      { id: 'right', left: 56, right: 80, top: 30, bottom: 70 },
    +    ]);
    +
    +    expect(result.obstacles).toEqual([{ id: 'left', left: 14, right: 86, top: 24, bottom: 76 }]);
    +    expect(result.verticalIntervals.intersects(50, 24, 76)).toBe(true);
    +  });
    +
    +  it('adds nearest-visible projections without Cartesian line intersections', () => {
    +    const result = topology([
    +      { id: 'center', left: 40, right: 60, top: 40, bottom: 60 },
    +      { id: 'lower-right', left: 75, right: 85, top: 70, bottom: 80 },
    +    ]);
    +    const points = result.vertices.map(({ point: value }) => value);
    +
    +    expect(points).toContainEqual(point(0, 34));
    +    expect(points).toContainEqual(point(34, 0));
    +    expect(points).not.toContainEqual(point(34, 64));
    +    expect(result.vertices.length).toBeLessThanOrEqual(5 * result.seedCount + 32);
    +    expect(result.adjacencyEntries).toBeLessThanOrEqual(4 * result.vertices.length);
    +  });
    +
    +  it('assigns stable exact ordinals and sorted immutable adjacency', () => {
    +    const forward = topology([
    +      { id: 'a', left: 10.25, right: 20.5, top: -0, bottom: 25.75 },
    +      { id: 'b', left: 70.125, right: 80.875, top: 65.5, bottom: 90.25 },
    +    ]);
    +    const reverse = topology([
    +      { id: 'b', left: 70.125, right: 80.875, top: 65.5, bottom: 90.25 },
    +      { id: 'a', left: 10.25, right: 20.5, top: -0, bottom: 25.75 },
    +    ]);
    +
    +    expect(reverse.vertices).toEqual(forward.vertices);
    +    expect([...reverse.adjacency]).toEqual([...forward.adjacency]);
    +    expect(JSON.stringify(forward.vertices)).not.toContain('-0');
    +    expect(Object.isFrozen(forward.vertices)).toBe(true);
    +    expect(() => (forward.adjacency as Map).set(999, [])).toThrowError();
    +
    +    for (const arcs of forward.adjacency.values()) {
    +      expect(arcs).toEqual(
    +        [...arcs].sort(
    +          (a, b) =>
    +            compareCodeUnits(a.kind, b.kind) ||
    +            compareCodeUnits(a.orientation, b.orientation) ||
    +            forward.vertices[a.to].point.x - forward.vertices[b.to].point.x ||
    +            forward.vertices[a.to].point.y - forward.vertices[b.to].point.y ||
    +            a.to - b.to
    +        )
    +      );
    +    }
    +  });
    +
    +  it('derives title-safe portal ranges with exact corner clearance', () => {
    +    const ranges = derivePortalRanges(
    +      'group',
    +      { left: 10, right: 110, top: 20, bottom: 100 },
    +      { left: 10, right: 110, top: 20, bottom: 42 }
    +    );
    +
    +    expect(ranges).toEqual([
    +      { ownerId: 'group', side: 'left', low: 48, high: 94 },
    +      { ownerId: 'group', side: 'right', low: 48, high: 94 },
    +      { ownerId: 'group', side: 'bottom', low: 16, high: 104 },
    +    ]);
    +  });
    +
    +  it('builds perpendicular paired portals exactly 6px inside and outside the frame', () => {
    +    const bounds = { left: 10, right: 110, top: 20, bottom: 100 };
    +    const title = { left: 10, right: 110, top: 20, bottom: 42 };
    +
    +    expect(buildPairedPortal('group', bounds, title, 'right', 48)).toEqual({
    +      ownerId: 'group',
    +      side: 'right',
    +      tangentialCoordinate: 48,
    +      interior: { x: 104, y: 48 },
    +      exterior: { x: 116, y: 48 },
    +      transition: {
    +        from: { x: 104, y: 48 },
    +        to: { x: 116, y: 48 },
    +        orientation: 'H',
    +        length: 12,
    +        kind: 'portal',
    +      },
    +    });
    +    expect(() => buildPairedPortal('group', bounds, title, 'right', 47)).toThrowError(
    +      /Illegal right portal/
    +    );
    +    expect(() => buildPairedPortal('group', bounds, title, 'top', 60)).toThrowError(
    +      /Illegal top portal/
    +    );
    +  });
    +
    +  it('reports repeatable topology counts for identical geometry', () => {
    +    const firstMetrics = createGridRoutingInstrumentation();
    +    const secondMetrics = createGridRoutingInstrumentation();
    +    const input = {
    +      containerId: '__grid_root__',
    +      bounds: { left: 0, right: 220, top: 0, bottom: 100 },
    +      obstacles: [
    +        { id: 'first', bounds: { left: 20, right: 80, top: 30, bottom: 70 } },
    +        { id: 'second', bounds: { left: 140, right: 200, top: 30, bottom: 70 } },
    +      ],
    +    };
    +
    +    const first = buildContainerRoutingTopology(input, { metrics: firstMetrics });
    +    const second = buildContainerRoutingTopology(input, { metrics: secondMetrics });
    +
    +    expect({
    +      vertices: first.vertices.length,
    +      adjacency: first.adjacencyEntries,
    +      events: firstMetrics.buildSweepEvents,
    +    }).toEqual({
    +      vertices: second.vertices.length,
    +      adjacency: second.adjacencyEntries,
    +      events: secondMetrics.buildSweepEvents,
    +    });
    +    expect(firstMetrics.baseTopologyBuilds).toBe(1);
    +    expect(firstMetrics.containersBuilt).toBe(1);
    +    expect(firstMetrics.estimatedBytes).toBeGreaterThan(0);
    +  });
    +
    +  it('counts strict boundary intervals and enforces their memory cap during construction', () => {
    +    const input = {
    +      containerId: '__grid_root__',
    +      bounds: { left: 0, right: 100, top: 0, bottom: 100 },
    +      obstacles: [
    +        { id: 'first', bounds: { left: 10, right: 30, top: 10, bottom: 40 } },
    +        { id: 'second', bounds: { left: 60, right: 80, top: 30, bottom: 70 } },
    +      ],
    +    };
    +
    +    const topology = buildContainerRoutingTopology(input);
    +
    +    expect(topology.horizontalIntervals.intervalCount).toBe(6);
    +    expect(topology.verticalIntervals.intervalCount).toBe(2);
    +    expect(() =>
    +      buildContainerRoutingTopology(input, { caps: { maxEstimatedBytes: 250 } })
    +    ).toThrowError(
    +      expect.objectContaining({
    +        reason: 'estimated_memory_cap',
    +        message: 'Grid routing estimated_memory_cap exceeded: 264 > 250',
    +      })
    +    );
    +  });
    +
    +  it.each([
    +    ['vertex_cap', { maxVertices: 1 }],
    +    ['adjacency_cap', { maxAdjacencyEntries: 1 }],
    +    ['estimated_memory_cap', { maxEstimatedBytes: 1 }],
    +  ] as const)('reports the deterministic %s resource cap', (reason, caps) => {
    +    expect(() =>
    +      buildContainerRoutingTopology(
    +        {
    +          containerId: '__grid_root__',
    +          bounds: { left: 0, right: 100, top: 0, bottom: 100 },
    +          obstacles: [{ id: 'center', bounds: { left: 40, right: 60, top: 40, bottom: 60 } }],
    +        },
    +        { caps }
    +      )
    +    ).toThrowError(expect.objectContaining({ reason }));
    +  });
    +});
    diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/grid/routerTopology.ts b/packages/mermaid/src/rendering-util/layout-algorithms/grid/routerTopology.ts
    new file mode 100644
    index 00000000000..ab3d8bb4edc
    --- /dev/null
    +++ b/packages/mermaid/src/rendering-util/layout-algorithms/grid/routerTopology.ts
    @@ -0,0 +1,1292 @@
    +import { compareCodeUnits } from '../layout-utils/helpers.js';
    +import {
    +  GridRoutingResourceLimitError,
    +  type GridRoutingInstrumentation,
    +} from './routerInstrumentation.js';
    +import type {
    +  ContainerRoutingTopology,
    +  GridOrientation,
    +  GridSide,
    +  OrthogonalIntervalIndex,
    +  PairedPortal,
    +  PortalRange,
    +  RouterArc,
    +  RouterObstacle,
    +  RouterPoint,
    +  RouterRect,
    +  RouterSearchArc,
    +  RouterVertex,
    +} from './types.js';
    +
    +// Builds immutable orthogonal visibility graphs per container. Endpoint-specific vertices are added
    +// through bounded overlays so the expensive obstacle sweep is paid once per container.
    +export const ROUTE_CLEARANCE_PX = 6;
    +
    +/**
    + * Parallel runs of different edges closer than this are hard to tell apart, so candidates avoid them.
    + * Perpendicular crossings are not affected. Lives here (rather than routerOccupancy.ts) because it is
    + * shared with routerPlanning.ts and routerConstraint.ts already imports from this module.
    + */
    +export const EDGE_CLEARANCE_PX = 24;
    +
    +// Caps bound both adversarial geometry and ordinary large-diagram memory growth. Exceeding one is a
    +// recoverable routing condition handled by the validated compatibility path.
    +const DEFAULT_MAX_VERTICES = 50_000;
    +const MAX_VISIBILITY_ADJACENCY_PER_VERTEX = 4;
    +export const DEFAULT_MAX_ROUTING_ESTIMATED_BYTES = 64 * 1024 * 1024;
    +
    +export interface RouterObstacleInput {
    +  id: string;
    +  bounds: RouterRect;
    +}
    +
    +export interface ContainerTopologyInput {
    +  containerId: string;
    +  bounds: RouterRect;
    +  obstacles: readonly RouterObstacleInput[];
    +  titleExclusions?: readonly RouterObstacleInput[];
    +  portalRanges?: readonly PortalRange[];
    +}
    +
    +export interface TopologyResourceCaps {
    +  maxVertices?: number;
    +  maxAdjacencyEntries?: number;
    +  maxEstimatedBytes?: number;
    +}
    +
    +export interface BuildTopologyOptions {
    +  metrics?: GridRoutingInstrumentation;
    +  caps?: TopologyResourceCaps;
    +}
    +
    +interface VertexRecord {
    +  point: RouterPoint;
    +  kind: RouterVertex['kind'];
    +  ownerId?: string;
    +  side?: GridSide;
    +}
    +
    +interface IndexedBand {
    +  low: number;
    +  high: number;
    +  intervals: readonly (readonly [number, number])[];
    +}
    +
    +class CompressedIntervalIndex implements OrthogonalIntervalIndex {
    +  // Obstacle occupancy is constant between sorted boundaries, so bands share merged interval lists.
    +  // Exact boundaries use strict intervals to allow visibility paths to run along obstacle edges.
    +  readonly coordinateCount: number;
    +  readonly intervalCount: number;
    +  private readonly byLow = new Map();
    +  private readonly byHigh = new Map();
    +
    +  constructor(
    +    private readonly bands: readonly IndexedBand[],
    +    private readonly strictBoundaryIntervals: ReadonlyMap<
    +      number,
    +      readonly (readonly [number, number])[]
    +    >
    +  ) {
    +    this.coordinateCount = new Set(bands.flatMap(({ low, high }) => [low, high])).size;
    +    let intervalCount = bands.reduce((total, band) => total + band.intervals.length, 0);
    +    for (const intervals of strictBoundaryIntervals.values()) {
    +      intervalCount += intervals.length;
    +    }
    +    this.intervalCount = intervalCount;
    +    for (const band of bands) {
    +      this.byLow.set(band.low, band);
    +      this.byHigh.set(band.high, band);
    +    }
    +    Object.freeze(this.bands);
    +    Object.freeze(this);
    +  }
    +
    +  private containingBand(coordinate: number): IndexedBand | undefined {
    +    let low = 0;
    +    let high = this.bands.length - 1;
    +    while (low <= high) {
    +      const middle = (low + high) >>> 1;
    +      const band = this.bands[middle];
    +      if (coordinate <= band.low) {
    +        high = middle - 1;
    +      } else if (coordinate >= band.high) {
    +        low = middle + 1;
    +      } else {
    +        return band;
    +      }
    +    }
    +    return undefined;
    +  }
    +
    +  private overlaps(
    +    intervals: readonly (readonly [number, number])[],
    +    low: number,
    +    high: number
    +  ): boolean {
    +    let left = 0;
    +    let right = intervals.length;
    +    while (left < right) {
    +      const middle = (left + right) >>> 1;
    +      if (intervals[middle][1] <= low) {
    +        left = middle + 1;
    +      } else {
    +        right = middle;
    +      }
    +    }
    +    return left < intervals.length && intervals[left][0] < high;
    +  }
    +
    +  intersects(coordinate: number, intervalStart: number, intervalEnd: number): boolean {
    +    const low = Math.min(intervalStart, intervalEnd);
    +    const high = Math.max(intervalStart, intervalEnd);
    +    const before = this.byHigh.get(coordinate);
    +    const after = this.byLow.get(coordinate);
    +    const containing = before || after ? undefined : this.containingBand(coordinate);
    +    if (containing) {
    +      return this.overlaps(containing.intervals, low, high);
    +    }
    +    if (!before || !after) {
    +      return false;
    +    }
    +    let beforeIndex = 0;
    +    let afterIndex = 0;
    +    while (beforeIndex < before.intervals.length && afterIndex < after.intervals.length) {
    +      const overlapLow = Math.max(
    +        low,
    +        before.intervals[beforeIndex][0],
    +        after.intervals[afterIndex][0]
    +      );
    +      const overlapHigh = Math.min(
    +        high,
    +        before.intervals[beforeIndex][1],
    +        after.intervals[afterIndex][1]
    +      );
    +      if (overlapLow < overlapHigh) {
    +        return true;
    +      }
    +      if (before.intervals[beforeIndex][1] < after.intervals[afterIndex][1]) {
    +        beforeIndex++;
    +      } else {
    +        afterIndex++;
    +      }
    +    }
    +    return false;
    +  }
    +
    +  contains(coordinate: number, varying: number): boolean {
    +    const intervals =
    +      this.strictBoundaryIntervals.get(coordinate) ?? this.containingBand(coordinate)?.intervals;
    +    if (!intervals) {
    +      return false;
    +    }
    +    let low = 0;
    +    let high = intervals.length;
    +    while (low < high) {
    +      const middle = (low + high) >>> 1;
    +      if (intervals[middle][0] < varying) {
    +        low = middle + 1;
    +      } else {
    +        high = middle;
    +      }
    +    }
    +    return low > 0 && varying < intervals[low - 1][1];
    +  }
    +
    +  nearestBoundary(coordinate: number, origin: number, direction: -1 | 1): number | undefined {
    +    const intervals =
    +      this.strictBoundaryIntervals.get(coordinate) ?? this.containingBand(coordinate)?.intervals;
    +    if (!intervals) {
    +      return undefined;
    +    }
    +    if (direction < 0) {
    +      let low = 0;
    +      let high = intervals.length;
    +      while (low < high) {
    +        const middle = (low + high) >>> 1;
    +        if (intervals[middle][1] <= origin) {
    +          low = middle + 1;
    +        } else {
    +          high = middle;
    +        }
    +      }
    +      return low > 0 ? intervals[low - 1][1] : undefined;
    +    }
    +    let low = 0;
    +    let high = intervals.length;
    +    while (low < high) {
    +      const middle = (low + high) >>> 1;
    +      if (intervals[middle][0] < origin) {
    +        low = middle + 1;
    +      } else {
    +        high = middle;
    +      }
    +    }
    +    return low < intervals.length ? intervals[low][0] : undefined;
    +  }
    +}
    +
    +class ImmutableMap implements ReadonlyMap {
    +  readonly [Symbol.toStringTag] = 'ImmutableMap';
    +
    +  constructor(private readonly backing: ReadonlyMap) {
    +    Object.freeze(this);
    +  }
    +
    +  get size(): number {
    +    return this.backing.size;
    +  }
    +
    +  entries(): MapIterator<[K, V]> {
    +    return this.backing.entries();
    +  }
    +
    +  forEach(callbackfn: (value: V, key: K, map: ReadonlyMap) => void, thisArg?: unknown): void {
    +    for (const [key, value] of this.backing) {
    +      callbackfn.call(thisArg, value, key, this);
    +    }
    +  }
    +
    +  get(key: K): V | undefined {
    +    return this.backing.get(key);
    +  }
    +
    +  has(key: K): boolean {
    +    return this.backing.has(key);
    +  }
    +
    +  keys(): MapIterator {
    +    return this.backing.keys();
    +  }
    +
    +  values(): MapIterator {
    +    return this.backing.values();
    +  }
    +
    +  [Symbol.iterator](): MapIterator<[K, V]> {
    +    return this.entries();
    +  }
    +}
    +
    +class OverlayMap implements ReadonlyMap {
    +  readonly [Symbol.toStringTag] = 'OverlayMap';
    +
    +  constructor(
    +    private readonly base: ReadonlyMap,
    +    private readonly overrides: ReadonlyMap
    +  ) {}
    +
    +  get size(): number {
    +    let added = 0;
    +    for (const key of this.overrides.keys()) {
    +      if (!this.base.has(key)) {
    +        added++;
    +      }
    +    }
    +    return this.base.size + added;
    +  }
    +
    +  private materialize(): Map {
    +    return new Map([...this.base, ...this.overrides]);
    +  }
    +
    +  entries(): MapIterator<[K, V]> {
    +    return this.materialize().entries();
    +  }
    +
    +  forEach(callbackfn: (value: V, key: K, map: ReadonlyMap) => void, thisArg?: unknown): void {
    +    for (const [key, value] of this) {
    +      callbackfn.call(thisArg, value, key, this);
    +    }
    +  }
    +
    +  get(key: K): V | undefined {
    +    return this.overrides.get(key) ?? this.base.get(key);
    +  }
    +
    +  has(key: K): boolean {
    +    return this.overrides.has(key) || this.base.has(key);
    +  }
    +
    +  keys(): MapIterator {
    +    return this.materialize().keys();
    +  }
    +
    +  values(): MapIterator {
    +    return this.materialize().values();
    +  }
    +
    +  [Symbol.iterator](): MapIterator<[K, V]> {
    +    return this.entries();
    +  }
    +}
    +
    +function overlayArray(base: readonly T[], additions: readonly T[]): readonly T[] {
    +  // Numeric access and iteration are enough for search. A proxy avoids copying the immutable base
    +  // array for every endpoint pair.
    +  return new Proxy([] as T[], {
    +    get(_target, property) {
    +      if (property === 'length') {
    +        return base.length + additions.length;
    +      }
    +      if (property === Symbol.iterator) {
    +        return function* () {
    +          yield* base;
    +          yield* additions;
    +        };
    +      }
    +      if (typeof property === 'string' && /^\d+$/.test(property)) {
    +        const index = Number(property);
    +        return index < base.length ? base[index] : additions[index - base.length];
    +      }
    +      return Reflect.get(Array.prototype, property);
    +    },
    +  });
    +}
    +
    +function overrideArray(
    +  base: readonly T[],
    +  overrides: ReadonlyMap,
    +  length: number
    +): readonly T[] {
    +  return new Proxy([] as T[], {
    +    get(_target, property) {
    +      if (property === 'length') {
    +        return length;
    +      }
    +      if (property === Symbol.iterator) {
    +        return function* () {
    +          for (let index = 0; index < length; index++) {
    +            yield overrides.get(index) ?? base[index];
    +          }
    +        };
    +      }
    +      if (typeof property === 'string' && /^\d+$/.test(property)) {
    +        const index = Number(property);
    +        return overrides.get(index) ?? base[index];
    +      }
    +      return Reflect.get(Array.prototype, property);
    +    },
    +  });
    +}
    +
    +export class EndpointOverlayScratch {
    +  // One scratch object belongs to one base topology. The undo log clears only overridden base
    +  // vertices, keeping reset cost proportional to the previous overlay rather than the whole graph.
    +  readonly addedVertices: RouterVertex[] = [];
    +  readonly adjacencyOverrides = new Map();
    +  readonly searchAdjacencyOverrides = new Map();
    +  readonly pointOverrides = new Map();
    +  readonly undoLog: number[] = [];
    +  resetCount = 0;
    +
    +  constructor(readonly base: ContainerRoutingTopology) {}
    +
    +  reset(base: ContainerRoutingTopology): void {
    +    if (base !== this.base) {
    +      throw new Error('Endpoint overlay scratch cannot be shared across base topologies');
    +    }
    +    for (const id of this.undoLog) {
    +      this.adjacencyOverrides.delete(id);
    +      this.searchAdjacencyOverrides.delete(id);
    +    }
    +    this.undoLog.length = 0;
    +    this.addedVertices.length = 0;
    +    this.pointOverrides.clear();
    +    this.resetCount++;
    +  }
    +
    +  setArcs(id: number, arcs: readonly RouterArc[]): void {
    +    if (!this.adjacencyOverrides.has(id)) {
    +      this.undoLog.push(id);
    +    }
    +    this.adjacencyOverrides.set(id, arcs);
    +    this.searchAdjacencyOverrides.set(id, searchArcs(arcs));
    +  }
    +}
    +
    +function normalizeCoordinate(value: number): number {
    +  return Object.is(value, -0) ? 0 : value;
    +}
    +
    +function normalizeRect(rect: RouterRect): RouterRect {
    +  const values = [rect.left, rect.right, rect.top, rect.bottom];
    +  if (!values.every(Number.isFinite)) {
    +    throw new Error('Grid routing geometry must contain only finite coordinates');
    +  }
    +  const left = normalizeCoordinate(Math.min(rect.left, rect.right));
    +  const right = normalizeCoordinate(Math.max(rect.left, rect.right));
    +  const top = normalizeCoordinate(Math.min(rect.top, rect.bottom));
    +  const bottom = normalizeCoordinate(Math.max(rect.top, rect.bottom));
    +  if (left === right || top === bottom) {
    +    // The production layout path should reject zero-area nodes before routing; this guards direct
    +    // topology callers and invalid derived geometry.
    +    throw new Error('Grid routing rectangles must have positive area');
    +  }
    +  return { left, right, top, bottom };
    +}
    +
    +function inflateAndClip(
    +  input: RouterObstacleInput,
    +  domain: RouterRect
    +): RouterObstacle | undefined {
    +  const bounds = normalizeRect(input.bounds);
    +  const obstacle = {
    +    id: input.id,
    +    left: Math.max(domain.left, bounds.left - ROUTE_CLEARANCE_PX),
    +    right: Math.min(domain.right, bounds.right + ROUTE_CLEARANCE_PX),
    +    top: Math.max(domain.top, bounds.top - ROUTE_CLEARANCE_PX),
    +    bottom: Math.min(domain.bottom, bounds.bottom + ROUTE_CLEARANCE_PX),
    +  };
    +  return obstacle.left < obstacle.right && obstacle.top < obstacle.bottom ? obstacle : undefined;
    +}
    +
    +function mergeObstacleIntervals(
    +  intervals: { low: number; high: number; id: string }[]
    +): { low: number; high: number; id: string }[] {
    +  const sorted = [...intervals].sort(
    +    (a, b) => a.low - b.low || a.high - b.high || compareCodeUnits(a.id, b.id)
    +  );
    +  const merged: { low: number; high: number; id: string }[] = [];
    +  for (const interval of sorted) {
    +    const previous = merged.at(-1);
    +    if (!previous || interval.low > previous.high) {
    +      merged.push({ ...interval });
    +      continue;
    +    }
    +    previous.high = Math.max(previous.high, interval.high);
    +    if (compareCodeUnits(interval.id, previous.id) < 0) {
    +      previous.id = interval.id;
    +    }
    +  }
    +  return merged;
    +}
    +
    +function unionObstacles(obstacles: readonly RouterObstacle[]): RouterObstacle[] {
    +  // Sweep x-slabs and merge active y-intervals into a non-overlapping obstacle union. This prevents
    +  // intersecting inflated nodes and title exclusions from multiplying visibility vertices.
    +  const events = new Map();
    +  for (const obstacle of obstacles) {
    +    const start = events.get(obstacle.left) ?? { starts: [], ends: [] };
    +    start.starts.push(obstacle);
    +    events.set(obstacle.left, start);
    +    const end = events.get(obstacle.right) ?? { starts: [], ends: [] };
    +    end.ends.push(obstacle);
    +    events.set(obstacle.right, end);
    +  }
    +  const xCoordinates = [...events.keys()].sort((a, b) => a - b);
    +  const active = new Set();
    +  const slabs: RouterObstacle[] = [];
    +  let previousByInterval = new Map();
    +  for (let index = 0; index < xCoordinates.length - 1; index++) {
    +    const left = xCoordinates[index];
    +    const right = xCoordinates[index + 1];
    +    const event = events.get(left)!;
    +    for (const obstacle of event.ends) {
    +      active.delete(obstacle);
    +    }
    +    for (const obstacle of event.starts) {
    +      active.add(obstacle);
    +    }
    +    const nextByInterval = new Map();
    +    for (const interval of mergeObstacleIntervals(
    +      [...active].map((obstacle) => ({
    +        low: obstacle.top,
    +        high: obstacle.bottom,
    +        id: obstacle.id,
    +      }))
    +    )) {
    +      const key = `${interval.low}:${interval.high}`;
    +      const previous = previousByInterval.get(key);
    +      if (previous) {
    +        previous.right = right;
    +        if (compareCodeUnits(interval.id, previous.id) < 0) {
    +          previous.id = interval.id;
    +        }
    +      } else {
    +        const slab = {
    +          id: interval.id,
    +          left,
    +          right,
    +          top: interval.low,
    +          bottom: interval.high,
    +        };
    +        slabs.push(slab);
    +        nextByInterval.set(key, slab);
    +        continue;
    +      }
    +      nextByInterval.set(key, previous);
    +    }
    +    previousByInterval = nextByInterval;
    +  }
    +  return slabs.sort(
    +    (a, b) =>
    +      a.top - b.top ||
    +      a.left - b.left ||
    +      a.bottom - b.bottom ||
    +      a.right - b.right ||
    +      compareCodeUnits(a.id, b.id)
    +  );
    +}
    +
    +export function routingPointKey(point: RouterPoint): string {
    +  return `${normalizeCoordinate(point.x)}:${normalizeCoordinate(point.y)}`;
    +}
    +
    +function topologySideOrdinal(side?: GridSide): number {
    +  return side === 'left'
    +    ? 0
    +    : side === 'right'
    +      ? 1
    +      : side === 'top'
    +        ? 2
    +        : side === 'bottom'
    +          ? 3
    +          : 4;
    +}
    +
    +function vertexRecordOrder(a: VertexRecord, b: VertexRecord): number {
    +  return (
    +    compareCodeUnits(a.kind, b.kind) ||
    +    topologySideOrdinal(a.side) - topologySideOrdinal(b.side) ||
    +    a.point.x - b.point.x ||
    +    a.point.y - b.point.y ||
    +    compareCodeUnits(a.ownerId ?? '', b.ownerId ?? '')
    +  );
    +}
    +
    +function canonicalRecords(records: readonly VertexRecord[]): VertexRecord[] {
    +  // Sorting before duplicate removal makes vertex identity and numeric ids independent of discovery
    +  // order.
    +  const byPoint = new Map();
    +  for (const record of [...records].sort(vertexRecordOrder)) {
    +    const normalized = {
    +      ...record,
    +      point: {
    +        x: normalizeCoordinate(record.point.x),
    +        y: normalizeCoordinate(record.point.y),
    +      },
    +    };
    +    const key = routingPointKey(normalized.point);
    +    if (!byPoint.has(key)) {
    +      byPoint.set(key, normalized);
    +    }
    +  }
    +  return [...byPoint.values()];
    +}
    +
    +function cornerRecords(
    +  bounds: RouterRect,
    +  ownerId: string,
    +  kind: RouterVertex['kind'] = 'corner'
    +): VertexRecord[] {
    +  return [
    +    { point: { x: bounds.left, y: bounds.top }, kind, ownerId },
    +    { point: { x: bounds.right, y: bounds.top }, kind, ownerId },
    +    { point: { x: bounds.left, y: bounds.bottom }, kind, ownerId },
    +    { point: { x: bounds.right, y: bounds.bottom }, kind, ownerId },
    +  ];
    +}
    +
    +function portalRecords(range: PortalRange, bounds: RouterRect): VertexRecord[] {
    +  const coordinate =
    +    range.coordinate ??
    +    (range.side === 'left'
    +      ? bounds.left
    +      : range.side === 'right'
    +        ? bounds.right
    +        : range.side === 'top'
    +          ? bounds.top
    +          : bounds.bottom);
    +  const points =
    +    range.side === 'left' || range.side === 'right'
    +      ? [
    +          { x: coordinate, y: range.low },
    +          { x: coordinate, y: range.high },
    +        ]
    +      : [
    +          { x: range.low, y: coordinate },
    +          { x: range.high, y: coordinate },
    +        ];
    +  return points.map((value) => ({
    +    point: value,
    +    kind: 'portal',
    +    ownerId: range.ownerId,
    +    side: range.side,
    +  }));
    +}
    +
    +function rayHit(
    +  point: RouterPoint,
    +  side: GridSide,
    +  bounds: RouterRect,
    +  horizontalIntervals: OrthogonalIntervalIndex,
    +  verticalIntervals: OrthogonalIntervalIndex
    +): RouterPoint {
    +  if (side === 'left' || side === 'right') {
    +    const hit =
    +      horizontalIntervals.nearestBoundary(point.y, point.x, side === 'left' ? -1 : 1) ??
    +      (side === 'left' ? bounds.left : bounds.right);
    +    return { x: hit, y: point.y };
    +  }
    +
    +  const hit =
    +    verticalIntervals.nearestBoundary(point.x, point.y, side === 'top' ? -1 : 1) ??
    +    (side === 'top' ? bounds.top : bounds.bottom);
    +  return { x: point.x, y: hit };
    +}
    +
    +function createIntervalIndex(
    +  obstacles: readonly RouterObstacle[],
    +  orientation: GridOrientation,
    +  budget: { intervalCount: number; maxEstimatedBytes: number }
    +): OrthogonalIntervalIndex {
    +  const coordinates = [
    +    ...new Set(
    +      obstacles.flatMap((obstacle) =>
    +        orientation === 'H' ? [obstacle.top, obstacle.bottom] : [obstacle.left, obstacle.right]
    +      )
    +    ),
    +  ].sort((a, b) => a - b);
    +  const bands: IndexedBand[] = [];
    +  const strictBoundaryIntervals = new Map();
    +  const starts = new Map();
    +  const ends = new Map();
    +  for (const obstacle of obstacles) {
    +    const start = orientation === 'H' ? obstacle.top : obstacle.left;
    +    const end = orientation === 'H' ? obstacle.bottom : obstacle.right;
    +    const startEvents = starts.get(start) ?? [];
    +    startEvents.push(obstacle);
    +    starts.set(start, startEvents);
    +    const endEvents = ends.get(end) ?? [];
    +    endEvents.push(obstacle);
    +    ends.set(end, endEvents);
    +  }
    +  const active = new Set();
    +  const activeIntervals = () =>
    +    mergeObstacleIntervals(
    +      [...active].map((obstacle) =>
    +        orientation === 'H'
    +          ? { low: obstacle.left, high: obstacle.right, id: obstacle.id }
    +          : { low: obstacle.top, high: obstacle.bottom, id: obstacle.id }
    +      )
    +    );
    +  const freezeIntervals = (
    +    intervals: readonly { low: number; high: number }[]
    +  ): readonly (readonly [number, number])[] => {
    +    const intervalCount = budget.intervalCount + intervals.length;
    +    enforceCap(
    +      estimatedTopologyBytes(0, 0, obstacles.length, intervalCount),
    +      budget.maxEstimatedBytes,
    +      'estimated_memory_cap'
    +    );
    +    budget.intervalCount = intervalCount;
    +    return Object.freeze(
    +      intervals.map((interval) => Object.freeze([interval.low, interval.high] as const))
    +    );
    +  };
    +  for (let index = 0; index < coordinates.length; index++) {
    +    const coordinate = coordinates[index];
    +    for (const obstacle of ends.get(coordinate) ?? []) {
    +      active.delete(obstacle);
    +    }
    +    strictBoundaryIntervals.set(coordinate, freezeIntervals(activeIntervals()));
    +    for (const obstacle of starts.get(coordinate) ?? []) {
    +      active.add(obstacle);
    +    }
    +    const high = coordinates[index + 1];
    +    if (high === undefined) {
    +      continue;
    +    }
    +    const intervals = activeIntervals();
    +    if (intervals.length > 0) {
    +      bands.push({
    +        low: coordinate,
    +        high,
    +        intervals: freezeIntervals(intervals),
    +      });
    +    }
    +  }
    +  return new CompressedIntervalIndex(
    +    Object.freeze(bands),
    +    new ImmutableMap(strictBoundaryIntervals)
    +  );
    +}
    +
    +function estimatedTopologyBytes(
    +  vertices: number,
    +  adjacencyEntries: number,
    +  obstacles: number,
    +  intervals: number
    +): number {
    +  return vertices * 64 + adjacencyEntries * 56 + obstacles * 48 + intervals * 24;
    +}
    +
    +function enforceCap(
    +  actual: number,
    +  maximum: number,
    +  reason: 'vertex_cap' | 'adjacency_cap' | 'estimated_memory_cap'
    +): void {
    +  if (actual > maximum) {
    +    throw new GridRoutingResourceLimitError(
    +      reason,
    +      `Grid routing ${reason} exceeded: ${actual} > ${maximum}`
    +    );
    +  }
    +}
    +
    +function indexVertexLines(
    +  vertices: readonly RouterVertex[],
    +  orientation: GridOrientation
    +): ReadonlyMap {
    +  const lines = new Map();
    +  for (const vertex of vertices) {
    +    const fixed = orientation === 'H' ? vertex.point.y : vertex.point.x;
    +    const line = lines.get(fixed) ?? [];
    +    line.push(vertex);
    +    lines.set(fixed, line);
    +  }
    +
    +  for (const line of lines.values()) {
    +    line.sort((a, b) =>
    +      orientation === 'H'
    +        ? a.point.x - b.point.x || a.id - b.id
    +        : a.point.y - b.point.y || a.id - b.id
    +    );
    +    Object.freeze(line);
    +  }
    +  return new ImmutableMap(lines);
    +}
    +
    +function searchArcs(arcs: readonly RouterArc[]): readonly RouterSearchArc[] {
    +  /*
    +   * Occupancy and crossing tie-breakers are left for future route-quality work.
    +   *
    +   * A minimal implementation could scan every committed route segment for every candidate arc:
    +   * - Sum collinear overlap into an occupied-length cost.
    +   * - Count perpendicular interior intersections as crossings.
    +   * - Pass those values into the search arcs.
    +   * - Recompute them after each committed route.
    +   *
    +   * That approach is simple, but likely too expensive: roughly routes x route segments x graph
    +   * arcs for each edge search. It also risks inconsistent handling of endpoints, terminal
    +   * segments, portals, and touching-versus-crossing geometry.
    +   *
    +   * A production-quality implementation would need:
    +   * - A container-scoped horizontal/vertical segment index.
    +   * - Efficient overlap and perpendicular-intersection queries.
    +   * - Dynamic search or overlay costs without mutating the immutable base topology.
    +   * - Updates only after route validation succeeds.
    +   * - Rollback support for bundle retries.
    +   * - Clear rules for endpoint touches, partial shared arcs, hierarchy segments, and routes that
    +   *   span multiple containers.
    +   * - Search tests proving occupancy changes only the intended late tie-breakers.
    +   * - Routing tests showing reduced sharing and crossings.
    +   * - Performance tests for the 500-edge contract.
    +   */
    +  return Object.freeze(
    +    arcs.map((arc) =>
    +      Object.freeze({
    +        to: arc.to,
    +        orientationOrdinal: arc.orientation === 'H' ? 1 : 2,
    +        length: arc.length,
    +        boundaryTransitions: arc.kind === 'portal' ? 1 : 0,
    +      } as const)
    +    )
    +  );
    +}
    +
    +function buildVisibilityGraph(
    +  records: readonly VertexRecord[],
    +  horizontalIntervals: OrthogonalIntervalIndex,
    +  verticalIntervals: OrthogonalIntervalIndex,
    +  caps: TopologyResourceCaps
    +): {
    +  vertices: readonly RouterVertex[];
    +  adjacency: ReadonlyMap;
    +  adjacencyEntries: number;
    +} {
    +  // Vertices connect only to their immediate visible neighbor on each shared x/y line; longer paths
    +  // are represented by graph traversal instead of quadratic all-pairs arcs.
    +  const canonical = canonicalRecords(records).sort(vertexRecordOrder);
    +  const vertexCap = caps.maxVertices ?? DEFAULT_MAX_VERTICES;
    +  enforceCap(canonical.length, vertexCap, 'vertex_cap');
    +  const vertices = canonical.map((record, id) =>
    +    Object.freeze({ id, ...record, point: Object.freeze({ ...record.point }) })
    +  );
    +  const adjacency = new Map(
    +    vertices.map((vertex) => [vertex.id, [] as RouterArc[]])
    +  );
    +  const addArcPair = (a: RouterVertex, b: RouterVertex, orientation: GridOrientation) => {
    +    const length = Math.abs(a.point.x - b.point.x) + Math.abs(a.point.y - b.point.y);
    +    if (length === 0) {
    +      return;
    +    }
    +
    +    const intervalStart =
    +      orientation === 'H' ? Math.min(a.point.x, b.point.x) : Math.min(a.point.y, b.point.y);
    +    const intervalEnd = intervalStart + length;
    +    for (const [from, to] of [
    +      [a, b],
    +      [b, a],
    +    ] as const) {
    +      adjacency.get(from.id)!.push({
    +        from: from.id,
    +        to: to.id,
    +        orientation,
    +        length,
    +        kind: 'visibility',
    +        intervalStart,
    +        intervalEnd,
    +      });
    +    }
    +  };
    +
    +  for (const orientation of ['H', 'V'] as const) {
    +    const lines = new Map();
    +    for (const vertex of vertices) {
    +      const coordinate = orientation === 'H' ? vertex.point.y : vertex.point.x;
    +      const line = lines.get(coordinate) ?? [];
    +      line.push(vertex);
    +      lines.set(coordinate, line);
    +    }
    +    for (const [fixed, line] of lines) {
    +      line.sort((a, b) =>
    +        orientation === 'H'
    +          ? a.point.x - b.point.x || a.id - b.id
    +          : a.point.y - b.point.y || a.id - b.id
    +      );
    +      for (let index = 1; index < line.length; index++) {
    +        const a = line[index - 1];
    +        const b = line[index];
    +        const start = orientation === 'H' ? a.point.x : a.point.y;
    +        const end = orientation === 'H' ? b.point.x : b.point.y;
    +        const intervalIndex = orientation === 'H' ? horizontalIntervals : verticalIntervals;
    +        if (!intervalIndex.intersects(fixed, start, end)) {
    +          addArcPair(a, b, orientation);
    +        }
    +      }
    +    }
    +  }
    +
    +  const arcKindOrder: Record = {
    +    visibility: 0,
    +    terminal: 1,
    +    portal: 2,
    +    lane: 3,
    +  };
    +  let adjacencyEntries = 0;
    +  const immutableAdjacency = new Map();
    +  for (const vertex of vertices) {
    +    const arcs = adjacency.get(vertex.id)!;
    +    arcs.sort(
    +      (a, b) =>
    +        arcKindOrder[a.kind] - arcKindOrder[b.kind] ||
    +        compareCodeUnits(a.orientation, b.orientation) ||
    +        vertices[a.to].point.x - vertices[b.to].point.x ||
    +        vertices[a.to].point.y - vertices[b.to].point.y ||
    +        a.to - b.to
    +    );
    +    const unique = arcs.filter(
    +      (arc, index) =>
    +        index === 0 ||
    +        arc.to !== arcs[index - 1].to ||
    +        arc.orientation !== arcs[index - 1].orientation ||
    +        arc.kind !== arcs[index - 1].kind
    +    );
    +    adjacencyEntries += unique.length;
    +    immutableAdjacency.set(vertex.id, Object.freeze(unique.map((arc) => Object.freeze(arc))));
    +  }
    +  enforceCap(
    +    adjacencyEntries,
    +    // Each vertex has at most one immediate visible neighbor in each cardinal direction. The
    +    // override remains a test seam for exercising adjacency-cap fallback behavior.
    +    caps.maxAdjacencyEntries ?? vertexCap * MAX_VISIBILITY_ADJACENCY_PER_VERTEX,
    +    'adjacency_cap'
    +  );
    +  return {
    +    vertices: Object.freeze(vertices),
    +    adjacency: new ImmutableMap(immutableAdjacency),
    +    adjacencyEntries,
    +  };
    +}
    +
    +export function derivePortalRanges(
    +  ownerId: string,
    +  rawBounds: RouterRect,
    +  rawTitle?: RouterRect
    +): PortalRange[] {
    +  const bounds = normalizeRect(rawBounds);
    +  const title = rawTitle ? normalizeRect(rawTitle) : undefined;
    +  const verticalLow = Math.max(
    +    bounds.top + ROUTE_CLEARANCE_PX,
    +    (title?.bottom ?? -Infinity) + ROUTE_CLEARANCE_PX
    +  );
    +  const verticalHigh = bounds.bottom - ROUTE_CLEARANCE_PX;
    +  const horizontalLow = bounds.left + ROUTE_CLEARANCE_PX;
    +  const horizontalHigh = bounds.right - ROUTE_CLEARANCE_PX;
    +  const ranges: PortalRange[] = [];
    +  if (verticalLow <= verticalHigh) {
    +    ranges.push(
    +      { ownerId, side: 'left', low: verticalLow, high: verticalHigh },
    +      { ownerId, side: 'right', low: verticalLow, high: verticalHigh }
    +    );
    +  }
    +  if (horizontalLow <= horizontalHigh) {
    +    ranges.push({ ownerId, side: 'bottom', low: horizontalLow, high: horizontalHigh });
    +    if (!title || title.bottom + ROUTE_CLEARANCE_PX <= bounds.top) {
    +      ranges.push({ ownerId, side: 'top', low: horizontalLow, high: horizontalHigh });
    +    }
    +  }
    +  return ranges;
    +}
    +
    +export function buildPairedPortal(
    +  ownerId: string,
    +  bounds: RouterRect,
    +  title: RouterRect | undefined,
    +  side: GridSide,
    +  tangentialCoordinate: number
    +): PairedPortal {
    +  // Interior and exterior points sit one clearance unit from the group boundary. The explicit
    +  // transition is the only hierarchy-crossing arc and contributes one boundary transition to cost.
    +  const range = derivePortalRanges(ownerId, bounds, title).find((entry) => entry.side === side);
    +  if (!range || tangentialCoordinate < range.low || tangentialCoordinate > range.high) {
    +    throw new Error(`Illegal ${side} portal for "${ownerId}" at ${tangentialCoordinate}`);
    +  }
    +  const boundary =
    +    side === 'left'
    +      ? bounds.left
    +      : side === 'right'
    +        ? bounds.right
    +        : side === 'top'
    +          ? bounds.top
    +          : bounds.bottom;
    +  const inward =
    +    side === 'left' || side === 'top'
    +      ? boundary + ROUTE_CLEARANCE_PX
    +      : boundary - ROUTE_CLEARANCE_PX;
    +  const outward =
    +    side === 'left' || side === 'top'
    +      ? boundary - ROUTE_CLEARANCE_PX
    +      : boundary + ROUTE_CLEARANCE_PX;
    +  const interior =
    +    side === 'left' || side === 'right'
    +      ? { x: inward, y: tangentialCoordinate }
    +      : { x: tangentialCoordinate, y: inward };
    +  const exterior =
    +    side === 'left' || side === 'right'
    +      ? { x: outward, y: tangentialCoordinate }
    +      : { x: tangentialCoordinate, y: outward };
    +  return {
    +    ownerId,
    +    side,
    +    tangentialCoordinate,
    +    interior,
    +    exterior,
    +    transition: {
    +      from: interior,
    +      to: exterior,
    +      orientation: side === 'left' || side === 'right' ? 'H' : 'V',
    +      length: ROUTE_CLEARANCE_PX * 2,
    +      kind: 'portal',
    +    },
    +  };
    +}
    +
    +export function buildContainerRoutingTopology(
    +  input: ContainerTopologyInput,
    +  options: BuildTopologyOptions = {}
    +): ContainerRoutingTopology {
    +  // Build phases are normalize/inflate, union obstacles, seed corners and portal endpoints, project
    +  // rays to blocking boundaries, then connect immediate line-of-sight neighbors.
    +  const bounds = normalizeRect(input.bounds);
    +  const inflated = [...input.obstacles, ...(input.titleExclusions ?? [])]
    +    .map((obstacle) => inflateAndClip(obstacle, bounds))
    +    .filter((obstacle): obstacle is RouterObstacle => Boolean(obstacle));
    +  const obstacles = unionObstacles(inflated);
    +  const portalRanges = [...(input.portalRanges ?? [])].sort(
    +    (a, b) =>
    +      topologySideOrdinal(a.side) - topologySideOrdinal(b.side) ||
    +      a.low - b.low ||
    +      a.high - b.high ||
    +      compareCodeUnits(a.ownerId, b.ownerId)
    +  );
    +
    +  const seeds = canonicalRecords([
    +    ...cornerRecords(bounds, input.containerId),
    +    ...obstacles.flatMap((obstacle) => cornerRecords(obstacle, obstacle.id)),
    +    ...portalRanges.flatMap((range) => portalRecords(range, bounds)),
    +  ]);
    +  const intervalBudget = {
    +    intervalCount: 0,
    +    maxEstimatedBytes: options.caps?.maxEstimatedBytes ?? DEFAULT_MAX_ROUTING_ESTIMATED_BYTES,
    +  };
    +  enforceCap(
    +    estimatedTopologyBytes(0, 0, obstacles.length, 0),
    +    intervalBudget.maxEstimatedBytes,
    +    'estimated_memory_cap'
    +  );
    +  const horizontalIntervals = createIntervalIndex(obstacles, 'H', intervalBudget);
    +  const verticalIntervals = createIntervalIndex(obstacles, 'V', intervalBudget);
    +  const projections: VertexRecord[] = [];
    +  const sides: readonly GridSide[] = ['left', 'right', 'top', 'bottom'];
    +  for (const seed of seeds) {
    +    for (const side of sides) {
    +      const hit = rayHit(seed.point, side, bounds, horizontalIntervals, verticalIntervals);
    +      if (hit.x !== seed.point.x || hit.y !== seed.point.y) {
    +        projections.push({ point: hit, kind: 'projection', side });
    +      }
    +    }
    +  }
    +
    +  const { vertices, adjacency, adjacencyEntries } = buildVisibilityGraph(
    +    [...seeds, ...projections],
    +    horizontalIntervals,
    +    verticalIntervals,
    +    options.caps ?? {}
    +  );
    +
    +  const estimatedBytes = estimatedTopologyBytes(
    +    vertices.length,
    +    adjacencyEntries,
    +    obstacles.length,
    +    horizontalIntervals.intervalCount + verticalIntervals.intervalCount
    +  );
    +  enforceCap(
    +    estimatedBytes,
    +    options.caps?.maxEstimatedBytes ?? DEFAULT_MAX_ROUTING_ESTIMATED_BYTES,
    +    'estimated_memory_cap'
    +  );
    +
    +  if (options.metrics) {
    +    options.metrics.containersBuilt++;
    +    options.metrics.baseTopologyBuilds++;
    +    options.metrics.baseVertices += vertices.length;
    +    options.metrics.baseAdjacencyEntries += adjacencyEntries;
    +    options.metrics.buildSweepEvents += seeds.length * 4 + obstacles.length * 4;
    +    options.metrics.estimatedBytes += estimatedBytes;
    +  }
    +
    +  return Object.freeze({
    +    containerId: input.containerId,
    +    bounds: Object.freeze(bounds),
    +    obstacles: Object.freeze(obstacles.map((obstacle) => Object.freeze(obstacle))),
    +    vertices: Object.freeze(vertices),
    +    pointVertexIds: new ImmutableMap(
    +      new Map(vertices.map(({ id, point }) => [routingPointKey(point), id]))
    +    ),
    +    horizontalVertexLines: indexVertexLines(vertices, 'H'),
    +    verticalVertexLines: indexVertexLines(vertices, 'V'),
    +    adjacency,
    +    adjacencyByVertex: Object.freeze(vertices.map(({ id }) => adjacency.get(id) ?? [])),
    +    searchAdjacencyByVertex: Object.freeze(
    +      vertices.map(({ id }) => searchArcs(adjacency.get(id) ?? []))
    +    ),
    +    adjacencyEntries,
    +    horizontalIntervals,
    +    verticalIntervals,
    +    portalRanges: Object.freeze(portalRanges.map((range) => Object.freeze(range))),
    +    seedCount: seeds.length,
    +    estimatedBytes,
    +  });
    +}
    +
    +function pointInsideObstacle(
    +  point: RouterPoint,
    +  horizontalIntervals: OrthogonalIntervalIndex
    +): boolean {
    +  return horizontalIntervals.contains(point.y, point.x);
    +}
    +
    +export function buildEndpointRoutingOverlay(
    +  base: ContainerRoutingTopology,
    +  source: RouterPoint,
    +  target: RouterPoint,
    +  metrics?: GridRoutingInstrumentation,
    +  reusableScratch?: EndpointOverlayScratch,
    +  lanePoints: readonly RouterPoint[] = []
    +): ContainerRoutingTopology {
    +  // Overlays are intentionally tiny: two endpoints, optional bundle lane points, orthogonal
    +  // projections, and four obstacle/bounds ray hits per endpoint. Local caps enforce that invariant.
    +  const scratch = reusableScratch ?? new EndpointOverlayScratch(base);
    +  scratch.reset(base);
    +  const endpointRecords: VertexRecord[] = [
    +    { point: source, kind: 'endpoint', ownerId: 'source' },
    +    { point: target, kind: 'endpoint', ownerId: 'target' },
    +    ...lanePoints
    +      .filter((point) => !pointInsideObstacle(point, base.horizontalIntervals))
    +      .map((point) => ({ point, kind: 'lane' as const })),
    +  ];
    +  for (const point of [
    +    { x: source.x, y: target.y },
    +    { x: target.x, y: source.y },
    +  ]) {
    +    if (!pointInsideObstacle(point, base.horizontalIntervals)) {
    +      endpointRecords.push({ point, kind: 'projection' });
    +    }
    +  }
    +  for (const record of endpointRecords.slice(0, 2)) {
    +    for (const side of ['left', 'right', 'top', 'bottom'] as const) {
    +      const hit = rayHit(
    +        record.point,
    +        side,
    +        base.bounds,
    +        base.horizontalIntervals,
    +        base.verticalIntervals
    +      );
    +      if (hit.x !== record.point.x || hit.y !== record.point.y) {
    +        endpointRecords.push({ point: hit, kind: 'projection', side });
    +      }
    +    }
    +  }
    +  const additions = canonicalRecords(endpointRecords)
    +    .filter(({ point }) => !base.pointVertexIds.has(routingPointKey(point)))
    +    .sort(vertexRecordOrder);
    +  enforceCap(additions.length, 32, 'vertex_cap');
    +  scratch.addedVertices.push(
    +    ...additions.map((record, index) =>
    +      Object.freeze({
    +        id: base.vertices.length + index,
    +        ...record,
    +        point: Object.freeze({ ...record.point }),
    +      })
    +    )
    +  );
    +  const vertices = overlayArray(base.vertices, scratch.addedVertices);
    +  const vertexAt = (id: number): RouterVertex =>
    +    id < base.vertices.length
    +      ? base.vertices[id]
    +      : scratch.addedVertices[id - base.vertices.length];
    +  const adjacency = new Map();
    +  const mutableArcs = (id: number): RouterArc[] => {
    +    let arcs = adjacency.get(id);
    +    if (!arcs) {
    +      arcs = [...(base.adjacency.get(id) ?? [])];
    +      adjacency.set(id, arcs);
    +    }
    +    return arcs;
    +  };
    +  for (const vertex of scratch.addedVertices) {
    +    adjacency.set(vertex.id, []);
    +  }
    +  const addedIds = new Set(scratch.addedVertices.map(({ id }) => id));
    +  const addedArcKeys = new Set();
    +  const connect = (a: RouterVertex, b: RouterVertex, orientation: GridOrientation): void => {
    +    const length = Math.abs(a.point.x - b.point.x) + Math.abs(a.point.y - b.point.y);
    +    if (length === 0) {
    +      return;
    +    }
    +    const intervalStart =
    +      orientation === 'H' ? Math.min(a.point.x, b.point.x) : Math.min(a.point.y, b.point.y);
    +    const intervalEnd = intervalStart + length;
    +    for (const [from, to] of [
    +      [a, b],
    +      [b, a],
    +    ] as const) {
    +      const key = `${from.id}:${to.id}:${orientation}:visibility`;
    +      if (
    +        addedArcKeys.has(key) ||
    +        (base.adjacency.get(from.id) ?? []).some(
    +          (arc) => arc.to === to.id && arc.orientation === orientation && arc.kind === 'visibility'
    +        )
    +      ) {
    +        continue;
    +      }
    +      addedArcKeys.add(key);
    +      mutableArcs(from.id).push({
    +        from: from.id,
    +        to: to.id,
    +        orientation,
    +        length,
    +        kind: 'visibility',
    +        intervalStart,
    +        intervalEnd,
    +      });
    +    }
    +  };
    +
    +  for (const orientation of ['H', 'V'] as const) {
    +    const baseLines = orientation === 'H' ? base.horizontalVertexLines : base.verticalVertexLines;
    +    const affected = new Set(
    +      scratch.addedVertices.map(({ point }) => (orientation === 'H' ? point.y : point.x))
    +    );
    +    for (const fixed of affected) {
    +      const line = [
    +        ...(baseLines.get(fixed) ?? []),
    +        ...scratch.addedVertices.filter(
    +          ({ point }) => (orientation === 'H' ? point.y : point.x) === fixed
    +        ),
    +      ];
    +      line.sort((a, b) =>
    +        orientation === 'H'
    +          ? a.point.x - b.point.x || a.id - b.id
    +          : a.point.y - b.point.y || a.id - b.id
    +      );
    +      for (let index = 1; index < line.length; index++) {
    +        const a = line[index - 1];
    +        const b = line[index];
    +        if (!addedIds.has(a.id) && !addedIds.has(b.id)) {
    +          continue;
    +        }
    +        const start = orientation === 'H' ? a.point.x : a.point.y;
    +        const end = orientation === 'H' ? b.point.x : b.point.y;
    +        const intervalIndex =
    +          orientation === 'H' ? base.horizontalIntervals : base.verticalIntervals;
    +        if (!intervalIndex.intersects(fixed, start, end)) {
    +          connect(a, b, orientation);
    +        }
    +      }
    +    }
    +  }
    +
    +  for (const [id, arcs] of adjacency) {
    +    arcs.sort(
    +      (a, b) =>
    +        compareCodeUnits(a.kind, b.kind) ||
    +        compareCodeUnits(a.orientation, b.orientation) ||
    +        vertexAt(a.to).point.x - vertexAt(b.to).point.x ||
    +        vertexAt(a.to).point.y - vertexAt(b.to).point.y ||
    +        a.to - b.to
    +    );
    +    const immutableArcs = Object.freeze(arcs.map((arc) => Object.freeze(arc)));
    +    scratch.setArcs(id, immutableArcs);
    +  }
    +  const adjacencyEntries = base.adjacencyEntries + addedArcKeys.size;
    +  enforceCap(adjacencyEntries, base.adjacencyEntries + 64, 'adjacency_cap');
    +  const addedVertices = additions.length;
    +  const estimatedBytes =
    +    base.estimatedBytes + addedVertices * 64 + (adjacencyEntries - base.adjacencyEntries) * 56;
    +  if (metrics) {
    +    metrics.endpointOverlayBuilds++;
    +    metrics.endpointOverlayVertices += addedVertices;
    +    metrics.estimatedBytes = Math.max(metrics.estimatedBytes, estimatedBytes);
    +  }
    +  const vertexCount = base.vertices.length + scratch.addedVertices.length;
    +  const adjacencyByVertex = overrideArray(
    +    base.adjacencyByVertex,
    +    scratch.adjacencyOverrides,
    +    vertexCount
    +  );
    +  const searchAdjacencyByVertex = overrideArray(
    +    base.searchAdjacencyByVertex,
    +    scratch.searchAdjacencyOverrides,
    +    vertexCount
    +  );
    +  for (const vertex of scratch.addedVertices) {
    +    scratch.pointOverrides.set(routingPointKey(vertex.point), vertex.id);
    +  }
    +  return Object.freeze({
    +    ...base,
    +    vertices,
    +    pointVertexIds: new OverlayMap(base.pointVertexIds, scratch.pointOverrides),
    +    adjacency: new OverlayMap(base.adjacency, scratch.adjacencyOverrides),
    +    adjacencyByVertex,
    +    searchAdjacencyByVertex,
    +    adjacencyEntries,
    +    estimatedBytes,
    +    vertexCount,
    +    getVertex: vertexAt,
    +    getSearchArcs: (id: number) =>
    +      scratch.searchAdjacencyOverrides.get(id) ?? base.searchAdjacencyByVertex[id] ?? [],
    +  });
    +}
    diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/grid/simple.ddlt.spec.ts b/packages/mermaid/src/rendering-util/layout-algorithms/grid/simple.ddlt.spec.ts
    new file mode 100644
    index 00000000000..1d66fcc8b80
    --- /dev/null
    +++ b/packages/mermaid/src/rendering-util/layout-algorithms/grid/simple.ddlt.spec.ts
    @@ -0,0 +1,11 @@
    +import { describe, expect, it } from 'vitest';
    +import { validateLayout } from '../layout-utils/validateLayout.js';
    +import { loadDdltFixture } from '../ddlt/loadDdltFixture.js';
    +
    +describe('DDLT grid — simple fixture', () => {
    +  it('validates the parser-backed simple fixture', { timeout: 20_000 }, async () => {
    +    const layout = await loadDdltFixture('grid/simple', { backendId: 'grid' });
    +    const report = validateLayout(layout);
    +    expect(report.ok).toBe(true);
    +  });
    +});
    diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/grid/testMatrix.ddlt.spec.ts b/packages/mermaid/src/rendering-util/layout-algorithms/grid/testMatrix.ddlt.spec.ts
    new file mode 100644
    index 00000000000..545093afe9c
    --- /dev/null
    +++ b/packages/mermaid/src/rendering-util/layout-algorithms/grid/testMatrix.ddlt.spec.ts
    @@ -0,0 +1,463 @@
    +import { resolve } from 'node:path';
    +import { beforeAll, describe, expect, it } from 'vitest';
    +import { addDiagrams } from '../../../diagram-api/diagram-orchestration.js';
    +import type { Edge, LayoutData, Node } from '../../types.js';
    +import { validateLayout } from '../layout-utils/validateLayout.js';
    +import { applyFixtureContentSizesStrict, loadFreshSizesFixture } from '../ddlt/fixtureSizes.js';
    +import { parseMmdFileToLayoutData } from '../ddlt/parseToLayoutData.js';
    +import { prepareGridLayout } from './edgeLabels.js';
    +import { runGridLayoutCore } from './layoutCore.js';
    +import {
    +  createGridRoutingInstrumentation,
    +  type GridRoutingInstrumentation,
    +} from './routerInstrumentation.js';
    +import type { GridItemLayoutMeta, GridLayoutResult } from './types.js';
    +import { normalizePolyline } from '../layout-utils/geometry.js';
    +
    +const FIXTURES_DIR = resolve(process.cwd(), 'e2e/platform/dev-diagrams/layout-tests/grid');
    +const CELL_AWARE_MAX_ESTIMATED_BYTES = 36_704;
    +
    +interface GridQualityBaseline {
    +  fixture: string;
    +  minScore?: number;
    +  maxBends: number;
    +  maxCrossings: number;
    +}
    +
    +const GRID_QUALITY_BASELINES: GridQualityBaseline[] = [
    +  { fixture: 'group-stack', minScore: 1000, maxBends: 0, maxCrossings: 0 },
    +  { fixture: 'placement-matrix-lr', minScore: 1000, maxBends: 0, maxCrossings: 0 },
    +  { fixture: 'placement-matrix-tb', minScore: 1000, maxBends: 0, maxCrossings: 0 },
    +  { fixture: 'routing-cell-aware-empty-cell', minScore: 995, maxBends: 2, maxCrossings: 0 },
    +  { fixture: 'routing-group-member', maxBends: 16, maxCrossings: 0 },
    +  { fixture: 'routing-hierarchy-portals', minScore: 1000, maxBends: 0, maxCrossings: 0 },
    +  { fixture: 'routing-loops-parallel-lr', minScore: 985, maxBends: 6, maxCrossings: 0 },
    +  { fixture: 'routing-outside-member', minScore: 1000, maxBends: 0, maxCrossings: 0 },
    +  { fixture: 'simple', minScore: 1000, maxBends: 0, maxCrossings: 0 },
    +  { fixture: 'singleton-alignments', minScore: 1000, maxBends: 0, maxCrossings: 0 },
    +  { fixture: 'stack-default', minScore: 1000, maxBends: 0, maxCrossings: 0 },
    +  { fixture: 'stack-gap-zero', minScore: 1000, maxBends: 0, maxCrossings: 0 },
    +];
    +
    +const H_ALIGN_FACTOR = {
    +  left: 0,
    +  center: 0.5,
    +  right: 1,
    +} as const;
    +
    +const V_ALIGN_FACTOR = {
    +  top: 0,
    +  center: 0.5,
    +  bottom: 1,
    +} as const;
    +
    +beforeAll(() => {
    +  addDiagrams();
    +});
    +
    +async function loadGridFixtureWithResult(
    +  name: string,
    +  metrics?: GridRoutingInstrumentation
    +): Promise<{
    +  layout: LayoutData;
    +  result: GridLayoutResult;
    +}> {
    +  const mmdPath = resolve(FIXTURES_DIR, `${name}.mmd`);
    +  const sizesPath = resolve(FIXTURES_DIR, `${name}.sizes.json`);
    +  const sizes = loadFreshSizesFixture(sizesPath, mmdPath, `grid/${name}`);
    +  const layout = await parseMmdFileToLayoutData(mmdPath, {
    +    stampFlowchartRendererFields: true,
    +  });
    +  prepareGridLayout(layout);
    +  applyFixtureContentSizesStrict(layout, sizes);
    +  const result = metrics ? runGridLayoutCore(layout, { metrics }) : runGridLayoutCore(layout);
    +  return { layout, result };
    +}
    +
    +function nodeById(layout: LayoutData, id: string): Node {
    +  const node = layout.nodes.find((item) => item.id === id);
    +  if (!node) {
    +    throw new Error(`Missing node "${id}"`);
    +  }
    +  return node;
    +}
    +
    +function itemMeta(result: GridLayoutResult, id: string): GridItemLayoutMeta {
    +  const meta = result.itemMeta.get(id);
    +  if (!meta) {
    +    throw new Error(`Missing item meta for "${id}"`);
    +  }
    +  return meta;
    +}
    +
    +function left(node: Node): number {
    +  return (node.x ?? 0) - (node.width ?? 0) / 2;
    +}
    +
    +function right(node: Node): number {
    +  return (node.x ?? 0) + (node.width ?? 0) / 2;
    +}
    +
    +function top(node: Node): number {
    +  return (node.y ?? 0) - (node.height ?? 0) / 2;
    +}
    +
    +function bottom(node: Node): number {
    +  return (node.y ?? 0) + (node.height ?? 0) / 2;
    +}
    +
    +function expectNodeAligned(
    +  node: Node,
    +  meta: GridItemLayoutMeta,
    +  horizontal: keyof typeof H_ALIGN_FACTOR,
    +  vertical: keyof typeof V_ALIGN_FACTOR
    +): void {
    +  expect(left(node)).toBeCloseTo(
    +    meta.cellLeft + (meta.cellWidth - (node.width ?? 0)) * H_ALIGN_FACTOR[horizontal],
    +    6
    +  );
    +  expect(top(node)).toBeCloseTo(
    +    meta.cellTop + (meta.cellHeight - (node.height ?? 0)) * V_ALIGN_FACTOR[vertical],
    +    6
    +  );
    +}
    +
    +function stackTopFor(
    +  nodes: Node[],
    +  meta: GridItemLayoutMeta,
    +  vertical: keyof typeof V_ALIGN_FACTOR,
    +  gap: number
    +) {
    +  const stackHeight =
    +    nodes.reduce((total, node) => total + (node.height ?? 0), 0) +
    +    gap * Math.max(0, nodes.length - 1);
    +  return meta.cellTop + (meta.cellHeight - stackHeight) * V_ALIGN_FACTOR[vertical];
    +}
    +
    +function sortByY(ids: string[], layout: LayoutData): string[] {
    +  return [...ids].sort((a, b) => (nodeById(layout, a).y ?? 0) - (nodeById(layout, b).y ?? 0));
    +}
    +
    +function primaryTrackCoordinate(edge: Edge): number {
    +  const segments = normalizePolyline(edge.points ?? []).segments;
    +  const primary = [...segments].sort((a, b) => {
    +    const aLength = Math.abs(a.a.x - a.b.x) + Math.abs(a.a.y - a.b.y);
    +    const bLength = Math.abs(b.a.x - b.b.x) + Math.abs(b.a.y - b.b.y);
    +    return bLength - aLength;
    +  })[0];
    +  if (!primary) {
    +    throw new Error(`Missing segments for edge "${edge.id}"`);
    +  }
    +  return primary.orientation === 'H' ? primary.a.y : primary.a.x;
    +}
    +
    +async function characterizeFixture(name: string) {
    +  const metrics = createGridRoutingInstrumentation();
    +  const { layout } = await loadGridFixtureWithResult(name, metrics);
    +  const validation = validateLayout(layout);
    +  const bends = layout.edges.reduce(
    +    (total, edge) => total + normalizePolyline(edge.points ?? []).bends,
    +    0
    +  );
    +
    +  return {
    +    valid: validation.ok,
    +    score: validation.score,
    +    bends,
    +    crossings: validation.breakdown.crossings,
    +    fallbacks: metrics.resourceLimitFallbacks,
    +    fallbackValidationFailures: metrics.fallbackValidationFailures,
    +    compatibilityValidationFailures: metrics.compatibilityValidationFailures,
    +  };
    +}
    +
    +describe('grid DDLT matrix fixtures', () => {
    +  it('routes through empty aligned cell space without a global-corridor detour', async () => {
    +    const metrics = createGridRoutingInstrumentation();
    +    const { layout } = await loadGridFixtureWithResult('routing-cell-aware-empty-cell', metrics);
    +    const routed = layout.edges.find((edge) => edge.start === 'v1' && edge.end === 'v2');
    +    const normalized = normalizePolyline(routed?.points ?? []);
    +
    +    expect(validateLayout(layout)).toMatchObject({ ok: true, issues: [] });
    +    expect(normalized.segments).toHaveLength(1);
    +    expect(normalized.segments[0]?.orientation).toBe('H');
    +    expect(normalized.points).toMatchInlineSnapshot(`
    +      [
    +        {
    +          "x": 180,
    +          "y": 27,
    +        },
    +        {
    +          "x": 460,
    +          "y": 27,
    +        },
    +      ]
    +    `);
    +    expect({
    +      baseVertices: metrics.baseVertices,
    +      baseAdjacencyEntries: metrics.baseAdjacencyEntries,
    +      endpointOverlayBuilds: metrics.endpointOverlayBuilds,
    +      endpointOverlayVertices: metrics.endpointOverlayVertices,
    +      searches: metrics.searches,
    +      expandedStates: metrics.expandedStates,
    +      maxOpenSet: metrics.maxOpenSet,
    +      resourceLimitFallbacks: metrics.resourceLimitFallbacks,
    +    }).toMatchInlineSnapshot(`
    +      {
    +        "baseAdjacencyEntries": 144,
    +        "baseVertices": 44,
    +        "endpointOverlayBuilds": 3,
    +        "endpointOverlayVertices": 32,
    +        "expandedStates": 12,
    +        "maxOpenSet": 8,
    +        "resourceLimitFallbacks": 0,
    +        "searches": 3,
    +      }
    +    `);
    +    expect(metrics.estimatedBytes).toBeLessThanOrEqual(CELL_AWARE_MAX_ESTIMATED_BYTES);
    +  });
    +
    +  it('routes hierarchy fixtures through exact paired boundary portals', async () => {
    +    const metrics = createGridRoutingInstrumentation();
    +    const { layout } = await loadGridFixtureWithResult('routing-hierarchy-portals', metrics);
    +    const transitions = Object.fromEntries(
    +      metrics.routes.map((route) => [route.edgeId, route.boundaryTransitionCount])
    +    );
    +
    +    expect(validateLayout(layout)).toMatchObject({ ok: true, issues: [] });
    +    expect(
    +      Object.fromEntries(
    +        layout.edges.map((edge) => [`${edge.start}->${edge.end}`, transitions[edge.id]])
    +      )
    +    ).toEqual({
    +      'SOURCE->TARGET': 3,
    +    });
    +    expect({
    +      baseTopologyBuilds: metrics.baseTopologyBuilds,
    +      hierarchyPortalPairs: metrics.hierarchyPortalPairs,
    +      hierarchyPortalTransitionLength: metrics.hierarchyPortalTransitionLength,
    +      hierarchyBoundaryTransitions: metrics.hierarchyBoundaryTransitions,
    +      resourceLimitFallbacks: metrics.resourceLimitFallbacks,
    +    }).toEqual({
    +      baseTopologyBuilds: 4,
    +      hierarchyPortalPairs: 3,
    +      hierarchyPortalTransitionLength: 36,
    +      hierarchyBoundaryTransitions: 3,
    +      resourceLimitFallbacks: 0,
    +    });
    +  });
    +
    +  it.each(GRID_QUALITY_BASELINES)(
    +    'keeps $fixture at or above its quality baseline',
    +    async ({ fixture, minScore, maxBends, maxCrossings }) => {
    +      const characterization = await characterizeFixture(fixture);
    +
    +      expect(characterization.valid).toBe(true);
    +      if (minScore !== undefined) {
    +        expect(characterization.score).toBeGreaterThanOrEqual(minScore);
    +      }
    +      expect(characterization.bends).toBeLessThanOrEqual(maxBends);
    +      expect(characterization.crossings).toBeLessThanOrEqual(maxCrossings);
    +      expect(characterization.fallbacks).toBe(0);
    +      expect(characterization.fallbackValidationFailures).toBe(0);
    +      expect(characterization.compatibilityValidationFailures).toBe(0);
    +    }
    +  );
    +
    +  it('covers partial/autoplacement, sparse tracks, disconnected nodes, and TB/LR parity', async () => {
    +    const tb = await loadGridFixtureWithResult('placement-matrix-tb');
    +    const lr = await loadGridFixtureWithResult('placement-matrix-lr');
    +
    +    const tbCoords = tb.layout.nodes.map((node) => [
    +      node.id,
    +      node.x,
    +      node.y,
    +      node.width,
    +      node.height,
    +    ]);
    +    const lrCoords = lr.layout.nodes.map((node) => [
    +      node.id,
    +      node.x,
    +      node.y,
    +      node.width,
    +      node.height,
    +    ]);
    +    expect(lrCoords).toEqual(tbCoords);
    +
    +    const a = nodeById(tb.layout, 'A');
    +    const b = nodeById(tb.layout, 'B');
    +    const c = nodeById(tb.layout, 'C');
    +    const d = nodeById(tb.layout, 'D');
    +    const e = nodeById(tb.layout, 'E');
    +    const f = nodeById(tb.layout, 'F');
    +    const g = nodeById(tb.layout, 'G');
    +
    +    expect(b.y).toBeCloseTo(a.y ?? 0, 6);
    +    expect(b.x ?? 0).toBeGreaterThan(a.x ?? 0);
    +    expect(c.y).toBeCloseTo(a.y ?? 0, 6);
    +    expect(c.x ?? 0).toBeGreaterThan(b.x ?? 0);
    +    expect(d.y ?? 0).toBeGreaterThan(a.y ?? 0);
    +    expect(e.y).toBeCloseTo(d.y ?? 0, 6);
    +    expect(e.x ?? 0).toBeGreaterThan(d.x ?? 0);
    +    expect(g.y).toBeCloseTo(d.y ?? 0, 6);
    +    expect(g.x ?? 0).toBeGreaterThan(e.x ?? 0);
    +    expect(f.y ?? 0).toBeGreaterThan(d.y ?? 0);
    +
    +    const dMeta = itemMeta(tb.result, 'D');
    +    const cMeta = itemMeta(tb.result, 'C');
    +    const fMeta = itemMeta(tb.result, 'F');
    +    expect(fMeta.cellTop - (dMeta.cellTop + dMeta.cellHeight)).toBeCloseTo(32, 6);
    +    expect(fMeta.cellLeft - (cMeta.cellLeft + cMeta.cellWidth)).toBeCloseTo(36, 6);
    +    expect(validateLayout(tb.layout)).toMatchObject({ ok: true, issues: [] });
    +  });
    +
    +  it('covers the full singleton alignment matrix', async () => {
    +    const { layout, result } = await loadGridFixtureWithResult('singleton-alignments');
    +
    +    for (const [id, horizontal, vertical] of [
    +      ['LT', 'left', 'top'],
    +      ['CT', 'center', 'top'],
    +      ['RT', 'right', 'top'],
    +      ['LC', 'left', 'center'],
    +      ['CC', 'center', 'center'],
    +      ['RC', 'right', 'center'],
    +      ['LB', 'left', 'bottom'],
    +      ['CB', 'center', 'bottom'],
    +      ['RB', 'right', 'bottom'],
    +    ] as const) {
    +      expectNodeAligned(nodeById(layout, id), itemMeta(result, id), horizontal, vertical);
    +    }
    +  });
    +
    +  it('covers default cellGap stacks, source order, mixed widths, and top/center/bottom stack alignment', async () => {
    +    const { layout, result } = await loadGridFixtureWithResult('stack-default');
    +    expect(validateLayout(layout)).toMatchObject({ ok: true, issues: [] });
    +
    +    const topIds = ['TopLeft', 'TopRight'] as const;
    +    const centerIds = ['CenterLeft', 'CenterRight'] as const;
    +    const bottomIds = ['BottomLeft', 'BottomCenter', 'BottomRight'] as const;
    +
    +    expect(sortByY([...topIds], layout)).toEqual([...topIds]);
    +    expect(sortByY([...centerIds], layout)).toEqual([...centerIds]);
    +    expect(sortByY([...bottomIds], layout)).toEqual([...bottomIds]);
    +
    +    const topMeta = itemMeta(result, 'TopLeft');
    +    const centerMeta = itemMeta(result, 'CenterLeft');
    +    const bottomMeta = itemMeta(result, 'BottomLeft');
    +
    +    const topNodes = topIds.map((id) => nodeById(layout, id));
    +    const centerNodes = centerIds.map((id) => nodeById(layout, id));
    +    const bottomNodes = bottomIds.map((id) => nodeById(layout, id));
    +
    +    expect(top(topNodes[0])).toBeCloseTo(stackTopFor(topNodes, topMeta, 'top', 20), 6);
    +    expect(top(centerNodes[0])).toBeCloseTo(stackTopFor(centerNodes, centerMeta, 'center', 20), 6);
    +    expect(top(bottomNodes[0])).toBeCloseTo(stackTopFor(bottomNodes, bottomMeta, 'bottom', 20), 6);
    +
    +    expect(top(topNodes[1]) - bottom(topNodes[0])).toBeCloseTo(20, 6);
    +    expect(top(centerNodes[1]) - bottom(centerNodes[0])).toBeCloseTo(20, 6);
    +    expect(top(bottomNodes[1]) - bottom(bottomNodes[0])).toBeCloseTo(20, 6);
    +    expect(top(bottomNodes[2]) - bottom(bottomNodes[1])).toBeCloseTo(20, 6);
    +
    +    expect(left(topNodes[0])).toBeCloseTo(topMeta.cellLeft, 6);
    +    expect(right(topNodes[1])).toBeCloseTo(topMeta.cellLeft + topMeta.cellWidth, 6);
    +    expect(left(centerNodes[0])).toBeCloseTo(centerMeta.cellLeft, 6);
    +    expect(right(centerNodes[1])).toBeCloseTo(centerMeta.cellLeft + centerMeta.cellWidth, 6);
    +    expect(left(bottomNodes[0])).toBeCloseTo(bottomMeta.cellLeft, 6);
    +    expect(left(bottomNodes[1])).toBeCloseTo(
    +      bottomMeta.cellLeft + (bottomMeta.cellWidth - (bottomNodes[1].width ?? 0)) / 2,
    +      6
    +    );
    +    expect(right(bottomNodes[2])).toBeCloseTo(bottomMeta.cellLeft + bottomMeta.cellWidth, 6);
    +  });
    +
    +  it('covers zero and custom cellGap, including nested groups as stack members', async () => {
    +    const zero = await loadGridFixtureWithResult('stack-gap-zero');
    +    const custom = await loadGridFixtureWithResult('group-stack');
    +
    +    const zeroA = nodeById(zero.layout, 'A');
    +    const zeroB = nodeById(zero.layout, 'B');
    +    expect(top(zeroB) - bottom(zeroA)).toBeCloseTo(0, 6);
    +
    +    const groupNode = nodeById(custom.layout, 'G');
    +    const customItems = ['A', 'G', 'B'].map((id) => nodeById(custom.layout, id));
    +    const ordered = [...customItems].sort((a, b) => (a.y ?? 0) - (b.y ?? 0));
    +    expect(groupNode.isGroup).toBe(true);
    +    expect(validateLayout(custom.layout)).toMatchObject({ ok: true, issues: [] });
    +    expect(top(ordered[1]) - bottom(ordered[0])).toBeCloseTo(12, 6);
    +    expect(top(ordered[2]) - bottom(ordered[1])).toBeCloseTo(12, 6);
    +    const inner = nodeById(custom.layout, 'G1');
    +    expect(left(inner)).toBeGreaterThan(left(groupNode));
    +    expect(right(inner)).toBeLessThan(right(groupNode));
    +    expect(top(inner)).toBeGreaterThan(top(groupNode));
    +    expect(bottom(inner)).toBeLessThan(bottom(groupNode));
    +  });
    +
    +  it('validates group/member fixtures and places the long HTML edge label', async () => {
    +    const groupMember = await loadGridFixtureWithResult('routing-group-member');
    +    const outsideMember = await loadGridFixtureWithResult('routing-outside-member');
    +
    +    expect(validateLayout(groupMember.layout)).toMatchObject({ ok: true, issues: [] });
    +    expect(validateLayout(outsideMember.layout)).toMatchObject({ ok: true, issues: [] });
    +
    +    const labelledEdge = groupMember.layout.edges.find(
    +      (edge) => edge.start === 'M' && edge.end === 'O'
    +    );
    +    const labelNode = groupMember.layout.nodes.find(
    +      (node) => node.id === labelledEdge?.labelNodeId
    +    );
    +    expect(labelledEdge?.labelNodeId).toBeDefined();
    +    expect(labelNode?.x).toEqual(expect.any(Number));
    +    expect(labelNode?.y).toEqual(expect.any(Number));
    +  });
    +
    +  it('covers self-loops, parallel/reverse edges, non-rect shapes, disconnected nodes, and LR routing', async () => {
    +    const metrics = createGridRoutingInstrumentation();
    +    const { layout } = await loadGridFixtureWithResult('routing-loops-parallel-lr', metrics);
    +    expect(validateLayout(layout)).toMatchObject({ ok: true, issues: [] });
    +
    +    const loopPoints = layout.edges
    +      .filter((edge) => edge.start === 'Loop' && edge.end === 'Loop')
    +      .map((edge) => JSON.stringify(edge.points));
    +    expect(new Set(loopPoints).size).toBe(3);
    +
    +    const trackCoords = layout.edges
    +      .filter(
    +        (edge) =>
    +          (edge.start === 'Source' && edge.end === 'Target') ||
    +          (edge.start === 'Target' && edge.end === 'Source')
    +      )
    +      .map((edge) => primaryTrackCoordinate(edge))
    +      .sort((a, b) => a - b);
    +    for (let index = 1; index < trackCoords.length; index++) {
    +      expect(trackCoords[index] - trackCoords[index - 1]).toBeGreaterThanOrEqual(8);
    +    }
    +    expect(metrics.routes.map(({ edgeId, laneOffset }) => [edgeId, laneOffset])).toEqual([
    +      ['L_Loop_Loop_0', -8],
    +      ['L_Loop_Loop_2', 0],
    +      ['L_Loop_Loop_3', 8],
    +      ['L_Source_Target_0', -12],
    +      ['L_Source_Target_2', -4],
    +      ['L_Source_Target_3', 4],
    +      ['L_Target_Source_0', 12],
    +    ]);
    +    expect({
    +      searches: metrics.searches,
    +      expandedStates: metrics.expandedStates,
    +      sharedLength: metrics.sharedLength,
    +      resourceLimitFallbacks: metrics.resourceLimitFallbacks,
    +    }).toMatchInlineSnapshot(`
    +      {
    +        "expandedStates": 30,
    +        "resourceLimitFallbacks": 0,
    +        "searches": 7,
    +        "sharedLength": 0,
    +      }
    +    `);
    +
    +    const source = nodeById(layout, 'Source');
    +    const target = nodeById(layout, 'Target');
    +    expect(source.width).toBeGreaterThan(nodeById(layout, 'Loop').width ?? 0);
    +    expect(target.width).toBeGreaterThan(nodeById(layout, 'Loop').width ?? 0);
    +    expect(layout.edges.some((edge) => edge.start === 'Lone' || edge.end === 'Lone')).toBe(false);
    +  });
    +});
    diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/grid/types.ts b/packages/mermaid/src/rendering-util/layout-algorithms/grid/types.ts
    new file mode 100644
    index 00000000000..00dbdb62fa0
    --- /dev/null
    +++ b/packages/mermaid/src/rendering-util/layout-algorithms/grid/types.ts
    @@ -0,0 +1,316 @@
    +import type { MermaidConfig } from '../../../config.type.js';
    +import type { GridHorizontalAlign, GridPlacement, GridVerticalAlign } from '../../../types.js';
    +import type { LayoutData, Node } from '../../types.js';
    +import type { Point } from '../../../types.js';
    +import { DEFAULT_EDGE_CORNER_RADIUS } from '../../edgeCornerRadius.js';
    +import type {
    +  GridRoutingFallbackReason,
    +  GridRoutingInstrumentation,
    +} from './routerInstrumentation.js';
    +import type { GridLabelRequirements } from './labelSpacing.js';
    +
    +// The root participates in the same container algorithms as groups but has no backing Node.
    +export const ROOT_CONTAINER_ID = '__grid_root__';
    +// Label helpers belong to edges and must not consume cells in the node-placement forest.
    +export const GRID_LABEL_PREFIX = 'edge-label-';
    +
    +export type GridContainerId = string;
    +export type GridSide = 'left' | 'right' | 'top' | 'bottom';
    +export type GridOrientation = 'H' | 'V';
    +export type GridCurve =
    +  | 'basis'
    +  | 'bumpX'
    +  | 'bumpY'
    +  | 'cardinal'
    +  | 'catmullRom'
    +  | 'linear'
    +  | 'monotoneX'
    +  | 'monotoneY'
    +  | 'natural'
    +  | 'step'
    +  | 'stepAfter'
    +  | 'stepBefore'
    +  | 'rounded';
    +export type RouterVertexId = number;
    +
    +// Routing uses a compact orthogonal visibility graph. The richer arc form supports validation and
    +// overlays; RouterSearchArc is the allocation-conscious representation consumed by hot searches.
    +export interface RouterPoint {
    +  x: number;
    +  y: number;
    +}
    +
    +export interface RouterRect {
    +  left: number;
    +  right: number;
    +  top: number;
    +  bottom: number;
    +}
    +
    +export interface RouterObstacle extends RouterRect {
    +  id: string;
    +}
    +
    +export interface PortalRange {
    +  ownerId: string;
    +  side: GridSide;
    +  low: number;
    +  high: number;
    +  coordinate?: number;
    +}
    +
    +// Crossing a group boundary is represented as two vertices plus one explicit transition. Keeping
    +// both sides of the boundary prevents a route from accidentally treating hierarchy as open space.
    +export interface PairedPortal {
    +  ownerId: string;
    +  side: GridSide;
    +  tangentialCoordinate: number;
    +  interior: RouterPoint;
    +  exterior: RouterPoint;
    +  transition: {
    +    from: RouterPoint;
    +    to: RouterPoint;
    +    orientation: GridOrientation;
    +    length: number;
    +    kind: 'portal';
    +  };
    +}
    +
    +export interface RouterVertex {
    +  id: RouterVertexId;
    +  point: RouterPoint;
    +  kind: 'corner' | 'projection' | 'portal' | 'endpoint' | 'lane';
    +  ownerId?: string;
    +  side?: GridSide;
    +}
    +
    +export interface RouterArc {
    +  from: RouterVertexId;
    +  to: RouterVertexId;
    +  orientation: GridOrientation;
    +  length: number;
    +  kind: 'visibility' | 'terminal' | 'portal' | 'lane';
    +  intervalStart: number;
    +  intervalEnd: number;
    +}
    +
    +export interface RouterSearchArc {
    +  to: RouterVertexId;
    +  orientationOrdinal: 1 | 2;
    +  length: number;
    +  boundaryTransitions: 0 | 1;
    +}
    +
    +export interface OrthogonalIntervalIndex {
    +  readonly coordinateCount: number;
    +  readonly intervalCount: number;
    +  intersects(coordinate: number, intervalStart: number, intervalEnd: number): boolean;
    +  contains(coordinate: number, varying: number): boolean;
    +  nearestBoundary(coordinate: number, origin: number, direction: -1 | 1): number | undefined;
    +}
    +
    +export interface ContainerRoutingTopology {
    +  // Base topologies are immutable and shared by all edges in a container. Optional accessors expose
    +  // lightweight endpoint overlays without copying the base vertex and adjacency arrays.
    +  containerId: GridContainerId;
    +  bounds: RouterRect;
    +  obstacles: readonly RouterObstacle[];
    +  vertices: readonly RouterVertex[];
    +  pointVertexIds: ReadonlyMap;
    +  horizontalVertexLines: ReadonlyMap;
    +  verticalVertexLines: ReadonlyMap;
    +  adjacency: ReadonlyMap;
    +  adjacencyByVertex: readonly (readonly RouterArc[])[];
    +  searchAdjacencyByVertex: readonly (readonly RouterSearchArc[])[];
    +  adjacencyEntries: number;
    +  horizontalIntervals: OrthogonalIntervalIndex;
    +  verticalIntervals: OrthogonalIntervalIndex;
    +  portalRanges: readonly PortalRange[];
    +  seedCount: number;
    +  estimatedBytes: number;
    +  readonly vertexCount?: number;
    +  getVertex?(id: RouterVertexId): RouterVertex | undefined;
    +  getSearchArcs?(id: RouterVertexId): readonly RouterSearchArc[];
    +}
    +
    +export interface GridRoutingContext {
    +  // Containers that exceed a topology cap are recorded here and routed through the validated
    +  // compatibility path; resource exhaustion is degradation, not permission to emit invalid paths.
    +  topologies: Map;
    +  fallbackContainers: Map;
    +  searchBudget: { expandedStates: number };
    +  searchBudgetWarningEmitted: boolean;
    +  baseEstimatedBytes: number;
    +  metrics?: GridRoutingInstrumentation;
    +}
    +
    +export interface GridLayoutConfigNormalized {
    +  placements: Map;
    +  columns: number;
    +  rowGap: number;
    +  columnGap: number;
    +  cellGap: number;
    +  containerPadding: number;
    +  titleGap: number;
    +  horizontalAlign: GridHorizontalAlign;
    +  verticalAlign: GridVerticalAlign;
    +  // Routing publishes these values on each edge so shared rendering and line hops use identical
    +  // corner geometry without depending on grid internals.
    +  curve: GridCurve;
    +  edgeCornerRadius: number;
    +}
    +
    +export interface GridResolvedPlacement {
    +  item: Node;
    +  row: number;
    +  column: number;
    +  horizontalAlign: GridHorizontalAlign;
    +  verticalAlign: GridVerticalAlign;
    +  sourceOrder: number;
    +  explicitRow: boolean;
    +  explicitColumn: boolean;
    +  explicitCell: boolean;
    +}
    +
    +// Multiple nodes may intentionally share a cell; they are laid out as one vertical stack.
    +export interface GridCellStack {
    +  row: number;
    +  column: number;
    +  items: GridResolvedPlacement[];
    +  width: number;
    +  height: number;
    +  verticalAlign: GridVerticalAlign;
    +}
    +
    +export interface GridItemLayoutMeta {
    +  containerId: GridContainerId;
    +  row: number;
    +  column: number;
    +  cellLeft: number;
    +  cellTop: number;
    +  cellWidth: number;
    +  cellHeight: number;
    +  leftCorridorX: number;
    +  rightCorridorX: number;
    +  topCorridorY: number;
    +  bottomCorridorY: number;
    +}
    +
    +export interface GridContainerLayoutMeta {
    +  id: GridContainerId;
    +  left: number;
    +  top: number;
    +  width: number;
    +  height: number;
    +  contentLeft: number;
    +  contentTop: number;
    +  contentRight: number;
    +  contentBottom: number;
    +  // Corridors describe empty track boundaries for the routing layer added by the next stack PR.
    +  verticalCorridors: number[];
    +  horizontalCorridors: number[];
    +}
    +
    +export interface GridForest {
    +  nodeById: Map;
    +  groupById: Map;
    +  childrenByParent: Map;
    +  rootChildren: Node[];
    +  // Children-first ordering lets parent groups measure already-sized nested groups.
    +  postOrderGroups: (Node & { isGroup: true })[];
    +  helperNodeIds: Set;
    +}
    +
    +export interface GridAttachmentDemand {
    +  // demandKey identifies one edge endpoint at one hierarchy boundary and remains stable across
    +  // retries so portal coordinates can be captured and restored transactionally.
    +  ownerId: string;
    +  side: GridSide;
    +  edgeId: string;
    +  demandKey: string;
    +  oppositeCoord: number;
    +  preferredCoord: number;
    +  compactPortal: boolean;
    +  // The next entry inward along the endpoint chain; a compact portal lines up with its port.
    +  inner?: { demandKey: string; side: GridSide };
    +}
    +
    +export interface GridAttachment {
    +  port: Point;
    +  connect: Point;
    +  orientation: GridOrientation;
    +  side: GridSide;
    +}
    +
    +export interface GridLayoutResult {
    +  forest: GridForest;
    +  config: GridLayoutConfigNormalized;
    +  containers: Map;
    +  itemMeta: Map;
    +  sourceOrder: Map;
    +  labelRequirements: GridLabelRequirements;
    +}
    +
    +export interface GridError extends Error {
    +  code: GridErrorCode;
    +  details?: Record;
    +}
    +
    +export type GridErrorCode =
    +  | 'GRID_INVALID_COORDINATE'
    +  | 'GRID_CELL_ALIGNMENT_CONFLICT'
    +  | 'GRID_INVALID_CONTAINMENT'
    +  | 'GRID_MISSING_MEASUREMENT'
    +  | 'GRID_MISSING_ENDPOINT'
    +  | 'GRID_ROUTE_NOT_FOUND';
    +
    +export function gridError(
    +  code: GridErrorCode,
    +  message: string,
    +  details?: Record
    +): GridError {
    +  const error = new Error(`${code}: ${message}`) as GridError;
    +  error.code = code;
    +  error.details = details;
    +  return error;
    +}
    +
    +export const GRID_DEFAULTS = {
    +  columns: 0,
    +  rowGap: 50,
    +  columnGap: 50,
    +  cellGap: 20,
    +  containerPadding: 20,
    +  titleGap: 8,
    +  horizontalAlign: 'center' as GridHorizontalAlign,
    +  verticalAlign: 'center' as GridVerticalAlign,
    +  curve: 'rounded' as GridCurve,
    +  edgeCornerRadius: DEFAULT_EDGE_CORNER_RADIUS,
    +};
    +
    +export type GridLayoutData = LayoutData & {
    +  // Use the public config contract here so grid does not grow a parallel internal config schema.
    +  config: MermaidConfig & {
    +    grid?: {
    +      placements?: Record;
    +      columns?: number;
    +      rowGap?: number;
    +      columnGap?: number;
    +      cellGap?: number;
    +      containerPadding?: number;
    +      titleGap?: number;
    +      horizontalAlign?: GridHorizontalAlign;
    +      verticalAlign?: GridVerticalAlign;
    +      curve?: GridCurve;
    +      edgeCornerRadius?: number;
    +    };
    +  };
    +};
    +
    +export function isEdgeLabelNode(node: Node): boolean {
    +  return Boolean((node as { isEdgeLabel?: boolean }).isEdgeLabel);
    +}
    +
    +export function isFinitePositiveNumber(value: unknown): value is number {
    +  return typeof value === 'number' && Number.isFinite(value) && value > 0;
    +}
    diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/layout-utils/geometry.ts b/packages/mermaid/src/rendering-util/layout-algorithms/layout-utils/geometry.ts
    index 0c09e8ddc2f..fa39bc2804f 100644
    --- a/packages/mermaid/src/rendering-util/layout-algorithms/layout-utils/geometry.ts
    +++ b/packages/mermaid/src/rendering-util/layout-algorithms/layout-utils/geometry.ts
    @@ -10,8 +10,8 @@ import type { Point } from './types.js';
     // Constants
     // ─────────────────────────────────────────────────────────────────────────────
     
    -/** General epsilon for coordinate comparisons */
    -export const EPS = 1;
    +/** Canonical one-pixel tolerance shared by routing geometry and final layout validation. */
    +export const PIXEL_EPSILON = 1;
     
     // ─────────────────────────────────────────────────────────────────────────────
     // Segment representation for geometry processing
    @@ -37,10 +37,10 @@ export interface NormalizedPolyline {
     export function segmentOrientation(a: Point, b: Point): 'H' | 'V' | 'Z' {
       const dx = Math.abs(b.x - a.x);
       const dy = Math.abs(b.y - a.y);
    -  if (dx <= EPS && dy <= EPS) {
    +  if (dx <= PIXEL_EPSILON && dy <= PIXEL_EPSILON) {
         return 'Z';
       }
    -  if (dy <= EPS) {
    +  if (dy <= PIXEL_EPSILON) {
         return 'H';
       }
       return 'V';
    @@ -79,8 +79,8 @@ export function mergeCollinear(segments: Segment[]): Segment[] {
         if (current.orientation === next.orientation) {
           if (
             current.orientation === 'H' &&
    -        Math.abs(current.b.y - next.a.y) <= EPS &&
    -        Math.abs(current.a.y - next.a.y) <= EPS
    +        Math.abs(current.b.y - next.a.y) <= PIXEL_EPSILON &&
    +        Math.abs(current.a.y - next.a.y) <= PIXEL_EPSILON
           ) {
             // Extend horizontal segment
             current.b = next.b;
    @@ -88,8 +88,8 @@ export function mergeCollinear(segments: Segment[]): Segment[] {
           }
           if (
             current.orientation === 'V' &&
    -        Math.abs(current.b.x - next.a.x) <= EPS &&
    -        Math.abs(current.a.x - next.a.x) <= EPS
    +        Math.abs(current.b.x - next.a.x) <= PIXEL_EPSILON &&
    +        Math.abs(current.a.x - next.a.x) <= PIXEL_EPSILON
           ) {
             // Extend vertical segment
             current.b = next.b;
    diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/layout-utils/helpers.spec.ts b/packages/mermaid/src/rendering-util/layout-algorithms/layout-utils/helpers.spec.ts
    index 1503586db3f..00c209401a2 100644
    --- a/packages/mermaid/src/rendering-util/layout-algorithms/layout-utils/helpers.spec.ts
    +++ b/packages/mermaid/src/rendering-util/layout-algorithms/layout-utils/helpers.spec.ts
    @@ -1,7 +1,20 @@
     import { describe, it, expect } from 'vitest';
    -import { clamp, pointInsideAnyRectInterior } from './helpers.js';
    +import type { Edge } from '../../types.js';
    +import {
    +  clamp,
    +  compareCodeUnits,
    +  hasTerminalMarker,
    +  orthogonalPolylineContainsPoint,
    +  pointInsideAnyRectInterior,
    +  terminalMarkerClearanceRect,
    +} from './helpers.js';
     
     describe('domus/core/helpers', () => {
    +  it('compares strings by locale-independent UTF-16 code-unit order', () => {
    +    expect(['ä', 'a', 'A', 'aa'].sort(compareCodeUnits)).toEqual(['A', 'a', 'aa', 'ä']);
    +    expect(compareCodeUnits('same', 'same')).toBe(0);
    +  });
    +
       it('clamp clamps to inclusive bounds', () => {
         expect(clamp(0, 1, 2)).toBe(1);
         expect(clamp(1, 1, 2)).toBe(1);
    @@ -19,4 +32,116 @@ describe('domus/core/helpers', () => {
         expect(pointInsideAnyRectInterior({ x: 20, y: 5 } as any, rects)).toBe(false); // boundary
         expect(pointInsideAnyRectInterior({ x: 15, y: 5 } as any, rects)).toBe(false);
       });
    +
    +  it('requires an orthogonal polyline to pass through the requested point', () => {
    +    expect(
    +      orthogonalPolylineContainsPoint(
    +        [
    +          { x: 0, y: 10 },
    +          { x: 20, y: 10 },
    +        ],
    +        { x: 10, y: 10 }
    +      )
    +    ).toBe(true);
    +    expect(
    +      orthogonalPolylineContainsPoint(
    +        [
    +          { x: 0, y: 0 },
    +          { x: 0, y: 20 },
    +        ],
    +        { x: 0, y: 10 }
    +      )
    +    ).toBe(true);
    +    expect(
    +      orthogonalPolylineContainsPoint(
    +        [
    +          { x: 0, y: 0 },
    +          { x: 20, y: 0 },
    +        ],
    +        { x: 10, y: 1 }
    +      )
    +    ).toBe(false);
    +    expect(
    +      orthogonalPolylineContainsPoint(
    +        [
    +          { x: 0, y: 0 },
    +          { x: 10, y: 10 },
    +        ],
    +        { x: 5, y: 5 }
    +      )
    +    ).toBe(false);
    +  });
    +
    +  it('builds terminal marker clearance inward from the terminal tip', () => {
    +    expect(
    +      terminalMarkerClearanceRect(
    +        [
    +          { x: 20, y: 10 },
    +          { x: 0, y: 10 },
    +        ],
    +        'start',
    +        12,
    +        7
    +      )
    +    ).toMatchObject({ left: 8, right: 20, top: 3, bottom: 17 });
    +    expect(
    +      terminalMarkerClearanceRect(
    +        [
    +          { x: 10, y: 20 },
    +          { x: 10, y: 0 },
    +        ],
    +        'start',
    +        12,
    +        7
    +      )
    +    ).toMatchObject({ left: 3, right: 17, top: 8, bottom: 20 });
    +    expect(
    +      terminalMarkerClearanceRect(
    +        [
    +          { x: 20, y: 10 },
    +          { x: 0, y: 10 },
    +        ],
    +        'end',
    +        12,
    +        7
    +      )
    +    ).toMatchObject({ left: 0, right: 12, top: 3, bottom: 17 });
    +    expect(
    +      terminalMarkerClearanceRect(
    +        [
    +          { x: 10, y: 20 },
    +          { x: 10, y: 0 },
    +        ],
    +        'end',
    +        12,
    +        7
    +      )
    +    ).toMatchObject({ left: 3, right: 17, top: 0, bottom: 12 });
    +  });
    +
    +  it('detects only terminals that render markers', () => {
    +    const edge = {
    +      arrowTypeStart: 'none',
    +      arrowTypeEnd: 'arrow_point',
    +      type: 'arrow_point',
    +    } as Edge;
    +
    +    expect(hasTerminalMarker(edge, 'start')).toBe(false);
    +    expect(hasTerminalMarker(edge, 'end')).toBe(true);
    +    expect(
    +      hasTerminalMarker({ ...edge, arrowTypeEnd: 'arrow_open', type: undefined } as Edge, 'end')
    +    ).toBe(false);
    +    expect(
    +      hasTerminalMarker(
    +        { ...edge, arrowTypeStart: 'none', type: 'double_arrow_point' } as Edge,
    +        'start'
    +      )
    +    ).toBe(false);
    +    expect(
    +      hasTerminalMarker(
    +        { ...edge, arrowTypeStart: undefined, type: 'double_arrow_point' } as Edge,
    +        'start'
    +      )
    +    ).toBe(true);
    +  });
     });
    diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/layout-utils/helpers.ts b/packages/mermaid/src/rendering-util/layout-algorithms/layout-utils/helpers.ts
    index 9157ca8c844..2db5961190a 100644
    --- a/packages/mermaid/src/rendering-util/layout-algorithms/layout-utils/helpers.ts
    +++ b/packages/mermaid/src/rendering-util/layout-algorithms/layout-utils/helpers.ts
    @@ -4,9 +4,16 @@
      * This module contains geometry helpers, distance calculations, and other
      * low-level utilities used across the routing stages.
      */
    -import type { Node } from '../../types.js';
    +import type { Edge, Node } from '../../types.js';
     import type { Point, Rect, PortSide } from './types.js';
     
    +/**
    + * Compare strings by UTF-16 code units so algorithmic ordering is independent of locale and ICU.
    + */
    +export function compareCodeUnits(a: string, b: string): number {
    +  return a < b ? -1 : a > b ? 1 : 0;
    +}
    +
     /**
      * Create a Rect from a Node's x, y, width, height properties.
      * The node's x, y are assumed to be the center coordinates.
    @@ -26,6 +33,79 @@ export function rectForNode(node: Node): Rect {
       };
     }
     
    +/**
    + * Reserve the marker-facing portion of an orthogonal terminal segment.
    + *
    + * Placement and validation share this geometry so a label accepted during routing cannot later
    + * fail because the validator modeled the arrowhead clearance differently.
    + */
    +export function terminalMarkerClearanceRect(
    +  points: Point[],
    +  terminal: 'start' | 'end',
    +  length: number,
    +  halfWidth: number,
    +  tolerance = 0
    +): Rect | null {
    +  if (points.length < 2) {
    +    return null;
    +  }
    +
    +  const tip = terminal === 'end' ? points[points.length - 1] : points[0];
    +  const inner = terminal === 'end' ? points[points.length - 2] : points[1];
    +  const dx = inner.x - tip.x;
    +  const dy = inner.y - tip.y;
    +
    +  if (Math.abs(dx) <= tolerance && Math.abs(dy) <= tolerance) {
    +    return null;
    +  }
    +  if (Math.abs(dy) <= tolerance) {
    +    const innerX = tip.x + Math.sign(dx) * length;
    +    const left = Math.min(tip.x, innerX);
    +    const right = Math.max(tip.x, innerX);
    +    return {
    +      cx: (left + right) / 2,
    +      cy: tip.y,
    +      left,
    +      right,
    +      top: tip.y - halfWidth,
    +      bottom: tip.y + halfWidth,
    +    };
    +  }
    +  if (Math.abs(dx) <= tolerance) {
    +    const innerY = tip.y + Math.sign(dy) * length;
    +    const top = Math.min(tip.y, innerY);
    +    const bottom = Math.max(tip.y, innerY);
    +    return {
    +      cx: tip.x,
    +      cy: (top + bottom) / 2,
    +      left: tip.x - halfWidth,
    +      right: tip.x + halfWidth,
    +      top,
    +      bottom,
    +    };
    +  }
    +  return null;
    +}
    +
    +export function hasTerminalMarker(edge: Edge, terminal: 'start' | 'end'): boolean {
    +  const markerType = terminal === 'start' ? edge.arrowTypeStart : edge.arrowTypeEnd;
    +  if (typeof markerType === 'string') {
    +    const trimmed = markerType.trim();
    +    if (trimmed.length > 0) {
    +      return trimmed !== 'none' && trimmed !== 'arrow_open';
    +    }
    +  }
    +
    +  if (typeof edge.type !== 'string') {
    +    return false;
    +  }
    +  // Flowchart/swimlane edges often carry marker semantics in `type`.
    +  if (terminal === 'start' && edge.type.startsWith('double_')) {
    +    return true;
    +  }
    +  return terminal === 'end' && /arrow_(point|cross|circle|barb)|double_arrow/.test(edge.type);
    +}
    +
     /**
      * Check if a point is strictly inside a rectangle (not on the boundary).
      */
    @@ -40,6 +120,35 @@ export function approxEqual(a: number, b: number, tol = 1e-6): boolean {
       return Math.abs(a - b) <= tol;
     }
     
    +export function orthogonalPolylineContainsPoint(
    +  points: Point[],
    +  point: Point,
    +  tolerance = 1e-6
    +): boolean {
    +  for (let index = 1; index < points.length; index++) {
    +    const a = points[index - 1];
    +    const b = points[index];
    +    if (approxEqual(a.y, b.y, tolerance) && !approxEqual(a.x, b.x, tolerance)) {
    +      if (
    +        approxEqual(point.y, a.y, tolerance) &&
    +        point.x >= Math.min(a.x, b.x) - tolerance &&
    +        point.x <= Math.max(a.x, b.x) + tolerance
    +      ) {
    +        return true;
    +      }
    +    } else if (approxEqual(a.x, b.x, tolerance) && !approxEqual(a.y, b.y, tolerance)) {
    +      if (
    +        approxEqual(point.x, a.x, tolerance) &&
    +        point.y >= Math.min(a.y, b.y) - tolerance &&
    +        point.y <= Math.max(a.y, b.y) + tolerance
    +      ) {
    +        return true;
    +      }
    +    }
    +  }
    +  return false;
    +}
    +
     /**
      * Calculate the total Manhattan length of a polyline.
      */
    diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/layout-utils/validateLayout.spec.ts b/packages/mermaid/src/rendering-util/layout-algorithms/layout-utils/validateLayout.spec.ts
    index 96cf17d5eb1..304e9753484 100644
    --- a/packages/mermaid/src/rendering-util/layout-algorithms/layout-utils/validateLayout.spec.ts
    +++ b/packages/mermaid/src/rendering-util/layout-algorithms/layout-utils/validateLayout.spec.ts
    @@ -721,6 +721,64 @@ describe('validateLayout new geometric issues', () => {
         expect(types).not.toContain('edge-label-off-edge');
       });
     
    +  it.each([
    +    {
    +      name: 'crosses the label away from its center',
    +      sharedDefaultAnchored: true,
    +      points: [
    +        { x: 20, y: -5 },
    +        { x: 180, y: -5 },
    +      ],
    +    },
    +    {
    +      name: 'runs along the label border',
    +      sharedDefaultAnchored: true,
    +      points: [
    +        { x: 20, y: -10 },
    +        { x: 180, y: -10 },
    +      ],
    +    },
    +    {
    +      name: 'touches only a label corner',
    +      sharedDefaultAnchored: false,
    +      points: [
    +        { x: 20, y: -10 },
    +        { x: 85, y: -10 },
    +        { x: 85, y: -40 },
    +        { x: 180, y: -40 },
    +      ],
    +    },
    +  ])(
    +    'supports layout-specific label anchoring when the owning edge $name',
    +    ({ points, sharedDefaultAnchored }) => {
    +      const a = mkNode('A', 0, 0);
    +      const b = mkNode('B', 200, 0);
    +      const label: Node = {
    +        id: 'edge-label-A-B-e1',
    +        x: 100,
    +        y: 0,
    +        width: 30,
    +        height: 20,
    +        isGroup: false,
    +        isEdgeLabel: true,
    +      } as any;
    +      const e = {
    +        id: 'e1',
    +        start: 'A',
    +        end: 'B',
    +        type: 'arrow',
    +        labelNodeId: 'edge-label-A-B-e1',
    +        points,
    +      } as unknown as Edge;
    +      const layout: LayoutData = { nodes: [a, b, label], edges: [e], config: {} as any };
    +
    +      expect(getIssueTypes(layout).includes('edge-label-off-edge')).toBe(!sharedDefaultAnchored);
    +      expect(
    +        validateLayout(layout, { requireEdgeLabelCenter: true }).issues.map((issue) => issue.type)
    +      ).toContain('edge-label-off-edge');
    +    }
    +  );
    +
       it('flags edge-endpoint-inside-node when an edge endpoint sits inside a non-endpoint node', () => {
         // Edge from S to T, but its end point lands inside an unrelated obstacle
         // node O rather than on T's boundary.
    diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/layout-utils/validateLayout.ts b/packages/mermaid/src/rendering-util/layout-algorithms/layout-utils/validateLayout.ts
    index aa6ab139e43..00be5645541 100644
    --- a/packages/mermaid/src/rendering-util/layout-algorithms/layout-utils/validateLayout.ts
    +++ b/packages/mermaid/src/rendering-util/layout-algorithms/layout-utils/validateLayout.ts
    @@ -1,16 +1,19 @@
    -import type { LayoutData, Node, Edge as _Edge } from '../../types.js';
    +import type { LayoutData, Node, Edge } from '../../types.js';
     import { log } from '../../../logger.js';
     import { DEBUG_KEY } from './debug.js';
     import {
       rectForNode,
       approxEqual,
    +  hasTerminalMarker,
    +  orthogonalPolylineContainsPoint,
       polylineIntersectsRect,
       segmentIntersectsRectInterior,
    +  terminalMarkerClearanceRect,
     } from './helpers.js';
     import type { Point, Rect } from './types.js';
     
     type PortSide = 'N' | 'E' | 'S' | 'W';
    -import { EPS, normalizePolyline, distance, segmentsCross } from './geometry.js';
    +import { PIXEL_EPSILON, normalizePolyline, distance, segmentsCross } from './geometry.js';
     import type { Segment, NormalizedPolyline } from './geometry.js';
     
     // ─────────────────────────────────────────────────────────────────────────────
    @@ -125,6 +128,14 @@ export interface Issue {
       details?: Record;
     }
     
    +export interface ValidateLayoutOptions {
    +  /**
    +   * Require each owning edge to pass through the rendered label center.
    +   * The shared default only requires intersection with the label rectangle.
    +   */
    +  requireEdgeLabelCenter?: boolean;
    +}
    +
     export interface ValidateLayoutResult {
       ok: boolean;
       issues: Issue[];
    @@ -196,13 +207,13 @@ function isSwimlaneGroup(node: Node | undefined): boolean {
     function direction(a: Point, b: Point): 'E' | 'W' | 'N' | 'S' | null {
       const dx = b.x - a.x;
       const dy = b.y - a.y;
    -  if (Math.abs(dx) <= EPS && Math.abs(dy) <= EPS) {
    +  if (Math.abs(dx) <= PIXEL_EPSILON && Math.abs(dy) <= PIXEL_EPSILON) {
         return null;
       }
    -  if (Math.abs(dy) <= EPS) {
    +  if (Math.abs(dy) <= PIXEL_EPSILON) {
         return dx > 0 ? 'E' : 'W';
       }
    -  if (Math.abs(dx) <= EPS) {
    +  if (Math.abs(dx) <= PIXEL_EPSILON) {
         return dy > 0 ? 'S' : 'N';
       }
       return null;
    @@ -232,14 +243,14 @@ function collinearOverlap(s1: Segment, s2: Segment): number {
         return 0;
       }
       if (s1.orientation === 'H') {
    -    // Both horizontal: check if same y (within EPS)
    -    if (Math.abs(s1.a.y - s2.a.y) > EPS) {
    +    // Both horizontal: check if same y (within PIXEL_EPSILON)
    +    if (Math.abs(s1.a.y - s2.a.y) > PIXEL_EPSILON) {
           return 0;
         }
         return rangeOverlap(s1.a.x, s1.b.x, s2.a.x, s2.b.x);
       } else {
    -    // Both vertical: check if same x (within EPS)
    -    if (Math.abs(s1.a.x - s2.a.x) > EPS) {
    +    // Both vertical: check if same x (within PIXEL_EPSILON)
    +    if (Math.abs(s1.a.x - s2.a.x) > PIXEL_EPSILON) {
           return 0;
         }
         return rangeOverlap(s1.a.y, s1.b.y, s2.a.y, s2.b.y);
    @@ -445,73 +456,6 @@ function labelRectForEdge(e: unknown): Rect | null {
       return { cx: x, cy: y, left: x - w / 2, right: x + w / 2, top: y - h / 2, bottom: y + h / 2 };
     }
     
    -type EdgeTerminal = 'start' | 'end';
    -
    -function hasTerminalMarker(e: _Edge, terminal: EdgeTerminal): boolean {
    -  const markerType = terminal === 'start' ? e.arrowTypeStart : e.arrowTypeEnd;
    -  if (typeof markerType === 'string') {
    -    const trimmed = markerType.trim();
    -    if (trimmed.length > 0 && trimmed !== 'none' && trimmed !== 'arrow_open') {
    -      return true;
    -    }
    -  }
    -
    -  if (typeof e.type !== 'string') {
    -    return false;
    -  }
    -  // Flowchart/swimlane edges often carry marker semantics in `type`.
    -  if (terminal === 'start' && e.type.startsWith('double_')) {
    -    return true;
    -  }
    -  return terminal === 'end' && /arrow_(point|cross|circle|barb)|double_arrow/.test(e.type);
    -}
    -
    -function terminalMarkerClearanceRect(points: Point[], terminal: EdgeTerminal): Rect | null {
    -  if (points.length < 2) {
    -    return null;
    -  }
    -
    -  const tip = terminal === 'end' ? points[points.length - 1] : points[0];
    -  const inner = terminal === 'end' ? points[points.length - 2] : points[1];
    -  const dx = inner.x - tip.x;
    -  const dy = inner.y - tip.y;
    -
    -  if (Math.abs(dx) <= EPS && Math.abs(dy) <= EPS) {
    -    return null;
    -  }
    -
    -  const len = EPS_MARKER_CLEARANCE_LENGTH;
    -  const half = EPS_MARKER_CLEARANCE_HALF_WIDTH;
    -  if (Math.abs(dy) <= EPS) {
    -    const x2 = tip.x + Math.sign(dx) * len;
    -    const left = Math.min(tip.x, x2);
    -    const right = Math.max(tip.x, x2);
    -    return {
    -      cx: (left + right) / 2,
    -      cy: tip.y,
    -      left,
    -      right,
    -      top: tip.y - half,
    -      bottom: tip.y + half,
    -    };
    -  }
    -  if (Math.abs(dx) <= EPS) {
    -    const y2 = tip.y + Math.sign(dy) * len;
    -    const top = Math.min(tip.y, y2);
    -    const bottom = Math.max(tip.y, y2);
    -    return {
    -      cx: tip.x,
    -      cy: (top + bottom) / 2,
    -      left: tip.x - half,
    -      right: tip.x + half,
    -      top,
    -      bottom,
    -    };
    -  }
    -
    -  return null;
    -}
    -
     function _polylineIsOrthogonal(points: Point[]): boolean {
       for (let i = 0; i < points.length - 1; i++) {
         const a = points[i];
    @@ -567,11 +511,11 @@ function nearEndpointBandDistance(seg: Segment, side: PortSide, rect: Rect): num
         }
         const x = seg.a.x;
         const distanceToSide = side === 'W' ? rect.left - x : x - rect.right;
    -    if (distanceToSide < -EPS || distanceToSide > EPS_ENDPOINT_BAND + EPS) {
    +    if (distanceToSide < -PIXEL_EPSILON || distanceToSide > EPS_ENDPOINT_BAND + PIXEL_EPSILON) {
           return null;
         }
         const overlap = rangeOverlap(seg.a.y, seg.b.y, rect.top, rect.bottom);
    -    return overlap > EPS ? Math.max(0, distanceToSide) : null;
    +    return overlap > PIXEL_EPSILON ? Math.max(0, distanceToSide) : null;
       }
     
       if (seg.orientation !== 'H') {
    @@ -579,11 +523,11 @@ function nearEndpointBandDistance(seg: Segment, side: PortSide, rect: Rect): num
       }
       const y = seg.a.y;
       const distanceToSide = side === 'N' ? rect.top - y : y - rect.bottom;
    -  if (distanceToSide < -EPS || distanceToSide > EPS_ENDPOINT_BAND + EPS) {
    +  if (distanceToSide < -PIXEL_EPSILON || distanceToSide > EPS_ENDPOINT_BAND + PIXEL_EPSILON) {
         return null;
       }
       const overlap = rangeOverlap(seg.a.x, seg.b.x, rect.left, rect.right);
    -  return overlap > EPS ? Math.max(0, distanceToSide) : null;
    +  return overlap > PIXEL_EPSILON ? Math.max(0, distanceToSide) : null;
     }
     
     /**
    @@ -601,11 +545,14 @@ function nearEndpointBandDistance(seg: Segment, side: PortSide, rect: Rect): num
      *
      * Also computes scoring based on bends and crossings.
      */
    -export function validateLayout(layout: LayoutData): ValidateLayoutResult {
    +export function validateLayout(
    +  layout: LayoutData,
    +  options: ValidateLayoutOptions = {}
    +): ValidateLayoutResult {
       const issues: Issue[] = [];
       const nodes = layout.nodes ?? [];
       const edges = layout.edges ?? [];
    -  const edgeById = new Map();
    +  const edgeById = new Map();
       for (const e of edges) {
         if (e?.id != null) {
           edgeById.set(String(e.id), e);
    @@ -908,8 +855,9 @@ export function validateLayout(layout: LayoutData): ValidateLayoutResult {
         for (const [groupId, titleRect] of groupTitleRects) {
           const hit = firstInteriorRectHit(points, titleRect, startAttach, endAttach, (a, b) => {
             const touchesStartAttach =
    -          distance(a, startAttach) <= EPS || distance(b, startAttach) <= EPS;
    -        const touchesEndAttach = distance(a, endAttach) <= EPS || distance(b, endAttach) <= EPS;
    +          distance(a, startAttach) <= PIXEL_EPSILON || distance(b, startAttach) <= PIXEL_EPSILON;
    +        const touchesEndAttach =
    +          distance(a, endAttach) <= PIXEL_EPSILON || distance(b, endAttach) <= PIXEL_EPSILON;
             return (
               (touchesStartAttach && sNode ? isAncestorGroup(groupId, sNode, byId) : false) ||
               (touchesEndAttach && tNode ? isAncestorGroup(groupId, tNode, byId) : false)
    @@ -988,14 +936,20 @@ export function validateLayout(layout: LayoutData): ValidateLayoutResult {
           }
         }
     
    -    // Check edge-label-off-edge: when an edge carries a `labelNodeId` it is
    -    // expected to thread through that label node (label-as-waypoint model).
    -    // The rendered label text sits at the label node's center, so if the
    -    // edge's polyline does not intersect the label's rectangle at all, the
    -    // label visually floats off the edge. Flag that as a hard violation.
    +    // Check edge-label-off-edge. Most layouts require the owning edge to intersect the rendered
    +    // label rectangle; grid opts into the stricter center-anchor contract.
         if (ownLabelId) {
           const labelRect = nodeRects.get(ownLabelId);
    -      if (labelRect && !polylineIntersectsRect(points, labelRect)) {
    +      const labelIsAnchored =
    +        labelRect &&
    +        (options.requireEdgeLabelCenter
    +          ? orthogonalPolylineContainsPoint(
    +              points,
    +              { x: labelRect.cx, y: labelRect.cy },
    +              PIXEL_EPSILON
    +            )
    +          : polylineIntersectsRect(points, labelRect));
    +      if (labelRect && !labelIsAnchored) {
             issues.push({
               type: 'edge-label-off-edge',
               message: `Edge "${edgeId}" does not pass through its label node "${ownLabelId}"`,
    @@ -1149,7 +1103,15 @@ export function validateLayout(layout: LayoutData): ValidateLayoutResult {
               if (!hasTerminalMarker(ownerEdge, terminal)) {
                 continue;
               }
    -          const markerRect = terminalMarkerClearanceRect(ownerMeta.normalized.points, terminal);
    +          // Use the same terminal reservation as label placement; validation should enforce the
    +          // routing contract rather than approximate marker clearance independently.
    +          const markerRect = terminalMarkerClearanceRect(
    +            ownerMeta.normalized.points,
    +            terminal,
    +            EPS_MARKER_CLEARANCE_LENGTH,
    +            EPS_MARKER_CLEARANCE_HALF_WIDTH,
    +            PIXEL_EPSILON
    +          );
               const overlap = markerRect ? rectsOverlap(labelRect, markerRect) : null;
               if (overlap) {
                 issues.push({
    @@ -1347,7 +1309,7 @@ export function validateLayout(layout: LayoutData): ValidateLayoutResult {
       // ─────────────────────────────────────────────────────────────────────────────
       const sortedEdges = [...edgeMetas].sort((a, b) => a.id.localeCompare(b.id));
       const segmentTouchesPoint = (seg: Segment, p: Point): boolean =>
    -    distance(seg.a, p) <= EPS || distance(seg.b, p) <= EPS;
    +    distance(seg.a, p) <= PIXEL_EPSILON || distance(seg.b, p) <= PIXEL_EPSILON;
       const isTerminalSegmentForNode = (em: EdgeMeta, seg: Segment, nodeId: string): boolean => {
         if (em.startId === nodeId && segmentTouchesPoint(seg, em.normalized.points[0])) {
           return true;
    @@ -1403,7 +1365,7 @@ export function validateLayout(layout: LayoutData): ValidateLayoutResult {
               if (
                 projectedOverlap >= L_MIN_SHARED &&
                 gap != null &&
    -            gap > EPS &&
    +            gap > PIXEL_EPSILON &&
                 gap < EPS_PARALLEL_EDGE_GAP
               ) {
                 const allInCorridor =
    diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/swimlanes/adjustLayout.ts b/packages/mermaid/src/rendering-util/layout-algorithms/swimlanes/adjustLayout.ts
    index 1b2cd440968..50b410f2d40 100644
    --- a/packages/mermaid/src/rendering-util/layout-algorithms/swimlanes/adjustLayout.ts
    +++ b/packages/mermaid/src/rendering-util/layout-algorithms/swimlanes/adjustLayout.ts
    @@ -15,9 +15,11 @@ export function applySwimlaneLineJumps(
       const edgeGeometries = data4Layout.edges
         .filter((edge) => Array.isArray(edge.points) && edge.points.length >= 2)
         .map((edge) => ({
    +      // The shared line-hop pass must reconstruct the same corners the renderer originally drew.
           id: edge.id,
           points: edge.points!,
           curve: edge.curve,
    +      cornerRadius: edge.cornerRadius,
           arrowTypeStart: edge.arrowTypeStart,
           arrowTypeEnd: edge.arrowTypeEnd,
         }));
    diff --git a/packages/mermaid/src/rendering-util/layoutFallback.spec.ts b/packages/mermaid/src/rendering-util/layoutFallback.spec.ts
    index 2dbd9b8011e..9e9169ee086 100644
    --- a/packages/mermaid/src/rendering-util/layoutFallback.spec.ts
    +++ b/packages/mermaid/src/rendering-util/layoutFallback.spec.ts
    @@ -15,6 +15,7 @@ describe('layout fallback', () => {
       it('returns a registered layout unchanged', () => {
         expect(getRegisteredLayoutAlgorithm('dagre')).toBe('dagre');
         expect(getRegisteredLayoutAlgorithm('swimlane')).toBe('swimlane');
    +    expect(getRegisteredLayoutAlgorithm('grid')).toBe('grid');
         // Bundled by default now; only the tiny build omits it.
         expect(getRegisteredLayoutAlgorithm('elk')).toBe('elk');
       });
    diff --git a/packages/mermaid/src/rendering-util/render.ts b/packages/mermaid/src/rendering-util/render.ts
    index 49145b2040c..7996caf1331 100644
    --- a/packages/mermaid/src/rendering-util/render.ts
    +++ b/packages/mermaid/src/rendering-util/render.ts
    @@ -80,6 +80,12 @@ const registerDefaultLayoutLoaders = () => {
           name: 'swimlane',
           loader: async () => await import('./layout-algorithms/swimlanes/index.js'),
         },
    +    // Register the public name here, but keep the implementation behind the same lazy boundary as
    +    // other optional layouts so importing Mermaid does not eagerly initialize grid internals.
    +    {
    +      name: 'grid',
    +      loader: async () => await import('./layout-algorithms/grid/index.js'),
    +    },
         // elkjs is ~1.6 MB of source, so it is excluded from the tiny build along
         // with the other large features. `getRegisteredLayoutAlgorithm` then falls
         // back to dagre for diagrams that ask for an ELK layout there.
    diff --git a/packages/mermaid/src/rendering-util/rendering-elements/edges.js b/packages/mermaid/src/rendering-util/rendering-elements/edges.js
    index 1f88ca42664..b8711d8d0a2 100644
    --- a/packages/mermaid/src/rendering-util/rendering-elements/edges.js
    +++ b/packages/mermaid/src/rendering-util/rendering-elements/edges.js
    @@ -29,8 +29,10 @@ import {
       select,
     } from 'd3';
     import rough from 'roughjs';
    +import { resolveEdgeCornerRadius } from '../edgeCornerRadius.js';
     import createLabel from './createLabel.js';
     import { addEdgeMarkers } from './edgeMarker.ts';
    +import { clipOrthogonalEndpointsToNodeOutlines } from './orthogonalEdgeClipping.js';
     import { isLabelStyle, styles2String } from './shapes/handDrawnShapeStyles.js';
     
     /**
    @@ -651,11 +653,15 @@ export const insertEdge = function (
         edgeClassStyles.push(edge.cssCompiledStyles[key]);
       }
     
    -  // Edge endpoint clipping. The swimlanes layout produces orthogonal edges whose
    -  // axis-aligned entry/exit segments must be preserved, so it uses a dedicated
    -  // boundary-clipping path. Every other layout (dagre, ELK, …) keeps the original
    -  // clipping below, so their edge ports are unaffected by swimlanes.
    -  if (layout === 'swimlane') {
    +  // Specialized clipping is selected by edge metadata rather than layout name so any orthogonal
    +  // router can preserve its bends. Exactly one policy runs; clipping twice destroys owned ports.
    +  if (edge.portClipping === 'outline-orthogonal' && !skipIntersect) {
    +    points = clipOrthogonalEndpointsToNodeOutlines(points, tail, head);
    +  } else if (layout === 'swimlane') {
    +    // Edge endpoint clipping. The swimlanes layout produces orthogonal edges whose
    +    // axis-aligned entry/exit segments must be preserved, so it uses a dedicated
    +    // boundary-clipping path. Every other layout (dagre, ELK, …) keeps the original
    +    // clipping below, so their edge ports are unaffected by swimlanes.
         if (
           !skipIntersect &&
           head.intersect &&
    @@ -697,7 +703,7 @@ export const insertEdge = function (
         }
         points = orthogonalizeToLabelClippedPoints(edge, points);
       } else if (head.intersect && tail.intersect && !skipIntersect) {
    -    // Original clipping — unchanged for dagre / ELK / every non-swimlanes layout.
    +    // Original clipping for layouts that do not own their final boundary ports.
         points = points.slice(1, edge.points.length - 1);
         points.unshift(tail.intersect(points[0]));
         points.push(head.intersect(points[points.length - 1]));
    @@ -724,9 +730,8 @@ export const insertEdge = function (
       let lineData = points.filter((p) => !Number.isNaN(p.y));
       // Resolve curve type: use edge.curve if it's a string, otherwise fall back to config default
       const edgeCurveType = resolveEdgeCurveType(edge.curve);
    -  // Apply fixCorners for non-rounded curves to pre-round right-angle corners
    -  // (rounded curve type uses generateRoundedPath instead)
    -  if (edgeCurveType !== 'rounded') {
    +  // Routers can opt out when their points already encode every terminal and corner invariant.
    +  if (edgeCurveType !== 'rounded' && !edge.skipCornerFix) {
         lineData = fixCorners(lineData);
       }
       let curve = curveLinear;
    @@ -807,7 +812,11 @@ export const insertEdge = function (
       let svgPath;
       let linePath =
         edgeCurveType === 'rounded'
    -      ? generateRoundedPath(applyMarkerOffsetsToPoints(lineData, edge), 5)
    +      ? generateRoundedPath(
    +          applyMarkerOffsetsToPoints(lineData, edge),
    +          // Resolve here instead of in a layout so rounded rendering stays generic.
    +          resolveEdgeCornerRadius(edge.cornerRadius)
    +        )
           : lineFunction(lineData);
       const edgeStyles = Array.isArray(edge.style) ? edge.style : [edge.style];
       let strokeColor = edgeStyles.find((style) => style?.startsWith('stroke:'));
    diff --git a/packages/mermaid/src/rendering-util/rendering-elements/edges.spec.js b/packages/mermaid/src/rendering-util/rendering-elements/edges.spec.js
    index b92b95531ce..8e02ce1e91a 100644
    --- a/packages/mermaid/src/rendering-util/rendering-elements/edges.spec.js
    +++ b/packages/mermaid/src/rendering-util/rendering-elements/edges.spec.js
    @@ -11,9 +11,17 @@ vi.mock('../../diagram-api/diagramAPI.js', () => ({
       })),
     }));
     
    -import { insertEdge, resolveEdgeCurveType, setTerminalWidth } from './edges.js';
    +import {
    +  generateRoundedPath,
    +  insertEdge,
    +  resolveEdgeCurveType,
    +  setTerminalWidth,
    +} from './edges.js';
    +import { resolveEdgeCornerRadius } from '../edgeCornerRadius.js';
     import { getConfig } from '../../diagram-api/diagramAPI.js';
     import { computeLabelTransform } from '../labelTransform.js';
    +import intersectPolygon from './intersect/intersect-polygon.js';
    +import intersectRect from './intersect/intersect-rect.js';
     
     describe('insertEdge clips for the engine that drew the edge', () => {
       /** A shape that reads the point it is handed, the way every real one does. */
    @@ -115,6 +123,123 @@ describe('resolveEdgeCurveType', () => {
       });
     });
     
    +describe('rounded edge corners', () => {
    +  it('uses a valid configured radius and defaults invalid values', () => {
    +    expect(resolveEdgeCornerRadius(12)).toBe(12);
    +    expect(resolveEdgeCornerRadius(0)).toBe(0);
    +    expect(resolveEdgeCornerRadius(-1)).toBe(5);
    +    expect(resolveEdgeCornerRadius(Number.NaN)).toBe(5);
    +  });
    +
    +  it('changes the rounded path geometry with the corner radius', () => {
    +    const points = [
    +      { x: 0, y: 0 },
    +      { x: 40, y: 0 },
    +      { x: 40, y: 40 },
    +    ];
    +
    +    expect(generateRoundedPath(points, 5)).not.toBe(generateRoundedPath(points, 12));
    +    expect(generateRoundedPath(points, 0)).toBe('M0,0L40,0Q40,0 40,0L40,40');
    +  });
    +});
    +
    +describe('insertEdge non-grid compatibility', () => {
    +  const layouts = ['dagre', 'elk', 'swimlane'];
    +  const points = [
    +    { x: 0, y: 0 },
    +    { x: 0, y: 20 },
    +    { x: 20, y: 20 },
    +    { x: 20, y: 40 },
    +  ];
    +
    +  const setLayout = (layout) => {
    +    vi.mocked(getConfig).mockReturnValue({
    +      layout,
    +      flowchart: { curve: 'rounded', arrowMarkerAbsolute: false },
    +      state: { arrowMarkerAbsolute: false },
    +      handDrawnSeed: 0,
    +    });
    +  };
    +
    +  const makeEdge = (overrides = {}) => ({
    +    id: 'non-grid-edge',
    +    cssCompiledStyles: {},
    +    style: [],
    +    thickness: 'normal',
    +    pattern: 'solid',
    +    classes: 'flowchart-link',
    +    look: 'classic',
    +    arrowTypeStart: 'none',
    +    arrowTypeEnd: 'none',
    +    points,
    +    ...overrides,
    +  });
    +
    +  it.each(layouts)('%s linear edges retain legacy corner fixing', (layout) => {
    +    setLayout(layout);
    +    document.body.innerHTML = '';
    +    const svg = select(document.body).append('svg');
    +
    +    insertEdge(
    +      svg,
    +      makeEdge({ curve: 'linear' }),
    +      null,
    +      'flowchart-v2',
    +      { intersect: vi.fn(() => points[0]) },
    +      { intersect: vi.fn(() => points.at(-1)) },
    +      'diagram'
    +    );
    +
    +    const path = svg.select('path');
    +    expect(JSON.parse(atob(path.attr('data-points')))).toEqual(points);
    +    expect(path.attr('d')).not.toBe('M0,0L0,20L20,20L20,40');
    +    expect(path.attr('d').match(/L/g)).toHaveLength(7);
    +  });
    +
    +  it.each(layouts)('%s rounded edges default to a 5px corner radius', (layout) => {
    +    setLayout(layout);
    +    document.body.innerHTML = '';
    +    const svg = select(document.body).append('svg');
    +
    +    insertEdge(
    +      svg,
    +      makeEdge({ curve: 'rounded' }),
    +      null,
    +      'flowchart-v2',
    +      { intersect: vi.fn(() => points[0]) },
    +      { intersect: vi.fn(() => points.at(-1)) },
    +      'diagram'
    +    );
    +
    +    expect(svg.select('path').attr('d')).toBe(generateRoundedPath(points, 5));
    +  });
    +
    +  it.each(layouts)('%s edges do not use grid orthogonal clipping', (layout) => {
    +    setLayout(layout);
    +    document.body.innerHTML = '';
    +    const svg = select(document.body).append('svg');
    +    const clippedStart = { x: -5, y: -5 };
    +    const clippedEnd = { x: 25, y: 45 };
    +
    +    insertEdge(
    +      svg,
    +      makeEdge({ curve: 'linear', skipCornerFix: true }),
    +      null,
    +      'flowchart-v2',
    +      { intersect: vi.fn(() => clippedStart) },
    +      { intersect: vi.fn(() => clippedEnd) },
    +      'diagram'
    +    );
    +
    +    expect(JSON.parse(atob(svg.select('path').attr('data-points')))).toEqual([
    +      clippedStart,
    +      points[1],
    +      points[2],
    +      clippedEnd,
    +    ]);
    +  });
    +});
    +
     describe('computeLabelTransform', () => {
       it('accounts for bbox.x/y offsets when centering SVG label (htmlLabels: false)', () => {
         // bbox.x = -2 simulates the 2px padding of the background  added by
    @@ -251,6 +376,288 @@ describe('insertEdge swimlane endpoint clipping', () => {
       });
     });
     
    +describe('insertEdge orthogonal endpoint clipping', () => {
    +  it('clips offset polygon ports along the terminal axis without adding a backtrack', () => {
    +    vi.mocked(getConfig).mockReturnValue({
    +      layout: 'dagre',
    +      flowchart: { curve: 'rounded', arrowMarkerAbsolute: false },
    +      state: { arrowMarkerAbsolute: false },
    +      handDrawnSeed: 0,
    +    });
    +    document.body.innerHTML = '';
    +    const svg = select(document.body).append('svg');
    +    const diamond = [
    +      { x: 0, y: 40 },
    +      { x: 40, y: 0 },
    +      { x: 80, y: 40 },
    +      { x: 40, y: 80 },
    +    ];
    +    const tail = { x: 100, y: 100, width: 80, height: 80 };
    +    tail.intersect = (point) => intersectPolygon(tail, diamond, point);
    +    const points = [
    +      { x: 80, y: 60 },
    +      { x: 80, y: 20 },
    +      { x: 300, y: 20 },
    +    ];
    +    const edge = {
    +      id: 'offset-diamond-port',
    +      cssCompiledStyles: {},
    +      style: [],
    +      thickness: 'normal',
    +      pattern: 'solid',
    +      classes: 'flowchart-link',
    +      curve: 'linear',
    +      look: 'classic',
    +      arrowTypeStart: 'none',
    +      arrowTypeEnd: 'none',
    +      portClipping: 'outline-orthogonal',
    +      skipCornerFix: true,
    +      points,
    +    };
    +
    +    insertEdge(svg, edge, null, 'flowchart-v2', tail, {}, 'diagram');
    +
    +    expect(JSON.parse(atob(svg.select('path').attr('data-points')))).toEqual([
    +      { x: 80, y: 80 },
    +      { x: 80, y: 20 },
    +      { x: 300, y: 20 },
    +    ]);
    +  });
    +
    +  it('clips router-owned ports along their terminal axes', () => {
    +    vi.mocked(getConfig).mockReturnValue({
    +      layout: 'dagre',
    +      flowchart: { curve: 'rounded', arrowMarkerAbsolute: false },
    +      state: { arrowMarkerAbsolute: false },
    +      handDrawnSeed: 0,
    +    });
    +    document.body.innerHTML = '';
    +    const svg = select(document.body).append('svg');
    +    const points = [
    +      { x: 130, y: 110 },
    +      { x: 100, y: 110 },
    +      { x: 100, y: 34 },
    +      { x: 80, y: 34 },
    +    ];
    +    const edge = {
    +      id: 'v2p-v1',
    +      cssCompiledStyles: {},
    +      style: [],
    +      thickness: 'normal',
    +      pattern: 'solid',
    +      classes: 'flowchart-link',
    +      curve: 'linear',
    +      look: 'classic',
    +      arrowTypeStart: 'none',
    +      arrowTypeEnd: 'arrow_point',
    +      portClipping: 'outline-orthogonal',
    +      skipCornerFix: true,
    +      points,
    +    };
    +    const tail = {
    +      intersect: vi.fn(() => ({ x: 135, y: 110 })),
    +    };
    +    const head = {
    +      intersect: vi.fn(() => ({ x: 75, y: 34 })),
    +    };
    +
    +    insertEdge(svg, edge, null, 'flowchart-v2', tail, head, 'diagram');
    +
    +    const path = svg.select('path');
    +    const renderedPoints = JSON.parse(atob(path.attr('data-points')));
    +    expect(tail.intersect).toHaveBeenCalledWith(points[1]);
    +    expect(head.intersect).toHaveBeenCalledWith(points.at(-2));
    +    expect(renderedPoints).toEqual([
    +      { x: 135, y: 110 },
    +      { x: 100, y: 110 },
    +      { x: 100, y: 34 },
    +      { x: 75, y: 34 },
    +    ]);
    +    for (let index = 1; index < renderedPoints.length; index++) {
    +      const previous = renderedPoints[index - 1];
    +      const current = renderedPoints[index];
    +      expect(current.x === previous.x || current.y === previous.y).toBe(true);
    +    }
    +  });
    +
    +  it('keeps rectangular outline points unchanged', () => {
    +    vi.mocked(getConfig).mockReturnValue({
    +      layout: 'dagre',
    +      flowchart: { curve: 'rounded', arrowMarkerAbsolute: false },
    +      state: { arrowMarkerAbsolute: false },
    +      handDrawnSeed: 0,
    +    });
    +    document.body.innerHTML = '';
    +    const svg = select(document.body).append('svg');
    +    const points = [
    +      { x: 140, y: 108 },
    +      { x: 80, y: 108 },
    +      { x: 80, y: 192 },
    +      { x: 20, y: 192 },
    +    ];
    +    const edge = {
    +      id: 'rectangular-grid-edge',
    +      cssCompiledStyles: {},
    +      style: [],
    +      thickness: 'normal',
    +      pattern: 'solid',
    +      classes: 'flowchart-link',
    +      curve: 'linear',
    +      look: 'classic',
    +      arrowTypeStart: 'none',
    +      arrowTypeEnd: 'arrow_point',
    +      portClipping: 'outline-orthogonal',
    +      skipCornerFix: true,
    +      points,
    +    };
    +
    +    insertEdge(
    +      svg,
    +      edge,
    +      null,
    +      'flowchart-v2',
    +      {
    +        intersect: vi.fn((point) =>
    +          intersectRect({ x: 200, y: 100, width: 120, height: 60 }, point)
    +        ),
    +      },
    +      {
    +        intersect: vi.fn((point) =>
    +          intersectRect({ x: -40, y: 200, width: 120, height: 60 }, point)
    +        ),
    +      },
    +      'diagram'
    +    );
    +
    +    expect(JSON.parse(atob(svg.select('path').attr('data-points')))).toEqual(points);
    +  });
    +
    +  it('keeps a two-point route orthogonal when both shape outlines are inset on its axis', () => {
    +    vi.mocked(getConfig).mockReturnValue({
    +      layout: 'dagre',
    +      flowchart: { curve: 'rounded', arrowMarkerAbsolute: false },
    +      state: { arrowMarkerAbsolute: false },
    +      handDrawnSeed: 0,
    +    });
    +    document.body.innerHTML = '';
    +    const svg = select(document.body).append('svg');
    +    const edge = {
    +      id: 'direct-grid-edge',
    +      cssCompiledStyles: {},
    +      style: [],
    +      thickness: 'normal',
    +      pattern: 'solid',
    +      classes: 'flowchart-link',
    +      curve: 'linear',
    +      look: 'classic',
    +      arrowTypeStart: 'none',
    +      arrowTypeEnd: 'arrow_point',
    +      portClipping: 'outline-orthogonal',
    +      skipCornerFix: true,
    +      points: [
    +        { x: 0, y: 10 },
    +        { x: 100, y: 10 },
    +      ],
    +    };
    +
    +    insertEdge(
    +      svg,
    +      edge,
    +      null,
    +      'flowchart-v2',
    +      { intersect: vi.fn(() => ({ x: -10, y: 10 })) },
    +      { intersect: vi.fn(() => ({ x: 110, y: 10 })) },
    +      'diagram'
    +    );
    +
    +    expect(JSON.parse(atob(svg.select('path').attr('data-points')))).toEqual([
    +      { x: -10, y: 10 },
    +      { x: 50, y: 10 },
    +      { x: 110, y: 10 },
    +    ]);
    +  });
    +
    +  it('keeps router-owned ports when a custom intersection cannot follow the terminal axis', () => {
    +    vi.mocked(getConfig).mockReturnValue({
    +      layout: 'dagre',
    +      flowchart: { curve: 'rounded', arrowMarkerAbsolute: false },
    +      state: { arrowMarkerAbsolute: false },
    +      handDrawnSeed: 0,
    +    });
    +    document.body.innerHTML = '';
    +    const svg = select(document.body).append('svg');
    +    const points = [
    +      { x: 0, y: 10 },
    +      { x: 100, y: 10 },
    +    ];
    +    const edge = {
    +      id: 'unsupported-axis-clipping',
    +      cssCompiledStyles: {},
    +      style: [],
    +      thickness: 'normal',
    +      pattern: 'solid',
    +      classes: 'flowchart-link',
    +      curve: 'linear',
    +      look: 'classic',
    +      arrowTypeStart: 'none',
    +      arrowTypeEnd: 'none',
    +      portClipping: 'outline-orthogonal',
    +      skipCornerFix: true,
    +      points,
    +    };
    +
    +    insertEdge(
    +      svg,
    +      edge,
    +      null,
    +      'flowchart-v2',
    +      { intersect: vi.fn(() => ({ x: -10, y: 5 })) },
    +      { intersect: vi.fn(() => ({ x: 110, y: 15 })) },
    +      'diagram'
    +    );
    +
    +    expect(JSON.parse(atob(svg.select('path').attr('data-points')))).toEqual(points);
    +  });
    +
    +  it('skips grid endpoint clipping when skipIntersect is true', () => {
    +    vi.mocked(getConfig).mockReturnValue({
    +      layout: 'grid',
    +      flowchart: { curve: 'rounded', arrowMarkerAbsolute: false },
    +      state: { arrowMarkerAbsolute: false },
    +      handDrawnSeed: 0,
    +    });
    +    document.body.innerHTML = '';
    +    const svg = select(document.body).append('svg');
    +    const points = [
    +      { x: 0, y: 10 },
    +      { x: 100, y: 10 },
    +    ];
    +    const edge = {
    +      id: 'skipped-grid-clipping',
    +      cssCompiledStyles: {},
    +      style: [],
    +      thickness: 'normal',
    +      pattern: 'solid',
    +      classes: 'flowchart-link',
    +      curve: 'linear',
    +      look: 'classic',
    +      arrowTypeStart: 'none',
    +      arrowTypeEnd: 'arrow_point',
    +      portClipping: 'outline-orthogonal',
    +      skipCornerFix: true,
    +      points,
    +    };
    +    const tail = { intersect: vi.fn(() => ({ x: -10, y: 5 })) };
    +    const head = { intersect: vi.fn(() => ({ x: 110, y: 15 })) };
    +
    +    insertEdge(svg, edge, null, 'flowchart-v2', tail, head, 'diagram', true);
    +
    +    expect(tail.intersect).not.toHaveBeenCalled();
    +    expect(head.intersect).not.toHaveBeenCalled();
    +    expect(JSON.parse(atob(svg.select('path').attr('data-points')))).toEqual(points);
    +  });
    +});
    +
     describe('setTerminalWidth', () => {
       // #8329: a hard-coded 12px height clipped the bottom of cardinalities whose text is 21px tall.
       it('never sizes the terminal label box smaller than the measured label', () => {
    diff --git a/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-ellipse.js b/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-ellipse.js
    index def637b2c53..cf1409881e1 100644
    --- a/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-ellipse.js
    +++ b/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-ellipse.js
    @@ -1,8 +1,38 @@
    +import { getLineIntersectionOrigin } from './intersection-query.js';
    +
     function intersectEllipse(node, rx, ry, point) {
       // Formulae from: https://mathworld.wolfram.com/Ellipse-LineIntersection.html
     
       var cx = node.x;
       var cy = node.y;
    +  const lineOrigin = getLineIntersectionOrigin(point, node);
    +
    +  if (lineOrigin !== node) {
    +    const originX = lineOrigin.x - cx;
    +    const originY = lineOrigin.y - cy;
    +    const deltaX = point.x - lineOrigin.x;
    +    const deltaY = point.y - lineOrigin.y;
    +    const rxSquared = rx * rx;
    +    const rySquared = ry * ry;
    +    const a = (deltaX * deltaX) / rxSquared + (deltaY * deltaY) / rySquared;
    +    const b = (2 * originX * deltaX) / rxSquared + (2 * originY * deltaY) / rySquared;
    +    const c = (originX * originX) / rxSquared + (originY * originY) / rySquared - 1;
    +    const discriminant = b * b - 4 * a * c;
    +
    +    if (a > 0 && discriminant >= 0) {
    +      const root = Math.sqrt(discriminant);
    +      const intersections = [(-b - root) / (2 * a), (-b + root) / (2 * a)]
    +        .filter((value) => value >= 0 && value <= 1)
    +        .sort((first, second) => second - first);
    +      if (intersections.length) {
    +        const parameter = intersections[0];
    +        return {
    +          x: lineOrigin.x + parameter * deltaX,
    +          y: lineOrigin.y + parameter * deltaY,
    +        };
    +      }
    +    }
    +  }
     
       var px = cx - point.x;
       var py = cy - point.y;
    diff --git a/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-line.spec.ts b/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-line.spec.ts
    index c0223a19afa..debb2456cc3 100644
    --- a/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-line.spec.ts
    +++ b/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-line.spec.ts
    @@ -1,6 +1,9 @@
     import { describe, expect, it } from 'vitest';
    +import intersectEllipse from './intersect-ellipse.js';
     import intersectLine from './intersect-line.js';
     import intersectPolygon from './intersect-polygon.js';
    +import intersectRect from './intersect-rect.js';
    +import { withLineIntersectionOrigin } from './intersection-query.js';
     
     interface Point {
       x: number;
    @@ -118,4 +121,51 @@ describe('intersectPolygon', () => {
         expect(result.x).toBeCloseTo(250, 9);
         expect(result.y).toBeCloseTo(200, 9);
       });
    +
    +  it('intersects an offset terminal axis instead of pulling it toward the node centre', () => {
    +    const node = { x: 100, y: 100, width: 80, height: 80 };
    +    const points = [
    +      { x: 0, y: 40 },
    +      { x: 40, y: 0 },
    +      { x: 80, y: 40 },
    +      { x: 40, y: 80 },
    +    ];
    +
    +    const result = intersectPolygon(
    +      node,
    +      points,
    +      withLineIntersectionOrigin({ x: 80, y: 20 }, { x: 80, y: 181 })
    +    );
    +
    +    expect(result.x).toBeCloseTo(80, 9);
    +    expect(result.y).toBeCloseTo(80, 9);
    +  });
    +});
    +
    +describe('terminal-axis intersection queries', () => {
    +  it('intersects a rectangle along an offset terminal axis', () => {
    +    const node = { x: 100, y: 100, width: 80, height: 80 };
    +
    +    const result = intersectRect(
    +      node,
    +      withLineIntersectionOrigin({ x: 80, y: 20 }, { x: 80, y: 181 })
    +    );
    +
    +    expect(result.x).toBeCloseTo(80, 9);
    +    expect(result.y).toBeCloseTo(60, 9);
    +  });
    +
    +  it('intersects an ellipse along an offset terminal axis', () => {
    +    const node = { x: 100, y: 100 };
    +
    +    const result = intersectEllipse(
    +      node,
    +      40,
    +      40,
    +      withLineIntersectionOrigin({ x: 80, y: 20 }, { x: 80, y: 181 })
    +    );
    +
    +    expect(result.x).toBeCloseTo(80, 9);
    +    expect(result.y).toBeCloseTo(100 - Math.sqrt(1200), 9);
    +  });
     });
    diff --git a/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-polygon.js b/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-polygon.js
    index 2e48ba8d01b..f18997ae8fa 100644
    --- a/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-polygon.js
    +++ b/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-polygon.js
    @@ -1,4 +1,5 @@
     import intersectLine from './intersect-line.js';
    +import { getLineIntersectionOrigin } from './intersection-query.js';
     
     /**
      * Returns the point ({x, y}) at which the point argument intersects with the node argument assuming
    @@ -7,6 +8,7 @@ import intersectLine from './intersect-line.js';
     function intersectPolygon(node, polyPoints, point) {
       let x1 = node.x;
       let y1 = node.y;
    +  const lineOrigin = getLineIntersectionOrigin(point, node);
     
       let intersections = [];
     
    @@ -29,7 +31,7 @@ function intersectPolygon(node, polyPoints, point) {
         let p1 = polyPoints[i];
         let p2 = polyPoints[i < polyPoints.length - 1 ? i + 1 : 0];
         let intersect = intersectLine(
    -      node,
    +      lineOrigin,
           point,
           { x: left + p1.x, y: top + p1.y },
           { x: left + p2.x, y: top + p2.y }
    diff --git a/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-rect.js b/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-rect.js
    index daf6b5eea00..4e82b2fd2c7 100644
    --- a/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-rect.js
    +++ b/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-rect.js
    @@ -1,4 +1,20 @@
    +import { getLineIntersectionOrigin } from './intersection-query.js';
    +import intersectPolygon from './intersect-polygon.js';
    +
     const intersectRect = (node, point) => {
    +  if (getLineIntersectionOrigin(point, node) !== node) {
    +    return intersectPolygon(
    +      node,
    +      [
    +        { x: 0, y: 0 },
    +        { x: node.width, y: 0 },
    +        { x: node.width, y: node.height },
    +        { x: 0, y: node.height },
    +      ],
    +      point
    +    );
    +  }
    +
       var x = node.x;
       var y = node.y;
     
    diff --git a/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersection-query.js b/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersection-query.js
    new file mode 100644
    index 00000000000..a40dfff38d0
    --- /dev/null
    +++ b/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersection-query.js
    @@ -0,0 +1,9 @@
    +const LINE_INTERSECTION_ORIGIN = Symbol('lineIntersectionOrigin');
    +
    +export const withLineIntersectionOrigin = (point, origin) => ({
    +  ...point,
    +  [LINE_INTERSECTION_ORIGIN]: origin,
    +});
    +
    +export const getLineIntersectionOrigin = (point, fallback) =>
    +  point?.[LINE_INTERSECTION_ORIGIN] ?? fallback;
    diff --git a/packages/mermaid/src/rendering-util/rendering-elements/lineJump.spec.ts b/packages/mermaid/src/rendering-util/rendering-elements/lineJump.spec.ts
    index 59179da7461..387d8c7f030 100644
    --- a/packages/mermaid/src/rendering-util/rendering-elements/lineJump.spec.ts
    +++ b/packages/mermaid/src/rendering-util/rendering-elements/lineJump.spec.ts
    @@ -494,6 +494,40 @@ describe('lineJump', () => {
           expect(newD).toMatch(/Q20,5/);
         });
     
    +    it('preserves a custom corner radius when rewriting a rounded edge', () => {
    +      const edges: EdgeGeom[] = [
    +        {
    +          id: 'verticalLine',
    +          points: [
    +            { x: 5, y: 0 },
    +            { x: 5, y: 25 },
    +          ],
    +          curve: 'rounded',
    +          cornerRadius: 12,
    +        },
    +        {
    +          id: 'horizontalBend',
    +          points: [
    +            { x: 0, y: 5 },
    +            { x: 20, y: 5 },
    +            { x: 20, y: 25 },
    +          ],
    +          curve: 'rounded',
    +          cornerRadius: 12,
    +        },
    +      ];
    +      const { group } = makeGroup([
    +        { id: 'verticalLine', d: 'M5,0 L5,25' },
    +        { id: 'horizontalBend', d: 'M0,5 L10,5 Q20,5 20,15 L20,25' },
    +      ]);
    +
    +      applyLineJumpsToSvg(group, edges, ARC_CONFIG);
    +
    +      const rewritten = group.node()!.querySelector('path[data-id="horizontalBend"]')!;
    +      expect(rewritten.getAttribute('d')).toContain('A1,1 0 0 1');
    +      expect(rewritten.getAttribute('d')).toContain('L10,5 Q20,5 20,15');
    +    });
    +
         it('skips edges whose curve hint is a true smoothing curve', () => {
           const edges: EdgeGeom[] = [
             {
    diff --git a/packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts b/packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts
    index 6b82c03f041..b4590394567 100644
    --- a/packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts
    +++ b/packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts
    @@ -12,10 +12,7 @@
     
     import type { D3Selection } from '../../types.js';
     import { markerOffsets } from '../../utils/lineWithOffset.js';
    -
    -/** Radius used by edges.js' generateRoundedPath. Kept in sync so rewritten
    - * paths look like the originals at bends. */
    -const ROUNDED_CORNER_RADIUS = 5;
    +import { resolveEdgeCornerRadius } from '../edgeCornerRadius.js';
     
     /** Skip the jump if its clamped radius falls below this — avoids invisible
      * zero-length arcs on very crowded paths. */
    @@ -64,6 +61,8 @@ export interface EdgeGeom {
        * corrupting smoothed geometry.
        */
       curve?: string;
    +  /** Radius used by the original rounded path; rewrites must reproduce the same corner cuts. */
    +  cornerRadius?: number;
       /** Arrow type at the start (first point) — used to apply marker offset so
        * the rewritten path's endpoint matches the original rendered geometry and
        * the arrow marker orients correctly. */
    @@ -188,17 +187,16 @@ function crossingSitsInRoundedCorner(edge: EdgeGeom, segIndex: number, t: number
       }
       const segLen = Math.hypot(b.x - a.x, b.y - a.y);
       const d = t * segLen;
    +  const cornerRadius = resolveEdgeCornerRadius(edge.cornerRadius);
     
       const entering =
    -    segIndex > 0 ? computeRoundedCorner(pts[segIndex - 1], a, b, ROUNDED_CORNER_RADIUS) : null;
    +    segIndex > 0 ? computeRoundedCorner(pts[segIndex - 1], a, b, cornerRadius) : null;
       if (entering && d < entering.cutLen) {
         return true;
       }
     
       const leaving =
    -    segIndex + 2 < pts.length
    -      ? computeRoundedCorner(a, b, pts[segIndex + 2], ROUNDED_CORNER_RADIUS)
    -      : null;
    +    segIndex + 2 < pts.length ? computeRoundedCorner(a, b, pts[segIndex + 2], cornerRadius) : null;
       return leaving !== null && segLen - d < leaving.cutLen;
     }
     
    @@ -422,6 +420,8 @@ function rewriteEdgePath(edge: EdgeGeom, jumps: Crossing[], config: LineJumpConf
       // Match edges.js: shift the first/last point inward so arrow markers line up.
       const points = applyMarkerOffsets(rawPoints, edge);
       const rounded = edge.curve === 'rounded';
    +  // A hop replaces the rendered path, so it must use the renderer's exact corner contract.
    +  const cornerRadius = resolveEdgeCornerRadius(edge.cornerRadius);
     
       // Jumps are indexed into the ORIGINAL (un-offset) segment list. For mid-
       // segments (i > 0 and i < n-2) the offsets don't change anything, and for
    @@ -464,7 +464,7 @@ function rewriteEdgePath(edge: EdgeGeom, jumps: Crossing[], config: LineJumpConf
             points[i - 1],
             points[i],
             points[i + 1] ?? points[i],
    -        ROUNDED_CORNER_RADIUS
    +        cornerRadius
           );
           if (corner) {
             segStartConsumed = corner.cutLen;
    @@ -479,7 +479,7 @@ function rewriteEdgePath(edge: EdgeGeom, jumps: Crossing[], config: LineJumpConf
             points[i],
             points[i + 1],
             points[i + 2] ?? points[i + 1],
    -        ROUNDED_CORNER_RADIUS
    +        cornerRadius
           );
           if (upcomingCorner) {
             segEndStop = segLen - upcomingCorner.cutLen;
    diff --git a/packages/mermaid/src/rendering-util/rendering-elements/orthogonalEdgeClipping.js b/packages/mermaid/src/rendering-util/rendering-elements/orthogonalEdgeClipping.js
    new file mode 100644
    index 00000000000..e806e0b780a
    --- /dev/null
    +++ b/packages/mermaid/src/rendering-util/rendering-elements/orthogonalEdgeClipping.js
    @@ -0,0 +1,139 @@
    +import { withLineIntersectionOrigin } from './intersect/intersection-query.js';
    +
    +// Clips only terminal legs. Interior bends remain router-owned so label reservations, line hops,
    +// and validation continue to observe the same orthogonal topology.
    +const ENDPOINT_EPSILON = 1e-6;
    +
    +const isFinitePoint = (point) => point && Number.isFinite(point.x) && Number.isFinite(point.y);
    +
    +const samePoint = (first, second) =>
    +  Math.abs(first.x - second.x) <= ENDPOINT_EPSILON &&
    +  Math.abs(first.y - second.y) <= ENDPOINT_EPSILON;
    +
    +const appendDistinctPoint = (points, point) => {
    +  if (!points.length || !samePoint(points.at(-1), point)) {
    +    points.push(point);
    +  }
    +};
    +
    +const outlineEndpoint = (node, port, adjacent) => {
    +  if (!node?.intersect) {
    +    return undefined;
    +  }
    +  const vertical = Math.abs(port.x - adjacent.x) <= ENDPOINT_EPSILON;
    +  const horizontal = Math.abs(port.y - adjacent.y) <= ENDPOINT_EPSILON;
    +  if (!vertical && !horizontal) {
    +    return undefined;
    +  }
    +  const inwardDirection = vertical
    +    ? Math.sign(port.y - adjacent.y)
    +    : Math.sign(port.x - adjacent.x);
    +  if (!inwardDirection) {
    +    return undefined;
    +  }
    +  const axisSize = vertical ? node.height : node.width;
    +  const terminalLength = vertical ? Math.abs(port.y - adjacent.y) : Math.abs(port.x - adjacent.x);
    +  const canQueryTerminalAxis =
    +    Number.isFinite(node.width) &&
    +    node.width > 0 &&
    +    Number.isFinite(node.height) &&
    +    node.height > 0 &&
    +    Number.isFinite(axisSize) &&
    +    Number.isFinite(terminalLength);
    +  const lineOrigin = vertical
    +    ? { x: port.x, y: port.y + inwardDirection * (axisSize + terminalLength + 1) }
    +    : { x: port.x + inwardDirection * (axisSize + terminalLength + 1), y: port.y };
    +  // Shared shape intersections understand this optional origin and intersect the terminal axis
    +  // instead of casting from the node centre. Older/custom intersections ignore it safely.
    +  const outline = node.intersect(
    +    canQueryTerminalAxis ? withLineIntersectionOrigin(adjacent, lineOrigin) : adjacent
    +  );
    +  if (
    +    !isFinitePoint(outline) ||
    +    (Number.isFinite(node.x) &&
    +      Number.isFinite(node.y) &&
    +      samePoint(outline, { x: node.x, y: node.y }))
    +  ) {
    +    return undefined;
    +  }
    +  // Never repair a centre-ray result with a sideways jog. Custom intersections that do not
    +  // understand terminal-axis queries retain the router-owned port instead.
    +  if (
    +    (vertical && Math.abs(outline.x - port.x) > ENDPOINT_EPSILON) ||
    +    (horizontal && Math.abs(outline.y - port.y) > ENDPOINT_EPSILON)
    +  ) {
    +    return undefined;
    +  }
    +  const inwardDepth = vertical
    +    ? (outline.y - port.y) * inwardDirection
    +    : (outline.x - port.x) * inwardDirection;
    +  // Ports on rectangular nodes already sit on the outline. Only replace ports whose shape
    +  // boundary is inward, otherwise clipping can push an endpoint outward or reverse its segment.
    +  if (inwardDepth <= ENDPOINT_EPSILON) {
    +    return undefined;
    +  }
    +  return {
    +    outline,
    +    elbow: vertical ? { x: outline.x, y: adjacent.y } : { x: adjacent.x, y: outline.y },
    +  };
    +};
    +
    +export const clipOrthogonalEndpointsToNodeOutlines = (points, tail, head) => {
    +  if (!Array.isArray(points) || points.length < 2) {
    +    return points;
    +  }
    +
    +  // Source and target decisions are independent: mixed shapes may require clipping at one end
    +  // while the other endpoint is already on its outline.
    +  const first = points[0];
    +  const second = points[1];
    +  const penultimate = points[points.length - 2];
    +  const last = points[points.length - 1];
    +  const source = outlineEndpoint(tail, first, second);
    +  const target = outlineEndpoint(head, last, penultimate);
    +  if (!source && !target) {
    +    return points;
    +  }
    +  const clipped = [];
    +
    +  if (points.length === 2) {
    +    const clippedFirst = source?.outline ?? first;
    +    const clippedLast = target?.outline ?? last;
    +    const vertical = Math.abs(first.x - last.x) <= ENDPOINT_EPSILON;
    +    const horizontal = Math.abs(first.y - last.y) <= ENDPOINT_EPSILON;
    +    if (!vertical && !horizontal) {
    +      return points;
    +    }
    +    // Add a midpoint dogleg so clipping differently shaped endpoints cannot create a diagonal.
    +    appendDistinctPoint(clipped, clippedFirst);
    +    if (vertical) {
    +      const middleY = (clippedFirst.y + clippedLast.y) / 2;
    +      appendDistinctPoint(clipped, { x: clippedFirst.x, y: middleY });
    +      appendDistinctPoint(clipped, { x: clippedLast.x, y: middleY });
    +    } else {
    +      const middleX = (clippedFirst.x + clippedLast.x) / 2;
    +      appendDistinctPoint(clipped, { x: middleX, y: clippedFirst.y });
    +      appendDistinctPoint(clipped, { x: middleX, y: clippedLast.y });
    +    }
    +    appendDistinctPoint(clipped, clippedLast);
    +    return clipped;
    +  }
    +
    +  if (source) {
    +    appendDistinctPoint(clipped, source.outline);
    +    appendDistinctPoint(clipped, source.elbow);
    +  } else {
    +    appendDistinctPoint(clipped, first);
    +  }
    +  for (const point of points.slice(1, -1)) {
    +    appendDistinctPoint(clipped, point);
    +  }
    +  if (target) {
    +    appendDistinctPoint(clipped, target.elbow);
    +    appendDistinctPoint(clipped, target.outline);
    +  } else {
    +    appendDistinctPoint(clipped, last);
    +  }
    +
    +  return clipped;
    +};
    diff --git a/packages/mermaid/src/rendering-util/types.ts b/packages/mermaid/src/rendering-util/types.ts
    index 8a385c3b2ee..89fe6ab22e4 100644
    --- a/packages/mermaid/src/rendering-util/types.ts
    +++ b/packages/mermaid/src/rendering-util/types.ts
    @@ -13,6 +13,11 @@ export type CheckFitFunction = (text: MarkdownLine) => boolean;
     
     interface BaseNode {
       id: string;
    +  /**
    +   * Stable authored identifier used to resolve placement maps when `id` is generated for rendering.
    +   * Edges and DOM lookup continue to use `id`; layouts use this field only as a configuration key.
    +   */
    +  placementId?: string;
       label?: string;
       description?: string[];
       /** Stereotype line rendered between label and description in multi-section labels, e.g. `[Container: Node.js]`. */
    @@ -181,6 +186,12 @@ export interface Edge {
       terminalLabelCenters?: Partial>;
       // Rendering specific properties
       curve?: string;
    +  /** Corner radius shared by initial path generation and later generic path rewrites. */
    +  cornerRadius?: number;
    +  /** Selects shape-aware clipping without coupling the shared renderer to a layout name. */
    +  portClipping?: 'outline-orthogonal';
    +  /** Preserves router-owned terminal and bend invariants during shared rendering. */
    +  skipCornerFix?: boolean;
       labelpos?: string;
       labelStyle?: string[];
       minlen?: number;
    diff --git a/packages/mermaid/src/schemas/config.schema.yaml b/packages/mermaid/src/schemas/config.schema.yaml
    index 8505aef4758..de899257ef5 100644
    --- a/packages/mermaid/src/schemas/config.schema.yaml
    +++ b/packages/mermaid/src/schemas/config.schema.yaml
    @@ -265,6 +265,9 @@ properties:
               When enabled, such edges are found before layout, on the graph with each subgraph collapsed, and handed to ELK in the other direction. The edge is drawn with its arrowhead at its real target. Cycles between plain nodes are left to `cycleBreakingStrategy`.
             type: boolean
             default: true
    +  grid:
    +    $ref: '#/$defs/GridLayoutConfig'
    +    default: {}
       darkMode:
         type: boolean
         default: false
    @@ -523,6 +526,118 @@ $defs: # JSON Schema definition (maybe we should move these to a separate file)
             description: |
               Defines which layout algorithm to use for rendering the diagram.
             type: string
    +  GridHorizontalAlign:
    +    description: Horizontal alignment for items within a grid cell.
    +    type: string
    +    enum:
    +      - left
    +      - center
    +      - right
    +  GridVerticalAlign:
    +    description: Vertical alignment for items or stacks within a grid cell.
    +    type: string
    +    enum:
    +      - top
    +      - center
    +      - bottom
    +  GridPlacement:
    +    title: Grid Placement
    +    type: object
    +    additionalProperties: false
    +    properties:
    +      row:
    +        description: Positive integer row number local to the item's direct parent grid.
    +        type: integer
    +        minimum: 1
    +      column:
    +        description: Positive integer column number local to the item's direct parent grid.
    +        type: integer
    +        minimum: 1
    +      horizontalAlign:
    +        description: Horizontal alignment within the resolved grid cell.
    +        $ref: '#/$defs/GridHorizontalAlign'
    +      verticalAlign:
    +        description: Vertical alignment for the resolved grid cell stack.
    +        $ref: '#/$defs/GridVerticalAlign'
    +  GridLayoutConfig:
    +    title: Grid Layout Config
    +    type: object
    +    additionalProperties: false
    +    properties:
    +      placements:
    +        description: Mapping of authored placement ids or emitted node ids to explicit grid placements. Arrays target repeated ids in source order; a single object is equivalent to a one-item array.
    +        type: object
    +        additionalProperties:
    +          oneOf:
    +            - $ref: '#/$defs/GridPlacement'
    +            - type: array
    +              minItems: 1
    +              items:
    +                $ref: '#/$defs/GridPlacement'
    +        default: {}
    +      columns:
    +        description: Non-negative integer candidate column count for auto-placed items; 0 means automatic.
    +        type: integer
    +        minimum: 0
    +        default: 0
    +      rowGap:
    +        description: Pixel gap between occupied rows.
    +        type: number
    +        minimum: 0
    +        default: 50
    +      columnGap:
    +        description: Pixel gap between occupied columns.
    +        type: number
    +        minimum: 0
    +        default: 50
    +      cellGap:
    +        description: Pixel gap between stacked items that share an explicit cell.
    +        type: number
    +        minimum: 0
    +        default: 20
    +      containerPadding:
    +        description: Minimum pixel padding inside groups on every side.
    +        type: number
    +        minimum: 0
    +        default: 20
    +      titleGap:
    +        description: Clearance between a measured group title and its child grid.
    +        type: number
    +        minimum: 0
    +        default: 8
    +      horizontalAlign:
    +        description: Default horizontal alignment within each grid cell.
    +        $ref: '#/$defs/GridHorizontalAlign'
    +        default: center
    +      verticalAlign:
    +        description: Default vertical alignment for each grid cell stack.
    +        $ref: '#/$defs/GridVerticalAlign'
    +        default: center
    +      curve:
    +        description: Curve style used to render grid edges.
    +        type: string
    +        enum:
    +          [
    +            'basis',
    +            'bumpX',
    +            'bumpY',
    +            'cardinal',
    +            'catmullRom',
    +            'linear',
    +            'monotoneX',
    +            'monotoneY',
    +            'natural',
    +            'step',
    +            'stepAfter',
    +            'stepBefore',
    +            'rounded',
    +          ]
    +        default: rounded
    +      edgeCornerRadius:
    +        description: Pixel radius used for grid edges when curve is rounded.
    +        type: number
    +        minimum: 0
    +        default: 5
       C4DiagramConfig:
         title: C4 Diagram Config
         allOf: [{ $ref: '#/$defs/BaseDiagramConfig' }]
    diff --git a/packages/mermaid/src/types.ts b/packages/mermaid/src/types.ts
    index 4650c11476b..6d4d283002b 100644
    --- a/packages/mermaid/src/types.ts
    +++ b/packages/mermaid/src/types.ts
    @@ -1,3 +1,17 @@
    +export type GridHorizontalAlign = 'left' | 'center' | 'right';
    +export type GridVerticalAlign = 'top' | 'center' | 'bottom';
    +
    +/**
    + * Authored, one-based grid placement. Omitted coordinates remain eligible for automatic placement.
    + * Only occupied rows and columns create tracks; empty indices contribute no size or additional gap.
    + */
    +export interface GridPlacement {
    +  row?: number;
    +  column?: number;
    +  horizontalAlign?: GridHorizontalAlign;
    +  verticalAlign?: GridVerticalAlign;
    +}
    +
     export interface NodeMetaData {
       shape?: string;
       label?: string;
    @@ -18,6 +32,11 @@ export interface NodeMetaData {
        * to it; `expanded` (the default) renders the subgraph normally.
        */
       view?: 'expanded' | 'collapsed';
    +  /** Inline grid placement metadata forwarded to layout after diagram-specific fields are handled. */
    +  row?: number;
    +  column?: number;
    +  horizontalAlign?: GridHorizontalAlign;
    +  verticalAlign?: GridVerticalAlign;
     }
     
     export interface ParticipantMetaData {
    diff --git a/packages/mermaid/src/utils/gridPlacement.spec.ts b/packages/mermaid/src/utils/gridPlacement.spec.ts
    new file mode 100644
    index 00000000000..45770ce7a3d
    --- /dev/null
    +++ b/packages/mermaid/src/utils/gridPlacement.spec.ts
    @@ -0,0 +1,32 @@
    +import { describe, expect, it } from 'vitest';
    +import {
    +  isGridHorizontalAlign,
    +  isGridVerticalAlign,
    +  isValidGridCoordinate,
    +} from './gridPlacement.js';
    +
    +describe('grid placement validation', () => {
    +  it('accepts positive safe integer coordinates', () => {
    +    expect(isValidGridCoordinate(1)).toBe(true);
    +    expect(isValidGridCoordinate(Number.MAX_SAFE_INTEGER)).toBe(true);
    +  });
    +
    +  it.each([0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, '1', {}, null])(
    +    'rejects invalid coordinate %j',
    +    (value) => {
    +      expect(isValidGridCoordinate(value)).toBe(false);
    +    }
    +  );
    +
    +  it('accepts only supported alignment values', () => {
    +    expect(isGridHorizontalAlign('left')).toBe(true);
    +    expect(isGridHorizontalAlign('center')).toBe(true);
    +    expect(isGridHorizontalAlign('right')).toBe(true);
    +    expect(isGridHorizontalAlign('middle')).toBe(false);
    +
    +    expect(isGridVerticalAlign('top')).toBe(true);
    +    expect(isGridVerticalAlign('center')).toBe(true);
    +    expect(isGridVerticalAlign('bottom')).toBe(true);
    +    expect(isGridVerticalAlign('middle')).toBe(false);
    +  });
    +});
    diff --git a/packages/mermaid/src/utils/gridPlacement.ts b/packages/mermaid/src/utils/gridPlacement.ts
    new file mode 100644
    index 00000000000..a4c258ebd97
    --- /dev/null
    +++ b/packages/mermaid/src/utils/gridPlacement.ts
    @@ -0,0 +1,163 @@
    +import type { GridHorizontalAlign, GridVerticalAlign } from '../types.js';
    +import { log } from '../logger.js';
    +
    +const UNSAFE_OBJECT_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
    +
    +export const GRID_HORIZONTAL_ALIGNMENTS = [
    +  'left',
    +  'center',
    +  'right',
    +] as const satisfies readonly GridHorizontalAlign[];
    +
    +export const GRID_VERTICAL_ALIGNMENTS = [
    +  'top',
    +  'center',
    +  'bottom',
    +] as const satisfies readonly GridVerticalAlign[];
    +
    +/** Grid coordinates are one-based author input, not zero-based internal cell indexes. */
    +export function isValidGridCoordinate(value: unknown): value is number {
    +  return (
    +    typeof value === 'number' && Number.isFinite(value) && Number.isSafeInteger(value) && value > 0
    +  );
    +}
    +
    +export function isGridHorizontalAlign(value: unknown): value is GridHorizontalAlign {
    +  return (
    +    typeof value === 'string' && (GRID_HORIZONTAL_ALIGNMENTS as readonly string[]).includes(value)
    +  );
    +}
    +
    +export function isGridVerticalAlign(value: unknown): value is GridVerticalAlign {
    +  return (
    +    typeof value === 'string' && (GRID_VERTICAL_ALIGNMENTS as readonly string[]).includes(value)
    +  );
    +}
    +
    +export function validateGridCoordinate(
    +  placementId: string,
    +  field: 'row' | 'column',
    +  value: unknown,
    +  nodeId = placementId
    +): asserts value is number | undefined {
    +  if (value === undefined) {
    +    return;
    +  }
    +  if (!isValidGridCoordinate(value)) {
    +    const generatedNodeContext = nodeId === placementId ? '' : ` (node "${nodeId}")`;
    +    const error = new Error(
    +      `GRID_INVALID_COORDINATE: Invalid ${field} for placement "${placementId}"${generatedNodeContext}`
    +    ) as Error & {
    +      code: 'GRID_INVALID_COORDINATE';
    +      details: Record;
    +    };
    +    error.code = 'GRID_INVALID_COORDINATE';
    +    error.details = {
    +      nodeId,
    +      placementId,
    +      field,
    +      value,
    +    };
    +    throw error;
    +  }
    +}
    +
    +export function validateGridPlacementCoordinates(
    +  placements: unknown,
    +  items: Iterable<{ id: string; metadata?: unknown }>
    +): void {
    +  if (typeof placements === 'object' && placements !== null && !Array.isArray(placements)) {
    +    for (const [placementId, value] of Object.entries(placements)) {
    +      const placementValues = Array.isArray(value) ? value : [value];
    +      for (const [index, placementValue] of placementValues.entries()) {
    +        if (
    +          typeof placementValue !== 'object' ||
    +          placementValue === null ||
    +          Array.isArray(placementValue)
    +        ) {
    +          continue;
    +        }
    +        const placement = placementValue as Record;
    +        const indexedPlacementId = Array.isArray(value) ? `${placementId}[${index}]` : placementId;
    +        if (Object.hasOwn(placement, 'row')) {
    +          validateGridCoordinate(indexedPlacementId, 'row', placement.row);
    +        }
    +        if (Object.hasOwn(placement, 'column')) {
    +          validateGridCoordinate(indexedPlacementId, 'column', placement.column);
    +        }
    +      }
    +    }
    +  }
    +
    +  for (const item of items) {
    +    if (
    +      typeof item.metadata !== 'object' ||
    +      item.metadata === null ||
    +      Array.isArray(item.metadata)
    +    ) {
    +      continue;
    +    }
    +    const metadata = item.metadata as Record;
    +    if (Object.hasOwn(metadata, 'row')) {
    +      validateGridCoordinate(item.id, 'row', metadata.row);
    +    }
    +    if (Object.hasOwn(metadata, 'column')) {
    +      validateGridCoordinate(item.id, 'column', metadata.column);
    +    }
    +  }
    +}
    +
    +/**
    + * Sanitizes the user-keyed placement dictionary without treating authored node IDs as config keys.
    + * Only the fixed placement shape crosses the directive trust boundary.
    + */
    +export function sanitizeGridPlacements(dict: Record): void {
    +  for (const key of Object.keys(dict)) {
    +    const value = dict[key];
    +    if (UNSAFE_OBJECT_KEYS.has(key) || typeof value !== 'object' || value === null) {
    +      log.debug('sanitize deleting object dictionary entry:', key, value);
    +      delete dict[key];
    +      continue;
    +    }
    +
    +    const placements = Array.isArray(value) ? value : [value];
    +    if (
    +      placements.length === 0 ||
    +      Object.keys(placements).length !== placements.length ||
    +      placements.some(
    +        (placement) =>
    +          typeof placement !== 'object' || placement === null || Array.isArray(placement)
    +      )
    +    ) {
    +      log.debug('sanitize deleting invalid grid placement sequence:', key, value);
    +      delete dict[key];
    +      continue;
    +    }
    +
    +    for (const [index, placementValue] of placements.entries()) {
    +      const placement = placementValue as Record;
    +      const placementId = Array.isArray(value) ? `${key}[${index}]` : key;
    +      for (const placementKey of Object.keys(placement)) {
    +        if (placementKey === 'row' || placementKey === 'column') {
    +          validateGridCoordinate(placementId, placementKey, placement[placementKey]);
    +          continue;
    +        }
    +        if (!isValidGridPlacementProperty(placementKey, placement[placementKey])) {
    +          log.debug('sanitize deleting grid placement property:', placementKey);
    +          delete placement[placementKey];
    +        }
    +      }
    +    }
    +  }
    +}
    +
    +function isValidGridPlacementProperty(key: string, value: unknown): boolean {
    +  switch (key) {
    +    case 'horizontalAlign':
    +      return isGridHorizontalAlign(value);
    +    case 'verticalAlign':
    +      return isGridVerticalAlign(value);
    +    default:
    +      return false;
    +  }
    +}
    diff --git a/packages/mermaid/src/utils/sanitizeDirective.spec.ts b/packages/mermaid/src/utils/sanitizeDirective.spec.ts
    index 0018b9b8363..8ccc3ce8d4f 100644
    --- a/packages/mermaid/src/utils/sanitizeDirective.spec.ts
    +++ b/packages/mermaid/src/utils/sanitizeDirective.spec.ts
    @@ -54,5 +54,153 @@ describe('sanitizeDirective', () => {
           sanitizeDirective(args);
           expect(args.sankey.nodeColors).toEqual({ a: '#ff0000', b: 'rgb(0, 0, 0)' });
         });
    +
    +    it('preserves grid placements keyed by node id', () => {
    +      const args = {
    +        grid: {
    +          placements: {
    +            G: { row: 1, column: 2 },
    +            protocolGateway: { row: 2 },
    +            prototypeA: { column: 3 },
    +            constraintSolver: { horizontalAlign: 'center' },
    +            constructorNode: { verticalAlign: 'top' },
    +            __proto__hack: { row: 4 },
    +            'group.with-punctuation': {
    +              horizontalAlign: 'left',
    +              verticalAlign: 'bottom',
    +            },
    +          },
    +        },
    +      };
    +      sanitizeDirective(args);
    +      expect(args.grid.placements).toEqual({
    +        G: { row: 1, column: 2 },
    +        protocolGateway: { row: 2 },
    +        prototypeA: { column: 3 },
    +        constraintSolver: { horizontalAlign: 'center' },
    +        constructorNode: { verticalAlign: 'top' },
    +        __proto__hack: { row: 4 },
    +        'group.with-punctuation': {
    +          horizontalAlign: 'left',
    +          verticalAlign: 'bottom',
    +        },
    +      });
    +    });
    +
    +    it('drops unsafe grid placement ids, non-object values, and unknown properties', () => {
    +      const args = {
    +        grid: {
    +          placements: {
    +            valid: { row: 1, notAConfigKey: 'x' },
    +            ['__proto__']: { column: 1 },
    +            constructor: { column: 2 },
    +            prototype: { column: 3 },
    +            scalar: 3,
    +          },
    +        },
    +      };
    +      sanitizeDirective(args);
    +      expect(args.grid.placements).toEqual({ valid: { row: 1 } });
    +    });
    +
    +    it('deletes invalid grid alignments but preserves valid placement properties', () => {
    +      const args = {
    +        grid: {
    +          placements: {
    +            mixed: {
    +              column: 2,
    +              horizontalAlign: 'middle',
    +              verticalAlign: 'bottom',
    +            },
    +          },
    +        },
    +      };
    +      sanitizeDirective(args);
    +      expect(args.grid.placements).toEqual({
    +        mixed: { column: 2, verticalAlign: 'bottom' },
    +      });
    +    });
    +
    +    it('preserves occurrence-indexed grid placement arrays', () => {
    +      const args = {
    +        grid: {
    +          placements: {
    +            service: [
    +              { row: 1, column: 1 },
    +              { row: 1, column: 2, horizontalAlign: 'right', unknown: true },
    +            ],
    +          },
    +        },
    +      };
    +
    +      sanitizeDirective(args);
    +
    +      expect(args.grid.placements).toEqual({
    +        service: [
    +          { row: 1, column: 1 },
    +          { row: 1, column: 2, horizontalAlign: 'right' },
    +        ],
    +      });
    +    });
    +
    +    it.each([{ service: [] }, { service: [null] }, { service: [{ row: 1 }, 'invalid'] }])(
    +      'drops invalid grid placement arrays %#',
    +      ({ service }) => {
    +        const args = {
    +          grid: {
    +            placements: { service },
    +          },
    +        };
    +
    +        sanitizeDirective(args);
    +
    +        expect(args.grid.placements).toEqual({});
    +      }
    +    );
    +
    +    it.each([
    +      ['row', 0],
    +      ['row', 'first'],
    +      ['column', 1.5],
    +      ['column', null],
    +    ])('rejects invalid grid placement %s values', (field, value) => {
    +      const args = {
    +        grid: {
    +          placements: {
    +            A: { [field]: value },
    +          },
    +        },
    +      };
    +
    +      expect(() => sanitizeDirective(args)).toThrow(
    +        `GRID_INVALID_COORDINATE: Invalid ${field} for placement "A"`
    +      );
    +    });
    +
    +    it('reports the array index for invalid occurrence coordinates', () => {
    +      const args = {
    +        grid: {
    +          placements: {
    +            service: [{ row: 1 }, { row: 0 }],
    +          },
    +        },
    +      };
    +
    +      expect(() => sanitizeDirective(args)).toThrow(
    +        'GRID_INVALID_COORDINATE: Invalid row for placement "service[1]"'
    +      );
    +    });
    +
    +    it('does not apply grid placement sanitization outside grid config', () => {
    +      const args = {
    +        flowchart: {
    +          placements: {
    +            protocolGateway: { row: 1 },
    +          },
    +        },
    +      };
    +      sanitizeDirective(args);
    +      expect(args.flowchart.placements).toEqual({});
    +    });
       });
     });
    diff --git a/packages/mermaid/src/utils/sanitizeDirective.ts b/packages/mermaid/src/utils/sanitizeDirective.ts
    index b9a23b4925d..f1f05e1da5e 100644
    --- a/packages/mermaid/src/utils/sanitizeDirective.ts
    +++ b/packages/mermaid/src/utils/sanitizeDirective.ts
    @@ -1,5 +1,6 @@
     import { configKeys } from '../defaultConfig.js';
     import { log } from '../logger.js';
    +import { sanitizeGridPlacements } from './gridPlacement.js';
     
     /**
      * Dictionary-style configs have arbitrary user-defined keys, so instead of
    @@ -30,12 +31,9 @@ const sanitizeDictionaryConfig = (dict: Record, valuePattern: R
       }
     };
     
    -/**
    - * Sanitizes directive objects
    - *
    - * @param args - Directive's JSON
    - */
    -export const sanitizeDirective = (args: any): void => {
    +// Track the containing config section because `placements` contains authored node IDs that are not
    +// present in configKeys and therefore needs shape validation instead of normal key recursion.
    +const sanitizeDirectiveValue = (args: any, parentKey?: string): void => {
       log.debug('sanitizeDirective called with', args);
     
       // Return if not an object
    @@ -45,7 +43,7 @@ export const sanitizeDirective = (args: any): void => {
     
       // Sanitize each element if an array
       if (Array.isArray(args)) {
    -    args.forEach((arg) => sanitizeDirective(arg));
    +    args.forEach((arg) => sanitizeDirectiveValue(arg, parentKey));
         return;
       }
     
    @@ -70,9 +68,11 @@ export const sanitizeDirective = (args: any): void => {
           const valuePattern = DICTIONARY_CONFIG_PATTERNS[key];
           if (valuePattern) {
             sanitizeDictionaryConfig(args[key], valuePattern);
    +      } else if (parentKey === 'grid' && key === 'placements') {
    +        sanitizeGridPlacements(args[key]);
           } else {
             log.debug('sanitizing object', key);
    -        sanitizeDirective(args[key]);
    +        sanitizeDirectiveValue(args[key], key);
           }
           continue;
         }
    @@ -97,6 +97,15 @@ export const sanitizeDirective = (args: any): void => {
       log.debug('After sanitization', args);
     };
     
    +/**
    + * Sanitizes directive objects
    + *
    + * @param args - Directive's JSON
    + */
    +export const sanitizeDirective = (args: any): void => {
    +  sanitizeDirectiveValue(args);
    +};
    +
     export const sanitizeCss = (str: string): string => {
       let startCnt = 0;
       let endCnt = 0;