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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ Configuration lives at exactly one of two levels: in the package (`eslint.config
| [`graph-schema`](packages/core/graph/schema/README.md) | The process model: typed identifier spaces, `defineModel` with compile-time reference checking, structural validation. |
| [`graph-projection`](packages/core/graph/projection/README.md) | Projections of the model into a flowchart, swimlane, state machine and sequence diagram, plus derived hand-offs. |
| [`graph-domain`](packages/core/graph/domain/README.md) | Reasoning over the model and a run overlay: control graph, illegal cycles, three-valued status, ready set, blocked-by, impact, what-if and critical path. |
| [`graph-layout`](packages/core/graph/layout/README.md) | Layered layout of a projected flowchart, swimlane or state machine into positions in the model's `views` shape. |
| [`process-graph`](packages/targets/process-graph/README.md) | The primary entry point: re-exports the schema, projection and domain APIs flat and the DAG API as the `dag` namespace. |

## Conventions
Expand Down
1 change: 1 addition & 0 deletions packages/core/graph/layout/AGENTS.md
1 change: 1 addition & 0 deletions packages/core/graph/layout/CLAUDE.md
64 changes: 64 additions & 0 deletions packages/core/graph/layout/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# @exadev/process-graph-layout

[![GitHub](https://img.shields.io/badge/GitHub-181717?logo=github&logoColor=white)](https://github.com/ExaDev/process-graph) [![npm](https://img.shields.io/badge/npm-CB3837?logo=npm&logoColor=white)](https://www.npmjs.com/package/@exadev/process-graph-layout) [![Release](https://img.shields.io/github/v/release/ExaDev/process-graph)](https://github.com/ExaDev/process-graph/releases/latest) [![CI](https://img.shields.io/github/actions/workflow/status/ExaDev/process-graph/ci.yml?branch=main)](https://github.com/ExaDev/process-graph/actions)

Layered layout of a projected view from `@exadev/process-graph-projection`. `layout(view, options)` is a pure async function: it reads a flowchart, swimlane or state machine, never mutates anything, and returns positions in the model's `views` shape, so layout stays apart from the semantic model and no projection reads a coordinate. It runs unchanged in a browser, a Worker or Node: `src/` imports nothing Node-only, and the default engine is elkjs's bundled build, which needs no Worker.

```ts
import { toSwimlane } from "@exadev/process-graph-projection";
import { layout } from "@exadev/process-graph-layout";

const { views, sizes, lanes } = await layout(toSwimlane(model), {
direction: "down",
signal: controller.signal,
});

const next = { ...model, views: { ...model.views, ...views } };
```

## API

| Export | Kind | What it is |
| -------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------- |
| `layout` | function | `layout(view, options?)` resolves to a `LayoutResult`; rejects with the signal's reason when it aborts. |
| `LayoutView` | type | The views that can be placed: `Flowchart`, `Swimlane` and `StateMachine`. A sequence diagram has no graph to place. |
| `LayoutOptions` | type | `direction`, `spacing`, `signal` and `engine`, all optional. |
| `LayoutResult` | type | `views`, `sizes` and `lanes`. |
| `LaneBounds` | type | A lane's participant and its `x`, `y`, `width` and `height`. |
| `LayoutEngine` | type | The injected port: `arrange(graph, signal)` resolves to one box per node. |
| `createElkEngine` | function | The default engine, elkjs's layered algorithm in the calling context. |
| `EngineGraph` and siblings | types | The engine-neutral request: nodes (leaves and groups), edges, direction and gaps. |

### Result

- `views` is `{ [notation]: { [elementId]: { x, y } } }`, exactly the model's `Views` shape, with the key `flowchart`, `swimlane` or `stateMachine` after the view laid out. A position is the top-left corner of the element's box. Assign it to the model's `views` to store it.
- `sizes` is each element's extent, which a position alone does not give. Sizes come from the element's shape and are constants of this package.
- `lanes` holds one entry per swimlane lane, in the view's lane order, and is empty for the other views. Every node of a lane lies inside that lane's bounds, and lanes do not overlap.

### Options

- `direction` is `"right"` (default), `"down"`, `"left"` or `"up"`: the way edges generally point. A fork or join bar is drawn across the flow, so its size follows the direction.
- `spacing.node` is the gap between nodes in one layer and `spacing.layer` the gap between layers. They default to constants derived from one grid unit, so shapes of different kinds align.
- `signal` aborts the layout: the promise rejects with `signal.reason`, whether the signal was already aborted or aborts while the engine works.
- `engine` replaces the default engine.

## Swimlanes

Each lane is a group that contains its nodes, and the layered algorithm lays the groups out together with their contents, so a hand-off between lanes takes part in layer assignment. Nodes with no actor sit outside every lane. A lane with no nodes keeps a minimum extent so it stays visible. Lanes are placed as the algorithm arranges them, not as parallel bands.

## Determinism

The same view and options give the same positions with the default engine: elkjs's randomised choices are seeded with a fixed value, and the request lists nodes in the view's own order.

## Injecting an engine

`LayoutEngine` is the only place elkjs appears. A test, or an environment that wants layout off the calling thread, supplies its own: `arrange` receives an `EngineGraph` and returns an absolute-coordinate `EngineBox` for every node, groups included. `layout` stops waiting when the signal aborts and passes the signal to the engine so it can stop too.

## Commands

```sh
pnpm build # tsc -b -> dist/ (ESM + .d.ts)
pnpm test # unit tests
pnpm test:smoke # the built dist/ exercised as a real artefact
pnpm test:mutation # Stryker
```
9 changes: 9 additions & 0 deletions packages/core/graph/layout/eslint.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { defineConfig } from "eslint/config";
import { packageLintConfig } from "../../../../eslint.shared.ts";

export default defineConfig(
packageLintConfig({
tsconfigRootDir: import.meta.dirname,
isomorphic: true,
}),
);
76 changes: 76 additions & 0 deletions packages/core/graph/layout/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
{
"name": "@exadev/process-graph-layout",
"version": "0.0.0",
"description": "Layered layout of a projected process view into positions keyed by element id, in the model's views shape.",
"type": "module",
"repository": {
"type": "git",
"url": "git+https://github.com/ExaDev/process-graph.git",
"directory": "packages/core/graph/layout"
},
"homepage": "https://github.com/ExaDev/process-graph/tree/main/packages/core/graph/layout#readme",
"bugs": {
"url": "https://github.com/ExaDev/process-graph/issues"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"publishConfig": {
"access": "public",
"provenance": true,
"registry": "https://registry.npmjs.org/"
},
"sideEffects": false,
"license": "MIT",
"scripts": {
"build": "turbo run _build",
"_build": "tsc -b tsconfig.build.json",
"lint": "turbo run _lint",
"_lint": "eslint . --cache --max-warnings 0",
"_lint:fix": "eslint . --fix --cache --max-warnings 0",
"typecheck": "turbo run _typecheck _typecheck:attw",
"_typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.node.json --noEmit",
"_typecheck:attw": "attw --pack --profile esm-only",
"test": "turbo run _test",
"_test": "vitest run --project unit",
"test:coverage": "turbo run _test:coverage",
"_test:coverage": "vitest run --project unit --coverage",
"test:smoke": "turbo run _test:smoke",
"_test:smoke": "vitest run --project smoke",
"test:mutation": "turbo run _test:mutation",
"_test:mutation": "stryker run stryker.config.ts",
"prepublishOnly": "pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run build && publint && attw --pack --profile esm-only"
},
"keywords": [
"process-model",
"layout",
"elk",
"flowchart",
"swimlane",
"state-machine"
],
"devDependencies": {
"@arethetypeswrong/cli": "0.18.5",
"@stryker-mutator/core": "10.0.0",
"@stryker-mutator/typescript-checker": "10.0.0",
"@stryker-mutator/vitest-runner": "10.0.0",
"@types/node": "26.6.4",
"@vitest/coverage-v8": "4.1.11",
"eslint": "10.12.0",
"publint": "0.3.25",
"typescript": "6.0.3",
"vitest": "4.1.11"
},
"dependencies": {
"@exadev/process-graph-projection": "1.0.1",
"@exadev/process-graph-schema": "1.0.1",
"elkjs": "0.12.0"
}
}
105 changes: 105 additions & 0 deletions packages/core/graph/layout/src/elk-engine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import ELK, {
type ElkExtendedEdge,
type ElkNode,
type LayoutOptions,
} from "elkjs/lib/elk.bundled.js";
import type {
Direction,
EngineBox,
EngineGraph,
EngineNode,
LayoutEngine,
} from "./engine.ts";

/**
* Fixes ELK's randomised tie-breaking, so the same graph always yields the same boxes. ELK's own default is the same value; it is set explicitly so a change of default cannot make output depend on the library version.
*/
const RANDOM_SEED = "1";

const elkDirection: Readonly<Record<Direction, string>> = {
right: "RIGHT",
down: "DOWN",
left: "LEFT",
up: "UP",
};

const toElkNode = (node: EngineNode, groupPadding: number): ElkNode => {
if (node.children === undefined) {
return { id: node.id, width: node.width, height: node.height };
}
const layoutOptions: LayoutOptions = {
"elk.padding": `[top=${groupPadding},left=${groupPadding},bottom=${groupPadding},right=${groupPadding}]`,
"elk.nodeSize.constraints": "MINIMUM_SIZE",
"elk.nodeSize.minimum": `(${node.width},${node.height})`,
};

return {
id: node.id,
layoutOptions,
children: node.children.map((child) => toElkNode(child, groupPadding)),
};
};

/** Flattens ELK's parent-relative coordinates into absolute boxes, outermost nodes first. */
const flatten = (root: ElkNode): readonly EngineBox[] => {
const boxes: EngineBox[] = [];
const stack: {
readonly node: ElkNode;
readonly x: number;
readonly y: number;
}[] = [];
for (const child of root.children ?? []) {
stack.push({ node: child, x: 0, y: 0 });
}
for (let next = stack.pop(); next !== undefined; next = stack.pop()) {
const { node, x: originX, y: originY } = next;
const x = originX + (node.x ?? 0);
const y = originY + (node.y ?? 0);
boxes.push({
id: node.id,
x,
y,
width: node.width ?? 0,
height: node.height ?? 0,
});
for (const child of node.children ?? []) {
stack.push({ node: child, x, y });
}
}

return boxes;
};

/**
* The default engine: elkjs's layered algorithm, run in the calling context from the bundled build so it needs neither a Worker nor Node-only modules. Groups are laid out together with their children (`INCLUDE_CHILDREN`), so an edge between lanes takes part in layer assignment.
*/
export const createElkEngine = (): LayoutEngine => {
const elk = new ELK();

return {
async arrange(graph: EngineGraph): Promise<readonly EngineBox[]> {
const edges: ElkExtendedEdge[] = graph.edges.map((edge) => ({
id: edge.id,
sources: [edge.source],
targets: [edge.target],
}));
const root: ElkNode = {
id: "root",
layoutOptions: {
"elk.algorithm": "layered",
"elk.direction": elkDirection[graph.direction],
"elk.spacing.nodeNode": String(graph.nodeGap),
"elk.layered.spacing.nodeNodeBetweenLayers": String(graph.layerGap),
"elk.hierarchyHandling": "INCLUDE_CHILDREN",
"elk.randomSeed": RANDOM_SEED,
},
children: graph.nodes.map((node) =>
toElkNode(node, graph.groupPadding),
),
edges,
};

return flatten(await elk.layout(root));
},
};
};
61 changes: 61 additions & 0 deletions packages/core/graph/layout/src/engine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/** A box with a fixed size, which the engine places. */
export interface EngineLeaf {
readonly id: string;
readonly width: number;
readonly height: number;
readonly children?: never;
}

/**
* A box that encloses other nodes. The engine sizes it to fit its children, and never makes it smaller than `width` by `height`, so an empty group keeps a usable extent.
*/
export interface EngineGroup {
readonly id: string;
readonly width: number;
readonly height: number;
readonly children: readonly EngineNode[];
}

export type EngineNode = EngineLeaf | EngineGroup;

/** A directed edge between two leaves, which may sit in different groups. */
export interface EngineEdge {
readonly id: string;
readonly source: string;
readonly target: string;
}

/** The direction in which edges generally point. */
export type Direction = "right" | "down" | "left" | "up";

/** A layered layout request, free of any engine's own vocabulary. */
export interface EngineGraph {
readonly direction: Direction;
/** The gap between two nodes in the same layer. */
readonly nodeGap: number;
/** The gap between two consecutive layers. */
readonly layerGap: number;
/** The space between a group's border and its children. */
readonly groupPadding: number;
readonly nodes: readonly EngineNode[];
readonly edges: readonly EngineEdge[];
}

/** One placed node, with its top-left corner in the coordinate space of the whole graph. */
export interface EngineBox {
readonly id: string;
readonly x: number;
readonly y: number;
readonly width: number;
readonly height: number;
}

/**
* The layout engine behind `layout`. An implementation must place every node of the graph, groups included, and return the same boxes for the same graph. It may run in the calling context or across a transport such as a Worker; `layout` stops waiting for it when the signal aborts, and an implementation should stop work as well.
*/
export interface LayoutEngine {
readonly arrange: (
graph: EngineGraph,
signal: AbortSignal | undefined,
) => Promise<readonly EngineBox[]>;
}
22 changes: 22 additions & 0 deletions packages/core/graph/layout/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Layered layout of a projected process view. `layout` places a flowchart, swimlane or state machine and returns positions in the model's `views` shape, so layout stays apart from the semantic model and no projection reads a coordinate.
*/
export { layout } from "./layout.ts";
export type {
LaneBounds,
LayoutOptions,
LayoutResult,
LayoutView,
} from "./layout.ts";
export { createElkEngine } from "./elk-engine.ts";
export type {
Direction,
EngineBox,
EngineEdge,
EngineGraph,
EngineGroup,
EngineLeaf,
EngineNode,
LayoutEngine,
} from "./engine.ts";
export type { Size } from "./metrics.ts";
Loading