diff --git a/README.md b/README.md index c6c1551..2293038 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/packages/core/graph/layout/AGENTS.md b/packages/core/graph/layout/AGENTS.md new file mode 120000 index 0000000..42061c0 --- /dev/null +++ b/packages/core/graph/layout/AGENTS.md @@ -0,0 +1 @@ +README.md \ No newline at end of file diff --git a/packages/core/graph/layout/CLAUDE.md b/packages/core/graph/layout/CLAUDE.md new file mode 120000 index 0000000..42061c0 --- /dev/null +++ b/packages/core/graph/layout/CLAUDE.md @@ -0,0 +1 @@ +README.md \ No newline at end of file diff --git a/packages/core/graph/layout/README.md b/packages/core/graph/layout/README.md new file mode 100644 index 0000000..a8fe942 --- /dev/null +++ b/packages/core/graph/layout/README.md @@ -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 +``` diff --git a/packages/core/graph/layout/eslint.config.ts b/packages/core/graph/layout/eslint.config.ts new file mode 100644 index 0000000..4c43201 --- /dev/null +++ b/packages/core/graph/layout/eslint.config.ts @@ -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, + }), +); diff --git a/packages/core/graph/layout/package.json b/packages/core/graph/layout/package.json new file mode 100644 index 0000000..de4ecf7 --- /dev/null +++ b/packages/core/graph/layout/package.json @@ -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" + } +} diff --git a/packages/core/graph/layout/src/elk-engine.ts b/packages/core/graph/layout/src/elk-engine.ts new file mode 100644 index 0000000..6ff7a5d --- /dev/null +++ b/packages/core/graph/layout/src/elk-engine.ts @@ -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> = { + 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 { + 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)); + }, + }; +}; diff --git a/packages/core/graph/layout/src/engine.ts b/packages/core/graph/layout/src/engine.ts new file mode 100644 index 0000000..a5036f3 --- /dev/null +++ b/packages/core/graph/layout/src/engine.ts @@ -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; +} diff --git a/packages/core/graph/layout/src/index.ts b/packages/core/graph/layout/src/index.ts new file mode 100644 index 0000000..5040655 --- /dev/null +++ b/packages/core/graph/layout/src/index.ts @@ -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"; diff --git a/packages/core/graph/layout/src/layout.ts b/packages/core/graph/layout/src/layout.ts new file mode 100644 index 0000000..2003496 --- /dev/null +++ b/packages/core/graph/layout/src/layout.ts @@ -0,0 +1,297 @@ +import type { + Flowchart, + FlowNode, + StateMachine, + StateMachineState, + Swimlane, +} from "@exadev/process-graph-projection"; +import type { + IdSpace, + Notation, + Point, + Views, +} from "@exadev/process-graph-schema"; +import { createElkEngine } from "./elk-engine.ts"; +import type { + Direction, + EngineBox, + EngineEdge, + EngineNode, + LayoutEngine, +} from "./engine.ts"; +import { + barSize, + DECISION_SIZE, + DEFAULT_LAYER_GAP, + DEFAULT_NODE_GAP, + LANE_MINIMUM_SIZE, + LANE_PADDING, + PROCESS_SIZE, + PSEUDO_STATE_SIZE, + TERMINATOR_SIZE, + type Size, +} from "./metrics.ts"; + +/** + * A projection result that can be laid out. A sequence diagram is not here: its lifelines and messages have a fixed arrangement, not a graph to place. + */ +export type LayoutView = + Flowchart | Swimlane | StateMachine; + +export interface LayoutOptions { + /** The direction in which edges generally point. Defaults to `"right"`. */ + readonly direction?: Direction; + /** Overrides for the gaps, in layout units. Each defaults to a grid-derived constant. */ + readonly spacing?: { + /** The gap between two nodes in the same layer. */ + readonly node?: number; + /** The gap between consecutive layers. */ + readonly layer?: number; + }; + /** Rejects the returned promise with the signal's reason when it aborts. */ + readonly signal?: AbortSignal; + /** The engine that places the nodes. Defaults to elkjs's layered algorithm run in the calling context. */ + readonly engine?: LayoutEngine; +} + +/** The bounds of one swimlane, in the same coordinate space as the node positions. */ +export interface LaneBounds { + readonly participant: I["participant"] | I["actor"]; + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +} + +export interface LayoutResult { + /** Top-left positions keyed by notation and element id: the model's `views` shape, ready to assign to `Model.views`. */ + readonly views: Views; + /** The extent of each placed element, which a position alone does not give. */ + readonly sizes: Readonly>>; + /** One entry per swimlane lane, in the view's lane order; empty for a flowchart and a state machine. */ + readonly lanes: readonly LaneBounds[]; +} + +const flowNodeSizes: Readonly< + Record["shape"], "bar">, Size> +> = { + process: PROCESS_SIZE, + terminator: TERMINATOR_SIZE, + decision: DECISION_SIZE, +}; + +const flowNodeSize = ( + shape: FlowNode["shape"], + direction: Direction, +): Size => (shape === "bar" ? barSize(direction) : flowNodeSizes[shape]); + +const stateSize = (role: StateMachineState["role"]): Size => + role === "state" ? PROCESS_SIZE : PSEUDO_STATE_SIZE; + +interface Prepared { + readonly notation: Extract< + Notation, + "flowchart" | "swimlane" | "stateMachine" + >; + readonly elements: readonly { + readonly id: I["element"]; + readonly size: Size; + }[]; + readonly edges: readonly { + readonly from: I["element"]; + readonly to: I["element"]; + }[]; + readonly lanes: readonly { + readonly participant: I["participant"] | I["actor"]; + readonly nodes: readonly I["element"][]; + }[]; +} + +const prepare = ( + view: LayoutView, + direction: Direction, +): Prepared => { + if ("states" in view) { + return { + notation: "stateMachine", + elements: view.states.map((state) => ({ + id: state.id, + size: stateSize(state.role), + })), + edges: view.transitions.map((transition) => ({ + from: transition.source, + to: transition.target, + })), + lanes: [], + }; + } + const elements = view.nodes.map((node) => ({ + id: node.id, + size: flowNodeSize(node.shape, direction), + })); + if ("lanes" in view) { + return { + notation: "swimlane", + elements, + edges: view.edges, + lanes: view.lanes, + }; + } + + return { notation: "flowchart", elements, edges: view.edges, lanes: [] }; +}; + +/** + * Settles by throwing the signal's own reason once it aborts, and never settles if `stop` aborts first, which also removes the listener. + */ +const whenAborted = async ( + signal: AbortSignal, + stop: AbortSignal, +): Promise => { + await new Promise((resolve) => { + signal.addEventListener( + "abort", + () => { + resolve(); + }, + { once: true, signal: stop }, + ); + }); + signal.throwIfAborted(); + throw new Error("The signal aborted without a reason"); +}; + +/** + * Places a projected view with a layered layout and returns where each element goes, as the model's `views` shape. + * + * Pure with respect to the model: it reads only the view, never mutates it, and the model stores the result apart from its semantics, so no projection reads a coordinate. The same view and options give the same positions with the default engine. Swimlane lanes are groups, and every node lies inside its lane's bounds. + * @param view - A flowchart, swimlane or state machine from the projection package. + * @param options - Direction, spacing, abort signal and engine. + * @returns Positions keyed by notation and element id, element sizes and lane bounds. + * @throws The signal's reason when `options.signal` aborts before the layout completes. + */ +export const layout = async ( + view: LayoutView, + options: LayoutOptions = {}, +): Promise> => { + const { + direction = "right", + spacing, + signal, + engine = createElkEngine(), + } = options; + signal?.throwIfAborted(); + + const prepared = prepare(view, direction); + + const elementIds = new Map(); + const leaves = new Map< + string, + { readonly id: I["element"]; readonly size: Size } + >(); + prepared.elements.forEach((element, index) => { + const id = `n${index}`; + elementIds.set(element.id, id); + leaves.set(id, element); + }); + + const leafNode = (id: string, size: Size): EngineNode => ({ id, ...size }); + const nodeFor = (element: I["element"]): EngineNode | undefined => { + const id = elementIds.get(element); + const leaf = id === undefined ? undefined : leaves.get(id); + + return id === undefined || leaf === undefined + ? undefined + : leafNode(id, leaf.size); + }; + + const placedInLane = new Set(); + const laneIds: string[] = []; + const nodes: EngineNode[] = []; + prepared.lanes.forEach((lane, index) => { + const id = `l${index}`; + laneIds.push(id); + const children: EngineNode[] = []; + for (const element of lane.nodes) { + const child = nodeFor(element); + if (child !== undefined) { + placedInLane.add(element); + children.push(child); + } + } + nodes.push({ id, ...LANE_MINIMUM_SIZE, children }); + }); + for (const element of prepared.elements) { + if (!placedInLane.has(element.id)) { + const node = nodeFor(element.id); + if (node !== undefined) { + nodes.push(node); + } + } + } + + const edges: EngineEdge[] = []; + prepared.edges.forEach((edge, index) => { + const source = elementIds.get(edge.from); + const target = elementIds.get(edge.to); + if (source !== undefined && target !== undefined) { + edges.push({ id: `e${index}`, source, target }); + } + }); + + const request = engine.arrange( + { + direction, + nodeGap: spacing?.node ?? DEFAULT_NODE_GAP, + layerGap: spacing?.layer ?? DEFAULT_LAYER_GAP, + groupPadding: LANE_PADDING, + nodes, + edges, + }, + signal, + ); + let boxes: readonly EngineBox[]; + if (signal === undefined) { + boxes = await request; + } else { + const stop = new AbortController(); + try { + boxes = await Promise.race([request, whenAborted(signal, stop.signal)]); + } finally { + stop.abort(); + } + } + + const byId = new Map(boxes.map((box) => [box.id, box])); + const positions: Partial> = {}; + const sizes: Partial> = {}; + for (const [id, element] of leaves) { + const box = byId.get(id); + if (box === undefined) { + throw new Error( + `The layout engine returned no box for element "${element.id}"`, + ); + } + positions[element.id] = { x: box.x, y: box.y }; + sizes[element.id] = { width: box.width, height: box.height }; + } + + const lanes: LaneBounds[] = prepared.lanes.map((lane, index) => { + const box = byId.get(laneIds[index] ?? ""); + if (box === undefined) { + throw new Error( + `The layout engine returned no box for lane "${lane.participant}"`, + ); + } + + return { + participant: lane.participant, + x: box.x, + y: box.y, + width: box.width, + height: box.height, + }; + }); + + return { views: { [prepared.notation]: positions }, sizes, lanes }; +}; diff --git a/packages/core/graph/layout/src/layout.unit.test.ts b/packages/core/graph/layout/src/layout.unit.test.ts new file mode 100644 index 0000000..1042cad --- /dev/null +++ b/packages/core/graph/layout/src/layout.unit.test.ts @@ -0,0 +1,368 @@ +import { + toFlowchart, + toStateMachine, + toSwimlane, +} from "@exadev/process-graph-projection"; +import { defineModel } from "@exadev/process-graph-schema"; +import { describe, expect, it } from "vitest"; +import type { + EngineBox, + EngineGraph, + EngineGroup, + LayoutEngine, +} from "./engine.ts"; +import { layout } from "./layout.ts"; + +const diamond = defineModel({ + participants: [], + events: [], + actions: [], + artefacts: [], + elements: [ + { kind: "activity", id: "split", label: "Split" }, + { kind: "activity", id: "left", label: "Left" }, + { kind: "activity", id: "right", label: "Right" }, + { kind: "activity", id: "join", label: "Join" }, + ], + transitions: [ + { id: "t1", source: "split", target: "left" }, + { id: "t2", source: "split", target: "right" }, + { id: "t3", source: "left", target: "join" }, + { id: "t4", source: "right", target: "join" }, + ], + fragments: [], + projections: [], +}); + +const handover = defineModel({ + participants: [ + { id: "customer", label: "Customer", kind: "role" }, + { id: "shop", label: "Shop", kind: "system" }, + { id: "courier", label: "Courier", kind: "role" }, + ], + events: [], + actions: [], + artefacts: [], + elements: [ + { kind: "pseudo", id: "start", role: "initial" }, + { kind: "activity", id: "order", label: "Order", actor: "customer" }, + { kind: "activity", id: "pack", label: "Pack", actor: "shop" }, + { kind: "activity", id: "pay", label: "Pay", actor: "customer" }, + { kind: "activity", id: "ship", label: "Ship", actor: "shop" }, + { kind: "pseudo", id: "end", role: "final" }, + ], + transitions: [ + { id: "t1", source: "start", target: "order" }, + { id: "t2", source: "order", target: "pack" }, + { id: "t3", source: "order", target: "pay" }, + { id: "t4", source: "pack", target: "ship" }, + { id: "t5", source: "pay", target: "ship" }, + { id: "t6", source: "ship", target: "end" }, + ], + fragments: [], + projections: [], +}); + +const lifecycle = defineModel({ + participants: [{ id: "invoice", label: "Invoice", kind: "entity" }], + events: [], + actions: [], + artefacts: [], + elements: [ + { kind: "pseudo", id: "start", role: "initial" }, + { kind: "state", id: "draft", label: "Draft" }, + { kind: "state", id: "paid", label: "Paid" }, + { kind: "pseudo", id: "end", role: "final" }, + ], + transitions: [ + { id: "t1", source: "start", target: "draft" }, + { id: "t2", source: "draft", target: "paid" }, + { id: "t3", source: "paid", target: "end" }, + ], + fragments: [], + projections: [], +}); + +const positionOf = ( + positions: + Readonly>> | undefined, + id: string, +): { x: number; y: number } => { + const position = positions?.[id]; + if (position === undefined) { + throw new Error(`no position for ${id}`); + } + + return position; +}; + +describe("layout of a flowchart", () => { + it("places the join to the right of both branches when edges run right", async () => { + const result = await layout(toFlowchart(diamond), { direction: "right" }); + const flowchart = result.views.flowchart; + const split = positionOf(flowchart, "split"); + const left = positionOf(flowchart, "left"); + const right = positionOf(flowchart, "right"); + const join = positionOf(flowchart, "join"); + + expect(left.x).toBeGreaterThan(split.x); + expect(right.x).toBe(left.x); + expect(left.y).not.toBe(right.y); + expect(join.x).toBeGreaterThan(left.x); + expect(join.x).toBeGreaterThan(right.x); + }); + + it("places the join below both branches when edges run down", async () => { + const result = await layout(toFlowchart(diamond), { direction: "down" }); + const flowchart = result.views.flowchart; + const left = positionOf(flowchart, "left"); + const right = positionOf(flowchart, "right"); + const join = positionOf(flowchart, "join"); + + expect(join.y).toBeGreaterThan(left.y); + expect(join.y).toBeGreaterThan(right.y); + expect(left.y).toBe(right.y); + expect(left.x).not.toBe(right.x); + }); + + it("keys positions by notation and reports no lanes", async () => { + const result = await layout(toFlowchart(diamond)); + + expect(Object.keys(result.views)).toEqual(["flowchart"]); + expect(Object.keys(result.views.flowchart ?? {}).sort()).toEqual([ + "join", + "left", + "right", + "split", + ]); + expect(result.lanes).toEqual([]); + }); + + it("gives the same positions for the same input", async () => { + const first = await layout(toFlowchart(handover)); + const second = await layout(toFlowchart(handover)); + + expect(second).toEqual(first); + }); + + it("widens the gap between layers when asked", async () => { + const compact = await layout(toFlowchart(diamond), { + spacing: { layer: 16 }, + }); + const loose = await layout(toFlowchart(diamond), { + spacing: { layer: 160 }, + }); + const gap = (flowchart: typeof compact.views.flowchart): number => + positionOf(flowchart, "left").x - positionOf(flowchart, "split").x; + + expect(gap(loose.views.flowchart)).toBeGreaterThan( + gap(compact.views.flowchart), + ); + }); +}); + +describe("layout of a swimlane", () => { + it("keeps every node inside the bounds of its lane", async () => { + const swimlane = toSwimlane(handover); + const result = await layout(swimlane); + + expect(result.lanes.map((lane) => lane.participant)).toEqual([ + "customer", + "shop", + "courier", + ]); + for (const lane of swimlane.lanes) { + const bounds = result.lanes.find( + (candidate) => candidate.participant === lane.participant, + ); + expect(bounds).toBeDefined(); + for (const id of lane.nodes) { + const position = positionOf(result.views.swimlane, id); + const size = result.sizes[id]; + expect(size).toBeDefined(); + expect(position.x).toBeGreaterThanOrEqual(bounds?.x ?? Infinity); + expect(position.y).toBeGreaterThanOrEqual(bounds?.y ?? Infinity); + expect(position.x + (size?.width ?? Infinity)).toBeLessThanOrEqual( + (bounds?.x ?? 0) + (bounds?.width ?? 0), + ); + expect(position.y + (size?.height ?? Infinity)).toBeLessThanOrEqual( + (bounds?.y ?? 0) + (bounds?.height ?? 0), + ); + } + } + }); + + it("gives an empty lane a usable extent and places nodes with no lane", async () => { + const result = await layout(toSwimlane(handover)); + const courier = result.lanes.find((lane) => lane.participant === "courier"); + + expect(courier?.width).toBeGreaterThan(0); + expect(courier?.height).toBeGreaterThan(0); + expect(positionOf(result.views.swimlane, "start")).toBeDefined(); + expect(positionOf(result.views.swimlane, "end")).toBeDefined(); + }); + + it("does not overlap lanes", async () => { + const { lanes } = await layout(toSwimlane(handover)); + + for (const [index, first] of lanes.entries()) { + for (const second of lanes.slice(index + 1)) { + const apart = + first.x + first.width <= second.x || + second.x + second.width <= first.x || + first.y + first.height <= second.y || + second.y + second.height <= first.y; + expect(apart).toBe(true); + } + } + }); +}); + +describe("layout of a state machine", () => { + it("orders states along the flow under the stateMachine notation", async () => { + const result = await layout(toStateMachine(lifecycle, "invoice")); + const states = result.views.stateMachine; + const xs = ["start", "draft", "paid", "end"].map( + (id) => positionOf(states, id).x, + ); + + expect(xs).toEqual([...xs].sort((a, b) => a - b)); + expect(new Set(xs).size).toBe(xs.length); + expect(result.views.flowchart).toBeUndefined(); + }); + + it("sizes the initial pseudo-state smaller than a state", async () => { + const result = await layout(toStateMachine(lifecycle, "invoice")); + + expect(result.sizes.start?.width).toBeLessThan( + result.sizes.draft?.width ?? 0, + ); + }); +}); + +describe("layout with an injected engine", () => { + const recorder = (): { + engine: LayoutEngine; + graphs: EngineGraph[]; + } => { + const graphs: EngineGraph[] = []; + const engine: LayoutEngine = { + arrange: async (graph) => { + await Promise.resolve(); + graphs.push(graph); + const boxes: EngineBox[] = []; + const visit = (nodes: EngineGraph["nodes"]): void => { + for (const node of nodes) { + boxes.push({ + id: node.id, + x: boxes.length, + y: 0, + width: node.width, + height: node.height, + }); + if (node.children !== undefined) { + visit(node.children); + } + } + }; + visit(graph.nodes); + + return boxes; + }, + }; + + return { engine, graphs }; + }; + + it("hands lanes to the engine as groups holding their nodes", async () => { + const { engine, graphs } = recorder(); + await layout(toSwimlane(handover), { engine }); + + const [graph] = graphs; + const groups = graph?.nodes.filter( + (node): node is EngineGroup => node.children !== undefined, + ); + expect(groups?.map((group) => group.children.length)).toEqual( + handover.participants.map( + (participant) => + handover.elements.filter( + (element) => + element.kind === "activity" && element.actor === participant.id, + ).length, + ), + ); + expect(graph?.edges).toHaveLength(handover.transitions.length); + }); + + it("reads positions from the engine's boxes", async () => { + const { engine } = recorder(); + const result = await layout(toFlowchart(diamond), { engine }); + + const ids = ["split", "left", "right", "join"]; + + // The recorder assigns x by visit order, which is the order of the view's nodes. + expect(ids.map((id) => positionOf(result.views.flowchart, id).x)).toEqual( + ids.map((_, index) => index), + ); + }); + + it("fails loudly when the engine omits a node", async () => { + const engine: LayoutEngine = { + arrange: async () => { + await Promise.resolve(); + + return []; + }, + }; + + await expect(layout(toFlowchart(diamond), { engine })).rejects.toThrow( + 'no box for element "split"', + ); + }); +}); + +describe("abort", () => { + it("rejects with the reason when already aborted", async () => { + const controller = new AbortController(); + const reason = new Error("stop"); + controller.abort(reason); + + await expect( + layout(toFlowchart(diamond), { signal: controller.signal }), + ).rejects.toBe(reason); + }); + + it("rejects when aborted while the engine is working", async () => { + const engine: LayoutEngine = { + arrange: async () => new Promise(() => undefined), + }; + const controller = new AbortController(); + const reason = new Error("cancelled"); + const pending = layout(toFlowchart(diamond), { + engine, + signal: controller.signal, + }); + controller.abort(reason); + + await expect(pending).rejects.toBe(reason); + }); + + it("passes the signal to the engine", async () => { + const seen: (AbortSignal | undefined)[] = []; + const engine: LayoutEngine = { + arrange: async (_graph, signal) => { + await Promise.resolve(); + seen.push(signal); + + return []; + }, + }; + const controller = new AbortController(); + await layout(toFlowchart(diamond), { + engine, + signal: controller.signal, + }).catch(() => undefined); + + expect(seen).toEqual([controller.signal]); + }); +}); diff --git a/packages/core/graph/layout/src/metrics.ts b/packages/core/graph/layout/src/metrics.ts new file mode 100644 index 0000000..c56e410 --- /dev/null +++ b/packages/core/graph/layout/src/metrics.ts @@ -0,0 +1,69 @@ +import type { Direction } from "./engine.ts"; + +/** + * The unit every dimension below is a multiple of, so nodes of different shapes and lanes align on one grid and a renderer that snaps to a grid snaps to the same points. + */ +const GRID = 8; + +/** The room a node of each kind of shape takes, in the coordinate space of the layout. */ +export interface Size { + readonly width: number; + readonly height: number; +} + +/** A process step or state: wide enough for a short label on one line. */ +const PROCESS_UNITS = { width: 20, height: 8 }; + +/** A start or end terminator: a rounded box smaller than a step, so it reads as a boundary. */ +const TERMINATOR_UNITS = { width: 12, height: 6 }; + +/** A decision diamond: taller than a step, because a diamond has less usable width than the box that encloses it. */ +const DECISION_UNITS = { width: 16, height: 12 }; + +/** The initial and final pseudo-states of a state machine: a dot, as in the notation. */ +const PSEUDO_STATE_UNITS = { width: 4, height: 4 }; + +/** The thin side of a fork or join bar, and its length. */ +const BAR_THICKNESS_UNITS = 1; +const BAR_LENGTH_UNITS = 12; + +/** The default gap between two nodes in the same layer: half a step's height, which keeps parallel edges distinguishable. */ +const NODE_GAP_UNITS = 4; + +/** The default gap between layers: wider than the node gap so an edge has room for a label and an arrowhead. */ +const LAYER_GAP_UNITS = 8; + +/** The space between a lane's border and the nodes inside it: enough for the lane's own header and for a node not to touch the border. */ +const LANE_PADDING_UNITS = 4; + +/** The least a lane may measure, so a lane with no nodes is still visible. */ +const LANE_MINIMUM_UNITS = { width: 24, height: 12 }; + +const sized = (units: { + readonly width: number; + readonly height: number; +}): Size => ({ + width: units.width * GRID, + height: units.height * GRID, +}); + +export const PROCESS_SIZE: Size = sized(PROCESS_UNITS); +export const TERMINATOR_SIZE: Size = sized(TERMINATOR_UNITS); +export const DECISION_SIZE: Size = sized(DECISION_UNITS); +export const PSEUDO_STATE_SIZE: Size = sized(PSEUDO_STATE_UNITS); +export const DEFAULT_NODE_GAP = NODE_GAP_UNITS * GRID; +export const DEFAULT_LAYER_GAP = LAYER_GAP_UNITS * GRID; +export const LANE_PADDING = LANE_PADDING_UNITS * GRID; +export const LANE_MINIMUM_SIZE: Size = sized(LANE_MINIMUM_UNITS); + +/** + * The bar across the flow: a fork or join is drawn perpendicular to the edges it gathers or splits, so it is tall when edges run sideways and wide when they run vertically. + */ +export const barSize = (direction: Direction): Size => { + const thickness = BAR_THICKNESS_UNITS * GRID; + const length = BAR_LENGTH_UNITS * GRID; + + return direction === "right" || direction === "left" + ? { width: thickness, height: length } + : { width: length, height: thickness }; +}; diff --git a/packages/core/graph/layout/stryker.config.ts b/packages/core/graph/layout/stryker.config.ts new file mode 100644 index 0000000..6429b27 --- /dev/null +++ b/packages/core/graph/layout/stryker.config.ts @@ -0,0 +1,3 @@ +import { packageStrykerConfig } from "../../../../stryker.shared.ts"; + +export default packageStrykerConfig(); diff --git a/packages/core/graph/layout/test/package.smoke.test.ts b/packages/core/graph/layout/test/package.smoke.test.ts new file mode 100644 index 0000000..58379f0 --- /dev/null +++ b/packages/core/graph/layout/test/package.smoke.test.ts @@ -0,0 +1,28 @@ +import { toFlowchart } from "@exadev/process-graph-projection"; +import { defineModel } from "@exadev/process-graph-schema"; +import { expect, test } from "vitest"; +// Deliberately imports the built dist/, not src/: this is the check that catches a wrong exports map or a build misconfiguration that a unit test importing straight from src/ never touches. +import { layout } from "../dist/index.js"; + +test("the built package lays out a flowchart with the default engine", async () => { + const model = defineModel({ + participants: [], + events: [], + actions: [], + artefacts: [], + elements: [ + { kind: "activity", id: "first", label: "First" }, + { kind: "activity", id: "second", label: "Second" }, + ], + transitions: [{ id: "t1", source: "first", target: "second" }], + fragments: [], + projections: [], + }); + + const { views } = await layout(toFlowchart(model)); + const first = views.flowchart?.first; + const second = views.flowchart?.second; + + expect(first).toBeDefined(); + expect(second?.x).toBeGreaterThan(first?.x ?? Infinity); +}); diff --git a/packages/core/graph/layout/tsconfig.build.json b/packages/core/graph/layout/tsconfig.build.json new file mode 100644 index 0000000..93341fb --- /dev/null +++ b/packages/core/graph/layout/tsconfig.build.json @@ -0,0 +1,20 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "declaration": true, + "declarationMap": false, + "rootDir": "src", + "outDir": "dist", + "rewriteRelativeImportExtensions": true + }, + "references": [ + { + "path": "../schema/tsconfig.build.json" + }, + { + "path": "../projection/tsconfig.build.json" + } + ] +} diff --git a/packages/core/graph/layout/tsconfig.json b/packages/core/graph/layout/tsconfig.json new file mode 100644 index 0000000..8895dd1 --- /dev/null +++ b/packages/core/graph/layout/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2024", "WebWorker"], + "types": [] + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts", "src/test-support/**"] +} diff --git a/packages/core/graph/layout/tsconfig.node.json b/packages/core/graph/layout/tsconfig.node.json new file mode 100644 index 0000000..8a460f4 --- /dev/null +++ b/packages/core/graph/layout/tsconfig.node.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2024"], + "types": ["node"] + }, + "include": ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts", "*.config.ts"] +} diff --git a/packages/core/graph/layout/vitest.config.ts b/packages/core/graph/layout/vitest.config.ts new file mode 100644 index 0000000..01b9391 --- /dev/null +++ b/packages/core/graph/layout/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + projects: [ + { + extends: true, + test: { name: "unit", include: ["src/**/*.test.ts"] }, + }, + { + extends: true, + test: { name: "smoke", include: ["test/package.smoke.test.ts"] }, + }, + ], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0baa788..c471da8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -342,6 +342,49 @@ importers: specifier: 4.1.11 version: 4.1.11(@types/node@26.6.4)(@vitest/coverage-v8@4.1.11)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) + packages/core/graph/layout: + dependencies: + '@exadev/process-graph-projection': + specifier: 1.0.1 + version: link:../projection + '@exadev/process-graph-schema': + specifier: 1.0.1 + version: link:../schema + elkjs: + specifier: 0.12.0 + version: 0.12.0 + devDependencies: + '@arethetypeswrong/cli': + specifier: 0.18.5 + version: 0.18.5 + '@stryker-mutator/core': + specifier: 10.0.0 + version: 10.0.0(@types/node@26.6.4) + '@stryker-mutator/typescript-checker': + specifier: 10.0.0 + version: 10.0.0(@stryker-mutator/core@10.0.0(@types/node@26.6.4))(typescript@6.0.3) + '@stryker-mutator/vitest-runner': + specifier: 10.0.0 + version: 10.0.0(@stryker-mutator/core@10.0.0(@types/node@26.6.4))(vitest@4.1.11) + '@types/node': + specifier: 26.6.4 + version: 26.6.4 + '@vitest/coverage-v8': + specifier: 4.1.11 + version: 4.1.11(vitest@4.1.11) + eslint: + specifier: 10.12.0 + version: 10.12.0(jiti@2.7.0)(supports-color@7.2.0) + publint: + specifier: 0.3.25 + version: 0.3.25 + typescript: + specifier: 6.0.3 + version: 6.0.3 + vitest: + specifier: 4.1.11 + version: 4.1.11(@types/node@26.6.4)(@vitest/coverage-v8@4.1.11)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) + packages/core/graph/projection: dependencies: '@exadev/process-graph-schema': @@ -2240,6 +2283,9 @@ packages: electron-to-chromium@1.5.444: resolution: {integrity: sha512-5ss/uJfoDYDHT0lfJzT6FbcskIzROIOPf0BbbFkGcvDzoJU7i//9GDrwwIHQVmIsrAGiF3ihpADBRIsrEFt1rQ==} + elkjs@0.12.0: + resolution: {integrity: sha512-YZcKynxVxYoKIOEpywEPwCFdg+BTbxQRNf3pbwdDCvc8O3kQD8bmIwSxKU1eOTVc4Xo+VG9Te+575mlfvOrhEQ==} + emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -6056,6 +6102,8 @@ snapshots: electron-to-chromium@1.5.444: {} + elkjs@0.12.0: {} + emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} diff --git a/tsconfig.build.json b/tsconfig.build.json index ee2ed55..4f0f94b 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -13,6 +13,9 @@ { "path": "packages/core/graph/domain/tsconfig.build.json" }, + { + "path": "packages/core/graph/layout/tsconfig.build.json" + }, { "path": "packages/targets/process-graph/tsconfig.build.json" }