diff --git a/.changeset/defaults-undefined-sources.md b/.changeset/defaults-undefined-sources.md new file mode 100644 index 0000000000..225dd3af84 --- /dev/null +++ b/.changeset/defaults-undefined-sources.md @@ -0,0 +1,5 @@ +--- +"@joint/core": patch +--- + +util - accept `undefined` source objects in the `defaults()`, `defaultsDeep()`, `supplement()` and `deepSupplement()` typings, which the implementations already skip diff --git a/.changeset/layout-elk-new-package.md b/.changeset/layout-elk-new-package.md new file mode 100644 index 0000000000..05f10cfef9 --- /dev/null +++ b/.changeset/layout-elk-new-package.md @@ -0,0 +1,5 @@ +--- +"@joint/layout-elk": minor +--- + +new package - automatic layout for JointJS graphs using the Eclipse Layout Kernel (ELK), including layout of embedded elements as containers and of element ports diff --git a/.changeset/port-label-position-type.md b/.changeset/port-label-position-type.md new file mode 100644 index 0000000000..59e97a88bc --- /dev/null +++ b/.changeset/port-label-position-type.md @@ -0,0 +1,5 @@ +--- +"@joint/core": patch +--- + +dia.Element - fix `PortLabelPositionType` type to use `PortLabelPositionJSON` for a port label's position diff --git a/.changeset/port-prop-overloads.md b/.changeset/port-prop-overloads.md new file mode 100644 index 0000000000..c69ec19c08 --- /dev/null +++ b/.changeset/port-prop-overloads.md @@ -0,0 +1,5 @@ +--- +"@joint/core": patch +--- + +dia.Element - fix types to allow `portProp(portId)` and `portProp(portId, object, opt)` diff --git a/examples/layout-elk-ts/.gitignore b/examples/layout-elk-containers-and-ports-ts/.gitignore similarity index 74% rename from examples/layout-elk-ts/.gitignore rename to examples/layout-elk-containers-and-ports-ts/.gitignore index 69c575d17f..1eae0cf670 100644 --- a/examples/layout-elk-ts/.gitignore +++ b/examples/layout-elk-containers-and-ports-ts/.gitignore @@ -1,3 +1,2 @@ -build/ dist/ node_modules/ diff --git a/examples/layout-elk-containers-and-ports-ts/README.md b/examples/layout-elk-containers-and-ports-ts/README.md new file mode 100644 index 0000000000..28b865d6c9 --- /dev/null +++ b/examples/layout-elk-containers-and-ports-ts/README.md @@ -0,0 +1,27 @@ +# JointJS ELK Containers & Ports Demo + +A fixed (non-random) web platform reference architecture laid out automatically with `@joint/layout-elk`: nested containers ("Client Layer" > "Edge", "Core Services" > "Data Layer", "Observability") grouping services that connect through ports, plus a couple of links connecting containers directly - including one that crosses from one container into another. Styled with Material Design, via JointJS's theme mechanism and CSS. + +## Setup + +Use Yarn to run this demo. + +You need to build *JointJS* first. Navigate to the root folder and run: +```bash +yarn install +yarn run build +``` + +Navigate to this directory, then run: +```bash +yarn dev +``` + +Then open the URL printed in the terminal (usually `http://localhost:5173`). `yarn build` +makes a production build in `dist/`, `yarn preview` serves it. + +## License + +The *JointJS* library is licensed under the [Mozilla Public License 2.0](https://github.com/clientIO/joint/blob/master/LICENSE). + +Copyright © 2013-2026 client IO diff --git a/examples/layout-elk-containers-and-ports-ts/index.html b/examples/layout-elk-containers-and-ports-ts/index.html new file mode 100644 index 0000000000..62920c7f13 --- /dev/null +++ b/examples/layout-elk-containers-and-ports-ts/index.html @@ -0,0 +1,25 @@ + + + + + + + + ELK Containers & Ports Layout | JointJS + + + + + + +
+ Zoom Out + Zoom In +
+
+ + + + + diff --git a/examples/layout-elk-ts/package.json b/examples/layout-elk-containers-and-ports-ts/package.json similarity index 53% rename from examples/layout-elk-ts/package.json rename to examples/layout-elk-containers-and-ports-ts/package.json index 1706be066c..18f38b5e3e 100644 --- a/examples/layout-elk-ts/package.json +++ b/examples/layout-elk-containers-and-ports-ts/package.json @@ -1,8 +1,8 @@ { - "name": "@joint/demo-layout-elk-ts", + "name": "@joint/demo-layout-elk-containers-and-ports-ts", "version": "4.3.1", - "description": "JointJS - ELK Layout Demo", - "main": "dist/bundle.js", + "type": "module", + "description": "JointJS - ELK Layout Containers & Ports Demo", "homepage": "https://jointjs.com", "author": { "name": "client IO", @@ -14,22 +14,18 @@ "hoistingLimits": "workspaces" }, "scripts": { - "start": "webpack-dev-server", - "build": "webpack" + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" }, "dependencies": { "@joint/core": "workspace:^", - "elkjs": "^0.11.0" + "@joint/layout-elk": "workspace:^" }, "devDependencies": { - "css-loader": "3.5.3", - "sass-loader": "8.0.2", - "style-loader": "1.2.1", - "ts-loader": "^9.2.5", + "sass": "^1.93.2", "typescript": "5.8.2", - "webpack": "5.98.0", - "webpack-cli": "6.0.1", - "webpack-dev-server": "5.2.0" + "vite": "^7.3.1" }, "volta": { "node": "22.14.0", diff --git a/examples/layout-elk-containers-and-ports-ts/src/example.ts b/examples/layout-elk-containers-and-ports-ts/src/example.ts new file mode 100644 index 0000000000..f2245ecf9f --- /dev/null +++ b/examples/layout-elk-containers-and-ports-ts/src/example.ts @@ -0,0 +1,208 @@ +import { dia } from '@joint/core'; + +// A fixed (non-random) system diagram: three top-level containers, two of +// them with a nested container of their own, grouping eight services that +// communicate over ports - plus a couple of links that connect two +// containers directly, rather than a pair of ports, including one that +// crosses container boundaries. A recognizable, if simplified, web platform +// reference architecture: a client layer talking through an edge (load +// balancer + API gateway) to core services (auth, guarding a data layer of +// cache + database), with logs/metrics/analytics flowing to observability. +// Every plain `example.Service` has exactly one 'in' and one 'out' port; the +// four "hub" services (Load Balancer, API Gateway, Auth Service, Monitoring) +// are `example.HubService` instead, with a custom number of ports and ELK's +// `FIXED_SIDE` port constraint, so it can reorder them to minimize crossings +// (see `index.ts`). +export const graphJSON: dia.Graph.JSON = { + cells: [ + // Containers + { + id: 'frontend', + type: 'example.Container', + attrs: { label: { text: 'Client Layer' } } + }, + { + id: 'edge', + type: 'example.Container', + parent: 'frontend', + attrs: { label: { text: 'Edge' } } + }, + { + id: 'backend', + type: 'example.Container', + attrs: { label: { text: 'Core Services' } } + }, + { + id: 'storage', + type: 'example.Container', + parent: 'backend', + attrs: { label: { text: 'Data Layer' } } + }, + { + id: 'observability', + type: 'example.Container', + attrs: { label: { text: 'Observability' } } + }, + + // Client Layer + { + id: 'webui', + type: 'example.Service', + parent: 'frontend', + attrs: { label: { text: 'Web App' } } + }, + { + id: 'mobileui', + type: 'example.Service', + parent: 'frontend', + attrs: { label: { text: 'Mobile App' } } + }, + { + id: 'lb', + type: 'example.HubService', + parent: 'edge', + size: { width: 130, height: 80 }, + attrs: { label: { text: 'Load Balancer' } }, + ports: { + items: [ + { id: 'in1', group: 'in', attrs: { text: { text: 'in1' } } }, + { id: 'in2', group: 'in', attrs: { text: { text: 'in2' } } }, + { id: 'out', group: 'out', attrs: { text: { text: 'out' } } } + ] + } + }, + { + id: 'gateway', + type: 'example.HubService', + parent: 'edge', + size: { width: 130, height: 80 }, + attrs: { label: { text: 'API Gateway' } }, + ports: { + items: [ + { id: 'in', group: 'in', attrs: { text: { text: 'in' } } }, + { id: 'out1', group: 'out', attrs: { text: { text: 'out1' } } }, + { id: 'out2', group: 'out', attrs: { text: { text: 'out2' } } } + ] + } + }, + + // Core Services + { + id: 'auth', + type: 'example.HubService', + parent: 'backend', + size: { width: 130, height: 80 }, + attrs: { label: { text: 'Auth Service' } }, + ports: { + items: [ + { id: 'in', group: 'in', attrs: { text: { text: 'in' } } }, + { id: 'out1', group: 'out', attrs: { text: { text: 'out1' } } }, + { id: 'out2', group: 'out', attrs: { text: { text: 'out2' } } } + ] + } + }, + { + id: 'cache', + type: 'example.Service', + parent: 'storage', + attrs: { label: { text: 'Redis Cache' } } + }, + { + id: 'db', + type: 'example.Service', + parent: 'storage', + attrs: { label: { text: 'PostgreSQL' } } + }, + + // Observability + { + id: 'monitoring', + type: 'example.HubService', + parent: 'observability', + size: { width: 130, height: 80 }, + attrs: { label: { text: 'Monitoring' } }, + ports: { + items: [ + { id: 'in1', group: 'in', attrs: { text: { text: 'in1' } } }, + { id: 'in2', group: 'in', attrs: { text: { text: 'in2' } } } + ] + } + }, + + // Links + { + id: 'l1', + type: 'example.InteractionLink', + source: { id: 'webui', port: 'out' }, + target: { id: 'lb', port: 'in1' }, + labels: [{ attrs: { text: { text: 'request' } } }] + }, + { + id: 'l2', + type: 'example.InteractionLink', + source: { id: 'mobileui', port: 'out' }, + target: { id: 'lb', port: 'in2' }, + labels: [{ attrs: { text: { text: 'request' } } }] + }, + { + id: 'l3', + type: 'example.InteractionLink', + source: { id: 'lb', port: 'out' }, + target: { id: 'gateway', port: 'in' }, + labels: [{ attrs: { text: { text: 'route' } } }] + }, + { + id: 'l4', + type: 'example.InteractionLink', + source: { id: 'gateway', port: 'out1' }, + target: { id: 'auth', port: 'in' }, + labels: [{ attrs: { text: { text: 'authenticate' } } }] + }, + { + id: 'l5', + type: 'example.InteractionLink', + source: { id: 'gateway', port: 'out2' }, + target: { id: 'monitoring', port: 'in1' }, + labels: [{ attrs: { text: { text: 'metrics' } } }] + }, + { + id: 'l6', + type: 'example.InteractionLink', + source: { id: 'auth', port: 'out1' }, + target: { id: 'cache', port: 'in' }, + labels: [{ attrs: { text: { text: 'lookup' } } }] + }, + { + id: 'l7', + type: 'example.InteractionLink', + source: { id: 'auth', port: 'out2' }, + target: { id: 'monitoring', port: 'in2' }, + labels: [{ attrs: { text: { text: 'logs' } } }] + }, + { + id: 'l8', + type: 'example.InteractionLink', + source: { id: 'cache', port: 'out' }, + target: { id: 'db', port: 'in' }, + labels: [{ attrs: { text: { text: 'query' } } }] + }, + + // Container-to-container links - connected to a `example.Container` cell + // itself rather than to one of its ports, aggregating what the individual + // service-to-service links above already carry. + { + id: 'l9', + type: 'example.InteractionLink', + source: { id: 'backend' }, + target: { id: 'observability' }, + labels: [{ attrs: { text: { text: 'metrics' } } }] + }, + { + id: 'l10', + type: 'example.InteractionLink', + source: { id: 'frontend' }, + target: { id: 'observability' }, + labels: [{ attrs: { text: { text: 'analytics' } } }] + } + ] +}; diff --git a/examples/layout-elk-containers-and-ports-ts/src/index.ts b/examples/layout-elk-containers-and-ports-ts/src/index.ts new file mode 100644 index 0000000000..cda174f1fb --- /dev/null +++ b/examples/layout-elk-containers-and-ports-ts/src/index.ts @@ -0,0 +1,205 @@ +import { dia, shapes, setTheme } from '@joint/core'; +import { + ElkLayoutOptions, + ExportElementCallback, + ExportPortCallback, + ExportPortLabelCallback, + createWorkerElk, + layout +} from '@joint/layout-elk'; +import { graphJSON } from './example'; +import { Container, HubService, InteractionLink, Service } from './shapes'; +import './styles.scss'; + +const ELK_DIRECTION = 'RIGHT'; + +const cellNamespace = { + ...shapes, + example: { + Container, + Service, + HubService, + InteractionLink + } +}; + +// ELK runs in a Web Worker, so a layout doesn't block the page - the worker is started on +// the first layout, then shared by every later one. +const elk = createWorkerElk(() => new Worker(new URL('@joint/layout-elk/worker', import.meta.url), { type: 'module' })); + +const init = () => { + + // Every view (paper and cells alike) picks up a `joint-theme-material` class - + // this example's own CSS gives that class its actual meaning (see `styles.scss`). + setTheme('material'); + + // Create JointJS graph and paper + const graph = new dia.Graph({}, { cellNamespace }); + const paper = new dia.Paper({ + model: graph, + cellViewNamespace: cellNamespace, + width: 1200, + height: 700, + gridSize: 1, + interactive: false, + async: true, + frozen: true, + defaultConnector: { + name: 'straight', + args: { + cornerType: 'cubic', + cornerRadius: 5 + } + } + }); + document.getElementById('canvas')!.appendChild(paper.el); + addZoomAndPanListeners(paper); + + // Load the fixed example data - `mergeArrays` merges each cell's array + // attributes (e.g. a link's `labels`) into its class defaults index by + // index, instead of the default behavior of replacing them outright. + graph.fromJSON(graphJSON, { mergeArrays: true }); + + const elkLayoutOptions: ElkLayoutOptions = { + /** + * Overall direction of the layout. + * 'UP' | 'DOWN' | 'LEFT' | 'RIGHT' + */ + 'elk.direction': ELK_DIRECTION, + + /** + * Spacing between nodes (siblings). + * A number value as a string. + */ + 'elk.spacing.nodeNode': '30', + + /** + * Spacing between layers (for layered algorithm). + * A number value as a string. + */ + 'elk.layered.spacing.nodeNodeBetweenLayers': '40', + + /** + * Edge routing style. + * 'ORTHOGONAL' | 'SPLINES' | 'POLYLINE' + */ + 'elk.edgeRouting': 'ORTHOGONAL', + + /** + * Desired width-to-height ratio of the drawing - ELK's wrapping + * strategy (below) targets this to decide how many rows to wrap + * onto. Tuned, together with the spacing above, for this + * particular graph. + */ + 'elk.aspectRatio': '1.2', + /** + * Wraps layers onto additional rows, connected by dedicated + * "wrap" edges, instead of growing a single row indefinitely. + * 'NONE' | 'SINGLE_EDGE' | 'MULTI_EDGE' + */ + 'elk.layered.wrapping.strategy': 'MULTI_EDGE' + }; + + // `FIXED_SIDE` (not the package's default `FIXED_POS`) lets ELK move each port + // along the side `exportPort` puts it on, to reduce crossings. + const exportElement: ExportElementCallback = ({ element, elkNode }) => { + if (element.hasPorts()) { + elkNode.layoutOptions['elk.portConstraints'] = 'FIXED_SIDE'; + } + + const padding = element.get('padding'); + if (padding) { + elkNode.layoutOptions['elk.padding'] = padding; + } + }; + + const exportPort: ExportPortCallback = ({ portId, element, elkPort }) => { + // @ts-expect-error a port id may be a number - clientIO/joint#3544 + switch (element.getPort(portId).group) { + case 'in': + elkPort.layoutOptions['elk.port.side'] = 'WEST'; + break; + case 'out': + elkPort.layoutOptions['elk.port.side'] = 'SOUTH'; + break; + } + }; + + const exportPortLabel: ExportPortLabelCallback = ({ portId, element, elkPortLabel }) => { + // @ts-expect-error a port id may be a number - clientIO/joint#3544 + const size = element.portProp(portId, 'label/size'); + // A port with no label text is never sized (see `Service` in `shapes.ts`) - it + // goes to ELK without a label rather than with an unsized one. + if (!size) return false; + elkPortLabel.width = size.width; + elkPortLabel.height = size.height; + }; + + // Freezes the paper for the (async) duration of the layout, so nothing renders + // mid-layout. + const runLayout = (): Promise => { + paper.freeze(); + return layout(graph, { + elk, + exportElement, + exportPort, + exportPortLabel, + elkLayoutOptions + }).then(() => { + paper.unfreeze(); + }).catch((error) => { + paper.unfreeze(); + console.error('ELK layout error:', error.message); + }); + }; + + // Initial layout of the fixed example data, fit to the paper's viewport. + runLayout().then(() => { + zoom(paper, 1); + }); +}; + +function zoom(paper: dia.Paper, zoomLevel: number): void { + paper.scale(zoomLevel); + paper.fitToContent({ + useModelGeometry: true, + padding: 40 * zoomLevel, + allowNewOrigin: 'any' + }); +} + +/** + * Add toolbar zoom in/out listeners to the paper and setup panning. + */ +function addZoomAndPanListeners(paper: dia.Paper): void { + + let zoomLevel = paper.scale().sx; + + document.getElementById('zoom-in')!.addEventListener('click', () => { + zoomLevel = Math.min(3, zoomLevel + 0.2); + zoom(paper, zoomLevel); + }); + + document.getElementById('zoom-out')!.addEventListener('click', () => { + zoomLevel = Math.max(0.2, zoomLevel - 0.2); + zoom(paper, zoomLevel); + }); + + paper.on('blank:pointerdown', (evt) => { + evt.data = { + scrollX: window.scrollX, + clientX: evt.clientX, + scrollY: window.scrollY, + clientY: evt.clientY + }; + }); + + paper.on('blank:pointermove', (evt) => { + window.scroll( + evt.data.scrollX + (evt.data.clientX - evt.clientX!), + evt.data.scrollY + (evt.data.clientY - evt.clientY!) + ); + }); +} + +init(); diff --git a/examples/layout-elk-containers-and-ports-ts/src/shapes.ts b/examples/layout-elk-containers-and-ports-ts/src/shapes.ts new file mode 100644 index 0000000000..831d1c665d --- /dev/null +++ b/examples/layout-elk-containers-and-ports-ts/src/shapes.ts @@ -0,0 +1,227 @@ +import { dia, shapes, util } from '@joint/core'; + +const PORT_SIZE = { width: 12, height: 12 }; +const PORT_ATTRS = { + circle: { + r: 6, + class: 'md-port' + }, + text: { + class: 'md-port-label' + } +}; + +// `@joint/layout-elk` reads a port label's `size` directly rather than measuring the +// rendered text itself, so it has to be estimated from the label text up front. +// `Service` (below) computes and assigns it for every port as soon as the port is +// added, from that port's own label text length. The estimate only needs to roughly +// match `.md-port-label`'s CSS font size/weight, since it never has to be exact. +const PORT_LABEL_FONT_SIZE = 11; +const PORT_LABEL_AVERAGE_CHAR_WIDTH = PORT_LABEL_FONT_SIZE * 0.6; +const PORT_LABEL_HEIGHT = PORT_LABEL_FONT_SIZE + 5; + +function estimatePortLabelSize(text: string): dia.Size { + return { + width: Math.ceil(text.length * PORT_LABEL_AVERAGE_CHAR_WIDTH), + height: PORT_LABEL_HEIGHT + }; +} + +// Square ports (rather than `PORT_ATTRS`' circles) set `HubService` apart as +// a hub with several ports fanning in/out on the same side. +const HUB_PORT_SIZE = { width: 14, height: 8 }; +const HUB_PORT_MARKUP = [{ + tagName: 'rect', + selector: 'rect' +}]; +const HUB_PORT_ATTRS = { + rect: { + x: -HUB_PORT_SIZE.width / 2, + y: -HUB_PORT_SIZE.height / 2, + width: HUB_PORT_SIZE.width, + height: HUB_PORT_SIZE.height, + class: 'md-port' + }, + text: { + class: 'md-port-label' + } +}; + +const CONTAINER_PADDING = '[top=40,left=20,bottom=20,right=20]'; + +/** + * A tonal container surface - its final size and position are computed by ELK to fit + * whatever gets embedded into it. Its label sits in the top-left corner, out of the + * way of embedded elements. + */ +export class Container extends shapes.standard.Rectangle { + defaults() { + return util.defaultsDeep({ + type: 'example.Container', + size: { width: 100, height: 100 }, + padding: CONTAINER_PADDING, + attrs: { + body: { + class: 'md-container' + }, + label: { + x: 12, + y: 10, + textAnchor: 'start', + textVerticalAnchor: 'top', + class: 'md-container-label' + } + } + }, super.defaults); + } +} + +/** + * A service node with exactly one 'in' (left) and one 'out' (right) port, + * always - only the label `text` is left for each instance to fill in. See + * `HubService` for a service with a custom number of ports. + */ +export class Service extends shapes.standard.Rectangle { + defaults() { + return util.defaultsDeep({ + type: 'example.Service', + size: { width: 130, height: 50 }, + attrs: { + body: { + class: 'md-card' + }, + label: { + class: 'md-card-label' + } + }, + ports: { + groups: { + in: { + position: { + name: 'absolute' + }, + label: { + position: { + name: 'manual' + } + }, + size: PORT_SIZE, + attrs: PORT_ATTRS, + }, + out: { + position: { + name: 'absolute' + }, + label: { + position: { + name: 'manual' + } + }, + size: PORT_SIZE, + attrs: PORT_ATTRS, + } + }, + items: [ + { id: 'in', group: 'in', attrs: { text: { text: 'in' } } }, + { id: 'out', group: 'out', attrs: { text: { text: 'out' } } } + ] + } + }, super.defaults); + } + + initialize(...args: any[]) { + super.initialize(...args); + + // Ports present from the start don't go through `ports:add` (it only fires for + // ports added after the element already exists), so size them here too. + this._sizePortLabels(this.getPorts()); + this.on('ports:add', (_element: this, addedPorts: dia.Element.Port[]) => { + this._sizePortLabels(addedPorts); + }); + } + + private _sizePortLabels(ports: dia.Element.Port[]) { + ports.forEach((port) => { + const text = port.attrs?.text?.text; + if (!port.id || typeof text !== 'string') return; + this.portProp(port.id, 'label/size', estimatePortLabelSize(text)); + }); + } +} + +/** + * A service with a custom (per-instance) number of ports - `ports.items` + * always comes from the instance, replacing `Service`'s fixed pair. Its own + * `md-card--hub` outline (an emphasis color, not a shape change) sets it apart + * as a hub with several ports fanning in/out on the same side. + */ +export class HubService extends Service { + defaults() { + // `super.defaults` (unlike extending a built-in `shapes.standard.*` + // class, whose `defaults` is a plain object) is a method here too - + // it must be called to get the merged object, not just referenced. + return util.defaultsDeep({ + type: 'example.HubService', + attrs: { + body: { + class: 'md-card md-card--hub' + } + }, + ports: { + groups: { + in: { + markup: HUB_PORT_MARKUP, + size: HUB_PORT_SIZE, + attrs: HUB_PORT_ATTRS + }, + out: { + markup: HUB_PORT_MARKUP, + size: HUB_PORT_SIZE, + attrs: HUB_PORT_ATTRS + } + } + } + }, super.defaults()); + } +} + +/** + * A link with a labelled, pill-shaped Material "assist chip" background - only + * the label `text` is left for each instance to fill in. + */ +export class InteractionLink extends shapes.standard.Link { + defaults() { + return util.defaultsDeep({ + type: 'example.InteractionLink', + attrs: { + // A CSS class alone can't color this: the arrowhead is a separate + // `` def (in ``, so it isn't reached by a class on the + // line) whose own color JointJS derives from this `stroke` value - + // see `attributes/defs.mjs`'s `contextMarker()`. + line: { + stroke: '#78909C', + class: 'md-link' + } + }, + defaultLabel: { + size: { width: 80, height: 20 }, + attrs: { + text: { + class: 'md-chip-text' + }, + rect: { + ref: null, + x: 'calc(x - calc(w / 2))', + y: 'calc(y - calc(h / 2))', + width: 'calc(w)', + height: 'calc(h)', + rx: 'calc(h / 2)', + ry: 'calc(h / 2)', + class: 'md-chip-bg' + } + }, + position: 0.5 + } + }, super.defaults); + } +} diff --git a/examples/layout-elk-containers-and-ports-ts/src/styles.scss b/examples/layout-elk-containers-and-ports-ts/src/styles.scss new file mode 100644 index 0000000000..431995d02f --- /dev/null +++ b/examples/layout-elk-containers-and-ports-ts/src/styles.scss @@ -0,0 +1,161 @@ +// A restrained Material Design palette + elevation system, scoped under the +// `joint-theme-material` class every view gets from this example's own +// `setTheme('material')` call (`index.ts`) - JointJS's theme mechanism is just that +// class; giving it meaning is entirely up to this stylesheet (see `mvc.View#setTheme`). +// Applied almost entirely through the classes below (see `shapes.ts`) - JointJS attrs +// are only used where CSS alone cannot reach: geometry (`calc(...)`-driven `rx`/ +// `width`/...), text content, and the link's `stroke` (its arrowhead marker is a +// separate `` def that derives its own color from that attrs value, not +// from the `line`'s CSS - see `shapes.ts`'s `InteractionLink`). +:root { + --md-primary: #3F51B5; + --md-primary-tint: #E8EAF6; + --md-on-primary-tint: #283593; + --md-surface: #FFFFFF; + --md-surface-container: #F3F4F9; + --md-outline: #D0D3E3; + --md-on-surface: rgba(0, 0, 0, 0.87); + --md-on-surface-variant: rgba(0, 0, 0, 0.6); +} + +html, body { + margin: 0; + padding: 0; + font-family: 'Roboto', 'Segoe UI', sans-serif; +} + +#canvas { + position: absolute; + margin-top: 50px; + margin-left: 20px; + border: 1px solid var(--md-outline); + background-color: #FAFAFA; + overflow: hidden; +} + +.toolbar { + display: flex; + position: fixed; + width: 100%; + top: 10px; + margin-left: 30px; + text-align: center; + justify-content: left; + z-index: 1; +} + +.toolbar-button { + outline: none; + background: var(--md-surface); + border: none; + border-radius: 4px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2), 0 1px 1px rgba(0, 0, 0, 0.14); + text-align: center; + font-family: inherit; + font-size: 13px; + font-weight: 500; + text-transform: uppercase; + padding: 8px 14px; + letter-spacing: 0.4px; + color: var(--md-primary); + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + margin: 0 4px; + transition: box-shadow 0.15s ease, background 0.15s ease; + + &:hover { + background: var(--md-primary-tint); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.24), 0 1px 2px rgba(0, 0, 0, 0.16); + } +} + +// Everything from here on only ever applies to a `joint-theme-material` view. +.joint-theme-material { + + // Containers - a tonal surface (no shadow: Material's own elevated surfaces + // don't float above the canvas the way a card does, they just sit at a + // slightly different tone). Containment itself already reads as hierarchy, so + // nested and top-level containers share one treatment rather than a color per + // nesting depth. + .md-container { + fill: var(--md-surface-container); + stroke: var(--md-outline); + stroke-width: 1px; + stroke-dasharray: 4 3; + rx: 8px; + ry: 8px; + } + + .md-container-label { + font-family: inherit; + font-weight: 500; + font-size: 12px; + letter-spacing: 0.5px; + text-transform: uppercase; + fill: var(--md-on-surface-variant); + } + + // Service cards - a plain elevated surface (Material's shadow is two stacked + // shadows, a tighter "key" one and a softer, larger "ambient" one; `filter: + // drop-shadow(...)`, unlike `box-shadow`, works on SVG shapes, and stacks the + // same way across multiple `drop-shadow()`s in one `filter`). A hub gets the + // primary color as its own outline (an emphasis Material otherwise expresses + // via a "tonal"/"outlined" variant) plus one step more elevation, instead of a + // one-off fill color per shape. + .md-card { + fill: var(--md-surface); + stroke: var(--md-outline); + stroke-width: 1px; + rx: 6px; + ry: 6px; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.3)) drop-shadow(0 1px 3px rgba(0, 0, 0, 0.15)); + } + + .md-card--hub { + stroke: var(--md-primary); + stroke-width: 2px; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.3)) drop-shadow(0 2px 6px rgba(0, 0, 0, 0.18)); + } + + .md-card-label { + font-family: inherit; + font-weight: 500; + font-size: 13px; + fill: var(--md-on-surface); + } + + // Ports - one consistent treatment (a small primary-outlined dot) regardless + // of direction or shape (`HubService`'s square ports reuse the same classes). + .md-port { + fill: var(--md-surface); + stroke: var(--md-primary); + stroke-width: 2px; + } + + .md-port-label { + font-family: inherit; + font-size: 11px; + fill: var(--md-on-surface-variant); + } + + // Links - `stroke` itself stays an attrs value (see the file-level comment + // above); this class only covers what CSS can fully own. + .md-link { + stroke-linecap: round; + } + + // A link label rendered as a Material "assist chip" - a filled pill. + .md-chip-bg { + fill: var(--md-primary-tint); + } + + .md-chip-text { + font-family: inherit; + font-size: 11px; + font-weight: 500; + fill: var(--md-on-primary-tint); + } +} diff --git a/examples/layout-elk-containers-and-ports-ts/tsconfig.json b/examples/layout-elk-containers-and-ports-ts/tsconfig.json new file mode 100644 index 0000000000..66924fb436 --- /dev/null +++ b/examples/layout-elk-containers-and-ports-ts/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "module": "ES2020", + "moduleResolution": "bundler", + "target": "es6", + "lib": [ + "es2022", + "dom" + ], + "noImplicitAny": false, + "resolveJsonModule": true, + "esModuleInterop": true, + "noEmit": true, + "types": [ + "vite/client" + ] + }, + "include": [ + "src" + ] +} diff --git a/examples/layout-elk-defaults-ts/.gitignore b/examples/layout-elk-defaults-ts/.gitignore new file mode 100644 index 0000000000..1eae0cf670 --- /dev/null +++ b/examples/layout-elk-defaults-ts/.gitignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ diff --git a/examples/layout-elk-defaults-ts/README.md b/examples/layout-elk-defaults-ts/README.md new file mode 100644 index 0000000000..821c5a5819 --- /dev/null +++ b/examples/layout-elk-defaults-ts/README.md @@ -0,0 +1,27 @@ +# JointJS ELK Default Usage Demo + +A small, fixed system diagram laid out automatically with `@joint/layout-elk`'s default behavior alone - no `exportElement`/`exportPort`/`setPortAttributes`/... callbacks, just plain ELK layout options (direction, spacing, edge routing). Containers, ports with labels, link labels, and all three link connectivity shapes (port-to-port, port-to-element, element-to-element) are laid out from the package's own defaults. Styled with Material Design, via JointJS's theme mechanism and CSS. + +## Setup + +Use Yarn to run this demo. + +You need to build *JointJS* first. Navigate to the root folder and run: +```bash +yarn install +yarn run build +``` + +Navigate to this directory, then run: +```bash +yarn dev +``` + +Then open the URL printed in the terminal (usually `http://localhost:5173`). `yarn build` +makes a production build in `dist/`, `yarn preview` serves it. + +## License + +The *JointJS* library is licensed under the [Mozilla Public License 2.0](https://github.com/clientIO/joint/blob/master/LICENSE). + +Copyright © 2013-2026 client IO diff --git a/examples/layout-elk-defaults-ts/index.html b/examples/layout-elk-defaults-ts/index.html new file mode 100644 index 0000000000..359e0c363d --- /dev/null +++ b/examples/layout-elk-defaults-ts/index.html @@ -0,0 +1,25 @@ + + + + + + + + ELK Default Usage | JointJS + + + + + + +
+ Zoom Out + Zoom In +
+
+ + + + + diff --git a/examples/layout-elk-defaults-ts/package.json b/examples/layout-elk-defaults-ts/package.json new file mode 100644 index 0000000000..99a6d8f320 --- /dev/null +++ b/examples/layout-elk-defaults-ts/package.json @@ -0,0 +1,35 @@ +{ + "name": "@joint/demo-layout-elk-defaults-ts", + "version": "4.3.1", + "type": "module", + "description": "JointJS - ELK Layout Default Usage Demo", + "homepage": "https://jointjs.com", + "author": { + "name": "client IO", + "url": "https://client.io" + }, + "license": "MPL-2.0", + "private": true, + "installConfig": { + "hoistingLimits": "workspaces" + }, + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@joint/core": "workspace:^", + "@joint/layout-elk": "workspace:^" + }, + "devDependencies": { + "sass": "^1.93.2", + "typescript": "5.8.2", + "vite": "^7.3.1" + }, + "volta": { + "node": "22.14.0", + "npm": "11.2.0", + "yarn": "4.18.0" + } +} diff --git a/examples/layout-elk-defaults-ts/src/example.ts b/examples/layout-elk-defaults-ts/src/example.ts new file mode 100644 index 0000000000..18c0b74200 --- /dev/null +++ b/examples/layout-elk-defaults-ts/src/example.ts @@ -0,0 +1,125 @@ +import { dia } from '@joint/core'; + +// A small, fixed system diagram exercising every connectivity/labeling shape +// `@joint/layout-elk` lays out with no custom export/import callbacks at all: +// two containers grouping services that talk over ports (port <-> port), +// a port connected straight to a portless element (port <-> element), and a +// link between the two containers themselves (element <-> element). +export const graphJSON: dia.Graph.JSON = { + cells: [ + // Containers + { + id: 'web', + type: 'example.Container', + attrs: { label: { text: 'Web Tier' } } + }, + { + id: 'data', + type: 'example.Container', + attrs: { label: { text: 'Data Tier' } } + }, + + // Web Tier + { + id: 'webapp', + type: 'example.Service', + parent: 'web', + attrs: { label: { text: 'Web App' } }, + ports: { + items: [ + { id: 'out', group: 'out', attrs: { text: { text: 'out' } } } + ] + } + }, + { + id: 'mobileapp', + type: 'example.Service', + parent: 'web', + attrs: { label: { text: 'Mobile App' } }, + ports: { + items: [ + { id: 'out', group: 'out', attrs: { text: { text: 'out' } } } + ] + } + }, + + // Data Tier + { + id: 'api', + type: 'example.Service', + parent: 'data', + // Taller than the default - two ports a side need enough vertical room + // between them that a port's own label (offset just above it) doesn't + // collide with its neighbor. + size: { width: 130, height: 90 }, + attrs: { label: { text: 'API Service' } }, + ports: { + items: [ + { id: 'in1', group: 'in', attrs: { text: { text: 'in1' } } }, + { id: 'in2', group: 'in', attrs: { text: { text: 'in2' } } }, + { id: 'out1', group: 'out', attrs: { text: { text: 'out1' } } }, + { id: 'out2', group: 'out', attrs: { text: { text: 'out2' } } } + ] + } + }, + { + id: 'db', + type: 'example.Service', + parent: 'data', + attrs: { label: { text: 'Database' } }, + ports: { + items: [ + { id: 'in', group: 'in', attrs: { text: { text: 'in' } } } + ] + } + }, + + // A standalone, portless element - the target of a port-to-element link. + { + id: 'monitoring', + type: 'example.Service', + attrs: { label: { text: 'Monitoring' } } + }, + + // Links between ports + { + id: 'l1', + type: 'example.InteractionLink', + source: { id: 'webapp', port: 'out' }, + target: { id: 'api', port: 'in1' }, + labels: [{ attrs: { text: { text: 'request' } } }] + }, + { + id: 'l2', + type: 'example.InteractionLink', + source: { id: 'mobileapp', port: 'out' }, + target: { id: 'api', port: 'in2' }, + labels: [{ attrs: { text: { text: 'request' } } }] + }, + { + id: 'l3', + type: 'example.InteractionLink', + source: { id: 'api', port: 'out1' }, + target: { id: 'db', port: 'in' }, + labels: [{ attrs: { text: { text: 'query' } } }] + }, + + // A link from a port straight to a portless element. + { + id: 'l4', + type: 'example.InteractionLink', + source: { id: 'api', port: 'out2' }, + target: { id: 'monitoring' }, + labels: [{ attrs: { text: { text: 'metrics' } } }] + }, + + // A link between the two containers themselves - neither end is a port. + { + id: 'l5', + type: 'example.InteractionLink', + source: { id: 'web' }, + target: { id: 'data' }, + labels: [{ attrs: { text: { text: 'traffic' } } }] + } + ] +}; diff --git a/examples/layout-elk-defaults-ts/src/index.ts b/examples/layout-elk-defaults-ts/src/index.ts new file mode 100644 index 0000000000..416260bfa3 --- /dev/null +++ b/examples/layout-elk-defaults-ts/src/index.ts @@ -0,0 +1,112 @@ +import { dia, shapes, setTheme } from '@joint/core'; +import { layout } from '@joint/layout-elk'; +import { graphJSON } from './example'; +import { Container, Service, InteractionLink } from './shapes'; +import './styles.scss'; + +const cellNamespace = { + ...shapes, + example: { + Container, + Service, + InteractionLink + } +}; + +const init = () => { + + // Every view (paper and cells alike) picks up a `joint-theme-material` class - + // this example's own CSS gives that class its actual meaning (see `styles.scss`). + setTheme('material'); + + // Create JointJS graph and paper + const graph = new dia.Graph({}, { cellNamespace }); + const paper = new dia.Paper({ + model: graph, + cellViewNamespace: cellNamespace, + width: 900, + height: 600, + gridSize: 1, + interactive: false, + async: true, + frozen: true, + defaultConnector: { + name: 'straight', + args: { + cornerType: 'cubic', + cornerRadius: 5 + } + } + }); + document.getElementById('canvas')!.appendChild(paper.el); + addZoomAndPanListeners(paper); + + // Load the fixed example data. + graph.fromJSON(graphJSON); + + // No `exportElement`/`exportPort`/`setPortAttributes`/... callbacks - this is + // `layout()` at its simplest, with only plain ELK layout options passed through. + // Containers, ports and link labels are all laid out from this package's own + // defaults alone - and with no `elk` option, ELK runs on the main thread (the + // other ELK examples run it in a Web Worker, see `createWorkerElk()`). + layout(graph, { + elkLayoutOptions: { + // `'elk.algorithm': 'layered'` is the package's default - not repeated here. + 'elk.direction': 'RIGHT', + 'elk.edgeRouting': 'ORTHOGONAL', + 'elk.spacing.nodeNode': '30', + 'elk.layered.spacing.nodeNodeBetweenLayers': '50' + }, + }).then(() => { + paper.unfreeze(); + zoom(paper, 1); + }).catch((error) => { + paper.unfreeze(); + console.error('ELK layout error:', error.message); + }); +}; + +function zoom(paper: dia.Paper, zoomLevel: number): void { + paper.scale(zoomLevel); + paper.fitToContent({ + useModelGeometry: true, + padding: 40 * zoomLevel, + allowNewOrigin: 'any' + }); +} + +/** + * Add toolbar zoom in/out listeners to the paper and setup panning. + */ +function addZoomAndPanListeners(paper: dia.Paper): void { + + let zoomLevel = paper.scale().sx; + + document.getElementById('zoom-in')!.addEventListener('click', () => { + zoomLevel = Math.min(3, zoomLevel + 0.2); + zoom(paper, zoomLevel); + }); + + document.getElementById('zoom-out')!.addEventListener('click', () => { + zoomLevel = Math.max(0.2, zoomLevel - 0.2); + zoom(paper, zoomLevel); + }); + + paper.on('blank:pointerdown', (evt) => { + evt.data = { + scrollX: window.scrollX, + clientX: evt.clientX, + scrollY: window.scrollY, + clientY: evt.clientY + }; + }); + + paper.on('blank:pointermove', (evt) => { + window.scroll( + evt.data.scrollX + (evt.data.clientX - evt.clientX!), + evt.data.scrollY + (evt.data.clientY - evt.clientY!) + ); + }); +} + +init(); diff --git a/examples/layout-elk-defaults-ts/src/shapes.ts b/examples/layout-elk-defaults-ts/src/shapes.ts new file mode 100644 index 0000000000..323a4f7abd --- /dev/null +++ b/examples/layout-elk-defaults-ts/src/shapes.ts @@ -0,0 +1,135 @@ +import { shapes, util } from '@joint/core'; + +const PORT_SIZE = { width: 12, height: 12 }; +const PORT_ATTRS = { + circle: { + r: 6, + class: 'md-port' + }, + text: { + class: 'md-port-label' + } +}; + +/** + * A tonal container surface - `@joint/layout-elk` sizes and positions it to fit + * whatever gets embedded into it, entirely through its own default container + * handling. Its label sits just above the box, so it never has to compete + * with embedded elements for space inside it. + */ +export class Container extends shapes.standard.Rectangle { + defaults() { + return util.defaultsDeep({ + type: 'example.Container', + size: { width: 100, height: 100 }, + attrs: { + body: { + class: 'md-container' + }, + label: { + x: 0, + y: -8, + textAnchor: 'start', + textVerticalAnchor: 'bottom', + class: 'md-container-label' + } + } + }, super.defaults); + } +} + +/** + * A service card - each instance declares its own `ports.items` (from none up + * to several), using a plain 'left'/'right' port group. Port labels are laid + * out by JointJS itself, not ELK - this example passes no `exportPortLabel` + * callback to customize that. + */ +export class Service extends shapes.standard.Rectangle { + defaults() { + return util.defaultsDeep({ + type: 'example.Service', + size: { width: 130, height: 50 }, + attrs: { + body: { + class: 'md-card' + }, + label: { + class: 'md-card-label' + } + }, + ports: { + groups: { + // The label sits above the port (not to its side, via `left`/`right`) + // so it never lands directly on the horizontal edge segment ELK's + // orthogonal routing always draws right up to a left/right port. + in: { + position: 'left', + label: { + position: { + name: 'outside', + args: { + y: 10 + } + } + }, + size: PORT_SIZE, + attrs: PORT_ATTRS + }, + out: { + position: 'right', + label: { + position: { + name: 'outside', + args: { + y: 10 + } + } + }, + size: PORT_SIZE, + attrs: PORT_ATTRS + } + } + } + }, super.defaults); + } +} + +/** + * A link with a labelled, pill-shaped Material "assist chip" - ELK positions + * the label on its own, since this example passes no `exportLinkLabel` + * callback to customize it. + */ +export class InteractionLink extends shapes.standard.Link { + defaults() { + return util.defaultsDeep({ + type: 'example.InteractionLink', + attrs: { + // A CSS class alone can't color this: the arrowhead is a separate + // `` def whose own color JointJS derives from this `stroke` + // value - see `attributes/defs.mjs`'s `contextMarker()`. + line: { + stroke: '#78909C', + class: 'md-link' + } + }, + defaultLabel: { + size: { width: 80, height: 20 }, + attrs: { + text: { + class: 'md-chip-text' + }, + rect: { + ref: null, + x: 'calc(x - calc(w / 2))', + y: 'calc(y - calc(h / 2))', + width: 'calc(w)', + height: 'calc(h)', + rx: 'calc(h / 2)', + ry: 'calc(h / 2)', + class: 'md-chip-bg' + } + } + } + }, super.defaults); + } +} diff --git a/examples/layout-elk-defaults-ts/src/styles.scss b/examples/layout-elk-defaults-ts/src/styles.scss new file mode 100644 index 0000000000..8df932ad06 --- /dev/null +++ b/examples/layout-elk-defaults-ts/src/styles.scss @@ -0,0 +1,149 @@ +// A restrained Material Design palette + elevation system, scoped under the +// `joint-theme-material` class every view gets from this example's own +// `setTheme('material')` call (`index.ts`) - JointJS's theme mechanism is just that +// class; giving it meaning is entirely up to this stylesheet (see `mvc.View#setTheme`). +// Applied almost entirely through the classes below (see `shapes.ts`) - JointJS attrs +// are only used where CSS alone cannot reach: geometry (`calc(...)`-driven `rx`/ +// `width`/...), text content, and the link's `stroke` (its arrowhead marker is a +// separate `` def that derives its own color from that attrs value, not +// from the `line`'s CSS - see `shapes.ts`'s `InteractionLink`). +:root { + --md-primary: #3F51B5; + --md-primary-tint: #E8EAF6; + --md-on-primary-tint: #283593; + --md-surface: #FFFFFF; + --md-surface-container: #F3F4F9; + --md-outline: #D0D3E3; + --md-on-surface: rgba(0, 0, 0, 0.87); + --md-on-surface-variant: rgba(0, 0, 0, 0.6); +} + +html, body { + margin: 0; + padding: 0; + font-family: 'Roboto', 'Segoe UI', sans-serif; +} + +#canvas { + position: absolute; + margin-top: 50px; + margin-left: 20px; + border: 1px solid var(--md-outline); + background-color: #FAFAFA; + overflow: hidden; +} + +.toolbar { + display: flex; + position: fixed; + width: 100%; + top: 10px; + margin-left: 30px; + text-align: center; + justify-content: left; + z-index: 1; +} + +.toolbar-button { + outline: none; + background: var(--md-surface); + border: none; + border-radius: 4px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2), 0 1px 1px rgba(0, 0, 0, 0.14); + text-align: center; + font-family: inherit; + font-size: 13px; + font-weight: 500; + text-transform: uppercase; + padding: 8px 14px; + letter-spacing: 0.4px; + color: var(--md-primary); + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + margin: 0 4px; + transition: box-shadow 0.15s ease, background 0.15s ease; + + &:hover { + background: var(--md-primary-tint); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.24), 0 1px 2px rgba(0, 0, 0, 0.16); + } +} + +// Everything from here on only ever applies to a `joint-theme-material` view. +.joint-theme-material { + + // Containers - a tonal surface (no shadow: Material's own elevated surfaces + // don't float above the canvas the way a card does, they just sit at a + // slightly different tone). + .md-container { + fill: var(--md-surface-container); + stroke: var(--md-outline); + stroke-width: 1px; + stroke-dasharray: 4 3; + rx: 8px; + ry: 8px; + } + + .md-container-label { + font-family: inherit; + font-weight: 500; + font-size: 12px; + letter-spacing: 0.5px; + text-transform: uppercase; + fill: var(--md-on-surface-variant); + } + + // Service cards - a plain elevated surface (Material's shadow is two stacked + // shadows, a tighter "key" one and a softer, larger "ambient" one; `filter: + // drop-shadow(...)`, unlike `box-shadow`, works on SVG shapes, and stacks the + // same way across multiple `drop-shadow()`s in one `filter`). + .md-card { + fill: var(--md-surface); + stroke: var(--md-outline); + stroke-width: 1px; + rx: 6px; + ry: 6px; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.3)) drop-shadow(0 1px 3px rgba(0, 0, 0, 0.15)); + } + + .md-card-label { + font-family: inherit; + font-weight: 500; + font-size: 13px; + fill: var(--md-on-surface); + } + + // Ports - one consistent treatment (a small primary-outlined dot). + .md-port { + fill: var(--md-surface); + stroke: var(--md-primary); + stroke-width: 2px; + } + + .md-port-label { + font-family: inherit; + font-size: 11px; + fill: var(--md-on-surface-variant); + } + + // Links - `stroke` itself stays an attrs value (see the file-level comment + // above); this class only covers what CSS can fully own. + .md-link { + stroke-linecap: round; + } + + // A link label rendered as a Material "assist chip" - a filled pill. + .md-chip-bg { + fill: var(--md-primary-tint); + } + + .md-chip-text { + font-family: inherit; + font-size: 11px; + font-weight: 500; + fill: var(--md-on-primary-tint); + } +} diff --git a/examples/layout-elk-defaults-ts/tsconfig.json b/examples/layout-elk-defaults-ts/tsconfig.json new file mode 100644 index 0000000000..cc77a5e2f0 --- /dev/null +++ b/examples/layout-elk-defaults-ts/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "module": "ES6", + "moduleResolution": "bundler", + "target": "es6", + "lib": [ + "es2022", + "dom" + ], + "noImplicitAny": false, + "resolveJsonModule": true, + "esModuleInterop": true, + "noEmit": true, + "types": [ + "vite/client" + ] + }, + "include": [ + "src" + ] +} diff --git a/examples/layout-elk-dependency-graph-ts/.gitignore b/examples/layout-elk-dependency-graph-ts/.gitignore new file mode 100644 index 0000000000..1eae0cf670 --- /dev/null +++ b/examples/layout-elk-dependency-graph-ts/.gitignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ diff --git a/examples/layout-elk-ts/README.md b/examples/layout-elk-dependency-graph-ts/README.md similarity index 51% rename from examples/layout-elk-ts/README.md rename to examples/layout-elk-dependency-graph-ts/README.md index d9eaedf7e5..50d999caf4 100644 --- a/examples/layout-elk-ts/README.md +++ b/examples/layout-elk-dependency-graph-ts/README.md @@ -1,4 +1,6 @@ -# JointJS ELK Demo +# JointJS ELK Dependency Graph Demo + +A package dependency graph laid out with [`@joint/layout-elk`](../../packages/joint-layout-elk) - the Eclipse Layout Kernel (ELK), running in a Web Worker. ## Setup @@ -12,9 +14,12 @@ yarn run build Navigate to this directory, then run: ```bash -yarn start +yarn dev ``` +Then open the URL printed in the terminal (usually `http://localhost:5173`). `yarn build` +makes a production build in `dist/`, `yarn preview` serves it. + ## License The *JointJS* library is licensed under the [Mozilla Public License 2.0](https://github.com/clientIO/joint/blob/master/LICENSE). diff --git a/examples/layout-elk-ts/index.html b/examples/layout-elk-dependency-graph-ts/index.html similarity index 91% rename from examples/layout-elk-ts/index.html rename to examples/layout-elk-dependency-graph-ts/index.html index ee2522d3cf..86e692c00a 100644 --- a/examples/layout-elk-ts/index.html +++ b/examples/layout-elk-dependency-graph-ts/index.html @@ -15,7 +15,7 @@ Zoom In
- + diff --git a/examples/layout-elk-dependency-graph-ts/package.json b/examples/layout-elk-dependency-graph-ts/package.json new file mode 100644 index 0000000000..2197a55a60 --- /dev/null +++ b/examples/layout-elk-dependency-graph-ts/package.json @@ -0,0 +1,35 @@ +{ + "name": "@joint/demo-layout-elk-dependency-graph-ts", + "version": "4.3.1", + "type": "module", + "description": "JointJS - ELK Layout Dependency Graph Demo", + "homepage": "https://jointjs.com", + "author": { + "name": "client IO", + "url": "https://client.io" + }, + "license": "MPL-2.0", + "private": true, + "installConfig": { + "hoistingLimits": "workspaces" + }, + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@joint/core": "workspace:^", + "@joint/layout-elk": "workspace:^" + }, + "devDependencies": { + "sass": "^1.93.2", + "typescript": "5.8.2", + "vite": "^7.3.1" + }, + "volta": { + "node": "22.14.0", + "npm": "11.2.0", + "yarn": "4.18.0" + } +} diff --git a/examples/layout-elk-ts/src/dependencies.json b/examples/layout-elk-dependency-graph-ts/src/dependencies.json similarity index 100% rename from examples/layout-elk-ts/src/dependencies.json rename to examples/layout-elk-dependency-graph-ts/src/dependencies.json diff --git a/examples/layout-elk-ts/src/index.ts b/examples/layout-elk-dependency-graph-ts/src/index.ts similarity index 55% rename from examples/layout-elk-ts/src/index.ts rename to examples/layout-elk-dependency-graph-ts/src/index.ts index 551512f4c5..675dff5754 100644 --- a/examples/layout-elk-ts/src/index.ts +++ b/examples/layout-elk-dependency-graph-ts/src/index.ts @@ -1,17 +1,17 @@ -import { dia, shapes, util, g } from '@joint/core'; -import ELK from 'elkjs/lib/elk-api.js'; -import type { ElkNode, ElkExtendedEdge, ElkLabel } from 'elkjs/lib/elk-api.d.ts'; +import { dia, shapes, g } from '@joint/core'; +import { createWorkerElk, layout } from '@joint/layout-elk'; import dependenciesJSON from './dependencies.json'; import './styles.scss'; -type Require = T & { [P in K]-?: T[P] }; -type ElkGraph = Require; - const colors = ['#F8FCDA', '#E3E9C2', '#F9FBB2', '#C89F9C']; const ELK_DIRECTION = 'RIGHT'; const DEFAULT_LABEL_WIDTH = 50; const DEFAULT_LABEL_HEIGHT = 20; +// ELK runs in a Web Worker, so a layout doesn't block the page - the worker is started on +// the first layout, then shared by every later one. +const elk = createWorkerElk(() => new Worker(new URL('@joint/layout-elk/worker', import.meta.url), { type: 'module' })); + const init = () => { // Create JointJS graph and paper @@ -42,17 +42,65 @@ const init = () => { // Generate JointJS cells from example data generateCells(dependenciesJSON, graph); - // Perform ELK layout - const elk = new ELK({ - workerUrl: '../node_modules/elkjs/lib/elk-worker.js', - }); - elk.layout(getElkGraph(graph)).then((elkGraph: ElkGraph) => { - updateGraph(elkGraph, graph); + layout(graph, { + elk, + elkLayoutOptions: { + /** + * Overall direction of the layout. + * 'UP' | 'DOWN' | 'LEFT' | 'RIGHT' + */ + 'elk.direction': ELK_DIRECTION, + + /** + * Spacing between nodes (siblings). + * A number value as a string. + */ + 'elk.spacing.nodeNode': '20', + + /** + * Spacing between layers (for layered algorithm). + * A number value as a string. + */ + 'elk.layered.spacing.nodeNodeBetweenLayers': '50', + + /** + * Edge routing style. + * 'ORTHOGONAL' | 'SPLINES' | 'POLYLINE' + */ + 'elk.edgeRouting': 'ORTHOGONAL', + + /** + * Node placement strategy for layered layout. + * 'SIMPLE' | 'BRANDES_KOEPF' | 'INTERACTIVE' | 'LINEAR_SEGMENTS' | 'NETWORK_SIMPLEX' + */ + 'elk.layered.nodePlacement.strategy': 'NETWORK_SIMPLEX', + + /** + * Merging edges that share the same source and target nodes into a single edge. + * 'true' | 'false' + */ + 'elk.layered.mergeEdges': 'false', + + /** + * Distance between edge labels and the edge itself. + * A number value as a string. + */ + 'elk.spacing.edgeLabel': '3', + + /** + * Enable partitioning i.e., assigning nodes to layers. You need to add + * `partitioning.partition` attribute to nodes for this to take effect. + * 'true' | 'false' + */ + 'elk.partitioning.activate': 'false', + } + }).then(() => { paper.unfreeze(); zoom(paper, 1); // Scroll into a busy area of the example window.scroll(650, 560); }).catch((error) => { + paper.unfreeze(); console.error('ELK layout error:', error.message); }); }; @@ -94,8 +142,8 @@ function addZoomAndPanListeners(paper: dia.Paper): void { paper.on('blank:pointermove', (evt) => { window.scroll( - evt.data.scrollX + (evt.data.clientX - evt.clientX), - evt.data.scrollY + (evt.data.clientY - evt.clientY) + evt.data.scrollX + (evt.data.clientX - evt.clientX!), + evt.data.scrollY + (evt.data.clientY - evt.clientY!) ); }); } @@ -169,7 +217,7 @@ function generateCells( graph: dia.Graph ): void { const elementMap = new Map(); - const cells = []; + const cells: dia.Cell[] = []; dependencies.forEach((dep) => { // The ELK graph uses string IDs const sourceId = `${dep.source}`; @@ -206,215 +254,4 @@ function generateCells( graph.resetCells(cells); } -/** - * Converts JointJS graph to ELK graph structure. - * @param {dia.Graph} graph - * @returns {Object} ELK graph structure - */ -function getElkGraph(graph: dia.Graph): ElkGraph { - const elkGraph: ElkGraph = { - id: 'root', - layoutOptions: { - /** - * Layout algorithm to use. - * 'box' | 'layered' | 'mrtree' | 'radial' | 'force' - */ - 'elk.algorithm': 'layered', - - /** - * Overall direction of the layout. - * 'UP' | 'DOWN' | 'LEFT' | 'RIGHT' - */ - 'elk.direction': ELK_DIRECTION, - - /** - * Spacing between nodes (siblings). - * A number value as a string. - */ - 'elk.spacing.nodeNode': '20', - - /** - * Spacing between layers (for layered algorithm). - * A number value as a string. - */ - 'elk.layered.spacing.nodeNodeBetweenLayers': '50', - - /** - * Edge routing style. - * 'ORTHOGONAL' | 'SPLINES' | 'POLYLINE' - */ - 'elk.edgeRouting': 'ORTHOGONAL', - - /** - * Node placement strategy for layered layout. - * 'SIMPLE' | 'BRANDES_KOEPF' | 'INTERACTIVE' | 'LINEAR_SEGMENTS' | 'NETWORK_SIMPLEX' - */ - 'elk.layered.nodePlacement.strategy': 'NETWORK_SIMPLEX', - - /** - * Merging edges that share the same source and target nodes into a single edge. - * 'true' | 'false' - */ - 'elk.layered.mergeEdges': 'false', - - /** - * Distance between edge labels and the edge itself. - * A number value as a string. - */ - 'elk.spacing.edgeLabel': '3', - - /** - * Enable partitioning i.e., assigning nodes to layers. You need to add - * `partitioning.partition` attribute to nodes for this to take effect. - * 'true' | 'false' - */ - 'elk.partitioning.activate': 'false', - - // Does not seem to work as expected: - // 'elk.layered.edgeLabels.centerLabelPlacementStrategy': 'HEAD_LAYER', - // 'elk.edgeLabels.placement': 'TAIL' - }, - children: [], - edges: [] - }; - - graph.getElements().forEach((element) => { - const size = element.size(); - const elkNode: ElkNode = { - id: `${element.id}`, - width: size.width, - height: size.height, - ports: [], - children: [] - }; - elkGraph.children.push(elkNode); - }); - - graph.getLinks().forEach((link) => { - const sourceId = `${link.source().id}`; - const targetId = `${link.target().id}`; - if (!sourceId || !targetId) { - return; // Skip if source or target is not defined - } - elkGraph.edges.push({ - id: `${link.id}`, - sources: [sourceId], - targets: [targetId], - labels: link.labels().map((label) => ({ - text: '-', // some text is required (ELK ignores empty labels) - width: label.size?.width || DEFAULT_LABEL_WIDTH, - height: label.size?.height || DEFAULT_LABEL_HEIGHT, - layoutOptions: { - // Place label directly on the edge. - 'edgeLabels.inline': 'true', - - // This works, but does not allocate space for the label - // 'edgeLabels.placement': 'HEAD' // 'CENTER' | 'HEAD' | 'TAIL' - } - })) - }); - }); - - return elkGraph; -} - -/** - * Update JointJS graph based on ELK layout result. - */ -function updateGraph(elkGraph: ElkGraph, graph: dia.Graph): void { - updateElements(elkGraph.children, graph); - updateLinks(elkGraph.edges, graph); -} - -/** - * Update JointJS elements based on ELK node layout. - */ -function updateElements(nodes: ElkNode[], graph: dia.Graph): void { - for (const node of nodes) { - const el = graph.getCell(node.id) as dia.Element; - el.position(node.x, node.y); - } -} - -/** - * Update JointJS links based on ELK edge layout. - */ -function updateLinks(edges: ElkExtendedEdge[], graph: dia.Graph): void { - for (const edge of edges) { - const { sections, labels: edgeLabels } = edge; - if (!sections) continue; - const linkAttributes: dia.Link.Attributes = {}; - const [{ bendPoints = [], endPoint, startPoint }] = sections; - // Update link vertices (bend points) - linkAttributes.vertices = bendPoints; - // Update link source and target anchors (startPoint, endPoint) - const link = graph.getCell(edge.id) as dia.Link; - linkAttributes.source = getLinkEnd(link.getSourceElement(), startPoint); - linkAttributes.target = getLinkEnd(link.getTargetElement(), endPoint); - // Update link labels positions - if (edgeLabels) { - const polyline = new g.Polyline([startPoint, ...bendPoints, endPoint]); - linkAttributes.labels = getLinkLabels(link, edgeLabels, polyline); - } - // Apply the updated attributes to the link - link.set(linkAttributes); - } -} - -/** - * Convert absolute label position to relative position on the link polyline. - */ -function getLinkLabelPosition( - polyline: g.Polyline, - edgeLabel: ElkLabel -): dia.Link.LabelPosition { - const labelPosition = { - x: edgeLabel.x + edgeLabel.width / 2, - y: edgeLabel.y + edgeLabel.height / 2 - }; - const length = polyline.closestPointLength(labelPosition); - const closestPoint = polyline.pointAtLength(length); - const distance = (length / polyline.length()); - const offset = new g.Point(labelPosition).difference(closestPoint).toJSON(); - return { - distance: distance, - offset: offset - }; -} - -/** - * Get link end definition for given element and absolute end point. - */ -function getLinkEnd( - endElement: dia.Element, - endPoint: dia.Point -): dia.Link.EndJSON { - const delta = endElement.getRelativePointFromAbsolute(endPoint); - return { - id: endElement.id, - anchor: { - name: 'topLeft', - args: { - dx: delta.x, - dy: delta.y, - useModelGeometry: true - } - } - }; -} - -function getLinkLabels( - link: dia.Link, - edgeLabels: ElkLabel[], - polyline: g.Polyline -): dia.Link.Label[] { - const labels = util.cloneDeep(link.labels()); - edgeLabels.forEach((edgeLabel, index) => { - // Note: If the diagram is meant to stay static, - // we could also create JointJS elements instead of using link labels. - labels[index].position = getLinkLabelPosition(polyline, edgeLabel); - }); - return labels; -} - init(); diff --git a/examples/layout-elk-ts/src/styles.scss b/examples/layout-elk-dependency-graph-ts/src/styles.scss similarity index 100% rename from examples/layout-elk-ts/src/styles.scss rename to examples/layout-elk-dependency-graph-ts/src/styles.scss diff --git a/examples/layout-elk-dependency-graph-ts/tsconfig.json b/examples/layout-elk-dependency-graph-ts/tsconfig.json new file mode 100644 index 0000000000..66924fb436 --- /dev/null +++ b/examples/layout-elk-dependency-graph-ts/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "module": "ES2020", + "moduleResolution": "bundler", + "target": "es6", + "lib": [ + "es2022", + "dom" + ], + "noImplicitAny": false, + "resolveJsonModule": true, + "esModuleInterop": true, + "noEmit": true, + "types": [ + "vite/client" + ] + }, + "include": [ + "src" + ] +} diff --git a/examples/layout-elk-rectpacking-ts/.gitignore b/examples/layout-elk-rectpacking-ts/.gitignore new file mode 100644 index 0000000000..1eae0cf670 --- /dev/null +++ b/examples/layout-elk-rectpacking-ts/.gitignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ diff --git a/examples/layout-elk-rectpacking-ts/README.md b/examples/layout-elk-rectpacking-ts/README.md new file mode 100644 index 0000000000..35328436de --- /dev/null +++ b/examples/layout-elk-rectpacking-ts/README.md @@ -0,0 +1,34 @@ +# JointJS ELK Rectangle Packing Demo + +A storage drive's folders and files packed with ELK's rectangle packing algorithm (`'elk.algorithm': 'rectpacking'`) via `@joint/layout-elk`. Every folder is a container, every file a tile whose area is proportional to the file's size. Each folder packs its own files, then the folders are packed together, so the whole board stays close to the chosen aspect ratio. The toolbar changes the `rectpacking` options and the layout re-runs with an animated transition: + +- **Optimization goal** - `elk.rectpacking.widthApproximation.optimizationGoal` (`MAX_SCALE_DRIVEN`, `ASPECT_RATIO_DRIVEN`, `AREA_DRIVEN`). +- **Aspect ratio** - `elk.aspectRatio`, the width/height the packing aims for. +- **White space** - `elk.rectpacking.whiteSpaceElimination.strategy`, which stretches tiles to fill the gaps left in a folder. `@joint/layout-elk` applies whatever size ELK computed, so a stretched tile is resized for you. The example's `exportElement` callback hands ELK each tile's original size, so a stretched tile doesn't keep growing from one layout to the next. +- **Order by size** - `elk.rectpacking.orderBySize`. Otherwise files are packed in model order, which **Shuffle** changes. + +ELK doesn't pass layout options down the hierarchy, so `exportElement` also sets `rectpacking` (and its options) on every folder. `elk.hierarchyHandling: 'SEPARATE_CHILDREN'` overrides the package's default `INCLUDE_CHILDREN`, which only matters for edges crossing a container's boundary. + +## Setup + +Use Yarn to run this demo. + +You need to build *JointJS* first. Navigate to the root folder and run: +```bash +yarn install +yarn run build +``` + +Navigate to this directory, then run: +```bash +yarn dev +``` + +Then open the URL printed in the terminal (usually `http://localhost:5173`). `yarn build` +makes a production build in `dist/`, `yarn preview` serves it. + +## License + +The *JointJS* library is licensed under the [Mozilla Public License 2.0](https://github.com/clientIO/joint/blob/master/LICENSE). + +Copyright © 2013-2026 client IO diff --git a/examples/layout-elk-rectpacking-ts/index.html b/examples/layout-elk-rectpacking-ts/index.html new file mode 100644 index 0000000000..c92724fa56 --- /dev/null +++ b/examples/layout-elk-rectpacking-ts/index.html @@ -0,0 +1,52 @@ + + + + + + + + ELK Rectangle Packing | JointJS + + + + + + +
+ + + + + Shuffle + Add File + Zoom Out + Zoom In +
+
+ + + + + diff --git a/examples/layout-elk-rectpacking-ts/package.json b/examples/layout-elk-rectpacking-ts/package.json new file mode 100644 index 0000000000..c2c85dd2b8 --- /dev/null +++ b/examples/layout-elk-rectpacking-ts/package.json @@ -0,0 +1,35 @@ +{ + "name": "@joint/demo-layout-elk-rectpacking-ts", + "version": "4.3.1", + "type": "module", + "description": "JointJS - ELK Rectangle Packing Layout Demo", + "homepage": "https://jointjs.com", + "author": { + "name": "client IO", + "url": "https://client.io" + }, + "license": "MPL-2.0", + "private": true, + "installConfig": { + "hoistingLimits": "workspaces" + }, + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@joint/core": "workspace:^", + "@joint/layout-elk": "workspace:^" + }, + "devDependencies": { + "sass": "^1.93.2", + "typescript": "5.8.2", + "vite": "^7.3.1" + }, + "volta": { + "node": "22.14.0", + "npm": "11.2.0", + "yarn": "4.18.0" + } +} diff --git a/examples/layout-elk-rectpacking-ts/src/example.ts b/examples/layout-elk-rectpacking-ts/src/example.ts new file mode 100644 index 0000000000..a292ff0674 --- /dev/null +++ b/examples/layout-elk-rectpacking-ts/src/example.ts @@ -0,0 +1,122 @@ +import { dia } from '@joint/core'; + +export type FileKind = 'photo' | 'video' | 'document' | 'music' | 'archive'; + +export interface FileData { + name: string; + sizeMB: number; + // Width / height of the tile - its area is set by `sizeMB` alone. + ratio: number; +} + +interface FolderData { + id: string; + name: string; + kind: FileKind; + files: FileData[]; +} + +// A fixed snapshot of a storage drive: every folder is a container, every file +// a tile whose area is proportional to the file's size - so the packed board +// doubles as a rough "what takes up my disk space" overview. +const folders: FolderData[] = [{ + id: 'photos', + name: 'Photos', + kind: 'photo', + files: [ + { name: 'beach.jpg', sizeMB: 48, ratio: 1.5 }, + { name: 'sunset.jpg', sizeMB: 36, ratio: 1.5 }, + { name: 'portrait.png', sizeMB: 64, ratio: 0.7 }, + { name: 'panorama.jpg', sizeMB: 120, ratio: 3 }, + { name: 'family.heic', sizeMB: 22, ratio: 1.3 }, + { name: 'skyline.raw', sizeMB: 96, ratio: 1.5 }, + { name: 'cat.jpg', sizeMB: 18, ratio: 1 } + ] +}, { + id: 'videos', + name: 'Videos', + kind: 'video', + files: [ + { name: 'wedding.mp4', sizeMB: 420, ratio: 1.8 }, + { name: 'trip-vlog.mov', sizeMB: 310, ratio: 1.8 }, + { name: 'drone.mp4', sizeMB: 180, ratio: 1.8 }, + { name: 'clip.webm', sizeMB: 40, ratio: 1.8 } + ] +}, { + id: 'documents', + name: 'Documents', + kind: 'document', + files: [ + { name: 'thesis.pdf', sizeMB: 32, ratio: 0.75 }, + { name: 'budget.xlsx', sizeMB: 12, ratio: 1.4 }, + { name: 'notes.md', sizeMB: 6, ratio: 1 }, + { name: 'contract.docx', sizeMB: 14, ratio: 0.75 }, + { name: 'slides.pptx', sizeMB: 58, ratio: 1.6 }, + { name: 'scan.pdf', sizeMB: 26, ratio: 0.75 }, + { name: 'invoice.pdf', sizeMB: 8, ratio: 0.75 }, + { name: 'resume.pdf', sizeMB: 6, ratio: 0.75 } + ] +}, { + id: 'music', + name: 'Music', + kind: 'music', + files: [ + { name: 'album.flac', sizeMB: 160, ratio: 1 }, + { name: 'live-set.wav', sizeMB: 110, ratio: 2 }, + { name: 'single.mp3', sizeMB: 9, ratio: 1 }, + { name: 'podcast.mp3', sizeMB: 54, ratio: 1.2 }, + { name: 'demo.ogg', sizeMB: 12, ratio: 1 } + ] +}, { + id: 'archives', + name: 'Archives', + kind: 'archive', + files: [ + { name: 'backup-2024.zip', sizeMB: 260, ratio: 1.2 }, + { name: 'project.tar.gz', sizeMB: 75, ratio: 1 }, + { name: 'fonts.7z', sizeMB: 20, ratio: 1.6 } + ] +}]; + +// Area per MB, and the smallest a tile may get so its label stays readable. +const PX2_PER_MB = 110; +const MIN_TILE_AREA = 96 * 52; +const MIN_TILE_WIDTH = 90; +const MIN_TILE_HEIGHT = 44; + +/** A tile's size - its area proportional to the file size, its shape given by `ratio`. */ +export function getTileSize({ sizeMB, ratio }: FileData): dia.Size { + const area = Math.max(MIN_TILE_AREA, sizeMB * PX2_PER_MB); + const width = Math.max(MIN_TILE_WIDTH, Math.round(Math.sqrt(area * ratio))); + const height = Math.max(MIN_TILE_HEIGHT, Math.round(area / width)); + return { width, height }; +} + +export function createFileJSON(folderId: string, kind: FileKind, file: FileData): dia.Cell.JSON { + const size = getTileSize(file); + return { + id: `${folderId}/${file.name}`, + type: 'example.FileTile', + parent: folderId, + kind, + size, + // The size ELK starts from on every layout - see `index.ts`'s `exportElement`. + baseSize: size, + attrs: { + body: { class: `md-tile md-tile-${kind}` }, + label: { text: `${file.name}\n${file.sizeMB} MB` } + } + }; +} + +export const graphJSON: dia.Graph.JSON = { + cells: folders.flatMap(({ id, name, kind, files }) => [{ + id, + type: 'example.Folder', + kind, + attrs: { label: { text: name } } + }, + ...files.map((file) => createFileJSON(id, kind, file)) + // Each folder comes right before its own files, so it renders below them. + ]).map((cell, index) => ({ ...cell, z: index + 1 })) +}; diff --git a/examples/layout-elk-rectpacking-ts/src/index.ts b/examples/layout-elk-rectpacking-ts/src/index.ts new file mode 100644 index 0000000000..54f2883ccb --- /dev/null +++ b/examples/layout-elk-rectpacking-ts/src/index.ts @@ -0,0 +1,203 @@ +import { dia, g, shapes, setTheme } from '@joint/core'; +import { createWorkerElk, layout } from '@joint/layout-elk'; +import { graphJSON, createFileJSON, type FileKind } from './example'; +import { Folder, FileTile, FOLDER_PADDING } from './shapes'; +import './styles.scss'; + +import type { + ElkLayoutOptions, + ExportElementCallback +} from '@joint/layout-elk'; + +const cellNamespace = { + ...shapes, + example: { + Folder, + FileTile + } +}; + +const FILE_EXTENSIONS: Record = { + photo: 'jpg', + video: 'mp4', + document: 'pdf', + music: 'mp3', + archive: 'zip' +}; + +// ELK runs in a Web Worker, so a layout doesn't block the page - the worker is started on +// the first layout, then shared by every later one. +const elk = createWorkerElk(() => new Worker(new URL('@joint/layout-elk/worker', import.meta.url), { type: 'module' })); + +const init = () => { + + setTheme('material'); + + const graph = new dia.Graph({}, { cellNamespace }); + const paper = new dia.Paper({ + model: graph, + cellViewNamespace: cellNamespace, + width: 900, + height: 600, + gridSize: 1, + interactive: false, + async: true, + frozen: true + }); + document.getElementById('canvas')!.appendChild(paper.el); + + graph.fromJSON(graphJSON); + + const controls = getControls(); + + // The `rectpacking` options, read from the toolbar. ELK doesn't inherit + // layout options down the hierarchy, so these go both on the root (to pack + // the folders) and on every folder (to pack its files) - see `exportElement`. + const getPackingOptions = (): ElkLayoutOptions => ({ + 'elk.algorithm': 'rectpacking', + 'elk.aspectRatio': controls.aspectRatio.value as `${number}`, + 'elk.rectpacking.widthApproximation.optimizationGoal': controls.optimizationGoal.value, + 'elk.rectpacking.orderBySize': `${controls.orderBySize.checked}` + }); + + // Every folder is packed on its own (its size then fixed), before the root + // packs the folders - `@joint/layout-elk`'s default `INCLUDE_CHILDREN` is meant + // for edges crossing container boundaries, and `rectpacking` has no edges. + const getRootOptions = (): ElkLayoutOptions => ({ + ...getPackingOptions(), + 'elk.hierarchyHandling': 'SEPARATE_CHILDREN', + 'elk.spacing.nodeNode': '20', + 'elk.padding': '[top=0,left=0,bottom=0,right=0]' + }); + + const exportElement: ExportElementCallback = ({ element, elkNode }) => { + if (element instanceof Folder) { + const { top, left, bottom, right } = FOLDER_PADDING; + Object.assign(elkNode.layoutOptions, getPackingOptions(), { + 'elk.spacing.nodeNode': '6', + 'elk.padding': `[top=${top},left=${left},bottom=${bottom},right=${right}]`, + // Applied to the files only, not to the root: stretching a folder + // (already packed on its own) would just leave empty space inside it. + 'elk.rectpacking.whiteSpaceElimination.strategy': controls.whiteSpaceElimination.value + }); + return; + } + // White space elimination stretches tiles - start every layout from the + // tile's original size, not from whatever the previous layout made of it. + const { width, height } = element.get('baseSize'); + elkNode.width = width; + elkNode.height = height; + }; + + let contentArea = new g.Rect(0, 0, 0, 0); + let zoomLevel = 1; + + const fit = () => { + paper.scale(zoomLevel); + paper.fitToContent({ + // The layout result itself - the paper is async, so measuring the views + // right after `unfreeze()` would read them before they have rendered. + contentArea, + padding: 40 * zoomLevel, + allowNewOrigin: 'any' + }); + }; + + // A change made while a layout is still running is not lost - one more + // layout follows, with whatever the toolbar says by then. + let running = false; + let pending = false; + const runLayout = async(): Promise => { + if (running) { + pending = true; + return; + } + running = true; + do { + pending = false; + try { + const { bbox } = await layout(graph, { + elk, + elkLayoutOptions: getRootOptions(), + exportElement + }); + contentArea = bbox; + paper.unfreeze(); + fit(); + } catch (error) { + // A failed layout doesn't drop a change made meanwhile - the loop goes on. + paper.unfreeze(); + console.error('ELK layout error:', (error as Error).message); + } + } while (pending); + running = false; + }; + + controls.aspectRatio.addEventListener('input', () => { + controls.aspectRatioValue.textContent = Number(controls.aspectRatio.value).toFixed(1); + runLayout(); + }); + controls.optimizationGoal.addEventListener('change', runLayout); + controls.whiteSpaceElimination.addEventListener('change', runLayout); + controls.orderBySize.addEventListener('change', runLayout); + + // `rectpacking` packs in model order (unless ordering by size) - so a new + // order of the same files gives a different packing. + document.getElementById('shuffle')!.addEventListener('click', () => { + const folders = shuffle(graph.getElements().filter((element) => element instanceof Folder)); + const cells: dia.Cell[] = folders.flatMap((folder) => [folder, ...shuffle(folder.getEmbeddedCells())]); + // The graph orders cells (and so `getEmbeddedCells()`) by `z` - a reset rebuilds + // that order. Each folder still comes right before its own files. + cells.forEach((cell, index) => cell.set('z', index + 1)); + graph.resetCells(cells); + runLayout(); + }); + + let fileCount = 0; + document.getElementById('add-file')!.addEventListener('click', () => { + const [folder] = shuffle(graph.getElements().filter((element) => element instanceof Folder)); + const kind: FileKind = folder.get('kind'); + const tile = new FileTile(createFileJSON(`${folder.id}`, kind, { + name: `new-${++fileCount}.${FILE_EXTENSIONS[kind]}`, + sizeMB: Math.round(10 + Math.random() * 150), + ratio: [0.75, 1, 1.5, 1.8][Math.floor(Math.random() * 4)] + })); + // Appears at its folder's corner, then moves to wherever ELK packs it. + tile.set({ position: folder.position().toJSON(), z: graph.maxZIndex() + 1 }); + graph.addCell(tile); + runLayout(); + }); + + document.getElementById('zoom-in')!.addEventListener('click', () => { + zoomLevel = Math.min(3, zoomLevel + 0.2); + fit(); + }); + + document.getElementById('zoom-out')!.addEventListener('click', () => { + zoomLevel = Math.max(0.2, zoomLevel - 0.2); + fit(); + }); + + runLayout(); +}; + +function shuffle(items: T[]): T[] { + const result = items.slice(); + for (let i = result.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [result[i], result[j]] = [result[j], result[i]]; + } + return result; +} + +function getControls() { + return { + optimizationGoal: document.getElementById('optimization-goal') as HTMLSelectElement, + aspectRatio: document.getElementById('aspect-ratio') as HTMLInputElement, + aspectRatioValue: document.getElementById('aspect-ratio-value') as HTMLSpanElement, + whiteSpaceElimination: document.getElementById('white-space-elimination') as HTMLSelectElement, + orderBySize: document.getElementById('order-by-size') as HTMLInputElement + }; +} + +init(); diff --git a/examples/layout-elk-rectpacking-ts/src/shapes.ts b/examples/layout-elk-rectpacking-ts/src/shapes.ts new file mode 100644 index 0000000000..d32ee8189b --- /dev/null +++ b/examples/layout-elk-rectpacking-ts/src/shapes.ts @@ -0,0 +1,54 @@ +import { shapes, util } from '@joint/core'; + +// Room left inside a folder for its title, above the packed files. +export const FOLDER_PADDING = { top: 34, left: 10, bottom: 10, right: 10 }; + +/** + * A folder - `@joint/layout-elk` sizes it to fit its packed files. Its title + * sits inside the box (in the padding `index.ts` hands ELK), not above it as in + * the other ELK examples: rectangle packing puts folders right next to each + * other, so a label outside the box would overlap a neighbor. + */ +export class Folder extends shapes.standard.Rectangle { + defaults() { + return util.defaultsDeep({ + type: 'example.Folder', + size: { width: 100, height: 100 }, + attrs: { + body: { + class: 'md-folder' + }, + label: { + x: FOLDER_PADDING.left, + y: FOLDER_PADDING.top / 2, + textAnchor: 'start', + textVerticalAnchor: 'middle', + class: 'md-folder-label' + } + } + }, super.defaults); + } +} + +/** + * A file tile. `baseSize` is the size it was created with - ELK may stretch a + * tile to fill leftover space (see `index.ts`'s white space elimination), and + * every layout starts again from `baseSize` rather than from the stretched size. + */ +export class FileTile extends shapes.standard.Rectangle { + defaults() { + return util.defaultsDeep({ + type: 'example.FileTile', + attrs: { + label: { + class: 'md-tile-label', + textWrap: { + width: -12, + height: -8, + ellipsis: true + } + } + } + }, super.defaults); + } +} diff --git a/examples/layout-elk-rectpacking-ts/src/styles.scss b/examples/layout-elk-rectpacking-ts/src/styles.scss new file mode 100644 index 0000000000..2bf52eb179 --- /dev/null +++ b/examples/layout-elk-rectpacking-ts/src/styles.scss @@ -0,0 +1,165 @@ +// A restrained Material Design palette, scoped under the `joint-theme-material` +// class every view gets from `setTheme('material')` (`index.ts`). Each file kind +// gets its own tonal tile color, so the packed board reads as a storage overview. +$layout-duration: 400ms; +$layout-easing: cubic-bezier(0.2, 0, 0, 1); + +:root { + --md-primary: #3F51B5; + --md-primary-tint: #E8EAF6; + --md-surface: #FFFFFF; + --md-surface-container: #F3F4F9; + --md-outline: #D0D3E3; + --md-on-surface: rgba(0, 0, 0, 0.87); + --md-on-surface-variant: rgba(0, 0, 0, 0.6); + + --md-photo: #E3F2FD; + --md-photo-outline: #90CAF9; + --md-video: #FCE4EC; + --md-video-outline: #F48FB1; + --md-document: #FFF8E1; + --md-document-outline: #FFD54F; + --md-music: #E8F5E9; + --md-music-outline: #A5D6A7; + --md-archive: #EDE7F6; + --md-archive-outline: #B39DDB; +} + +html, body { + margin: 0; + padding: 0; + font-family: 'Roboto', 'Segoe UI', sans-serif; +} + +#canvas { + position: absolute; + margin-top: 64px; + margin-left: 20px; + border: 1px solid var(--md-outline); + background-color: #FAFAFA; + overflow: hidden; +} + +.toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + position: fixed; + top: 10px; + left: 20px; + right: 20px; + z-index: 1; + font-size: 13px; + color: var(--md-on-surface-variant); +} + +.toolbar-field { + display: inline-flex; + align-items: center; + gap: 6px; + background: var(--md-surface); + border-radius: 4px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2), 0 1px 1px rgba(0, 0, 0, 0.14); + padding: 4px 10px; + min-height: 26px; + + select { + font-family: inherit; + font-size: 13px; + color: var(--md-on-surface); + border: 1px solid var(--md-outline); + border-radius: 4px; + background: var(--md-surface); + padding: 2px 4px; + } + + input[type="range"] { + width: 100px; + accent-color: var(--md-primary); + } + + input[type="checkbox"] { + margin: 0; + accent-color: var(--md-primary); + } +} + +.toolbar-value { + min-width: 2em; + font-variant-numeric: tabular-nums; + color: var(--md-on-surface); +} + +.toolbar-button { + background: var(--md-surface); + border-radius: 4px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2), 0 1px 1px rgba(0, 0, 0, 0.14); + font-weight: 500; + text-transform: uppercase; + padding: 8px 14px; + letter-spacing: 0.4px; + color: var(--md-primary); + cursor: pointer; + user-select: none; + transition: box-shadow 0.15s ease, background 0.15s ease; + + &:hover { + background: var(--md-primary-tint); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.24), 0 1px 2px rgba(0, 0, 0, 0.16); + } +} + +// The layout writes each element's position and size once and the browser animates the +// change: an element's position is a `transform` on its group, and a tile's size reaches +// its `rect` as `width`/`height`, both of which CSS can interpolate. ELK resizes tiles +// here (white space elimination), so both are in play. +.joint-element { + transition: transform $layout-duration $layout-easing; + + rect { + transition: + width $layout-duration $layout-easing, + height $layout-duration $layout-easing; + } +} + +// Everything from here on only ever applies to a `joint-theme-material` view. +.joint-theme-material { + + .md-folder { + fill: var(--md-surface-container); + stroke: var(--md-outline); + stroke-width: 1px; + rx: 8px; + ry: 8px; + } + + .md-folder-label { + font-family: inherit; + font-weight: 500; + font-size: 12px; + letter-spacing: 0.5px; + text-transform: uppercase; + fill: var(--md-on-surface-variant); + } + + .md-tile { + stroke-width: 1px; + rx: 4px; + ry: 4px; + } + + @each $kind in photo, video, document, music, archive { + .md-tile-#{$kind} { + fill: var(--md-#{$kind}); + stroke: var(--md-#{$kind}-outline); + } + } + + .md-tile-label { + font-family: inherit; + font-size: 11px; + fill: var(--md-on-surface); + } +} diff --git a/examples/layout-elk-rectpacking-ts/tsconfig.json b/examples/layout-elk-rectpacking-ts/tsconfig.json new file mode 100644 index 0000000000..66924fb436 --- /dev/null +++ b/examples/layout-elk-rectpacking-ts/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "module": "ES2020", + "moduleResolution": "bundler", + "target": "es6", + "lib": [ + "es2022", + "dom" + ], + "noImplicitAny": false, + "resolveJsonModule": true, + "esModuleInterop": true, + "noEmit": true, + "types": [ + "vite/client" + ] + }, + "include": [ + "src" + ] +} diff --git a/examples/layout-elk-ts/tsconfig.json b/examples/layout-elk-ts/tsconfig.json deleted file mode 100644 index 39951cb836..0000000000 --- a/examples/layout-elk-ts/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "compilerOptions": { - "module": "commonjs", - "target": "es5", - "noImplicitAny": false, - "sourceMap": false, - "outDir": "./build", - "resolveJsonModule": true, - "esModuleInterop": true - } -} diff --git a/examples/layout-elk-ts/webpack.config.js b/examples/layout-elk-ts/webpack.config.js deleted file mode 100644 index 525bf11dc1..0000000000 --- a/examples/layout-elk-ts/webpack.config.js +++ /dev/null @@ -1,33 +0,0 @@ -const path = require('path'); - -module.exports = { - resolve: { - extensions: ['.ts', '.tsx', '.js'], - }, - entry: './src/index.ts', - output: { - filename: 'bundle.js', - path: path.resolve(__dirname, 'dist'), - publicPath: '/dist/', - }, - mode: 'development', - module: { - rules: [ - { test: /\.ts$/, loader: 'ts-loader' }, - { - test: /\.s[ac]ss$/i, - use: [ - 'style-loader', - 'css-loader', - 'sass-loader', - ], - }, - ], - }, - devServer: { - static: { - directory: __dirname, - }, - compress: true, - }, -}; diff --git a/package.json b/package.json index 6d15128813..43a98e859f 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "test-bundles": "yarn workspaces foreach --all -tvv run test-bundles", "lint": "yarn workspaces foreach --all -tvv run lint", "lint-fix": "yarn workspaces foreach --all -tvv run lint-fix", - "pack-all": "yarn workspaces foreach --all -tvv --include \"@joint/core\" --include \"@joint/layout-directed-graph\" --include \"@joint/layout-msagl\" --include \"@joint/router-avoid\" pack --out %s-%v.tgz" + "pack-all": "yarn workspaces foreach --all -tvv --include \"@joint/core\" --include \"@joint/layout-directed-graph\" --include \"@joint/layout-msagl\" --include \"@joint/router-avoid\" --include \"@joint/layout-elk\" pack --out %s-%v.tgz" }, "devDependencies": { "@changesets/cli": "3.0.0" diff --git a/packages/joint-core/src/dia/link-labels.mjs b/packages/joint-core/src/dia/link-labels.mjs index 402b8cd075..a061514f3e 100644 --- a/packages/joint-core/src/dia/link-labels.mjs +++ b/packages/joint-core/src/dia/link-labels.mjs @@ -1,7 +1,7 @@ import { cloneDeep, merge } from '../util/index.mjs'; // A label as given (own `markup`/`attrs`/`size`/`position`, any of which may be missing), -// resolved against `link`'s `defaultLabel` and its built-in default. +// computed against `link`'s `defaultLabel` and its built-in default. export function getComputedLabel(link, label = {}) { const builtinDefaultLabel = link._builtins.defaultLabel; const defaultLabel = link._getDefaultLabel(); @@ -11,7 +11,7 @@ export function getComputedLabel(link, label = {}) { // built-in markup, so they don't apply once a custom one is in play. const hasCustomMarkup = !!(label.markup || defaultLabel.markup); - // The resolved `markup`/`attrs`/`size`/`position` are always new objects - never the + // The computed `markup`/`attrs`/`size`/`position` are always new objects - never the // stored label's, `defaultLabel`'s or the (shared by all links) built-in default's - // so mutating a computed label can't change them. Custom properties are passed through as-is. return Object.assign({}, defaultLabel, label, { @@ -22,7 +22,7 @@ export function getComputedLabel(link, label = {}) { }); } -// Only a label's `position`, resolved exactly as in `getComputedLabel()` - for callers that need +// Only a label's `position`, computed exactly as in `getComputedLabel()` - for callers that need // nothing else (e.g. `LinkView`'s frequent label position updates), so the label's `markup`, // `attrs` and `size` aren't cloned or merged for nothing. export function getComputedLabelPosition(link, label = {}) { diff --git a/packages/joint-core/test/ts/index.test.ts b/packages/joint-core/test/ts/index.test.ts index ab365ffe88..8bcd2536a6 100644 --- a/packages/joint-core/test/ts/index.test.ts +++ b/packages/joint-core/test/ts/index.test.ts @@ -72,6 +72,22 @@ const rectangle = new joint.shapes.standard.Rectangle({ } }); +// `portProp()` - whole port getter, path getter/setter, object setter +const port: joint.dia.Element.Port = rectangle.portProp('port1'); +const portGroup = rectangle.portProp('port1', 'group'); +rectangle.portProp('port1', ['position', 'args'], { x: 10, y: 20 }, { silent: true }); +const portPropObjectResult = rectangle.portProp('port1', { + position: { args: { x: 10, y: 20 }}, + label: { position: { args: { x: 5, y: -5 }}} +}, { rewrite: true }); +const isElementAfterObjectSet: AssertExtends = true; + +// A port label's position `args` are the label layout's own options. +const portLabelPosition: joint.dia.Element.PortLabelPositionType = { + name: 'manual', + args: { x: 5, y: -5, attrs: { labelText: { textAnchor: 'start' }}} +}; + const link = new joint.shapes.standard.Link({ attrs: { line: { diff --git a/packages/joint-core/types/dia.d.ts b/packages/joint-core/types/dia.d.ts index 4e2d191224..a43bac9583 100644 --- a/packages/joint-core/types/dia.d.ts +++ b/packages/joint-core/types/dia.d.ts @@ -840,7 +840,7 @@ export namespace Element { type PositionType = string | PortPositionCallback | PortPositionJSON; - type PortLabelPositionType = PortLabelPositionCallback | PortPositionJSON; + type PortLabelPositionType = PortLabelPositionCallback | PortLabelPositionJSON; interface PortGroup { position?: PositionType; @@ -983,8 +983,12 @@ export class Element, opt?: S): Element; + portProp(portId: Element.PortID, path: Path, value?: any, opt?: S): Element; protected generatePortId(): Element.PortID; diff --git a/packages/joint-core/types/util.d.ts b/packages/joint-core/types/util.d.ts index f3bb0c7b8f..d267eff7ad 100644 --- a/packages/joint-core/types/util.d.ts +++ b/packages/joint-core/types/util.d.ts @@ -246,14 +246,14 @@ export function deepMixin(destinationObject: object, ...sourceObjects: object[]) export function assign(destinationObject: object, ...sourceObjects: object[]): object; /** @deprecated use joint.util.defaults */ -export function supplement(destinationObject: object, ...sourceObjects: object[]): object; +export function supplement(destinationObject: object, ...sourceObjects: (object | undefined)[]): object; /** @deprecated use joint.util.defaultsDeep */ -export function deepSupplement(destinationObject: object, ...sourceObjects: object[]): object; +export function deepSupplement(destinationObject: object, ...sourceObjects: (object | undefined)[]): object; -export function defaults(destinationObject: object, ...sourceObjects: object[]): object; +export function defaults(destinationObject: object, ...sourceObjects: (object | undefined)[]): object; -export function defaultsDeep(destinationObject: object, ...sourceObjects: object[]): object; +export function defaultsDeep(destinationObject: object, ...sourceObjects: (object | undefined)[]): object; export function invoke(collection: Collection, methodPath: PropertyPath, args?: any[]): any[]; export function invoke(collection: Collection, functionToInvokeForAll: IterateeFunction, ...args: ArgsT[]): any[]; diff --git a/packages/joint-layout-elk/.gitignore b/packages/joint-layout-elk/.gitignore new file mode 100644 index 0000000000..27d0955d98 --- /dev/null +++ b/packages/joint-layout-elk/.gitignore @@ -0,0 +1,4 @@ +build +dist +node_modules +coverage diff --git a/packages/joint-layout-elk/CHANGELOG.md b/packages/joint-layout-elk/CHANGELOG.md new file mode 100644 index 0000000000..8bf530e74d --- /dev/null +++ b/packages/joint-layout-elk/CHANGELOG.md @@ -0,0 +1 @@ +# @joint/layout-elk diff --git a/packages/joint-layout-elk/LICENSE b/packages/joint-layout-elk/LICENSE new file mode 100644 index 0000000000..04980886c3 --- /dev/null +++ b/packages/joint-layout-elk/LICENSE @@ -0,0 +1,376 @@ +Copyright 2013 client IO +http://client.io + +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. \ No newline at end of file diff --git a/packages/joint-layout-elk/README.md b/packages/joint-layout-elk/README.md new file mode 100644 index 0000000000..f9b6c3554d --- /dev/null +++ b/packages/joint-layout-elk/README.md @@ -0,0 +1,209 @@ +# JointJS ELK Layout + +A module for automatic layout of *[JointJS](https://www.jointjs.com)* graphs using the [Eclipse Layout Kernel (ELK)](https://www.eclipse.org/elk/), via its JavaScript port [elkjs](https://github.com/kieler/elkjs). + +This library depends on [JointJS](https://github.com/clientio/joint) (*>=4.0*), so please read its `README.md` before using this library. + +`layout()` is a one-off, asynchronous transform: it builds an ELK graph from a JointJS graph (embedded elements become nested containers at any depth, ports and link labels are included), runs ELK's layout algorithm, and writes the result (positions, sizes, link vertices/anchors, label positions) back onto the graph. It does not keep the graph laid out afterwards - call it again after further changes. + +## 🚀 Quick Start + +### Installation + +```bash +npm install @joint/layout-elk +``` + +#### UMD build (script tags) + +`dist/umd/index.js` keeps `@joint/core` and `elkjs` external - load both first: `@joint/core` as the `joint` global, and `elkjs/lib/elk.bundled.js` as the `ELK` global, which `layout()` runs on the main thread. The package then is `joint.layout.ELK`. + +```html + + + + +``` + +### Basic Usage + +```ts +import { dia, shapes } from '@joint/core'; +import { layout } from '@joint/layout-elk'; + +const graph = new dia.Graph({}, { cellNamespace: shapes }); +const paper = new dia.Paper({ + model: graph, + cellViewNamespace: shapes, + el: document.getElementById('paper'), +}); + +const rect1 = new shapes.standard.Rectangle({ id: 'a', size: { width: 80, height: 40 }, attrs: { label: { text: 'A' }}}); +const rect2 = new shapes.standard.Rectangle({ id: 'b', size: { width: 80, height: 40 }, attrs: { label: { text: 'B' }}}); +const link = new shapes.standard.Link({ source: { id: 'a' }, target: { id: 'b' }}); + +graph.addCells([rect1, rect2, link]); + +const { bbox } = await layout(graph, { + elkLayoutOptions: { + 'elk.algorithm': 'layered', + 'elk.direction': 'RIGHT', + 'elk.edgeRouting': 'ORTHOGONAL' + } +}); +``` + +## 📖 API Reference + +### `layout(graphOrCells, options?): Promise` + +- `graphOrCells`: `dia.Graph | dia.Cell[]` - the graph to lay out, or the cells to lay out. +- `options?`: `LayoutOptions` - layout configuration (see below). + +A list of cells is both the selection and the order: + +- **Selection** - only the given elements are laid out. An element whose parent isn't given is laid out as a top-level element. +- **Links** - a link is laid out only if both of the elements it connects are given too. One that isn't - including a link connected to a point, to another link, or one that is in no graph, whose ends cannot be resolved at all - is left out of the layout entirely: no `exportLink` call, no route, and its vertices, anchors and label positions are left as they are. +- **Order** - the top-level elements follow the order they are given in, and so do each container's children. The links follow their order too, inside each container. +- Elements and links go in the same list, in any order - a link may come before the elements it connects. A cell listed more than once is laid out once. +- ELK identifies everything by a string, while a JointJS id may be a string or a number - so two cells, or two ports of one element, whose ids differ only in type (`5` and `'5'`) would share one id in the ELK graph. `layout()` rejects rather than lay one of them out and leave the other where it was. + +The layout is applied in a single `'layout'` batch on the graph the cells belong to, so it emits one combined change rather than one per element, port and link. An element that is in no graph is laid out and positioned like any other, but contributes no change events - which makes it useful as a container that only exists to group others for the layout. + +```ts +// Lay out a selection, with its embedded elements and the links between its elements. +// Everything else in the graph is left alone. +await layout(graph.getSubgraph(selectedElements, { deep: true })); +``` + +```ts +interface LayoutResult { + bbox: g.Rect; // Tight bounding box of the laid out graph + elkGraph: ElkNode; // The raw ELK layout result (e.g. for junction points) +} +``` + +### `LayoutOptions` + +```ts +interface LayoutOptions { + // The ELK instance to lay out with, e.g. one running in a Web Worker (see `createWorkerElk()` below). + elk?: WorkerElk | ELK; // Default: a shared instance running on the main thread + // ELK layout options, passed through to ELK unmodified. + elkLayoutOptions?: ElkLayoutOptions; // Default: { 'elk.algorithm': 'layered', 'elk.hierarchyHandling': 'INCLUDE_CHILDREN', 'elk.layered.considerModelOrder.portModelOrder': 'true', 'elk.json.edgeCoords': 'ROOT' } + // Aborts the layout - `layout()` rejects with the signal's reason and applies nothing. + signal?: AbortSignal; + + // Export callbacks (JointJS graph -> ELK graph) - see below. + exportElement?: ExportElementCallback; + exportPort?: ExportPortCallback; + exportPortLabel?: ExportPortLabelCallback; + exportLink?: ExportLinkCallback; + exportLinkLabel?: ExportLinkLabelCallback; + + // Import callbacks (ELK layout result -> JointJS graph) - see below. + setElementAttributes?: SetElementAttributesCallback; + setPortAttributes?: SetPortAttributesCallback; + setLinkAttributes?: SetLinkAttributesCallback; +} +``` + +### Export callbacks + +Each receives an ELK draft already populated with what this package computed for that element/port/link/label, to mutate in place (e.g. to set `elk.*` `layoutOptions`). Returning `false` instead drops it from the ELK graph entirely - for `exportElement`, its whole subtree (embeds, ports, any connected edge) goes with it, and for `exportPort`, the links connected to that port, which ELK can no longer route to. A link left out this way keeps the route it already had. + +```ts +type ExportElementCallback = (params: { element: dia.Element; elkNode: ElkNodeDraft }) => void | false; +type ExportPortCallback = (params: { portId: string; element: dia.Element; elkPort: ElkPortDraft }) => void | false; +type ExportPortLabelCallback = (params: { portId: string; element: dia.Element; elkPortLabel: ElkLabelDraft }) => void | false; +type ExportLinkCallback = (params: { link: dia.Link; elkEdge: ElkEdgeDraft }) => void | false; +type ExportLinkLabelCallback = (params: { link: dia.Link; labelIndex: number; elkEdgeLabel: ElkLabelDraft }) => void | false; +``` + +### Import callbacks + +Each applies the ELK-computed attributes itself, in place of the package's own default (`element.set(...)`/`element.portProp(...)`/`link.set(...)`) - use one to redirect where the result goes, e.g. into a `transition()`. + +```ts +type SetElementAttributesCallback = (params: { element: dia.Element; attributes: { position: dia.Point; size?: dia.Size }; elkNode: ElkNode }) => void; +type SetPortAttributesCallback = (params: { element: dia.Element; portId: dia.Cell.ID; attributes: { position: { args: dia.Point }; label?: { position: { args: dia.Point } } }; elkPort: ElkPort; hasMoved: boolean }) => void; +type SetLinkAttributesCallback = (params: { link: dia.Link; attributes: { vertices: dia.Point[]; source?: dia.Link.EndJSON; target?: dia.Link.EndJSON; labels?: dia.Link.Label[] }; elkEdge: ElkExtendedEdge }) => void; +``` + +### Running ELK in a Web Worker + +By default, `layout()` runs ELK on the main thread - nothing to set up with a bundler or in Node, and it works anywhere (browsers, Node/SSR, tests, and the UMD build once `elkjs` is loaded - see "UMD build" above). ELK blocks the page while it runs, though: a few milliseconds for a small graph, but up to seconds for one with thousands of elements. To keep the page responsive, run ELK in a Web Worker instead - start one with `createWorkerElk()` and pass it to `layout()` as `elk`: + +```ts +import { layout, createWorkerElk } from '@joint/layout-elk'; + +const elk = createWorkerElk(() => new Worker(new URL('@joint/layout-elk/worker', import.meta.url), { type: 'module' })); + +await layout(graph, { elk }); + +// Once no longer needed. +elk.terminate(); +``` + +#### `createWorkerElk(createWorker: () => Worker): WorkerElk` + +`createWorker` starts the worker - running `@joint/layout-elk/worker`, the package's worker script (ELK's own, `elkjs/lib/elk-worker.min.js`). Starting it is up to you, since only your bundler knows where the script ends up: + +- **webpack 5, Vite** (dev server and builds) - `new Worker(new URL('@joint/layout-elk/worker', import.meta.url), { type: 'module' })`, as above. Both bundle the worker script as a file of its own. With webpack, keep `output.publicPath` at `'auto'` (the default), so the file is found wherever the app is served from. +- **Vite** - also `import ElkWorker from '@joint/layout-elk/worker?worker'`, then `createWorkerElk(() => new ElkWorker())`. +- **No bundler** (e.g. the UMD build) - serve a copy of `elkjs/lib/elk-worker.min.js`, then `createWorkerElk(() => new Worker('/path/to/elk-worker.min.js'))`. + +The returned `WorkerElk`: + +- **Starts the worker on its first layout**, then lays out every graph in it, one at a time - share one instance between layouts. +- **Stops an aborted layout** (see `signal` below) by terminating the worker, if it is busy with it - a new worker takes over the layouts still waiting. +- **Rejects the layout the worker crashes during** (e.g. out of memory) - a new worker takes over the layouts still waiting. +- **Rejects every layout when the worker fails to load** (e.g. its script isn't served where `createWorker` starts it from) or can't be started (e.g. a CSP `worker-src` that blocks it) - with an error saying so, rather than running ELK on the main thread. The next layout tries again with a new worker. +- **`terminate()`** terminates the worker - layouts not settled yet are rejected, and a later layout starts a new worker. + +Any other ELK instance works as `elk` too, e.g. `elkjs`'s own `new ELK({ workerUrl })` (`elkjs/lib/elk-api.js`) - without stopping an aborted layout, though (see below). + +### Aborting a layout + +`layout()` is asynchronous, so the graph may change while ELK is still computing - pass an `AbortSignal` to drop a layout that is no longer wanted (or takes too long). An aborted `layout()` rejects with the signal's reason (an `AbortError` `DOMException` by default) and applies nothing to the graph. + +```ts +let controller: AbortController | undefined; + +async function runLayout() { + // Only the latest layout is applied. + controller?.abort(); + controller = new AbortController(); + try { + await layout(graph, { signal: controller.signal }); + } catch (error) { + if ((error as Error).name !== 'AbortError') throw error; + } +} + +// Give up after 5 seconds. +await layout(graph, { signal: AbortSignal.timeout(5000) }); +``` + +ELK can't stop a layout in progress, so a layout a `createWorkerElk()` worker is busy with is stopped by terminating the worker - a new one takes over the layouts still waiting. A layout on the main thread, or in any other `elk` instance, keeps running - only its result is ignored. + +## ⚠️ Caveats & Known Limitations + +- **Edge coordinates are graph-absolute** - `layout()` sets `elk.json.edgeCoords: 'ROOT'`, so ELK returns every edge's route points and labels relative to the root, whichever container the edge is in, and the default import applies them as they are. Overriding it (e.g. `'CONTAINER'`) is allowed, but the default import then misplaces vertices, end anchors and labels of edges inside containers - convert them yourself in `setLinkAttributes` (from `elkEdge`). The same applies to the raw `elkGraph` in `layout()`'s result. +- **Node labels are not supported** - ELK's node-label placement assumes labels are layout participants, whereas JointJS labels are attrs inside the shape. Link labels are supported. +- **Element rotation is not taken into account** - an ELK node is a box with a position and a size, with no rotation, so an element is laid out by the geometry it would have at `angle: 0` and ELK reserves the wrong area for a rotated one. `layout()` warns the first time it sees one. +- **Ports keep their JointJS-computed position by default** - every element with ports is exported with `elk.portConstraints: 'FIXED_POS'`, so ELK keeps each port where the element's port groups place it and edges route to/from that exact spot. Opt into ELK repositioning/reordering them by overriding it (e.g. `'FIXED_SIDE'`/`'FREE'`) in `exportElement` - setting it in `elkLayoutOptions` has no effect, since the per-node value takes precedence. `layout()` also defaults `elk.layered.considerModelOrder.portModelOrder` to `'true'`, so that a port freed this way keeps the order of the element's `ports.items`; override it in `elkLayoutOptions` to let ELK reorder them to reduce edge crossings. +- **Asynchronous** - unlike `@joint/layout-directed-graph`, `layout()` returns a `Promise`, since `elkjs` computes layouts asynchronously - even on the main thread. +- **Main thread by default** - without an `elk` option, ELK runs on the main thread and blocks the page while it runs - see "Running ELK in a Web Worker" above. In the ESM build, the main-thread copy of ELK (`elkjs/lib/elk.bundled.js`) is imported dynamically, so bundlers split it into a chunk of its own, only loaded by the first layout without an `elk` option. The UMD build can't load a chunk - it uses the `ELK` global instead, which the page has to load first (see "UMD build" above). + +## 📄 License + +[Mozilla Public License 2.0](https://www.mozilla.org/en-US/MPL/2.0/) + +The code in this package is licensed under the Mozilla Public License 2.0, same as the rest of JointJS. It contains no ELK code: it only calls ELK through its API, and its TypeScript option types link to [ELK's option reference](https://eclipse.dev/elk/reference/options.html) instead of reproducing it. + +It depends on [`elkjs`](https://github.com/kieler/elkjs), which is dual-licensed under the [Eclipse Public License 2.0](https://github.com/kieler/elkjs/blob/master/LICENSE.md) or GPL-3.0-or-later (`EPL-2.0 OR GPL-3.0-or-later`) - you can use it under the EPL-2.0. `elkjs` is installed as a regular dependency and kept external to this package's own builds (ESM and UMD) - it is never copied or inlined into them. An application that bundles this package does ship `elkjs` code though (the main-thread chunk, and the Web Worker file if it uses `@joint/layout-elk/worker`), under that license: keep `elkjs`'s license notice with it (e.g. with your bundler's license extraction), and note where its source is available (it is published on [GitHub](https://github.com/kieler/elkjs) and [npm](https://www.npmjs.com/package/elkjs)). + +Copyright © 2013-2026 client IO diff --git a/packages/joint-layout-elk/SECURITY.md b/packages/joint-layout-elk/SECURITY.md new file mode 100644 index 0000000000..88228bbc7e --- /dev/null +++ b/packages/joint-layout-elk/SECURITY.md @@ -0,0 +1,12 @@ +# Security Policy + +## Supported Versions + +The [latest released version](https://github.com/clientIO/joint/releases) of JointJS is supported. + +## Reporting a Vulnerability + +Please email security@client.io, and we will respond as quickly as possible. + +If the vulnerability is considered valid and accepted, a patch will be made for the latest JointJS version. +If the vulnerability is deemed invalid, no further action is required. diff --git a/packages/joint-layout-elk/coverage.json b/packages/joint-layout-elk/coverage.json new file mode 100644 index 0000000000..4f9779702c --- /dev/null +++ b/packages/joint-layout-elk/coverage.json @@ -0,0 +1,8 @@ +{ + "global": { + "statements": 87, + "branches": 60, + "functions": 100, + "lines": 95 + } +} diff --git a/packages/joint-layout-elk/eslint.config.mjs b/packages/joint-layout-elk/eslint.config.mjs new file mode 100644 index 0000000000..c47ad9e5e6 --- /dev/null +++ b/packages/joint-layout-elk/eslint.config.mjs @@ -0,0 +1,22 @@ +import { tsConfig, jsConfig, rollupConfig } from '@joint/eslint-config'; +import { defineConfig } from 'eslint/config'; + +export default defineConfig([ + ...jsConfig, + ...tsConfig, + ...rollupConfig, + { + // Autogenerated folder + ignores: ['coverage/'], + }, + { + // Tests + files: ['**/test/**'], + languageOptions: { + globals: { + joint: 'readonly', + QUnit: 'readonly', + }, + }, + }, +]); diff --git a/packages/joint-layout-elk/karma.conf.js b/packages/joint-layout-elk/karma.conf.js new file mode 100644 index 0000000000..943296fe15 --- /dev/null +++ b/packages/joint-layout-elk/karma.conf.js @@ -0,0 +1,66 @@ +const puppeteer = require('puppeteer'); +const coverageThresholds = require('./coverage.json'); + +// Used for starting Chrome if using ChromeHeadless in Jenkins +process.env.CHROME_BIN = puppeteer.executablePath(); + +// Coverage is collected for this and source-mapped to `src/*.mts` +const TEST_BUNDLE = './build/test/index.js'; + +// Which path should .lcov files record as root they are relative to? +// - SonarQube (`.github/workflows/sonar.yml`) needs this to be the repo root +const REPOSITORY_ROOT = '../..'; + +module.exports = function(config) { + config.set({ + basePath: '.', + files: [ + './node_modules/@joint/core/build/joint.js', + './node_modules/elkjs/lib/elk.bundled.js', + // Served (not loaded) for the tests starting ELK's default Web Worker. + { pattern: './node_modules/elkjs/lib/elk-worker.min.js', included: false }, + TEST_BUNDLE, + + './test/index.js' + ], + singleRun: true, + frameworks: ['qunit'], + plugins: [ + 'karma-qunit', + 'karma-coverage', + 'karma-sourcemap-loader', + 'karma-chrome-launcher' + ], + reporters: ['progress', 'coverage'], + proxies: {}, + browsers: ['ChromeHeadless_custom'], + customLaunchers: { + ChromeHeadless_custom: { + base: 'ChromeHeadless', + flags: [ + // --no-sandbox needed for Jenkins build + '--no-sandbox', + '--headless', + '--disable-gpu', + '--disable-dev-shm-usage' + ] + } + }, + exclude: [], + preprocessors: { + [TEST_BUNDLE]: ['sourcemap', 'coverage'] + }, + coverageReporter: { + // specify a common output directory + dir: 'coverage/', + // coverage baseline - falling below any of these fails the test + check: coverageThresholds, + reporters: ((process.env.COVERAGE_REPORTER === 'lcov') + ? [{ type: 'lcovonly', subdir: '.', file: 'lcov.info', projectRoot: REPOSITORY_ROOT }] + : [ + { type: 'html', subdir: '.' }, + { type: 'text-summary' } + ]) + } + }); +}; diff --git a/packages/joint-layout-elk/package.json b/packages/joint-layout-elk/package.json new file mode 100644 index 0000000000..4c95c5bab6 --- /dev/null +++ b/packages/joint-layout-elk/package.json @@ -0,0 +1,93 @@ +{ + "name": "@joint/layout-elk", + "title": "JointJS ELK Layout", + "version": "4.3.0", + "description": "ELK Layout module for JointJS", + "sideEffects": false, + "main": "./dist/esm/index.mjs", + "module": "./dist/esm/index.mjs", + "types": "./dist/esm/index.d.mts", + "homepage": "https://jointjs.com", + "author": { + "name": "client IO", + "url": "https://client.io" + }, + "repository": { + "type": "git", + "url": "https://github.com/clientIO/joint.git", + "directory": "packages/joint-layout-elk" + }, + "bugs": { + "url": "https://github.com/clientIO/joint/issues" + }, + "license": "MPL-2.0", + "installConfig": { + "hoistingLimits": "workspaces" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "prepublishOnly": "echo \"Publishing via NPM is not allowed!\" && exit 1", + "prepack": "yarn build", + "dist": "yarn build", + "build": "yarn clean && yarn build:esm && yarn build:umd", + "clean": "rm -rf dist", + "build:esm": "tsc --project tsconfig.esm.json", + "build:umd": "rollup --config", + "watch": "yarn build && concurrently \"tsc --project tsconfig.esm.json --watch\" \"rollup --config --watch\"", + "test": "yarn build && karma start karma.conf.js", + "test-coverage": "karma start karma.conf.js", + "test-coverage-lcov": "COVERAGE_REPORTER=lcov yarn run test-coverage", + "lint": "eslint .", + "lint-fix": "yarn run lint --fix" + }, + "exports": { + ".": { + "types": "./dist/esm/index.d.mts", + "import": "./dist/esm/index.mjs", + "default": "./dist/esm/index.mjs" + }, + "./worker": "./dist/esm/elk.worker.mjs" + }, + "files": [ + "dist", + "SECURITY.md", + "README.md", + "LICENSE", + "CHANGELOG.md" + ], + "dependencies": { + "@joint/core": "workspace:~", + "elkjs": "0.12.0" + }, + "devDependencies": { + "@joint/eslint-config": "workspace:*", + "@rollup/plugin-node-resolve": "^16.0.1", + "@rollup/plugin-terser": "^0.4.4", + "@rollup/plugin-typescript": "^12.1.1", + "concurrently": "^9.2.0", + "eslint": "9.39.2", + "karma": "^6.4.2", + "karma-chrome-launcher": "^3.2.0", + "karma-coverage": "^2.2.1", + "karma-qunit": "^4.1.2", + "karma-sourcemap-loader": "^0.4.0", + "puppeteer": "24.22.0", + "qunit": "^2.24.1", + "rollup": "4.36.0", + "rollup-plugin-banner2": "^1.2.2", + "typescript": "^5.7.3" + }, + "volta": { + "node": "22.14.0", + "npm": "11.2.0", + "yarn": "4.18.0" + }, + "keywords": [ + "jointjs", + "layout", + "elk", + "elkjs" + ] +} diff --git a/packages/joint-layout-elk/rollup.config.mjs b/packages/joint-layout-elk/rollup.config.mjs new file mode 100644 index 0000000000..f5f3f4ee43 --- /dev/null +++ b/packages/joint-layout-elk/rollup.config.mjs @@ -0,0 +1,128 @@ +import packageJson from './package.json' with { type: 'json' }; +import banner from 'rollup-plugin-banner2'; +import terser from '@rollup/plugin-terser'; +import { nodeResolve } from '@rollup/plugin-node-resolve'; +import typescript from '@rollup/plugin-typescript'; + +// JointJS banner. +// - see `joint-core/grunt/resources/banner.js` +const today = new Date(); +const formattedDate = `${today.toLocaleDateString('en-US', { year: 'numeric' })}-${today.toLocaleDateString('en-US', { month: '2-digit' })}-${today.toLocaleDateString('en-US', { day: '2-digit' })}`; +const bannerText = `/*! ${packageJson.title} v${packageJson.version} (${formattedDate}) - ${packageJson.description}\n\nThis Source Code Form is subject to the terms of the Mozilla Public\nLicense, v. 2.0. If a copy of the MPL was not distributed with this\nfile, You can obtain one at http://mozilla.org/MPL/2.0/.\n*/\n\n`; + +const input = ['./dist/esm/index.mjs']; + +// Replaces the module imported as `.mjs` with `code`. +const replaceModule = (name, code) => { + const id = `\0${name}`; + const pattern = new RegExp(`(^|/)${name}\\.mjs$`); + return { + name: `replace-${name}`, + resolveId(source) { + return pattern.test(source) ? id : null; + }, + load(loadId) { + return (loadId === id) ? code : null; + } + }; +}; + +// A UMD bundle can't load a chunk of its own - replace the module that imports +// main-thread ELK dynamically (see `src/mainThreadElk.mts`) with one importing it +// statically, i.e. the `ELK` global. +const staticMainThreadElk = replaceModule( + 'mainThreadElk', + 'import ElkConstructor from \'elkjs/lib/elk.bundled.js\'; export function loadMainThreadElk() { return Promise.resolve(ElkConstructor); }' +); + +// The unit test bundle loads main-thread ELK the same way, unless a test hands it a loader +// of its own (`window.__loadMainThreadElk`), so failing to load it can be tested too. +const testMainThreadElk = replaceModule( + 'mainThreadElk', + 'import ElkConstructor from \'elkjs/lib/elk.bundled.js\'; export function loadMainThreadElk() { return window.__loadMainThreadElk ? window.__loadMainThreadElk() : Promise.resolve(ElkConstructor); }' +); + +export default [ + { + input, + external: [ + '@joint/core', + 'elkjs/lib/elk.bundled.js' + ], + output: [ + { + file: 'dist/umd/index.js', + format: 'umd', + name: 'joint.layout.ELK', + extend: true, + globals: { + '@joint/core': 'joint', + 'elkjs/lib/elk.bundled.js': 'ELK' + }, + plugins: [ + banner(() => bannerText) + ] + }, + { + file: 'dist/umd/index.min.js', + format: 'umd', + name: 'joint.layout.ELK', + extend: true, + globals: { + '@joint/core': 'joint', + 'elkjs/lib/elk.bundled.js': 'ELK' + }, + plugins: [ + terser({ format: { ascii_only: true }}), + banner(() => bannerText) + ] + }, + ], + plugins: [ + staticMainThreadElk, + nodeResolve({ + preferBuiltins: false + }) + ] + }, + // Source-mapped bundle for unit tests (see `karma.conf.js`) + // - Compiles TypeScript directly instead of reusing from `dist`/`esm` + // - (Because Rollup cannot follow inline maps left behind by `tsc`) + { + input: ['./src/index.mts'], + external: [ + '@joint/core', + 'elkjs/lib/elk.bundled.js' + ], + output: [ + { + file: 'build/test/index.js', + format: 'umd', + name: 'joint.layout.ELK', + extend: true, + globals: { + '@joint/core': 'joint', + 'elkjs/lib/elk.bundled.js': 'ELK' + }, + sourcemap: true + } + ], + plugins: [ + testMainThreadElk, + nodeResolve({ + preferBuiltins: false + }), + typescript({ + tsconfig: './tsconfig.json', + compilerOptions: { + // Rollup writes its own source map + inlineSourceMap: false, + inlineSources: false, + sourceMap: true, + declaration: false, + declarationMap: false, + } + }) + ] + } +]; diff --git a/packages/joint-layout-elk/src/abort.mts b/packages/joint-layout-elk/src/abort.mts new file mode 100644 index 0000000000..6bb6d00d1e --- /dev/null +++ b/packages/joint-layout-elk/src/abort.mts @@ -0,0 +1,31 @@ +// Not part of the public API (not re-exported from `index.mts`). + +// The signal's reason - whatever it is, `null` included. Only where the browser doesn't +// support `reason` (it is `undefined` then) a default one is made up. +export function getAbortReason(signal: AbortSignal): unknown { + return (signal.reason !== undefined) ? signal.reason : new DOMException('The layout was aborted.', 'AbortError'); +} + +export function throwIfAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) throw getAbortReason(signal); +} + +/** + * Settles as `promise` does, or rejects with `signal`'s reason as soon as it is aborted - + * whichever comes first. `promise` itself is left to run (e.g. ELK on the main thread, + * which can't be stopped), only its result is ignored. + */ +export function abortable(promise: Promise, signal: AbortSignal | undefined): Promise { + if (!signal) return promise; + return new Promise((resolve, reject) => { + const onAbort = () => reject(getAbortReason(signal)); + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener('abort', onAbort, { once: true }); + promise + .then(resolve, reject) + .finally(() => signal.removeEventListener('abort', onAbort)); + }); +} diff --git a/packages/joint-layout-elk/src/defaultElk.mts b/packages/joint-layout-elk/src/defaultElk.mts new file mode 100644 index 0000000000..91c5c31465 --- /dev/null +++ b/packages/joint-layout-elk/src/defaultElk.mts @@ -0,0 +1,39 @@ +import { loadMainThreadElk } from './mainThreadElk.mjs'; +import { abortable, throwIfAborted } from './abort.mjs'; + +import type { ELK, ElkNode } from 'elkjs'; + +// Loaded on the first layout without an `elk` option, then shared by every later one. +let mainThreadElk: Promise | undefined; + +function getMainThreadElk(): Promise { + if (!mainThreadElk) { + // The `catch` covers the constructor too, not just the import - in the UMD build + // `ElkConstructor` is the page's `ELK` global, and is `undefined` when elkjs was + // not loaded first. + const pending: Promise = loadMainThreadElk() + .then((ElkConstructor) => new ElkConstructor()) + .catch((error) => { + // E.g. a chunk that failed to load - tried again on the next layout. + if (mainThreadElk === pending) mainThreadElk = undefined; + throw error; + }); + mainThreadElk = pending; + } + return mainThreadElk; +} + +/** + * Lays out `elkGraph` with the default ELK instance, on the main thread. + * + * Aborting `signal` rejects with its reason straight away. ELK on the main thread can't be + * stopped once it started - its result is only ignored. + */ +export function layoutWithDefaultElk(elkGraph: ElkNode, signal?: AbortSignal): Promise { + const layout = getMainThreadElk().then((elk) => { + // Aborted while ELK was loading - no need to start it. + throwIfAborted(signal); + return elk.layout(elkGraph); + }); + return abortable(layout, signal); +} diff --git a/packages/joint-layout-elk/src/elk.worker.mts b/packages/joint-layout-elk/src/elk.worker.mts new file mode 100644 index 0000000000..844a8d2bc4 --- /dev/null +++ b/packages/joint-layout-elk/src/elk.worker.mts @@ -0,0 +1,5 @@ +// The script of a Web Worker running ELK, for `createWorkerElk()` - published as +// `@joint/layout-elk/worker`, so `elkjs` resolves from this package rather than from the +// app (which may not depend on it, or on another version of it). ELK's own worker script +// sets up the worker's message handling as soon as it's loaded. +import 'elkjs/lib/elk-worker.min.js'; diff --git a/packages/joint-layout-elk/src/elkIds.mts b/packages/joint-layout-elk/src/elkIds.mts new file mode 100644 index 0000000000..83efa2d892 --- /dev/null +++ b/packages/joint-layout-elk/src/elkIds.mts @@ -0,0 +1,50 @@ +import type { dia } from '@joint/core'; + +// Not part of the public API (not re-exported from `index.mts`). + +// The ids of everything in the ELK graph, unique across the whole graph - ELK resolves an +// edge's `sources`/`targets` against nodes and ports alike, and the import looks every node, +// port, edge and label up by its id. A cell's own id goes in with `\` and `:` escaped, so: +// - a node's or an edge's id has no unescaped `:` (JointJS cell ids are unique across +// elements and links already), +// - a port's has exactly one - between its element's id and its own, +// - a link label's has two - `:labels:`, +// - the root's starts with two - which none of the above does, whatever the cell ids. + +/** The id of the ELK graph's root node. */ +export const ELK_ROOT_ID = '::root'; + +function escapeId(id: string | number): string { + return `${id}`.replace(/[\\:]/g, (char) => `\\${char}`); +} + +/** The id of an element's ELK node - the element's id, unless it has a `\` or `:` in it. */ +export function getElkNodeId(element: dia.Element): string { + return escapeId(element.id); +} + +/** The id of an element port's ELK port - `:`. */ +export function getElkPortId(element: dia.Element, portId: dia.Cell.ID): string { + return `${getElkNodeId(element)}:${escapeId(portId)}`; +} + +/** The id of a link's ELK edge - the link's id, unless it has a `\` or `:` in it. */ +export function getElkEdgeId(link: dia.Link): string { + return escapeId(link.id); +} + +// The id of the ELK label for a link's label - it carries the label's index in the link's +// `labels` array, so the result can be applied back to that label even when some of the +// link's labels were left out of the ELK graph (see `ExportLinkLabelCallback`). +export function getLinkLabelId(elkEdgeId: string, labelIndex: number): string { + return `${elkEdgeId}:labels:${labelIndex}`; +} + +// The index in the link's `labels` array of the label an ELK label was made for, or +// `undefined` for an ELK label `getLinkLabelId` didn't make (e.g. one added in `exportLink`). +export function getLinkLabelIndex(elkEdgeId: string, elkLabelId: string | undefined): number | undefined { + const prefix = `${elkEdgeId}:labels:`; + if (!elkLabelId || !elkLabelId.startsWith(prefix)) return undefined; + const index = elkLabelId.slice(prefix.length); + return /^\d+$/.test(index) ? Number(index) : undefined; +} diff --git a/packages/joint-layout-elk/src/export.mts b/packages/joint-layout-elk/src/export.mts new file mode 100644 index 0000000000..25758de4a8 --- /dev/null +++ b/packages/joint-layout-elk/src/export.mts @@ -0,0 +1,529 @@ +import { type dia } from '@joint/core'; +import { ELK_ROOT_ID, getElkEdgeId, getElkNodeId, getElkPortId, getLinkLabelId } from './elkIds.mjs'; + +import type { + ElkNode, + ElkPort, + ElkExtendedEdge, + ElkLabel, + ElkLayoutOptions, + NodeElkLayoutOptions, + PortElkLayoutOptions, + EdgeElkLayoutOptions, + LabelElkLayoutOptions +} from './types/index.mjs'; + +// ELK ignores labels with no text. +const ELK_LABEL_TEXT = '-'; + +/** + * An ELK label draft + */ +export interface ElkLabelDraft { + x?: number; + y?: number; + width: number; + height: number; + layoutOptions: LabelElkLayoutOptions; +} + +/** An ELK node draft, one per JointJS element. */ +export interface ElkNodeDraft { + /** The element's id - with any `\` and `:` in it escaped with a `\`. */ + readonly id: string; + /** Relative to the parent node. */ + x?: number; + y?: number; + /** The element's own size - ELK overrides it for a node that ends up with children. */ + width: number; + height: number; + /** `{ 'elk.portConstraints': 'FIXED_POS' }` for an element with ports, empty otherwise. */ + layoutOptions: NodeElkLayoutOptions; + /** + * Empty. JointJS elements carry no labels, so add them only if ELK should size around + * them - which also needs `elk.nodeLabels.placement` (and usually `MINIMUM_SIZE` + * alongside `NODE_LABELS`), e.g. `{ 'elk.nodeSize.constraints': 'NODE_LABELS + * MINIMUM_SIZE', 'elk.nodeLabels.placement': 'INSIDE V_CENTER H_CENTER' }`. Only the + * size of a label matters here - its text is this package's own placeholder. + */ + labels?: ElkLabelDraft[]; +} + +/** + * Mutate `elkNode` to customize what this package computed for an element, or + * return `false` to drop the element - and its whole subtree (embeds, ports, + * any edge connected to any of it) - from the ELK graph entirely. + */ +export type ExportElementCallback = (params: ExportElementCallbackParams) => void | false; +export type ExportElementCallbackParams = { + element: dia.Element; + elkNode: ElkNodeDraft; +}; + +/** An ELK port draft, one per JointJS port. */ +export interface ElkPortDraft { + /** `:` - with any `\` and `:` in either escaped with a `\`. */ + readonly id: string; + x: number; + y: number; + width: number; + height: number; + layoutOptions: PortElkLayoutOptions; +} + +/** + * Mutate `elkPort` to customize what this package computed for a port, or return + * `false` to keep the port out of the ELK graph - ELK then neither positions it nor + * routes to it, so the links connected to it are left out of the layout too and keep + * the route they already had. + */ +export type ExportPortCallback = (params: ExportPortCallbackParams) => void | false; +export type ExportPortCallbackParams = { + portId: dia.Cell.ID; + element: dia.Element; + elkPort: ElkPortDraft; +}; + +/** An ELK edge draft, one per JointJS link. */ +export interface ElkEdgeDraft { + /** The link's id - with any `\` and `:` in it escaped with a `\`. */ + readonly id: string; + layoutOptions: EdgeElkLayoutOptions; +} + +/** + * Mutate `elkEdge` to customize what this package computed for a link, or return + * `false` to drop the edge from the ELK graph - it is simply not routed/laid out. + */ +export type ExportLinkCallback = (params: ExportLinkCallbackParams) => void | false; +export type ExportLinkCallbackParams = { + link: dia.Link; + elkEdge: ElkEdgeDraft; +}; + +/** + * Size `elkPortLabel` (it starts at `0`x`0`, which leaves the port without a label in the + * ELK graph) for ELK to place the port's label, or return `false` to leave it out. + */ +export type ExportPortLabelCallback = (params: ExportPortLabelCallbackParams) => void | false; +export type ExportPortLabelCallbackParams = { + portId: dia.Cell.ID; + element: dia.Element; + elkPortLabel: ElkLabelDraft; +}; + +export type ExportLinkLabelCallback = (params: ExportLinkLabelCallbackParams) => void | false; +export type ExportLinkLabelCallbackParams = { + link: dia.Link; + labelIndex: number; + elkEdgeLabel: ElkLabelDraft; +}; + +export interface ElkGraphPort { + element: dia.Element; + portId: dia.Cell.ID; + /** The port's top-left as it was sent to ELK - what came back is `ElkPort`'s own. */ + x: number; + y: number; +} + +export interface ElkGraphData { + elkGraph: ElkNode; + elementsById: Map; + linksById: Map; + portsById: Map; +} + +export interface ExportGraphOptions { + exportElement?: ExportElementCallback; + exportPort?: ExportPortCallback; + exportPortLabel?: ExportPortLabelCallback; + exportLink?: ExportLinkCallback; + exportLinkLabel?: ExportLinkLabelCallback; +} + +let exportGraphOptions: ExportGraphOptions; + +let elementsById: Map; +let linksById: Map; +let portsById: Map; +// Every container node (plus the root), keyed by element id (`undefined` for the root) - +// used to file each edge under the lowest common ancestor of its source and target. +let edgeContainersById: Map; +// Every exported node's parent node in the ELK graph (`undefined` for a top-level one) - +// not always its JointJS parent (see `exportGraph`). +let elkParentIdsById: Map; +// Each element's position in the list of elements given to `exportGraph`, by the +// element's own id - `5` and `'5'` are two different cells, so the key is not a string. +let elementIndicesById: Map; +// Whether this call has already reported a rotated element (see `buildElkNode`). +let hasWarnedAboutRotation: boolean; +// The elements laid out inside each container, keyed by the container's element id and +// in the order of the list given to `exportGraph`. Built from each element's `parent()` +// rather than read off the graph, so it covers an element that is in no graph (e.g. a +// container that only exists to group others for the layout). +let childElementsByParentId: Map; + +/** + * (Re)initializes all the module-level state above for a single `exportGraph` call, so + * that no callback, option or lookup table can leak from one call into the next. + */ +function init(options: ExportGraphOptions, elements: dia.Element[]): void { + exportGraphOptions = options; + + elementsById = new Map(); + linksById = new Map(); + portsById = new Map(); + edgeContainersById = new Map(); + elkParentIdsById = new Map(); + elementIndicesById = new Map(elements.map((element, index) => [element.id, index])); + hasWarnedAboutRotation = false; + + // A second pass - a container may be listed after the elements inside it. + childElementsByParentId = new Map(); + elements.forEach((element) => { + const parentId = element.parent(); + if (parentId === undefined || parentId === null) return; + if (!elementIndicesById.has(parentId)) return; + const children = childElementsByParentId.get(parentId); + if (children) { + children.push(element); + } else { + childElementsByParentId.set(parentId, [element]); + } + }); +} + +// Whether an element's parent is laid out too - i.e. the element is laid out inside it, +// rather than as a top-level node. +function hasLaidOutParent(element: dia.Element): boolean { + const parentId = element.parent(); + if (parentId === undefined || parentId === null) return false; + return elementIndicesById.has(parentId); +} + +// An element's embedded elements that take part in the layout, in the order of the list +// of elements given to `exportGraph`. +function getEmbeddedElements(element: dia.Element): dia.Element[] { + return childElementsByParentId.get(element.id) ?? []; +} + +// ELK's ids are strings, while a JointJS cell's id may be a string or a number - so two +// cells whose ids differ only in type (`5` and `'5'`) would share one ELK id. ELK accepts +// the duplicate and lays both out, and the import then resolves them to the same cell, +// leaving the other one where it was. +function throwIfIdTaken(taken: boolean, cell: dia.Cell, id: string): void { + if (!taken) return; + throw new Error(`@joint/layout-elk: the id of ${cell.isLink() ? 'link' : 'element'} \`${cell.id}\` collides with another cell's in the ELK graph ("${id}").`); +} + +/** + * Builds a node's ELK ports, starting from the position JointJS already computed for + * them. `exportPort` (if given) may mutate a port's draft, or return `false` to drop + * it from the ELK graph (see `ExportPortCallback`). + */ +function buildPorts(element: dia.Element): ElkPort[] | undefined { + if (!element.hasPorts()) return undefined; + + const ports: ElkPort[] = []; + + element.getPorts().forEach((port) => { + // A port id may be a number, and `5` and `'5'` are two different ports, so it has + // to go back to `portProp()` exactly as it came - only the ELK id is a string. + const portId: dia.Cell.ID = port.id!; + const elkPortId = getElkPortId(element, portId); + + // ELK takes a port's top-left corner, not its center. No `elk.port.borderOffset`: + // ELK places the port just outside the border, and `importLayout` moves its center + // onto the border (a negative offset would also shift the port along the side). + const { x, y, width, height } = element.getPortRelativeRect(portId); + + const elkPort: ElkPortDraft = { + id: elkPortId, + x, + y, + width, + height, + layoutOptions: {} + }; + + if (exportGraphOptions.exportPort?.({ portId, element, elkPort }) === false) return; + + const portLabel: ElkLabelDraft = { + width: 0, + height: 0, + layoutOptions: {} + }; + + const isLabelExported = exportGraphOptions.exportPortLabel?.({ portId, element, elkPortLabel: portLabel }) !== false; + + let labels: ElkLabel[] = []; + if (isLabelExported && portLabel.width && portLabel.height) { + labels = [{ + ...portLabel, + text: ELK_LABEL_TEXT + }]; + } + + if (portsById.has(elkPort.id)) { + throw new Error(`@joint/layout-elk: the id of port \`${portId}\` of element \`${element.id}\` collides with another port's in the ELK graph ("${elkPort.id}").`); + } + // What was sent - `exportPort` may have moved the port from where it is now. + portsById.set(elkPort.id, { element, portId, x: elkPort.x, y: elkPort.y }); + ports.push({ + ...elkPort, + labels + }); + }); + + return ports; +} + +/** + * ELK positions a node's children/edges relative to its own origin - `containerPosition` + * converts an element's graph-absolute position into that frame as we recurse down. + * Returns `null` if `exportElement` dropped the element - its whole subtree goes with it, + * so nothing is registered and nothing downstream (a child, a port, a connected edge) + * can end up referencing it. + */ +function buildElkNode(element: dia.Element, parentId?: string): ElkNode | null { + const id = getElkNodeId(element); + + const embeds = getEmbeddedElements(element); + + // A container goes in without a size of its own, because ELK computes one from its + // content. Not merely redundant to send one: `rectpacking` keeps the size it is given, + // so a container would stay at whatever it grew to on an earlier layout and never + // shrink back. `0` stands for "no size" because `ElkNodeDraft` types it as required - + // `exportElement` may still set one, and a container whose embeds it drops gets the + // element's own size back below. + const { width, height } = element.size(); + const isContainer = embeds.length > 0; + + const elkNode: ElkNodeDraft = { + id, + width: isContainer ? 0 : width, + height: isContainer ? 0 : height, + // Ports stay where JointJS already places them - without it, ELK is free to move + // them to another side or reorder them. `exportElement` may override it. + layoutOptions: element.hasPorts() ? { 'elk.portConstraints': 'FIXED_POS' } : {} + }; + + if (exportGraphOptions.exportElement?.({ element, elkNode }) === false) + return null; + + // An ELK node is a box with a position and a size, with no rotation, so the element + // goes in with the geometry it would have at `angle: 0` and ELK reserves the wrong + // area for a rotated one. Reported only for an element that is actually laid out. + if (!hasWarnedAboutRotation && element.angle() !== 0) { + hasWarnedAboutRotation = true; + console.warn('@joint/layout-elk: element rotation is not supported - exclude rotated elements from the layout.'); + } + + throwIfIdTaken(elementsById.has(id), element, id); + elementsById.set(id, element); + elkParentIdsById.set(id, parentId); + + const ports = buildPorts(element); + + let children: ElkNode[] | undefined; + let edges: ElkExtendedEdge[] | undefined; + if (embeds.length > 0) { + children = embeds + .map((embed) => buildElkNode(embed, id)) + .filter((node): node is ElkNode => node !== null); + if (children.length > 0) { + // Shared with `edgeContainersById` (see there) - edges filed under this + // container by `buildEdge` need to end up on the node itself. + edges = []; + edgeContainersById.set(id, edges); + } else { + // `exportElement` dropped every embed - laid out as a leaf, so the element's + // own size takes the place of the container placeholder, per dimension: + // `exportElement` may have set only one of the two. + children = undefined; + if (elkNode.width === 0) elkNode.width = width; + if (elkNode.height === 0) elkNode.height = height; + } + } + + return { + ...elkNode, + // ELK skips a label with no text, so `exportElement` only has to size one. + labels: elkNode.labels?.map((label) => ({ ...label, text: ELK_LABEL_TEXT })), + children, + ports, + edges + }; +} + +// The lowest common ancestor of an element and itself/an ancestor is the element's parent chain - +// this returns that chain in the ELK graph, ordered from the outermost ancestor to the +// immediate parent. +function getAncestorPath(element: dia.Element): string[] { + const path: string[] = []; + let parentId = elkParentIdsById.get(getElkNodeId(element)); + while (parentId !== undefined) { + path.unshift(parentId); + parentId = elkParentIdsById.get(parentId); + } + return path; +} + +// The id shared by the last matching entries of two ancestor paths (or `undefined` if +// they don't share a root, i.e. one of them is the top-level root itself). +function getLowestCommonAncestorId(sourcePath: string[], targetPath: string[]): string | undefined { + let commonId: string | undefined; + const length = Math.min(sourcePath.length, targetPath.length); + for (let i = 0; i < length; i++) { + if (sourcePath[i] !== targetPath[i]) break; + commonId = sourcePath[i]; + } + return commonId; +} + +// Whether a link end's port is one `exportPort` dropped - the port is there, it is only +// hidden from ELK, so there is nothing for an edge to attach to. +function isEndPortDropped(element: dia.Element, port: string | number | undefined | null): boolean { + if (port === undefined || port === null) return false; + // `port` as the link holds it - `5` and `'5'` are two different ports, so a stringified + // id would ask about a port the element may not have. Only the ELK id is a string. + return element.hasPort(port) && !portsById.has(getElkPortId(element, port)); +} + +/** + * The id a link end anchors on in the ELK graph - its port's, or the element's when the + * end has no port, or references one the element no longer has. JointJS renders such an + * end against the element's bbox, while ELK rejects the whole graph over the dangling + * reference, so the edge is attached to the node instead. + */ +function getElkEndId(element: dia.Element, port: string | number | undefined | null): string { + if (port !== undefined && port !== null) { + const elkPortId = getElkPortId(element, `${port}`); + if (portsById.has(elkPortId)) return elkPortId; + } + return getElkNodeId(element); +} + +/** + * Builds a link's ELK edge. `exportLink` (if given) may mutate the edge's draft, or + * return `false` to drop it from the ELK graph (see `ExportLinkCallback`). + */ +function buildEdge(link: dia.Link): void { + // `getSourceCell()`, not `getSourceElement()` - the latter walks through a chain of + // links and returns the element at its far end, so a link connected to another link + // would pass the guard below and be exported as an edge between the wrong ends. + const sourceCell = link.getSourceCell(); + const targetCell = link.getTargetCell(); + // Links not connected to two elements (e.g. connected to a point or + // to another link) are not part of the layout. + if (!sourceCell?.isElement() || !targetCell?.isElement()) return; + const sourceElement = sourceCell as dia.Element; + const targetElement = targetCell as dia.Element; + // Covers both a link connected to an element `exportElement` dropped, and one + // connected to an element that was never part of the layout to begin with. + if (!elementsById.has(getElkNodeId(sourceElement)) || !elementsById.has(getElkNodeId(targetElement))) return; + // A port `exportPort` dropped takes the links connected to it out of the layout too - + // the same rule as an element that isn't laid out, one level down. + if (isEndPortDropped(sourceElement, link.source().port)) return; + if (isEndPortDropped(targetElement, link.target().port)) return; + + const id = getElkEdgeId(link); + + const sources = [getElkEndId(sourceElement, link.source().port)]; + const targets = [getElkEndId(targetElement, link.target().port)]; + + const elkEdge: ElkEdgeDraft = { + id, + layoutOptions: {} + }; + + if (exportGraphOptions.exportLink?.({ link, elkEdge }) === false) + return; + + throwIfIdTaken(linksById.has(id), link, id); + linksById.set(id, link); + + // Computed (`link.getComputedLabels()`) - `size` falls back through `defaultLabel`/the + // built-in default the same way `@joint/core` itself computes it for rendering, so it + // can be read directly here instead of from the label's raw JSON. + // @ts-expect-error `getComputedLabels()` is `protected` in `@joint/core`'s types for now - it + // will become public in the future (it is on every link at runtime already). + const computedLabels: dia.Link.ComputedLabel[] = link.getComputedLabels(); + let labels: ElkLabel[] = []; + if (computedLabels.length > 0) { + labels = computedLabels.reduce((result: ElkLabel[], label, labelIndex) => { + const { width, height } = label.size!; + const labelDraft: ElkLabelDraft = { + width, + height, + layoutOptions: { + 'elk.edgeLabels.inline': 'true' + } + }; + + if (exportGraphOptions.exportLinkLabel?.({ link, labelIndex, elkEdgeLabel: labelDraft }) === false) + return result; + + result.push({ + ...labelDraft, + id: getLinkLabelId(id, labelIndex), + text: ELK_LABEL_TEXT + }); + return result; + }, []); + } + + const edge: ElkExtendedEdge = { + ...elkEdge, + sources, + targets, + labels + }; + + const lcaId = getLowestCommonAncestorId(getAncestorPath(sourceElement), getAncestorPath(targetElement)); + const edges = edgeContainersById.get(lcaId); + // `edges` is always defined - `lcaId` is either `undefined` (the root) or the id of + // one of `sourceElement`/`targetElement`'s ancestors, and every ancestor still part + // of the ELK graph has already been registered in `edgeContainersById` by the time + // links are processed (an excluded ancestor would have failed the guard clause above). + (edges as ElkExtendedEdge[]).push(edge); +} + +/** + * Converts JointJS elements (with their embedded elements and ports) and the links + * between them to an ELK graph structure. + * + * `elements`/`links` are what takes part - a link only if both its ends do too - and + * set its order: the root's children and edges follow them, and so do each container's + * own children. An element whose parent isn't in `elements` becomes a top-level node. + */ +export function exportGraph( + elements: dia.Element[], + links: dia.Link[], + options: ExportGraphOptions, + elkLayoutOptions: ElkLayoutOptions +): ElkGraphData { + + init(options, elements); + + const topLevelElements = elements.filter((element) => !hasLaidOutParent(element)); + + const children: ElkNode[] = topLevelElements + .map((element) => buildElkNode(element)) + .filter((node): node is ElkNode => node !== null); + + const elkGraph: ElkNode = { + id: ELK_ROOT_ID, + layoutOptions: elkLayoutOptions, + children, + edges: [] + }; + // Shared with `edgeContainersById` (see there) - edges filed under the root by + // `buildEdge` need to end up on `elkGraph` itself. + edgeContainersById.set(undefined, elkGraph.edges as ElkExtendedEdge[]); + + links.forEach(buildEdge); + + return { elkGraph, elementsById, linksById, portsById }; +} diff --git a/packages/joint-layout-elk/src/import.mts b/packages/joint-layout-elk/src/import.mts new file mode 100644 index 0000000000..9e384987eb --- /dev/null +++ b/packages/joint-layout-elk/src/import.mts @@ -0,0 +1,366 @@ +import { type dia, g } from '@joint/core'; +import type { ElkPoint } from 'elkjs'; +import type { ElkNode, ElkExtendedEdge, ElkPort } from './types/index.mjs'; +import type { ElkGraphPort } from './export.mjs'; +import { getLinkLabelIndex } from './elkIds.mjs'; + +/** Applies the ELK-computed position (and, for a container, size) to `element`. */ +export type SetElementAttributesCallback = (params: SetElementAttributesCallbackParams) => void; +export type SetElementAttributesCallbackParams = { + element: dia.Element; + attributes: { + position: dia.Point; + // Present only for a container - its size is computed by ELK to fit its + // (recursively laid out) content; a leaf element keeps its existing size. + size?: dia.Size; + }; + elkNode: ElkNode +}; + +/** Applies the ELK-computed position (and, for a labeled port, label position) to a port. */ +export type SetPortAttributesCallback = (params: SetPortAttributesCallbackParams) => void; +export type SetPortAttributesCallbackParams = { + element: dia.Element; + portId: dia.Cell.ID; + // Shaped for `element.portProp(portId, attributes)` - only the port's own + // `position.args`/`label.position.args` (a group's `position`/`label.position` + // decides which layout function reads them - handled separately, see `importNode`). + attributes: { + position: { args: dia.Point }; + // Present only when the port has a label. + label?: { position: { args: dia.Point } }; + }; + elkPort: ElkPort; + /** + * Whether the port is not where it was. ELK returns one untouched under the default + * `elk.portConstraints: 'FIXED_POS'`, unless the node's border moved, and places it + * itself under e.g. `'FIXED_SIDE'`. + */ + hasMoved: boolean; +}; + +/** Applies the ELK-computed vertices, end anchors and label positions to a link. */ +export type SetLinkAttributesCallback = (params: SetLinkAttributesCallbackParams) => void; +export type SetLinkAttributesCallbackParams = { + link: dia.Link; + // Shaped for `link.set(attributes)`. + attributes: { + vertices: dia.Point[]; + // Present only for an end ELK anchored on the element rather than on a port - + // carries the end's existing `id`/`port`/... alongside the new `anchor`, since + // `link.set(...)` replaces `source`/`target` outright rather than merging into them. + source?: dia.Link.EndJSON; + target?: dia.Link.EndJSON; + // Present only when the link has labels - the whole current `labels` array, + // with each routed label's `position` replaced. + labels?: dia.Link.Label[]; + }; + elkEdge: ElkExtendedEdge +}; + +export interface ImportLayoutOptions { + setElementAttributes?: SetElementAttributesCallback; + setLinkAttributes?: SetLinkAttributesCallback; + setPortAttributes?: SetPortAttributesCallback; +} + +// The anchor for a link end ELK routed to an element - computed the same way JointJS +// computes one for a port-connected end, so both react the same way to future moves. +// Only ends that carry no port, or one the element does not have, are routed this way - +// which is also when `useModelGeometry` measures `topLeft` from the element's own bbox. +// +// Measured from the position ELK gave the element, not from the one it has now: the two +// are the same only once `setElementAttributes` has written it, and a callback is free to +// defer that (the `transition()` the README suggests does). +function getElementAnchorAtPoint( + element: dia.Element, + elementRect: g.Rect, + point: dia.Point +): NonNullable { + // `Cell#getRelativePointFromAbsolute()` does this against the element's current + // position; `elementRect` is the one ELK computed, which the element may not have yet. + const angle = element.angle(); + const local = new g.Point(point); + if (angle) local.rotate(elementRect.center(), angle); + const delta = local.difference(elementRect.topLeft()); + return { + name: 'topLeft', + args: { + dx: delta.x, + dy: delta.y, + useModelGeometry: true, + rotate: true + } + }; +} + +// A label's `distance` in `(0, 1]` is read back as a ratio of the link's length, not as a +// length (see `LinkView.prototype._getLabelTransformationMatrix`) - a label that close to +// the link's start is pinned to the start instead. +function toLabelDistance(length: number): number { + return (length > 0 && length <= 1) ? 0 : length; +} + +/** + * The end to write back, or `null` when ELK anchored it on a port - JointJS computes that + * anchor itself. The end's existing `id`/`port`/... is kept, since `link.set()` replaces + * `source`/`target` outright rather than merging into them. + */ +function buildEnd( + currentEnd: dia.Link.EndJSON, + refs: string[] | undefined, + point: dia.Point +): dia.Link.EndJSON | null { + const [elkId] = refs || []; + if (elkId === undefined) return null; + const element = elementsById.get(elkId); + if (!element) return null; + const elementRect = elementRectsById.get(elkId); + if (!elementRect) return null; + // The end gained a port while the layout was running - `topLeft` would now resolve + // against that port's bbox, not the element's, so the delta below would not apply. + // `port` as the link holds it: `5` and `'5'` are two different ports. + const { port } = currentEnd; + if (port !== undefined && port !== null && element.hasPort(port)) return null; + return { + ...currentEnd, + anchor: getElementAnchorAtPoint(element, elementRect, point) + }; +} + +const defaultSetElementAttributes: SetElementAttributesCallback = ({ element, attributes }) => { + element.set(attributes); +}; + +const defaultSetPortAttributes: SetPortAttributesCallback = ({ element, portId, attributes }) => { + // `portProp()` looks a port up with `===`, so the id has to be passed on as it came. + element.portProp(portId, attributes); +}; + +const defaultSetLinkAttributes: SetLinkAttributesCallback = ({ link, attributes }) => { + link.set(attributes); +}; + +let importLayoutOptions: ImportLayoutOptions; + +let elementsById: Map; +let linksById: Map; +let portsById: Map; +// The graph-absolute rect ELK gave each node, filled in as `importNode` walks down. A +// node's children, and the edges filed under it, are imported after it, so an edge's ends +// are always in here by the time `importEdges` reaches it. +let elementRectsById: Map; + +/** + * (Re)initializes all the module-level state above for a single `importLayout` call, so + * that no lookup table or option can leak from one call into the next. + */ +function init( + elements: Map, + links: Map, + ports: Map, + options: ImportLayoutOptions +): void { + importLayoutOptions = options; + + elementsById = elements; + linksById = links; + portsById = ports; + elementRectsById = new Map(); +} + +// ELK positions a node's children relative to that node's own origin - `containerPosition` +// accumulates the offset needed to turn those relative coordinates into graph-absolute +// ones as we walk down the hierarchy. Edges need no such conversion: `layout()` defaults +// `elk.json.edgeCoords` to 'ROOT', so ELK already returns their coordinates graph-absolute. +function toAbsolute(point: ElkPoint, containerPosition: dia.Point): dia.Point { + return { + x: containerPosition.x + point.x, + y: containerPosition.y + point.y + }; +} + +// A port's center along one axis, from the box ELK returned for it. +// +// ELK draws a port's box beyond the border it sits on - an east port of a 100 wide node +// comes back at `x: 100` - while JointJS puts a port's center on that border. Undoing +// that is a shift of half the port, inwards, and only for a port ELK placed itself: +// the same box can equally be a port a group put outside the element on purpose +// (`args.dx`, `ellipseLayout`'s `dr`) and handed back untouched, which is left alone. +function toPortCenter(start: number, size: number, nodeSize: number, hasMoved: boolean): number { + const center = start + size / 2; + if (!hasMoved) return center; + if (start >= nodeSize) return center - size / 2; + if (start + size <= 0) return center + size / 2; + return center; +} + +/** + * Applies a container's (or the root's) own ELK edges back onto their JointJS links, + * via `setLinkAttributes` (or the default). Their coordinates are used as they are - see + * `toAbsolute` above. + */ +function importEdges(edges: ElkExtendedEdge[] | undefined): void { + const setLinkAttributes = importLayoutOptions.setLinkAttributes ?? defaultSetLinkAttributes; + + (edges || []).forEach((edge) => { + const link = linksById.get(edge.id); + if (!link) return; + + const [section] = edge.sections || []; + if (!section) return; + + const { startPoint, endPoint, bendPoints = [] } = section; + + const vertices = bendPoints.map(({ x, y }) => ({ x, y })); + + // Which end ELK anchored on is read back from the edge itself, rather than from + // the link - the graph may have changed while the layout was running. + const source = buildEnd(link.source(), edge.sources, startPoint); + const target = buildEnd(link.target(), edge.targets, endPoint); + + let labels: dia.Link.Label[] | undefined; + if (edge.labels && edge.labels.length > 0) { + const polyline = new g.Polyline([startPoint, ...bendPoints, endPoint]); + // `link.getComputedLabels()` (`@joint/core`) returns each label computed against + // `defaultLabel`/the built-in default - reading `labels` (the raw model attribute) + // directly instead, so writing `labels[index]` back below doesn't bake that + // computed `markup`/`attrs`/`size` permanently into the label's own stored JSON. + const currentLabels: dia.Link.Label[] = link.get('labels') || []; + labels = currentLabels.slice(); + edge.labels.forEach((label) => { + // Not the label's index in `edge.labels` - labels `exportLinkLabel` dropped + // are missing there (see `getLinkLabelId`). + const index = getLinkLabelIndex(edge.id, label.id); + if (index === undefined || !currentLabels[index]) return; + const { x = 0, y = 0, width = 0, height = 0 } = label; + const center = new g.Point(x + width / 2, y + height / 2); + const length = polyline.closestPointLength(center); + // Get the tangent at the closest point to calculate the offset + const tangent = polyline.tangentAtLength(length); + const currentPosition = currentLabels[index]!.position; + labels![index] = { + ...currentLabels[index], + position: { + // `angle`, `args` and any other position setting the label already + // had are kept - only its place along the link is ELK's to decide. + ...(typeof currentPosition === 'object' ? currentPosition : {}), + distance: toLabelDistance(length), + offset: tangent ? tangent.pointOffset(center) : 0 + } + }; + }); + } + + setLinkAttributes({ + link, + attributes: { + vertices, + ...(source ? { source } : {}), + ...(target ? { target } : {}), + ...(labels ? { labels } : {}) + }, + elkEdge: edge + }); + }); +} + +/** + * Applies one ELK node's result (position, size, ports) back onto its JointJS element, + * then recurses into its children and its own edges. + */ +function importNode(node: ElkNode, containerPosition: dia.Point = { x: 0, y: 0 }): void { + const position = toAbsolute({ x: node.x || 0, y: node.y || 0 }, containerPosition); + elementRectsById.set(node.id, new g.Rect(position.x, position.y, node.width || 0, node.height || 0)); + + const element = elementsById.get(node.id); + if (element) { + // ELK sizes a container to fit its content, and some algorithms resize a leaf too + // (`rectpacking`'s white space elimination, `elk.nodeSize.constraints`). The size + // it laid the node out at is the one the element should end up with - compared + // against the element's own, so an unchanged one is not written again. + const { width, height } = element.size(); + const hasResized = (node.width || 0) !== width || (node.height || 0) !== height; + const setElementAttributes = importLayoutOptions.setElementAttributes ?? defaultSetElementAttributes; + setElementAttributes({ + element, + attributes: { + position, + // Omitted entirely, not set to `undefined` - `attributes` goes straight to + // `element.set(...)`, which would otherwise wipe the element's size. + ...(hasResized ? { size: { width: node.width || 0, height: node.height || 0 }} : {}) + }, + elkNode: node + }); + } + + if (node.ports) { + const setPortAttributes = importLayoutOptions.setPortAttributes ?? defaultSetPortAttributes; + + node.ports.forEach((port) => { + const found = portsById.get(port.id); + if (!found) return; + const { element, portId, x: sentX, y: sentY } = found; + // The port was removed while the layout was running - `portsById` was built + // before it went, and `portProp()` throws for a port the element no longer has. + if (!element.hasPort(portId)) return; + const hasMoved = (port.x || 0) !== sentX || (port.y || 0) !== sentY; + + const center = { + x: toPortCenter(port.x || 0, port.width || 0, node.width || 0, hasMoved), + y: toPortCenter(port.y || 0, port.height || 0, node.height || 0, hasMoved) + }; + + let labelPosition: dia.Point | undefined; + const [label] = port.labels || []; + if (label) { + // ELK's `label.x`/`y` are relative to the port's top-left corner, but + // 'manual' label position expects an offset from the port's center - the + // label keeps its place next to the port as the port moves onto the border. + labelPosition = { + x: (label.x || 0) - (port.width || 0) / 2, + y: (label.y || 0) - (port.height || 0) / 2 + }; + } + + setPortAttributes({ + element, + portId, + attributes: { + position: { + args: center + }, + ...(labelPosition ? { + label: { + position: { + args: labelPosition + } + } + } : {}) + }, + elkPort: port, + hasMoved + }); + }); + } + + (node.children || []).forEach((child) => importNode(child, position)); + importEdges(node.edges); +} + +/** + * Applies an ELK layout result back onto the JointJS graph. + */ +export function importLayout( + elkGraph: ElkNode, + elementsById: Map, + linksById: Map, + portsById: Map, + options: ImportLayoutOptions +): void { + + init(elementsById, linksById, portsById, options); + + (elkGraph.children || []).forEach((node) => importNode(node)); + importEdges(elkGraph.edges); +} diff --git a/packages/joint-layout-elk/src/index.mts b/packages/joint-layout-elk/src/index.mts new file mode 100644 index 0000000000..826d32c16a --- /dev/null +++ b/packages/joint-layout-elk/src/index.mts @@ -0,0 +1,9 @@ +// Public API barrel: the `layout()` entry point, its export/import callback types, and +// the ELK-facing graph/option types. +export * from './layout.mjs'; +export * from './import.mjs'; +export * from './export.mjs'; +export { createWorkerElk } from './workerElk.mjs'; +export type { WorkerElk } from './workerElk.mjs'; + +export type * from './types/index.mjs'; diff --git a/packages/joint-layout-elk/src/layout.mts b/packages/joint-layout-elk/src/layout.mts new file mode 100644 index 0000000000..a77bdb9f81 --- /dev/null +++ b/packages/joint-layout-elk/src/layout.mts @@ -0,0 +1,211 @@ +import { util, g } from '@joint/core'; +import { importLayout } from './import.mjs'; +import { layoutWithDefaultElk } from './defaultElk.mjs'; +import { exportGraph } from './export.mjs'; +import { abortable, throwIfAborted } from './abort.mjs'; + +import type { ExportGraphOptions } from './export.mjs'; +import type { ImportLayoutOptions } from './import.mjs'; +import type { WorkerElk } from './workerElk.mjs'; +import type { ElkLayoutOptions, ElkNode } from './types/index.mjs'; +import type { dia } from '@joint/core'; +import type { ELK, ElkNode as RawElkNode } from 'elkjs'; + +const LAYOUT_BATCH_NAME = 'layout'; + +const DEFAULT_LAYOUT_OPTIONS: ElkLayoutOptions = { + 'elk.algorithm': 'layered', + // Lay out embedded elements (containers) as part of the same pass as their + // parent, so that edges crossing a container's boundary are routed and + // accounted for correctly, instead of only being considered afterwards. + 'elk.hierarchyHandling': 'INCLUDE_CHILDREN', + // Keep the order of ports on a node consistent with the order of their + // `ports.items` array, instead of reordering them to reduce edge crossings. + 'elk.layered.considerModelOrder.portModelOrder': 'true', + // Return every edge's route points and labels graph-absolute, whichever container + // the edge belongs to - `importLayout` applies them as they are. Overriding this + // means handling the coordinates yourself (e.g. in `setLinkAttributes`). + 'elk.json.edgeCoords': 'ROOT' +}; + +/** + * Layout configuration options. + */ +export interface LayoutOptions extends ImportLayoutOptions, ExportGraphOptions { + + /** + * The ELK instance to lay out with - e.g. one running in a Web Worker, so the layout + * doesn't block the page (see `createWorkerElk()`), or any `elkjs` instance of your + * own. It is never terminated by the package. + * @defaultValue a shared instance running on the main thread (`elkjs/lib/elk.bundled.js`, + * loaded on the first layout that needs it) + * @example + * const elk = createWorkerElk(() => new Worker(new URL('@joint/layout-elk/worker', import.meta.url), { type: 'module' })); + * layout(graph, { elk }); + */ + elk?: WorkerElk | ELK; + /** + * ELK layout options, passed through to ELK unmodified. + * @see https://eclipse.dev/elk/reference/options.html + * @defaultValue `{ 'elk.algorithm': 'layered', 'elk.hierarchyHandling': 'INCLUDE_CHILDREN', 'elk.layered.considerModelOrder.portModelOrder': 'true', 'elk.json.edgeCoords': 'ROOT' }` + */ + elkLayoutOptions?: ElkLayoutOptions; + /** + * Aborts the layout - e.g. once the graph has changed since it started, or it takes too + * long. `layout()` then rejects with the signal's reason, and nothing is applied to the + * graph. A layout a `createWorkerElk()` worker is busy with is stopped by terminating the + * worker (a new one takes over the layouts still waiting). ELK on the main thread, or + * any other `elk` instance, can't be stopped - its result is only ignored. + * @example + * const controller = new AbortController(); + * layout(graph, { signal: controller.signal }); + * graph.once('change', () => controller.abort()); + */ + signal?: AbortSignal; +} + +export interface LayoutResult { + /** Tight bounding box of the laid out graph. */ + bbox: g.Rect; + /** The raw ELK layout result, for anything not mapped back onto the graph (e.g. junction points). */ + elkGraph: ElkNode; +} + +// ELK's own default, used for every side `elk.padding` does not name. +const DEFAULT_PADDING = 12; + +/** + * Tight bounding box of an ELK layout result. + * + * ELK sizes the root node around everything it laid out - nodes, edge routes and edge + * labels alike - plus the root's own padding, so the box is that size with the padding + * taken off, the way `@joint/layout-directed-graph` and `@joint/layout-msagl` take off + * the margins they were given. + */ +function getBBox(elkGraph: ElkNode, elkLayoutOptions: ElkLayoutOptions): g.Rect { + const padding = { top: DEFAULT_PADDING, left: DEFAULT_PADDING, bottom: DEFAULT_PADDING, right: DEFAULT_PADDING }; + const value = elkLayoutOptions['elk.padding']; + if (value) { + // `[top=12,left=12,bottom=12,right=12]`, with any side left out keeping the default. + const sides = /(top|left|bottom|right)\s*=\s*(-?[\d.]+)/g; + let side; + while ((side = sides.exec(value)) !== null) { + padding[side[1] as keyof typeof padding] = Number(side[2]); + } + } + const { top, left, bottom, right } = padding; + const width = Math.max(0, (elkGraph.width || 0) - left - right); + const height = Math.max(0, (elkGraph.height || 0) - top - bottom); + // Nothing was laid out - the root is its own padding, which is not a box at `left`/`top`. + if (width === 0 && height === 0) return new g.Rect(0, 0, 0, 0); + return new g.Rect(left, top, width, height); +} + +// A `WorkerElk` takes the signal itself and terminates the worker busy with the layout. +// `instanceof` would miss any implementation that is not literally our own class - a +// caller's, or `createWorkerElk()`'s from a second copy of the package in one bundle. +// `elkjs` has no `terminate()`. +function isWorkerElk(elk: WorkerElk | ELK): elk is WorkerElk { + return typeof (elk as WorkerElk).terminate === 'function'; +} + +// The elements and the links to lay out, in the order they were given, and the graphs the +// layout's batch runs on. A batch groups only the changes emitted by its own graph's cells, +// so it has to run on every graph the cells come from - in practice one. A cell that is in +// no graph (e.g. a container added only to shape the layout) emits nothing and brings none. +function splitCells(graphOrCells: dia.Graph | dia.Cell[]): { + elements: dia.Element[], + links: dia.Link[], + graphs: dia.Graph[] +} { + if (!Array.isArray(graphOrCells)) { + return { + elements: graphOrCells.getElements(), + links: graphOrCells.getLinks(), + graphs: [graphOrCells] + }; + } + const elements: dia.Element[] = []; + const links: dia.Link[] = []; + const graphs = new Set(); + // A cell listed twice would be laid out twice - two ELK nodes sharing an id, so ELK + // reserves room for both and the import applies whichever came last. + new Set(graphOrCells).forEach((cell) => { + if (cell.isElement()) { + elements.push(cell as dia.Element); + } else { + links.push(cell as dia.Link); + } + if (cell.graph) graphs.add(cell.graph); + }); + return { elements, links, graphs: Array.from(graphs) }; +} + +/** + * Lays out a whole JointJS graph, or only the given cells. + * + * A list of cells is laid out in the order it is given: the top-level nodes follow it, and + * so do each container's children. An element whose parent is not listed becomes a + * top-level node, and a link is laid out only if both of its ends are listed too. A cell + * listed more than once is laid out once. + * + * The layout is applied in a `'layout'` batch on the graph the cells belong to, so that it + * emits one combined change rather than one per element, port and link. + */ +export async function layout(graphOrCells: dia.Graph | dia.Cell[], opt?: LayoutOptions): Promise { + + const options: LayoutOptions = opt ?? {}; + const elkLayoutOptions = util.defaults( + {}, + opt?.elkLayoutOptions, + DEFAULT_LAYOUT_OPTIONS + ) as ElkLayoutOptions; + const signal = opt?.signal; + + throwIfAborted(signal); + + const { elements, links, graphs } = splitCells(graphOrCells); + + const { elkGraph, elementsById, linksById, portsById } = exportGraph( + elements, + links, + options as ExportGraphOptions, + elkLayoutOptions + ); + + // elkjs types every `layoutOptions` as `{ [key: string]: string }`, which this package + // narrows to its own option interfaces - and their optional properties force + // `| undefined` into the index signature, so `ElkNode` is not assignable to elkjs's + // own type although it is the same object at runtime. ELK's result needs no assertion + // back: `{ [key: string]: string }` does satisfy the narrowed interfaces. + const rawElkGraph = elkGraph as RawElkNode; + const elk = opt?.elk; + let layoutResult: Promise; + if (!elk) { + layoutResult = layoutWithDefaultElk(rawElkGraph, signal); + } else if (isWorkerElk(elk)) { + // Stops the worker's layout when aborted, rather than only ignoring its result. + layoutResult = elk.layout(rawElkGraph, { signal }); + } else { + layoutResult = abortable(elk.layout(rawElkGraph), signal); + } + const result = await layoutResult; + + // Aborted after ELK settled, but before the result was applied. + throwIfAborted(signal); + + // Wraps the import in a single batch, so it emits one combined change instead of + // one per element/port/link. Closed even if a `set*Attributes` callback throws - + // a batch left open would e.g. keep a command manager from ever closing its undo step. + graphs.forEach((graph) => graph.startBatch(LAYOUT_BATCH_NAME)); + try { + importLayout(result, elementsById, linksById, portsById, options); + } finally { + graphs.forEach((graph) => graph.stopBatch(LAYOUT_BATCH_NAME)); + } + + return { + bbox: getBBox(result, elkLayoutOptions), + elkGraph: result + }; +} diff --git a/packages/joint-layout-elk/src/mainThreadElk.mts b/packages/joint-layout-elk/src/mainThreadElk.mts new file mode 100644 index 0000000000..31b67f95d8 --- /dev/null +++ b/packages/joint-layout-elk/src/mainThreadElk.mts @@ -0,0 +1,15 @@ +import type { ELK } from 'elkjs'; + +/** + * Loads ELK to run on the main thread (`elkjs/lib/elk.bundled.js`, see `defaultElk.mts`). + * + * Imported dynamically, so bundlers split it into a chunk of its own, loaded only if a + * layout ever runs on the main thread - not by an app that always passes an `elk` of its + * own (e.g. one running in a Web Worker, see `createWorkerElk()`). In its own module so + * the UMD build, which can't load a chunk, can replace it with one importing it + * statically (see `rollup.config.mjs`). + */ +export async function loadMainThreadElk(): Promise ELK> { + const { default: ElkConstructor } = await import('elkjs/lib/elk.bundled.js'); + return ElkConstructor; +} diff --git a/packages/joint-layout-elk/src/types/elkEdgeOptions.mts b/packages/joint-layout-elk/src/types/elkEdgeOptions.mts new file mode 100644 index 0000000000..67855fc619 --- /dev/null +++ b/packages/joint-layout-elk/src/types/elkEdgeOptions.mts @@ -0,0 +1,68 @@ +import type { EdgeType } from './elkEnums.mjs'; + +export interface EdgeElkLayoutOptions { + // Falls back to a plain string for any ELK option beyond this package's Core/Layered coverage. + [key: string]: string | undefined; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-bendPoints.html + */ + 'elk.bendPoints'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-individual.html + */ + 'elk.spacing.individual'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-priority.html + */ + 'elk.priority'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-junctionPoints.html + * @defaultValue `new KVectorChain()` + */ + 'elk.junctionPoints'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-noLayout.html + * @defaultValue 'false' + */ + 'elk.noLayout'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-insideSelfLoops-yo.html + * @defaultValue 'false' + */ + 'elk.insideSelfLoops.yo'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-edge-thickness.html + * @defaultValue '1' + */ + 'elk.edge.thickness'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-edge-type.html + * @defaultValue 'NONE' + */ + 'elk.edge.type'?: EdgeType; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-priority-direction.html + * @defaultValue '0' + */ + 'elk.layered.priority.direction'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-priority-shortness.html + * @defaultValue '0' + */ + 'elk.layered.priority.shortness'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-priority-straightness.html + * @defaultValue '0' + */ + 'elk.layered.priority.straightness'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-groupModelOrder-crossingMinimizationId.html + * @defaultValue '0' + */ + 'elk.layered.considerModelOrder.groupModelOrder.crossingMinimizationId'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-groupModelOrder-componentGroupId.html + * @defaultValue '0' + */ + 'elk.layered.considerModelOrder.groupModelOrder.componentGroupId'?: `${number}`; +} diff --git a/packages/joint-layout-elk/src/types/elkEnums.mts b/packages/joint-layout-elk/src/types/elkEnums.mts new file mode 100644 index 0000000000..0225c2ef9c --- /dev/null +++ b/packages/joint-layout-elk/src/types/elkEnums.mts @@ -0,0 +1,47 @@ +// ELK enum-typed option values. +export type ElkAlgorithm = 'layered' | 'stress' | 'mrtree' | 'radial' | 'force' | 'disco' | 'box' | 'fixed' | 'random' | (string & {}); +export type Alignment = 'AUTOMATIC' | 'LEFT' | 'RIGHT' | 'TOP' | 'BOTTOM' | 'CENTER'; +export type ContentAlignment = 'V_TOP' | 'V_CENTER' | 'V_BOTTOM' | 'H_LEFT' | 'H_CENTER' | 'H_RIGHT'; +export type Direction = 'UNDEFINED' | 'RIGHT' | 'LEFT' | 'DOWN' | 'UP'; +export type EdgeRouting = 'UNDEFINED' | 'POLYLINE' | 'ORTHOGONAL' | 'SPLINES'; +export type HierarchyHandling = 'INHERIT' | 'INCLUDE_CHILDREN' | 'SEPARATE_CHILDREN'; +export type ShapeCoords = 'INHERIT' | 'PARENT' | 'ROOT'; +export type EdgeCoords = 'INHERIT' | 'CONTAINER' | 'PARENT' | 'ROOT'; +export type NodeLabelPlacement = 'H_LEFT' | 'H_CENTER' | 'H_RIGHT' | 'V_TOP' | 'V_CENTER' | 'V_BOTTOM' | 'INSIDE' | 'OUTSIDE' | 'H_PRIORITY'; +export type PortAlignment = 'DISTRIBUTED' | 'JUSTIFIED' | 'BEGIN' | 'CENTER' | 'END'; +export type PortConstraints = 'UNDEFINED' | 'FREE' | 'FIXED_SIDE' | 'FIXED_ORDER' | 'FIXED_RATIO' | 'FIXED_POS'; +export type SizeConstraint = 'PORTS' | 'PORT_LABELS' | 'NODE_LABELS' | 'MINIMUM_SIZE'; +export type SizeOptions = 'DEFAULT_MINIMUM_SIZE' | 'MINIMUM_SIZE_ACCOUNTS_FOR_PADDING' | 'COMPUTE_PADDING' | 'OUTSIDE_NODE_LABELS_OVERHANG' | 'PORTS_OVERHANG' | 'UNIFORM_PORT_SPACING' | 'SPACE_EFFICIENT_PORT_LABELS' | 'FORCE_TABULAR_NODE_LABELS' | 'ASYMMETRICAL'; +export type EdgeLabelPlacement = 'CENTER' | 'HEAD' | 'TAIL'; +export type PortSide = 'UNDEFINED' | 'NORTH' | 'EAST' | 'SOUTH' | 'WEST'; +export type PortLabelPlacement = 'OUTSIDE' | 'INSIDE' | 'NEXT_TO_PORT_IF_POSSIBLE' | 'ALWAYS_SAME_SIDE' | 'ALWAYS_OTHER_SAME_SIDE' | 'SPACE_EFFICIENT'; +export type TopdownNodeTypes = 'PARALLEL_NODE' | 'HIERARCHICAL_NODE' | 'ROOT_NODE'; +export type EdgeType = 'NONE' | 'DIRECTED' | 'UNDIRECTED' | 'ASSOCIATION' | 'GENERALIZATION' | 'DEPENDENCY'; +export type CycleBreakingStrategy = 'GREEDY' | 'DEPTH_FIRST' | 'INTERACTIVE' | 'MODEL_ORDER' | 'GREEDY_MODEL_ORDER' | 'SCC_CONNECTIVITY' | 'SCC_NODE_TYPE' | 'DFS_NODE_ORDER' | 'BFS_NODE_ORDER'; +export type LayeringStrategy = 'NETWORK_SIMPLEX' | 'LONGEST_PATH' | 'LONGEST_PATH_SOURCE' | 'COFFMAN_GRAHAM' | 'INTERACTIVE' | 'STRETCH_WIDTH' | 'MIN_WIDTH' | 'BF_MODEL_ORDER' | 'DF_MODEL_ORDER'; +export type LayerConstraint = 'NONE' | 'FIRST' | 'FIRST_SEPARATE' | 'LAST' | 'LAST_SEPARATE'; +export type NodePromotionStrategy = 'NONE' | 'NIKOLOV' | 'NIKOLOV_PIXEL' | 'NIKOLOV_IMPROVED' | 'NIKOLOV_IMPROVED_PIXEL' | 'DUMMYNODE_PERCENTAGE' | 'NODECOUNT_PERCENTAGE' | 'NO_BOUNDARY' | 'MODEL_ORDER_LEFT_TO_RIGHT' | 'MODEL_ORDER_RIGHT_TO_LEFT'; +export type CrossingMinimizationStrategy = 'LAYER_SWEEP' | 'MEDIAN_LAYER_SWEEP' | 'INTERACTIVE' | 'NONE'; +export type GreedySwitchType = 'ONE_SIDED' | 'TWO_SIDED' | 'OFF'; +export type NodePlacementStrategy = 'SIMPLE' | 'INTERACTIVE' | 'LINEAR_SEGMENTS' | 'BRANDES_KOEPF' | 'NETWORK_SIMPLEX'; +export type EdgeStraighteningStrategy = 'NONE' | 'IMPROVE_STRAIGHTNESS'; +export type FixedAlignment = 'NONE' | 'LEFTUP' | 'RIGHTUP' | 'LEFTDOWN' | 'RIGHTDOWN' | 'BALANCED'; +export type NodeFlexibility = 'NONE' | 'PORT_POSITION' | 'NODE_SIZE_WHERE_SPACE_PERMITS' | 'NODE_SIZE'; +export type SplineRoutingMode = 'CONSERVATIVE' | 'CONSERVATIVE_SOFT' | 'SLOPPY'; +export type SelfLoopDistributionStrategy = 'EQUALLY' | 'NORTH' | 'NORTH_SOUTH'; +export type SelfLoopOrderingStrategy = 'STACKED' | 'REVERSE_STACKED' | 'SEQUENCED'; +export type GraphCompactionStrategy = 'NONE' | 'LEFT' | 'RIGHT' | 'LEFT_RIGHT_CONSTRAINT_LOCKING' | 'LEFT_RIGHT_CONNECTION_LOCKING' | 'EDGE_LENGTH'; +export type ConstraintCalculationStrategy = 'QUADRATIC' | 'SCANLINE'; +export type WrappingStrategy = 'OFF' | 'SINGLE_EDGE' | 'MULTI_EDGE'; +export type CuttingStrategy = 'ARD' | 'MSD' | 'MANUAL'; +export type ValidifyStrategy = 'NO' | 'GREEDY' | 'LOOK_BACK'; +export type LayerUnzippingStrategy = 'NONE' | 'ALTERNATING'; +export type EdgeLabelSideSelection = 'ALWAYS_UP' | 'ALWAYS_DOWN' | 'DIRECTION_UP' | 'DIRECTION_DOWN' | 'SMART_UP' | 'SMART_DOWN'; +export type CenterEdgeLabelPlacementStrategy = 'MEDIAN_LAYER' | 'TAIL_LAYER' | 'HEAD_LAYER' | 'SPACE_EFFICIENT_LAYER' | 'WIDEST_LAYER' | 'CENTER_LAYER'; +export type OrderingStrategy = 'NONE' | 'NODES_AND_EDGES' | 'PREFER_EDGES' | 'PREFER_NODES'; +export type ComponentOrderingStrategy = 'NONE' | 'INSIDE_PORT_SIDE_GROUPS' | 'GROUP_MODEL_ORDER' | 'MODEL_ORDER'; +export type LongEdgeOrderingStrategy = 'DUMMY_NODE_OVER' | 'DUMMY_NODE_UNDER' | 'EQUAL'; +export type GroupOrderStrategy = 'ONLY_WITHIN_GROUP' | 'MODEL_ORDER' | 'ENFORCED'; +export type DirectionCongruency = 'READING_DIRECTION' | 'ROTATION'; +export type InteractiveReferencePoint = 'CENTER' | 'TOP_LEFT'; +export type PortSortingStrategy = 'INPUT_ORDER' | 'PORT_DEGREE'; diff --git a/packages/joint-layout-elk/src/types/elkGraph.mts b/packages/joint-layout-elk/src/types/elkGraph.mts new file mode 100644 index 0000000000..f2837d2c91 --- /dev/null +++ b/packages/joint-layout-elk/src/types/elkGraph.mts @@ -0,0 +1,35 @@ +import type { + ElkNode as RawElkNode, + ElkPort as RawElkPort, + ElkExtendedEdge as RawElkExtendedEdge, + ElkLabel as RawElkLabel +} from 'elkjs'; + +import type { NodeElkLayoutOptions } from './elkNodeOptions.mjs'; +import type { PortElkLayoutOptions } from './elkPortOptions.mjs'; +import type { EdgeElkLayoutOptions } from './elkEdgeOptions.mjs'; +import type { LabelElkLayoutOptions } from './elkLabelOptions.mjs'; + +// ELK graph element shapes, narrowing `layoutOptions` (and, on `ElkNode`, the element +// arrays it nests) from elkjs's own loosely-typed versions to the option types above. +export interface ElkLabel extends Omit { + layoutOptions?: LabelElkLayoutOptions; +} + +export interface ElkPort extends Omit { + layoutOptions?: PortElkLayoutOptions; + labels?: ElkLabel[]; +} + +export interface ElkExtendedEdge extends Omit { + layoutOptions?: EdgeElkLayoutOptions; + labels?: ElkLabel[]; +} + +export interface ElkNode extends Omit { + layoutOptions?: NodeElkLayoutOptions; + children?: ElkNode[]; + ports?: ElkPort[]; + edges?: ElkExtendedEdge[]; + labels?: ElkLabel[]; +} diff --git a/packages/joint-layout-elk/src/types/elkLabelOptions.mts b/packages/joint-layout-elk/src/types/elkLabelOptions.mts new file mode 100644 index 0000000000..446bfceba9 --- /dev/null +++ b/packages/joint-layout-elk/src/types/elkLabelOptions.mts @@ -0,0 +1,52 @@ +import type { EdgeLabelPlacement, CenterEdgeLabelPlacementStrategy } from './elkEnums.mjs'; + +export interface LabelElkLayoutOptions { + // Falls back to a plain string for any ELK option beyond this package's Core/Layered coverage. + [key: string]: string | undefined; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-individual.html + */ + 'elk.spacing.individual'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-nodeLabels-placement.html + * @defaultValue `NodeLabelPlacement.fixed` + */ + 'elk.nodeLabels.placement'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-position.html + */ + 'elk.position'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-edgeLabels-placement.html + * @defaultValue 'CENTER' + */ + 'elk.edgeLabels.placement'?: EdgeLabelPlacement; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-edgeLabels-inline.html + * @defaultValue 'false' + */ + 'elk.edgeLabels.inline'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-font-name.html + */ + 'elk.font.name'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-font-size.html + */ + 'elk.font.size'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-softwrappingFuzziness.html + * @defaultValue '0.0' + */ + 'elk.softwrappingFuzziness'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-noLayout.html + * @defaultValue 'false' + */ + 'elk.noLayout'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-edgeLabels-centerLabelPlacementStrategy.html + * @defaultValue 'MEDIAN_LAYER' + */ + 'elk.layered.edgeLabels.centerLabelPlacementStrategy'?: CenterEdgeLabelPlacementStrategy; +} diff --git a/packages/joint-layout-elk/src/types/elkLayoutOptions.mts b/packages/joint-layout-elk/src/types/elkLayoutOptions.mts new file mode 100644 index 0000000000..8904ddc780 --- /dev/null +++ b/packages/joint-layout-elk/src/types/elkLayoutOptions.mts @@ -0,0 +1,926 @@ +import type { + ElkAlgorithm, + Alignment, + Direction, + EdgeRouting, + HierarchyHandling, + ShapeCoords, + EdgeCoords, + PortAlignment, + PortConstraints, + EdgeLabelPlacement, + TopdownNodeTypes, + CycleBreakingStrategy, + LayeringStrategy, + LayerConstraint, + NodePromotionStrategy, + CrossingMinimizationStrategy, + GreedySwitchType, + NodePlacementStrategy, + EdgeStraighteningStrategy, + FixedAlignment, + NodeFlexibility, + SplineRoutingMode, + SelfLoopDistributionStrategy, + SelfLoopOrderingStrategy, + GraphCompactionStrategy, + ConstraintCalculationStrategy, + WrappingStrategy, + CuttingStrategy, + ValidifyStrategy, + LayerUnzippingStrategy, + EdgeLabelSideSelection, + CenterEdgeLabelPlacementStrategy, + OrderingStrategy, + ComponentOrderingStrategy, + LongEdgeOrderingStrategy, + GroupOrderStrategy, + DirectionCongruency, + InteractiveReferencePoint, + PortSortingStrategy, + EdgeType, + PortSide +} from './elkEnums.mjs'; + +export interface ElkLayoutOptions { + // Falls back to a plain string for any ELK option beyond this package's Core/Layered coverage. + [key: string]: string | undefined; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-box-packingMode.html + * @defaultValue `BoxLayoutProvider.PackingMode.SIMPLE` + */ + 'elk.box.packingMode'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-algorithm.html + */ + 'elk.algorithm'?: ElkAlgorithm; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-alignment.html + * @defaultValue 'AUTOMATIC' + */ + 'elk.alignment'?: Alignment; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-aspectRatio.html + */ + 'elk.aspectRatio'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-bendPoints.html + */ + 'elk.bendPoints'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-contentAlignment.html + * @defaultValue `ContentAlignment.topLeft()` + */ + 'elk.contentAlignment'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-debugMode.html + * @defaultValue 'false' + */ + 'elk.debugMode'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-direction.html + * @defaultValue 'UNDEFINED' + */ + 'elk.direction'?: Direction; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-edgeRouting.html + * @defaultValue 'UNDEFINED' + */ + 'elk.edgeRouting'?: EdgeRouting; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-expandNodes.html + * @defaultValue 'false' + */ + 'elk.expandNodes'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-hierarchyHandling.html + * @defaultValue 'INHERIT' + */ + 'elk.hierarchyHandling'?: HierarchyHandling; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-padding.html + * @defaultValue `new ElkPadding(12)` + */ + 'elk.padding'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-interactive.html + * @defaultValue 'false' + */ + 'elk.interactive'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-interactiveLayout.html + * @defaultValue 'false' + */ + 'elk.interactiveLayout'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-omitNodeMicroLayout.html + * @defaultValue 'false' + */ + 'elk.omitNodeMicroLayout'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-json-shapeCoords.html + * @defaultValue 'INHERIT' + */ + 'elk.json.shapeCoords'?: ShapeCoords; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-json-edgeCoords.html + * @defaultValue 'INHERIT' + */ + 'elk.json.edgeCoords'?: EdgeCoords; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-commentComment.html + * @defaultValue '10' + */ + 'elk.spacing.commentComment'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-commentNode.html + * @defaultValue '10' + */ + 'elk.spacing.commentNode'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-componentComponent.html + * @defaultValue '20' + */ + 'elk.spacing.componentComponent'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-edgeEdge.html + * @defaultValue '10' + */ + 'elk.spacing.edgeEdge'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-edgeLabel.html + * @defaultValue '2' + */ + 'elk.spacing.edgeLabel'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-edgeNode.html + * @defaultValue '10' + */ + 'elk.spacing.edgeNode'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-labelLabel.html + * @defaultValue '0' + */ + 'elk.spacing.labelLabel'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-labelNode.html + * @defaultValue '5' + */ + 'elk.spacing.labelNode'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-labelPortHorizontal.html + * @defaultValue '1' + */ + 'elk.spacing.labelPortHorizontal'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-labelPortVertical.html + * @defaultValue '1' + */ + 'elk.spacing.labelPortVertical'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-nodeNode.html + * @defaultValue '20' + */ + 'elk.spacing.nodeNode'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-nodeSelfLoop.html + * @defaultValue '10' + */ + 'elk.spacing.nodeSelfLoop'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-portPort.html + * @defaultValue '10' + */ + 'elk.spacing.portPort'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-individual.html + */ + 'elk.spacing.individual'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-portsSurrounding.html + * @defaultValue `new ElkMargin(0)` + */ + 'elk.spacing.portsSurrounding'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-partitioning-partition.html + */ + 'elk.partitioning.partition'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-partitioning-activate.html + * @defaultValue 'false' + */ + 'elk.partitioning.activate'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-nodeLabels-padding.html + * @defaultValue `new ElkPadding(5)` + */ + 'elk.nodeLabels.padding'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-nodeLabels-placement.html + * @defaultValue `NodeLabelPlacement.fixed` + */ + 'elk.nodeLabels.placement'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portAlignment-default.html + * @defaultValue 'DISTRIBUTED' + */ + 'elk.portAlignment.default'?: PortAlignment; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portAlignment-north.html + */ + 'elk.portAlignment.north'?: PortAlignment; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portAlignment-south.html + */ + 'elk.portAlignment.south'?: PortAlignment; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portAlignment-west.html + */ + 'elk.portAlignment.west'?: PortAlignment; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portAlignment-east.html + */ + 'elk.portAlignment.east'?: PortAlignment; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portConstraints.html + * @defaultValue 'UNDEFINED' + */ + 'elk.portConstraints'?: PortConstraints; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-position.html + */ + 'elk.position'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-priority.html + */ + 'elk.priority'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-randomSeed.html + */ + 'elk.randomSeed'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-separateConnectedComponents.html + */ + 'elk.separateConnectedComponents'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-nodeSize-constraints.html + * @defaultValue `EnumSet.noneOf(SizeConstraint)` + */ + 'elk.nodeSize.constraints'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-nodeSize-options.html + * @defaultValue `EnumSet.of(SizeOptions.DEFAULT_MINIMUM_SIZE)` + */ + 'elk.nodeSize.options'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-nodeSize-minimum.html + * @defaultValue `new KVector(0, 0)` + */ + 'elk.nodeSize.minimum'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-nodeSize-fixedGraphSize.html + * @defaultValue 'false' + */ + 'elk.nodeSize.fixedGraphSize'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-junctionPoints.html + * @defaultValue `new KVectorChain()` + */ + 'elk.junctionPoints'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-commentBox.html + * @defaultValue 'false' + */ + 'elk.commentBox'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-edgeLabels-placement.html + * @defaultValue 'CENTER' + */ + 'elk.edgeLabels.placement'?: EdgeLabelPlacement; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-edgeLabels-inline.html + * @defaultValue 'false' + */ + 'elk.edgeLabels.inline'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-font-name.html + */ + 'elk.font.name'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-font-size.html + */ + 'elk.font.size'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-hypernode.html + * @defaultValue 'false' + */ + 'elk.hypernode'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-softwrappingFuzziness.html + * @defaultValue '0.0' + */ + 'elk.softwrappingFuzziness'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-margins.html + * @defaultValue `new ElkMargin()` + */ + 'elk.margins'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-noLayout.html + * @defaultValue 'false' + */ + 'elk.noLayout'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-port-anchor.html + */ + 'elk.port.anchor'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-port-index.html + */ + 'elk.port.index'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-port-side.html + * @defaultValue 'UNDEFINED' + */ + 'elk.port.side'?: PortSide; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-port-borderOffset.html + */ + 'elk.port.borderOffset'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portLabels-placement.html + * @defaultValue `PortLabelPlacement.outside` + */ + 'elk.portLabels.placement'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portLabels-nextToPortIfPossible.html + * @defaultValue 'false' + */ + 'elk.portLabels.nextToPortIfPossible'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portLabels-treatAsGroup.html + * @defaultValue 'true' + */ + 'elk.portLabels.treatAsGroup'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-scaleFactor.html + * @defaultValue '1' + */ + 'elk.scaleFactor'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-childAreaWidth.html + */ + 'elk.childAreaWidth'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-childAreaHeight.html + */ + 'elk.childAreaHeight'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-topdownLayout.html + * @defaultValue 'false' + */ + 'elk.topdownLayout'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-topdown-sizeCategories.html + * @defaultValue '3' + */ + 'elk.topdown.sizeCategories'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-topdown-sizeCategoriesHierarchicalNodeWeight.html + * @defaultValue '4' + */ + 'elk.topdown.sizeCategoriesHierarchicalNodeWeight'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-topdown-scaleFactor.html + * @defaultValue '1' + */ + 'elk.topdown.scaleFactor'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-topdown-sizeApproximator.html + * @defaultValue `null` + */ + 'elk.topdown.sizeApproximator'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-topdown-hierarchicalNodeWidth.html + * @defaultValue '150' + */ + 'elk.topdown.hierarchicalNodeWidth'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-topdown-hierarchicalNodeAspectRatio.html + * @defaultValue '1.414' + */ + 'elk.topdown.hierarchicalNodeAspectRatio'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-topdown-nodeType.html + * @defaultValue `null` + */ + 'elk.topdown.nodeType'?: TopdownNodeTypes; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-topdown-scaleCap.html + * @defaultValue '1' + */ + 'elk.topdown.scaleCap'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-insideSelfLoops-activate.html + * @defaultValue 'false' + */ + 'elk.insideSelfLoops.activate'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-insideSelfLoops-yo.html + * @defaultValue 'false' + */ + 'elk.insideSelfLoops.yo'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-edge-thickness.html + * @defaultValue '1' + */ + 'elk.edge.thickness'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-edge-type.html + * @defaultValue 'NONE' + */ + 'elk.edge.type'?: EdgeType; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-animate.html + * @defaultValue 'true' + */ + 'elk.animate'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-animTimeFactor.html + * @defaultValue '100' + */ + 'elk.animTimeFactor'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layoutAncestors.html + * @defaultValue 'false' + */ + 'elk.layoutAncestors'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-maxAnimTime.html + * @defaultValue '4000' + */ + 'elk.maxAnimTime'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-minAnimTime.html + * @defaultValue '400' + */ + 'elk.minAnimTime'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-progressBar.html + * @defaultValue 'false' + */ + 'elk.progressBar'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-validateGraph.html + * @defaultValue 'false' + */ + 'elk.validateGraph'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-validateOptions.html + * @defaultValue 'true' + */ + 'elk.validateOptions'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-zoomToFit.html + * @defaultValue 'false' + */ + 'elk.zoomToFit'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-cycleBreaking-strategy.html + * @defaultValue 'GREEDY' + */ + 'elk.layered.cycleBreaking.strategy'?: CycleBreakingStrategy; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layering-strategy.html + * @defaultValue 'NETWORK_SIMPLEX' + */ + 'elk.layered.layering.strategy'?: LayeringStrategy; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layering-layerConstraint.html + * @defaultValue 'NONE' + */ + 'elk.layered.layering.layerConstraint'?: LayerConstraint; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layering-layerChoiceConstraint.html + * @defaultValue `null` + */ + 'elk.layered.layering.layerChoiceConstraint'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layering-layerId.html + * @defaultValue '-1' + */ + 'elk.layered.layering.layerId'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layering-minWidth-upperBoundOnWidth.html + * @defaultValue '4' + */ + 'elk.layered.layering.minWidth.upperBoundOnWidth'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layering-minWidth-upperLayerEstimationScalingFactor.html + * @defaultValue '2' + */ + 'elk.layered.layering.minWidth.upperLayerEstimationScalingFactor'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layering-nodePromotion-strategy.html + * @defaultValue 'NONE' + */ + 'elk.layered.layering.nodePromotion.strategy'?: NodePromotionStrategy; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layering-nodePromotion-maxIterations.html + * @defaultValue '0' + */ + 'elk.layered.layering.nodePromotion.maxIterations'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layering-coffmanGraham-layerBound.html + * @defaultValue 'MAX_VALUE' + */ + 'elk.layered.layering.coffmanGraham.layerBound'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-crossingMinimization-strategy.html + * @defaultValue 'LAYER_SWEEP' + */ + 'elk.layered.crossingMinimization.strategy'?: CrossingMinimizationStrategy; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-crossingMinimization-forceNodeModelOrder.html + * @defaultValue 'false' + */ + 'elk.layered.crossingMinimization.forceNodeModelOrder'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-crossingMinimization-hierarchicalSweepiness.html + * @defaultValue '0.1' + */ + 'elk.layered.crossingMinimization.hierarchicalSweepiness'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-crossingMinimization-greedySwitch-activationThreshold.html + * @defaultValue '40' + */ + 'elk.layered.crossingMinimization.greedySwitch.activationThreshold'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-crossingMinimization-greedySwitch-type.html + * @defaultValue 'TWO_SIDED' + */ + 'elk.layered.crossingMinimization.greedySwitch.type'?: GreedySwitchType; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-crossingMinimization-greedySwitchHierarchical-type.html + * @defaultValue 'OFF' + */ + 'elk.layered.crossingMinimization.greedySwitchHierarchical.type'?: GreedySwitchType; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-crossingMinimization-semiInteractive.html + * @defaultValue 'false' + */ + 'elk.layered.crossingMinimization.semiInteractive'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-crossingMinimization-inLayerPredOf.html + * @defaultValue `null` + */ + 'elk.layered.crossingMinimization.inLayerPredOf'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-crossingMinimization-inLayerSuccOf.html + * @defaultValue `null` + */ + 'elk.layered.crossingMinimization.inLayerSuccOf'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-crossingMinimization-positionChoiceConstraint.html + * @defaultValue `null` + */ + 'elk.layered.crossingMinimization.positionChoiceConstraint'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-crossingMinimization-positionId.html + * @defaultValue '-1' + */ + 'elk.layered.crossingMinimization.positionId'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-nodePlacement-strategy.html + * @defaultValue 'BRANDES_KOEPF' + */ + 'elk.layered.nodePlacement.strategy'?: NodePlacementStrategy; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-nodePlacement-favorStraightEdges.html + */ + 'elk.layered.nodePlacement.favorStraightEdges'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-nodePlacement-bk-edgeStraightening.html + * @defaultValue 'IMPROVE_STRAIGHTNESS' + */ + 'elk.layered.nodePlacement.bk.edgeStraightening'?: EdgeStraighteningStrategy; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-nodePlacement-bk-fixedAlignment.html + * @defaultValue 'NONE' + */ + 'elk.layered.nodePlacement.bk.fixedAlignment'?: FixedAlignment; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-nodePlacement-linearSegments-deflectionDampening.html + * @defaultValue '0.3' + */ + 'elk.layered.nodePlacement.linearSegments.deflectionDampening'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-nodePlacement-networkSimplex-nodeFlexibility.html + */ + 'elk.layered.nodePlacement.networkSimplex.nodeFlexibility'?: NodeFlexibility; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-nodePlacement-networkSimplex-nodeFlexibility-default.html + * @defaultValue 'NONE' + */ + 'elk.layered.nodePlacement.networkSimplex.nodeFlexibility.default'?: NodeFlexibility; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-nodePlacement-networkSimplex-nodeFlexibility-recomputeNodePlacement.html + * @defaultValue `null` + */ + 'elk.layered.nodePlacement.networkSimplex.nodeFlexibility.recomputeNodePlacement'?: NodePlacementStrategy; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-edgeRouting-splines-mode.html + * @defaultValue 'SLOPPY' + */ + 'elk.layered.edgeRouting.splines.mode'?: SplineRoutingMode; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-edgeRouting-splines-sloppy-layerSpacingFactor.html + * @defaultValue '0.2' + */ + 'elk.layered.edgeRouting.splines.sloppy.layerSpacingFactor'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-edgeRouting-polyline-slopedEdgeZoneWidth.html + * @defaultValue '2.0' + */ + 'elk.layered.edgeRouting.polyline.slopedEdgeZoneWidth'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-edgeRouting-selfLoopDistribution.html + * @defaultValue 'NORTH' + */ + 'elk.layered.edgeRouting.selfLoopDistribution'?: SelfLoopDistributionStrategy; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-edgeRouting-selfLoopOrdering.html + * @defaultValue 'STACKED' + */ + 'elk.layered.edgeRouting.selfLoopOrdering'?: SelfLoopOrderingStrategy; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-spacing-baseValue.html + */ + 'elk.layered.spacing.baseValue'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-spacing-edgeNodeBetweenLayers.html + * @defaultValue '10' + */ + 'elk.layered.spacing.edgeNodeBetweenLayers'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-spacing-edgeEdgeBetweenLayers.html + * @defaultValue '10' + */ + 'elk.layered.spacing.edgeEdgeBetweenLayers'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-spacing-nodeNodeBetweenLayers.html + * @defaultValue '20' + */ + 'elk.layered.spacing.nodeNodeBetweenLayers'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-priority-direction.html + * @defaultValue '0' + */ + 'elk.layered.priority.direction'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-priority-shortness.html + * @defaultValue '0' + */ + 'elk.layered.priority.shortness'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-priority-straightness.html + * @defaultValue '0' + */ + 'elk.layered.priority.straightness'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-compaction-postCompaction-strategy.html + * @defaultValue 'NONE' + */ + 'elk.layered.compaction.postCompaction.strategy'?: GraphCompactionStrategy; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-compaction-postCompaction-constraints.html + * @defaultValue 'SCANLINE' + */ + 'elk.layered.compaction.postCompaction.constraints'?: ConstraintCalculationStrategy; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-compaction-connectedComponents.html + * @defaultValue 'false' + */ + 'elk.layered.compaction.connectedComponents'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-highDegreeNodes-treatment.html + * @defaultValue 'false' + */ + 'elk.layered.highDegreeNodes.treatment'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-highDegreeNodes-threshold.html + * @defaultValue '16' + */ + 'elk.layered.highDegreeNodes.threshold'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-highDegreeNodes-treeHeight.html + * @defaultValue '5' + */ + 'elk.layered.highDegreeNodes.treeHeight'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-wrapping-strategy.html + * @defaultValue 'OFF' + */ + 'elk.layered.wrapping.strategy'?: WrappingStrategy; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-wrapping-additionalEdgeSpacing.html + * @defaultValue '10' + */ + 'elk.layered.wrapping.additionalEdgeSpacing'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-wrapping-correctionFactor.html + * @defaultValue '1.0' + */ + 'elk.layered.wrapping.correctionFactor'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-wrapping-cutting-strategy.html + * @defaultValue 'MSD' + */ + 'elk.layered.wrapping.cutting.strategy'?: CuttingStrategy; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-wrapping-cutting-cuts.html + */ + 'elk.layered.wrapping.cutting.cuts'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-wrapping-cutting-msd-freedom.html + * @defaultValue '1' + */ + 'elk.layered.wrapping.cutting.msd.freedom'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-wrapping-validify-strategy.html + * @defaultValue 'GREEDY' + */ + 'elk.layered.wrapping.validify.strategy'?: ValidifyStrategy; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-wrapping-validify-forbiddenIndices.html + */ + 'elk.layered.wrapping.validify.forbiddenIndices'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-wrapping-multiEdge-improveCuts.html + * @defaultValue 'true' + */ + 'elk.layered.wrapping.multiEdge.improveCuts'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-wrapping-multiEdge-distancePenalty.html + * @defaultValue '2.0' + */ + 'elk.layered.wrapping.multiEdge.distancePenalty'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-wrapping-multiEdge-improveWrappedEdges.html + * @defaultValue 'true' + */ + 'elk.layered.wrapping.multiEdge.improveWrappedEdges'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layerUnzipping-strategy.html + * @defaultValue 'NONE' + */ + 'elk.layered.layerUnzipping.strategy'?: LayerUnzippingStrategy; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layerUnzipping-minimizeEdgeLength.html + * @defaultValue 'false' + */ + 'elk.layered.layerUnzipping.minimizeEdgeLength'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layerUnzipping-layerSplit.html + * @defaultValue '2' + */ + 'elk.layered.layerUnzipping.layerSplit'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layerUnzipping-resetOnLongEdges.html + * @defaultValue 'true' + */ + 'elk.layered.layerUnzipping.resetOnLongEdges'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-edgeLabels-sideSelection.html + * @defaultValue 'SMART_DOWN' + */ + 'elk.layered.edgeLabels.sideSelection'?: EdgeLabelSideSelection; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-edgeLabels-centerLabelPlacementStrategy.html + * @defaultValue 'MEDIAN_LAYER' + */ + 'elk.layered.edgeLabels.centerLabelPlacementStrategy'?: CenterEdgeLabelPlacementStrategy; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-strategy.html + * @defaultValue 'NONE' + */ + 'elk.layered.considerModelOrder.strategy'?: OrderingStrategy; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-portModelOrder.html + * @defaultValue 'false' + */ + 'elk.layered.considerModelOrder.portModelOrder'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-noModelOrder.html + * @defaultValue 'false' + */ + 'elk.layered.considerModelOrder.noModelOrder'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-components.html + * @defaultValue 'NONE' + */ + 'elk.layered.considerModelOrder.components'?: ComponentOrderingStrategy; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-longEdgeStrategy.html + * @defaultValue 'DUMMY_NODE_OVER' + */ + 'elk.layered.considerModelOrder.longEdgeStrategy'?: LongEdgeOrderingStrategy; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-crossingCounterNodeInfluence.html + * @defaultValue '0' + */ + 'elk.layered.considerModelOrder.crossingCounterNodeInfluence'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-crossingCounterPortInfluence.html + * @defaultValue '0' + */ + 'elk.layered.considerModelOrder.crossingCounterPortInfluence'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-groupModelOrder-cycleBreakingId.html + * @defaultValue '0' + */ + 'elk.layered.considerModelOrder.groupModelOrder.cycleBreakingId'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-groupModelOrder-crossingMinimizationId.html + * @defaultValue '0' + */ + 'elk.layered.considerModelOrder.groupModelOrder.crossingMinimizationId'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-groupModelOrder-componentGroupId.html + * @defaultValue '0' + */ + 'elk.layered.considerModelOrder.groupModelOrder.componentGroupId'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-groupModelOrder-cbGroupOrderStrategy.html + * @defaultValue 'ONLY_WITHIN_GROUP' + */ + 'elk.layered.considerModelOrder.groupModelOrder.cbGroupOrderStrategy'?: GroupOrderStrategy; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-groupModelOrder-cbPreferredSourceId.html + */ + 'elk.layered.considerModelOrder.groupModelOrder.cbPreferredSourceId'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-groupModelOrder-cbPreferredTargetId.html + */ + 'elk.layered.considerModelOrder.groupModelOrder.cbPreferredTargetId'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-groupModelOrder-cmGroupOrderStrategy.html + * @defaultValue 'ONLY_WITHIN_GROUP' + */ + 'elk.layered.considerModelOrder.groupModelOrder.cmGroupOrderStrategy'?: GroupOrderStrategy; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-groupModelOrder-cmEnforcedGroupOrders.html + * @defaultValue `#[1, 2, 6, 7, 10, 11]` + */ + 'elk.layered.considerModelOrder.groupModelOrder.cmEnforcedGroupOrders'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-directionCongruency.html + * @defaultValue 'READING_DIRECTION' + */ + 'elk.layered.directionCongruency'?: DirectionCongruency; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-feedbackEdges.html + * @defaultValue 'false' + */ + 'elk.layered.feedbackEdges'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-interactiveReferencePoint.html + * @defaultValue 'CENTER' + */ + 'elk.layered.interactiveReferencePoint'?: InteractiveReferencePoint; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-mergeEdges.html + * @defaultValue 'false' + */ + 'elk.layered.mergeEdges'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-mergeHierarchyEdges.html + * @defaultValue 'true' + */ + 'elk.layered.mergeHierarchyEdges'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-allowNonFlowPortsToSwitchSides.html + * @defaultValue 'false' + */ + 'elk.layered.allowNonFlowPortsToSwitchSides'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-portSortingStrategy.html + * @defaultValue 'INPUT_ORDER' + */ + 'elk.layered.portSortingStrategy'?: PortSortingStrategy; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-thoroughness.html + * @defaultValue '7' + */ + 'elk.layered.thoroughness'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-unnecessaryBendpoints.html + * @defaultValue 'false' + */ + 'elk.layered.unnecessaryBendpoints'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-generatePositionAndLayerIds.html + * @defaultValue 'false' + */ + 'elk.layered.generatePositionAndLayerIds'?: 'true' | 'false'; +} diff --git a/packages/joint-layout-elk/src/types/elkNodeOptions.mts b/packages/joint-layout-elk/src/types/elkNodeOptions.mts new file mode 100644 index 0000000000..a246f33ce4 --- /dev/null +++ b/packages/joint-layout-elk/src/types/elkNodeOptions.mts @@ -0,0 +1,247 @@ +import type { + Alignment, + HierarchyHandling, + PortAlignment, + PortConstraints, + LayerConstraint, + NodeFlexibility, + SelfLoopDistributionStrategy, + SelfLoopOrderingStrategy, + TopdownNodeTypes +} from './elkEnums.mjs'; + +export interface NodeElkLayoutOptions { + // Falls back to a plain string for any ELK option beyond this package's Core/Layered coverage. + [key: string]: string | undefined; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-alignment.html + * @defaultValue 'AUTOMATIC' + */ + 'elk.alignment'?: Alignment; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-hierarchyHandling.html + * @defaultValue 'INHERIT' + */ + 'elk.hierarchyHandling'?: HierarchyHandling; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-padding.html + * @defaultValue `new ElkPadding(12)` + */ + 'elk.padding'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-portPort.html + * @defaultValue '10' + */ + 'elk.spacing.portPort'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-individual.html + */ + 'elk.spacing.individual'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-partitioning-partition.html + */ + 'elk.partitioning.partition'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-nodeLabels-placement.html + * @defaultValue `NodeLabelPlacement.fixed` + */ + 'elk.nodeLabels.placement'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portAlignment-default.html + * @defaultValue 'DISTRIBUTED' + */ + 'elk.portAlignment.default'?: PortAlignment; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portAlignment-north.html + */ + 'elk.portAlignment.north'?: PortAlignment; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portAlignment-south.html + */ + 'elk.portAlignment.south'?: PortAlignment; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portAlignment-west.html + */ + 'elk.portAlignment.west'?: PortAlignment; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portAlignment-east.html + */ + 'elk.portAlignment.east'?: PortAlignment; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portConstraints.html + * @defaultValue 'UNDEFINED' + */ + 'elk.portConstraints'?: PortConstraints; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-position.html + */ + 'elk.position'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-priority.html + */ + 'elk.priority'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-nodeSize-constraints.html + * @defaultValue `EnumSet.noneOf(SizeConstraint)` + */ + 'elk.nodeSize.constraints'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-nodeSize-options.html + * @defaultValue `EnumSet.of(SizeOptions.DEFAULT_MINIMUM_SIZE)` + */ + 'elk.nodeSize.options'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-nodeSize-minimum.html + * @defaultValue `new KVector(0, 0)` + */ + 'elk.nodeSize.minimum'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-commentBox.html + * @defaultValue 'false' + */ + 'elk.commentBox'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-hypernode.html + * @defaultValue 'false' + */ + 'elk.hypernode'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-margins.html + * @defaultValue `new ElkMargin()` + */ + 'elk.margins'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-noLayout.html + * @defaultValue 'false' + */ + 'elk.noLayout'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portLabels-placement.html + * @defaultValue `PortLabelPlacement.outside` + */ + 'elk.portLabels.placement'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portLabels-nextToPortIfPossible.html + * @defaultValue 'false' + */ + 'elk.portLabels.nextToPortIfPossible'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portLabels-treatAsGroup.html + * @defaultValue 'true' + */ + 'elk.portLabels.treatAsGroup'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-scaleFactor.html + * @defaultValue '1' + */ + 'elk.scaleFactor'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-topdown-sizeApproximator.html + * @defaultValue `null` + */ + 'elk.topdown.sizeApproximator'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-topdown-hierarchicalNodeWidth.html + * @defaultValue '150' + */ + 'elk.topdown.hierarchicalNodeWidth'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-topdown-hierarchicalNodeAspectRatio.html + * @defaultValue '1.414' + */ + 'elk.topdown.hierarchicalNodeAspectRatio'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-topdown-nodeType.html + * @defaultValue `null` + */ + 'elk.topdown.nodeType'?: TopdownNodeTypes; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-insideSelfLoops-activate.html + * @defaultValue 'false' + */ + 'elk.insideSelfLoops.activate'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layering-layerConstraint.html + * @defaultValue 'NONE' + */ + 'elk.layered.layering.layerConstraint'?: LayerConstraint; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layering-layerChoiceConstraint.html + * @defaultValue `null` + */ + 'elk.layered.layering.layerChoiceConstraint'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layering-layerId.html + * @defaultValue '-1' + */ + 'elk.layered.layering.layerId'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-crossingMinimization-inLayerPredOf.html + * @defaultValue `null` + */ + 'elk.layered.crossingMinimization.inLayerPredOf'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-crossingMinimization-inLayerSuccOf.html + * @defaultValue `null` + */ + 'elk.layered.crossingMinimization.inLayerSuccOf'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-crossingMinimization-positionChoiceConstraint.html + * @defaultValue `null` + */ + 'elk.layered.crossingMinimization.positionChoiceConstraint'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-crossingMinimization-positionId.html + * @defaultValue '-1' + */ + 'elk.layered.crossingMinimization.positionId'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-nodePlacement-networkSimplex-nodeFlexibility.html + */ + 'elk.layered.nodePlacement.networkSimplex.nodeFlexibility'?: NodeFlexibility; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-edgeRouting-selfLoopDistribution.html + * @defaultValue 'NORTH' + */ + 'elk.layered.edgeRouting.selfLoopDistribution'?: SelfLoopDistributionStrategy; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-edgeRouting-selfLoopOrdering.html + * @defaultValue 'STACKED' + */ + 'elk.layered.edgeRouting.selfLoopOrdering'?: SelfLoopOrderingStrategy; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layerUnzipping-minimizeEdgeLength.html + * @defaultValue 'false' + */ + 'elk.layered.layerUnzipping.minimizeEdgeLength'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layerUnzipping-layerSplit.html + * @defaultValue '2' + */ + 'elk.layered.layerUnzipping.layerSplit'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layerUnzipping-resetOnLongEdges.html + * @defaultValue 'true' + */ + 'elk.layered.layerUnzipping.resetOnLongEdges'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-noModelOrder.html + * @defaultValue 'false' + */ + 'elk.layered.considerModelOrder.noModelOrder'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-groupModelOrder-cycleBreakingId.html + * @defaultValue '0' + */ + 'elk.layered.considerModelOrder.groupModelOrder.cycleBreakingId'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-groupModelOrder-crossingMinimizationId.html + * @defaultValue '0' + */ + 'elk.layered.considerModelOrder.groupModelOrder.crossingMinimizationId'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-groupModelOrder-componentGroupId.html + * @defaultValue '0' + */ + 'elk.layered.considerModelOrder.groupModelOrder.componentGroupId'?: `${number}`; +} diff --git a/packages/joint-layout-elk/src/types/elkPortOptions.mts b/packages/joint-layout-elk/src/types/elkPortOptions.mts new file mode 100644 index 0000000000..048f83a8c9 --- /dev/null +++ b/packages/joint-layout-elk/src/types/elkPortOptions.mts @@ -0,0 +1,51 @@ +import type { PortSide } from './elkEnums.mjs'; + +export interface PortElkLayoutOptions { + // Falls back to a plain string for any ELK option beyond this package's Core/Layered coverage. + [key: string]: string | undefined; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-individual.html + */ + 'elk.spacing.individual'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-position.html + */ + 'elk.position'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-noLayout.html + * @defaultValue 'false' + */ + 'elk.noLayout'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-port-anchor.html + */ + 'elk.port.anchor'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-port-index.html + */ + 'elk.port.index'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-port-side.html + * @defaultValue 'UNDEFINED' + */ + 'elk.port.side'?: PortSide; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-port-borderOffset.html + */ + 'elk.port.borderOffset'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-groupModelOrder-crossingMinimizationId.html + * @defaultValue '0' + */ + 'elk.layered.considerModelOrder.groupModelOrder.crossingMinimizationId'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-groupModelOrder-componentGroupId.html + * @defaultValue '0' + */ + 'elk.layered.considerModelOrder.groupModelOrder.componentGroupId'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-allowNonFlowPortsToSwitchSides.html + * @defaultValue 'false' + */ + 'elk.layered.allowNonFlowPortsToSwitchSides'?: 'true' | 'false'; +} diff --git a/packages/joint-layout-elk/src/types/index.mts b/packages/joint-layout-elk/src/types/index.mts new file mode 100644 index 0000000000..a83c809f66 --- /dev/null +++ b/packages/joint-layout-elk/src/types/index.mts @@ -0,0 +1,7 @@ +export type * from './elkEnums.mjs'; +export type * from './elkNodeOptions.mjs'; +export type * from './elkEdgeOptions.mjs'; +export type * from './elkPortOptions.mjs'; +export type * from './elkLabelOptions.mjs'; +export type * from './elkLayoutOptions.mjs'; +export type * from './elkGraph.mjs'; diff --git a/packages/joint-layout-elk/src/workerElk.mts b/packages/joint-layout-elk/src/workerElk.mts new file mode 100644 index 0000000000..55352d89da --- /dev/null +++ b/packages/joint-layout-elk/src/workerElk.mts @@ -0,0 +1,240 @@ +import { getAbortReason } from './abort.mjs'; + +import type { ElkNode } from 'elkjs'; + +// The algorithms `elkjs/lib/elk-api.js` registers with a worker by default. +const ALGORITHMS = ['layered', 'stress', 'mrtree', 'radial', 'force', 'disco', 'sporeOverlap', 'sporeCompaction', 'rectpacking']; + +// The id of the message registering `ALGORITHMS` - layouts are numbered from 1. +const REGISTER_ID = 0; + +interface LayoutJob { + id: number; + graph: ElkNode; + resolve: (result: ElkNode) => void; + reject: (reason: unknown) => void; +} + +/** + * ELK running in a Web Worker - see `createWorkerElk()`. + */ +export interface WorkerElk { + /** + * Lays out `graph` in the worker. Aborting `signal` rejects with its reason - if the + * worker is busy with this layout, the worker is terminated, and a new one takes over + * the layouts still waiting. + */ + layout(graph: ElkNode, options?: { signal?: AbortSignal }): Promise; + /** + * Terminates the worker - layouts not settled yet are rejected. A later layout starts + * a new worker. + */ + terminate(): void; +} + +/** + * Talks to ELK's own worker script (`elkjs/lib/elk-worker.min.js`) in place of + * `elkjs/lib/elk-api.js`, which can neither cancel a layout nor settle one whose worker + * fails or is terminated. + * + * The worker lays out one graph at a time, in the order they were posted. `jobs` holds the + * layouts not settled yet, `posted` the ids sent to the current worker that it hasn't + * answered yet - including those of layouts aborted while waiting their turn, which it + * still lays out - so the first of `posted` is the one the worker is busy with. + */ +export class ElkWorkerClient implements WorkerElk { + + private worker: Worker | undefined; + // Whether the worker has answered its first message - i.e. its script loaded and runs. + private isLoaded = false; + private readonly jobs = new Map(); + private posted: number[] = []; + private nextId = REGISTER_ID + 1; + + constructor(private readonly createWorker: () => Worker) {} + + layout(graph: ElkNode, { signal }: { signal?: AbortSignal } = {}): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(getAbortReason(signal)); + return; + } + const onAbort = () => { + this.cancel(job); + reject(getAbortReason(signal as AbortSignal)); + }; + const settle = () => signal?.removeEventListener('abort', onAbort); + const job: LayoutJob = { + id: this.nextId++, + graph, + resolve: (result) => { + settle(); + resolve(result); + }, + reject: (reason) => { + settle(); + reject(reason); + } + }; + this.jobs.set(job.id, job); + if (this.worker) { + this.post(job); + } else { + // Posts `job` too. + this.start(); + } + // `start()` may have already rejected it. + if (this.jobs.has(job.id)) signal?.addEventListener('abort', onAbort, { once: true }); + }); + } + + terminate(): void { + this.stopWorker(); + this.rejectAll(new Error('@joint/layout-elk: the ELK worker was terminated.')); + } + + /** + * Starts a worker, and posts it every job not settled yet. Rejects them all if no + * worker could be started (e.g. a worker script the page's CSP doesn't allow). + */ + private start(): void { + let worker: Worker; + try { + worker = this.createWorker(); + } catch (error) { + this.rejectAll(error); + return; + } + this.worker = worker; + this.isLoaded = false; + worker.addEventListener('message', (event: MessageEvent) => { + if (worker === this.worker) this.receive(event.data); + }); + // ELK itself never listens for a worker's `error` event - a worker script that fails + // to load (e.g. one the bundler didn't emit), or a worker that crashes (e.g. out of + // memory), would leave every layout pending. + worker.addEventListener('error', (event) => { + event.preventDefault(); + if (worker !== this.worker) return; + if (this.isLoaded) { + this.crash(event); + } else { + this.fail(); + } + }); + worker.postMessage({ id: REGISTER_ID, cmd: 'register', algorithms: ALGORITHMS }); + // A copy - `post()` drops a job that can't be posted. + Array.from(this.jobs.values()).forEach((job) => this.post(job)); + } + + /** + * Posts `job` to the worker - or, if it can't be (e.g. a `DataCloneError` for a value + * an export callback left in the ELK graph that can't be cloned), rejects it. + */ + private post(job: LayoutJob): void { + if (!this.worker) return; + try { + // The worker gets its own (structured) clone of `graph` - a job re-posted after + // a restart starts from the original. + this.worker.postMessage({ id: job.id, cmd: 'layout', graph: job.graph, layoutOptions: {}, options: {}}); + } catch (error) { + this.jobs.delete(job.id); + job.reject(error); + return; + } + this.posted.push(job.id); + } + + private receive(data: { id: number, data?: ElkNode, error?: unknown }): void { + if (data.id === REGISTER_ID) { + // ELK answers a `register` that threw with an error in place of the + // acknowledgement. Its algorithms are gone, so every layout the worker is + // given would fail - the worker is replaced rather than kept as loaded. + if (data.error) { + this.stopWorker(); + this.rejectAll(data.error); + return; + } + this.isLoaded = true; + return; + } + this.posted = this.posted.filter((id) => id !== data.id); + const job = this.jobs.get(data.id); + if (!job) return; + this.jobs.delete(data.id); + if (data.error) { + job.reject(data.error); + } else { + job.resolve(data.data as ElkNode); + } + } + + /** + * Drops `job`. ELK can't stop a layout in progress - if the worker is busy with `job`, + * it is terminated, and a new one takes over the jobs still waiting. A job still + * waiting its turn is laid out regardless, its result ignored. + */ + private cancel(job: LayoutJob): void { + if (!this.jobs.has(job.id)) return; + this.jobs.delete(job.id); + if (this.posted[0] === job.id) this.restart(); + } + + /** + * The worker crashed (after it loaded) - most likely because of the job it was busy + * with, which is rejected rather than retried (unless it was aborted already). A new + * worker takes over the jobs still waiting. + */ + private crash(event: ErrorEvent): void { + const [runningId] = this.posted; + const running = (runningId === undefined) ? undefined : this.jobs.get(runningId); + if (running) { + this.jobs.delete(running.id); + const details = event.message ? ` (${event.message})` : ''; + running.reject(new Error(`@joint/layout-elk: the ELK worker crashed during the layout${details}.`)); + } + this.restart(); + } + + // With no job waiting, the next layout starts the new worker. + private restart(): void { + this.stopWorker(); + if (this.jobs.size > 0) this.start(); + } + + /** + * The worker failed to load - every job not settled yet is rejected. The next layout + * tries again with a new worker. + */ + private fail(): void { + this.stopWorker(); + this.rejectAll(new Error('@joint/layout-elk: the ELK worker failed to load - check that its script is served where `createWorkerElk()` starts it from.')); + } + + private rejectAll(reason: unknown): void { + const jobs = Array.from(this.jobs.values()); + this.jobs.clear(); + jobs.forEach((job) => job.reject(reason)); + } + + private stopWorker(): void { + this.worker?.terminate(); + this.worker = undefined; + this.posted = []; + } +} + +/** + * Creates an ELK instance running in a Web Worker, to pass to `layout()` as its `elk` + * option - so a layout doesn't block the page. `createWorker` starts the worker, running + * `@joint/layout-elk/worker` (ELK's own worker script, `elkjs/lib/elk-worker.min.js`) - + * however your bundler loads a worker script (see "Running ELK in a Web Worker" in the + * README). It is called on the first layout, and again whenever the worker is replaced + * (e.g. after an aborted layout). + * @example + * const elk = createWorkerElk(() => new Worker(new URL('@joint/layout-elk/worker', import.meta.url), { type: 'module' })); + * await layout(graph, { elk }); + */ +export function createWorkerElk(createWorker: () => Worker): WorkerElk { + return new ElkWorkerClient(createWorker); +} diff --git a/packages/joint-layout-elk/test/index.html b/packages/joint-layout-elk/test/index.html new file mode 100644 index 0000000000..f8334c54a9 --- /dev/null +++ b/packages/joint-layout-elk/test/index.html @@ -0,0 +1,18 @@ + + + + + JointJS ELK Layout test suite + + + +
+
+ + + + + + + + diff --git a/packages/joint-layout-elk/test/index.js b/packages/joint-layout-elk/test/index.js new file mode 100644 index 0000000000..0c2b1fb580 --- /dev/null +++ b/packages/joint-layout-elk/test/index.js @@ -0,0 +1,1928 @@ +QUnit.module('sanity check', () => { + QUnit.test('should load', assert => { + assert.ok(typeof joint.layout.ELK !== 'undefined'); + assert.ok(typeof joint.layout.ELK.layout === 'function'); + }); +}); + +// First: main-thread ELK is loaded once, then shared by every later layout. +QUnit.module('loading main-thread ELK', (hooks) => { + + hooks.after(() => { + delete window.__loadMainThreadElk; + }); + + QUnit.test('should load it again on the next layout after it failed to load', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ id: 'a', size: { width: 100, height: 100 }}); + const el2 = new joint.shapes.standard.Rectangle({ id: 'b', size: { width: 100, height: 100 }}); + graph.resetCells([el1, el2]); + + // E.g. a chunk that failed to load. + const error = new Error('chunk failed to load'); + window.__loadMainThreadElk = () => Promise.reject(error); + await assert.rejects(joint.layout.ELK.layout(graph), error); + assert.ok(joint.g.intersection.exists(el1.getBBox(), el2.getBBox())); + + delete window.__loadMainThreadElk; + await joint.layout.ELK.layout(graph); + assert.notOk(joint.g.intersection.exists(el1.getBBox(), el2.getBBox())); + }); +}); + +QUnit.module('layout()', () => { + + function createGraph() { + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ id: 'a', size: { width: 100, height: 100 }}); + const el2 = new joint.shapes.standard.Rectangle({ id: 'b', size: { width: 100, height: 100 }}); + const el3 = new joint.shapes.standard.Rectangle({ id: 'c', size: { width: 100, height: 100 }}); + const el4 = new joint.shapes.standard.Rectangle({ id: 'd', size: { width: 100, height: 100 }}); + + const link1 = new joint.shapes.standard.Link({ source: { id: el1.id }, target: { id: el2.id }}); + const link2 = new joint.shapes.standard.Link({ source: { id: el1.id }, target: { id: el3.id }}); + const link3 = new joint.shapes.standard.Link({ source: { id: el2.id }, target: { id: el4.id }}); + const link4 = new joint.shapes.standard.Link({ source: { id: el3.id }, target: { id: el4.id }}); + + graph.resetCells([el1, el2, el3, el4, link1, link2, link3, link4]); + + return { graph, el1, el2, el3, el4 }; + } + + QUnit.test('should position elements without overlapping them', async(assert) => { + + const { graph, el1, el2, el3, el4 } = createGraph(); + + const initialBBox = graph.getBBox(); + assert.equal(initialBBox.x, 0); + assert.equal(initialBBox.y, 0); + + const { bbox } = await joint.layout.ELK.layout(graph); + + assert.ok(bbox.width > 0); + assert.ok(bbox.height > 0); + + const boundaries = [ + el1.getBBox(), + el2.getBBox(), + el3.getBBox(), + el4.getBBox() + ]; + + const overlaps = boundaries.some((box, i) => + boundaries.slice(i + 1).some(other => joint.g.intersection.exists(box, other)) + ); + + assert.ok(!overlaps); + }); + + QUnit.test('should route links and set vertices/anchors', async(assert) => { + + const { graph } = createGraph(); + + await joint.layout.ELK.layout(graph, { + elkLayoutOptions: { + 'elk.algorithm': 'layered', + 'elk.direction': 'RIGHT', + 'elk.edgeRouting': 'ORTHOGONAL' + } + }); + + graph.getLinks().forEach((link) => { + assert.ok(Array.isArray(link.vertices())); + assert.equal(link.prop('source/anchor/name'), 'topLeft'); + assert.equal(link.prop('target/anchor/name'), 'topLeft'); + }); + }); + + QUnit.test('should position labelled links', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ id: 'a', size: { width: 100, height: 100 }}); + const el2 = new joint.shapes.standard.Rectangle({ id: 'b', size: { width: 100, height: 100 }}); + const link = new joint.shapes.standard.Link({ + source: { id: el1.id }, + target: { id: el2.id }, + labels: [{ size: { width: 40, height: 20 }}] + }); + + graph.resetCells([el1, el2, link]); + + await joint.layout.ELK.layout(graph); + + const label = link.label(0); + assert.ok(label.position && typeof label.position.distance === 'number'); + }); + + QUnit.test('should size a link label from `defaultLabel` when the label\'s own `size` is not set', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ id: 'a', size: { width: 100, height: 100 }}); + const el2 = new joint.shapes.standard.Rectangle({ id: 'b', size: { width: 100, height: 100 }}); + const link = new joint.shapes.standard.Link({ + source: { id: el1.id }, + target: { id: el2.id }, + defaultLabel: { size: { width: 80, height: 20 }}, + // No own `size` - resolved only through `defaultLabel` (see `Link#labels`). + labels: [{}] + }); + + graph.resetCells([el1, el2, link]); + + const { elkGraph } = await joint.layout.ELK.layout(graph); + + const [elkEdge] = elkGraph.edges; + assert.equal(elkEdge.labels[0].width, 80); + assert.equal(elkEdge.labels[0].height, 20); + }); + + QUnit.test('should lay out embedded elements (containers) and resize their parent to fit them', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const parent = new joint.shapes.standard.Rectangle({ id: 'parent', size: { width: 10, height: 10 }, position: { x: 0, y: 0 }}); + const child1 = new joint.shapes.standard.Rectangle({ id: 'child1', size: { width: 50, height: 50 }}); + const child2 = new joint.shapes.standard.Rectangle({ id: 'child2', size: { width: 50, height: 50 }}); + const childLink = new joint.shapes.standard.Link({ source: { id: 'child1' }, target: { id: 'child2' }}); + parent.embed(child1); + parent.embed(child2); + + graph.resetCells([parent, child1, child2, childLink]); + + await joint.layout.ELK.layout(graph); + + const parentBBox = parent.getBBox(); + const child1BBox = child1.getBBox(); + const child2BBox = child2.getBBox(); + + // The parent is resized (and positioned) by ELK to fit its content. + assert.ok(parentBBox.width >= child1BBox.width + child2BBox.width); + assert.ok(parentBBox.containsPoint(child1BBox.center())); + assert.ok(parentBBox.containsPoint(child2BBox.center())); + assert.ok(!joint.g.intersection.exists(child1BBox, child2BBox)); + }); + + QUnit.test('should route a link crossing a container boundary', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const parent = new joint.shapes.standard.Rectangle({ id: 'parent', size: { width: 10, height: 10 }}); + const child = new joint.shapes.standard.Rectangle({ id: 'child', size: { width: 50, height: 50 }}); + const outside = new joint.shapes.standard.Rectangle({ id: 'outside', size: { width: 50, height: 50 }}); + const link = new joint.shapes.standard.Link({ source: { id: 'child' }, target: { id: 'outside' }}); + parent.embed(child); + + graph.resetCells([parent, child, outside, link]); + + await joint.layout.ELK.layout(graph); + + assert.ok(Array.isArray(link.vertices())); + assert.ok(!joint.g.intersection.exists(parent.getBBox(), outside.getBBox())); + }); + + QUnit.test('should place a link inside nested containers using ELK\'s graph-absolute edge coordinates', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const outer = new joint.shapes.standard.Rectangle({ id: 'outer', size: { width: 10, height: 10 }}); + const inner = new joint.shapes.standard.Rectangle({ id: 'inner', size: { width: 10, height: 10 }}); + const a = new joint.shapes.standard.Rectangle({ id: 'a', size: { width: 60, height: 40 }}); + const b = new joint.shapes.standard.Rectangle({ id: 'b', size: { width: 60, height: 80 }}); + const link = new joint.shapes.standard.Link({ + source: { id: 'a' }, + target: { id: 'b' }, + labels: [{ size: { width: 30, height: 12 }}] + }); + outer.embed(inner); + inner.embed(a); + inner.embed(b); + + graph.resetCells([outer, inner, a, b, link]); + + const { elkGraph } = await joint.layout.ELK.layout(graph, { + elkLayoutOptions: { + 'elk.padding': '[top=40,left=20,bottom=20,right=20]' + } + }); + + assert.equal(elkGraph.layoutOptions['elk.json.edgeCoords'], 'ROOT'); + + const elkInner = elkGraph.children[0].children[0]; + const [elkEdge] = elkInner.edges; + const { startPoint, endPoint } = elkEdge.sections[0]; + + // ELK's own (graph-absolute) end points land on the laid out elements' borders - + // relative to `inner`, they'd be off by both containers' offsets. + assert.ok(a.getBBox().inflate(1).containsPoint(startPoint)); + assert.ok(b.getBBox().inflate(1).containsPoint(endPoint)); + + // ...and are applied as they are: each end's anchor resolves to that same point. + const { dx, dy } = link.source().anchor.args; + assert.deepEqual(a.position().offset(dx, dy).toJSON(), { x: startPoint.x, y: startPoint.y }); + + // The label stays on the link's path. + const [label] = link.labels(); + assert.ok(Math.abs(label.position.offset) < 30); + }); + + QUnit.test('should let `elkLayoutOptions` override `elk.json.edgeCoords`', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ id: 'a', size: { width: 50, height: 50 }}); + const el2 = new joint.shapes.standard.Rectangle({ id: 'b', size: { width: 50, height: 50 }}); + const link = new joint.shapes.standard.Link({ source: { id: 'a' }, target: { id: 'b' }}); + + graph.resetCells([el1, el2, link]); + + const { elkGraph } = await joint.layout.ELK.layout(graph, { + elkLayoutOptions: { 'elk.json.edgeCoords': 'CONTAINER' } + }); + + assert.equal(elkGraph.layoutOptions['elk.json.edgeCoords'], 'CONTAINER'); + }); + + QUnit.test('should route links to/from ports without overriding their anchor', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ + id: 'a', + size: { width: 100, height: 100 }, + ports: { + groups: { + out: { position: 'right' } + }, + items: [{ id: 'out1', group: 'out' }] + } + }); + const el2 = new joint.shapes.standard.Rectangle({ + id: 'b', + size: { width: 100, height: 100 }, + ports: { + groups: { + in: { position: 'left' } + }, + items: [{ id: 'in1', group: 'in' }] + } + }); + const link = new joint.shapes.standard.Link({ + source: { id: 'a', port: 'out1' }, + target: { id: 'b', port: 'in1' } + }); + + graph.resetCells([el1, el2, link]); + + await joint.layout.ELK.layout(graph); + + assert.notOk(link.prop('source/anchor')); + assert.notOk(link.prop('target/anchor')); + assert.ok(Array.isArray(link.vertices())); + }); + + QUnit.test('should call exportPort for each port', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ + id: 'a', + size: { width: 100, height: 100 }, + ports: { + groups: { + out: { position: 'right' } + }, + items: [{ id: 'out1', group: 'out' }] + } + }); + const el2 = new joint.shapes.standard.Rectangle({ id: 'b', size: { width: 100, height: 100 }}); + const link = new joint.shapes.standard.Link({ source: { id: 'a', port: 'out1' }, target: { id: 'b' }}); + + graph.resetCells([el1, el2, link]); + + const seen = []; + await joint.layout.ELK.layout(graph, { + exportPort: ({ portId, element }) => { + seen.push([portId, element.id]); + } + }); + + assert.deepEqual(seen, [['out1', 'a']]); + }); + + QUnit.test('should let exportElement/exportPort/exportLink add to the computed layoutOptions without losing it', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ + id: 'a', + size: { width: 100, height: 100 }, + ports: { + groups: { + out: { position: 'right' } + }, + items: [{ id: 'out1', group: 'out' }] + } + }); + const el2 = new joint.shapes.standard.Rectangle({ id: 'b', size: { width: 100, height: 100 }}); + const link = new joint.shapes.standard.Link({ source: { id: 'a', port: 'out1' }, target: { id: 'b' }}); + + graph.resetCells([el1, el2, link]); + + // Each callback mutates the draft it's given in place, rather than returning a + // value to merge - so what this package itself already put on that same draft + // (e.g. an `elkNode`/`elkPort`'s own `width`) survives + // alongside whatever the callback itself adds. + const { elkGraph } = await joint.layout.ELK.layout(graph, { + exportElement: ({ elkNode }) => { + elkNode.layoutOptions['elk.portConstraints'] = 'FIXED_SIDE'; + elkNode.layoutOptions['elk.custom'] = 'node'; + }, + exportPort: ({ elkPort }) => { + elkPort.layoutOptions['elk.custom'] = 'port'; + }, + exportLink: ({ elkEdge }) => { + elkEdge.layoutOptions['elk.custom'] = 'edge'; + } + }); + + const elkNode = elkGraph.children.find((node) => node.id === 'a'); + assert.equal(elkNode.layoutOptions['elk.custom'], 'node'); + assert.equal(elkNode.layoutOptions['elk.portConstraints'], 'FIXED_SIDE'); + assert.equal(typeof elkNode.width, 'number'); + + const elkPort = elkNode.ports.find((port) => port.id === 'a:out1'); + assert.equal(elkPort.layoutOptions['elk.custom'], 'port'); + assert.equal(typeof elkPort.width, 'number'); + + const [elkEdge] = elkGraph.edges; + assert.equal(elkEdge.layoutOptions['elk.custom'], 'edge'); + }); + + QUnit.test('should keep ports at their JointJS-computed position by default', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ + id: 'a', + size: { width: 100, height: 100 }, + ports: { + groups: { + out: { position: 'right' } + }, + items: [{ id: 'out1', group: 'out' }] + } + }); + + // An incoming edge to a port on the right - ELK would move the port to the left + // side if it were free to. + const el2 = new joint.shapes.standard.Rectangle({ id: 'b', size: { width: 100, height: 100 }}); + const link = new joint.shapes.standard.Link({ source: { id: 'b' }, target: { id: 'a', port: 'out1' }}); + + graph.resetCells([el1, el2, link]); + + const portPosition = el1.getPortsPositions('out').out1; + + const { elkGraph } = await joint.layout.ELK.layout(graph); + + assert.equal(elkGraph.children.find((node) => node.id === 'a').layoutOptions['elk.portConstraints'], 'FIXED_POS'); + assert.notOk(elkGraph.children.find((node) => node.id === 'b').layoutOptions['elk.portConstraints']); + // Untouched - still the original group config, not switched to 'absolute'. + assert.equal(el1.prop(['ports', 'groups', 'out', 'position']), 'right'); + // Still on the right side, where JointJS placed it. + const { x, y } = el1.getPortsPositions('out').out1; + assert.deepEqual({ x, y }, { x: portPosition.x, y: portPosition.y }); + }); + + QUnit.test('should let ELK position ports when `exportElement` opts a node into `FIXED_SIDE`', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ + id: 'a', + size: { width: 100, height: 100 }, + ports: { + groups: { + out: { position: 'right' } + }, + items: [{ id: 'out1', group: 'out' }] + } + }); + const el2 = new joint.shapes.standard.Rectangle({ + id: 'b', + size: { width: 100, height: 100 }, + ports: { + groups: { + in: { position: 'left' } + }, + items: [{ id: 'in1', group: 'in' }] + } + }); + const link = new joint.shapes.standard.Link({ + source: { id: 'a', port: 'out1' }, + target: { id: 'b', port: 'in1' } + }); + + graph.resetCells([el1, el2, link]); + + await joint.layout.ELK.layout(graph, { + exportElement: ({ elkNode }) => { + elkNode.layoutOptions['elk.portConstraints'] = 'FIXED_SIDE'; + } + }); + + // The group config itself is untouched - a built-in position function + // ('right'/'left'/'top'/'bottom') already prioritizes a port's own + // `position.args`, set below, over its own even-spacing fallback, so there's no + // need to switch it to 'absolute' for the ELK-computed position to apply. + assert.equal(el1.prop(['ports', 'groups', 'out', 'position']), 'right'); + assert.equal(el2.prop(['ports', 'groups', 'in', 'position']), 'left'); + + const position = el1.portProp('out1', ['position', 'args']); + assert.equal(typeof position.x, 'number'); + assert.equal(typeof position.y, 'number'); + + // The port's rendered position reflects the position ELK computed for it. + const relativePosition = el1.getPortRelativePosition('out1'); + assert.equal(relativePosition.x, position.x); + assert.equal(relativePosition.y, position.y); + }); + + QUnit.test('should keep a port where `exportPort` put it under FIXED_POS port constraints', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ + id: 'a', + size: { width: 100, height: 60 }, + ports: { + groups: { out: { position: { name: 'absolute', args: { x: 100, y: 30 }}, size: { width: 20, height: 10 }}}, + items: [{ id: 'out1', group: 'out' }] + } + }); + const el2 = new joint.shapes.standard.Rectangle({ id: 'b', size: { width: 100, height: 60 }}); + const link = new joint.shapes.standard.Link({ source: { id: 'a', port: 'out1' }, target: { id: 'b' }}); + + graph.resetCells([el1, el2, link]); + + await joint.layout.ELK.layout(graph, { + exportElement: ({ elkNode }) => { + elkNode.layoutOptions['elk.portConstraints'] = 'FIXED_POS'; + }, + // Stood off the element, the way `args.dx` or `ellipseLayout`'s `dr` does it. + exportPort: ({ elkPort }) => { + elkPort.x = 150; + } + }); + + // ELK handed the port back untouched, so it stays where it was put - the center + // of the box `exportPort` sent, not half a port further in. + assert.deepEqual(el1.portProp('out1', 'position/args'), { x: 160, y: 30 }); + }); + + QUnit.test('should keep a port where it is under FIXED_POS port constraints', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ + id: 'a', + size: { width: 100, height: 60 }, + ports: { + groups: { + right: { position: 'right', size: { width: 20, height: 10 }} + }, + items: [{ id: 'right1', group: 'right' }] + } + }); + const el2 = new joint.shapes.standard.Rectangle({ id: 'b', size: { width: 100, height: 60 }}); + const link = new joint.shapes.standard.Link({ source: { id: 'a', port: 'right1' }, target: { id: 'b' }}); + + graph.resetCells([el1, el2, link]); + + const before = el1.getPortRelativePosition('right1'); + + await joint.layout.ELK.layout(graph, { + exportElement: ({ elkNode }) => { + elkNode.layoutOptions['elk.portConstraints'] = 'FIXED_POS'; + } + }); + + const after = el1.getPortRelativePosition('right1'); + assert.deepEqual({ x: after.x, y: after.y }, { x: before.x, y: before.y }); + }); + + QUnit.test('should center a non-square port on whichever side ELK puts it', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + // 'absolute' with no args - every port starts at the element's corner, so only + // `elk.port.side` (not the port's own position) says which side it belongs to. + const group = { position: { name: 'absolute' }, size: { width: 14, height: 8 }}; + const el1 = new joint.shapes.standard.Rectangle({ + id: 'a', + size: { width: 130, height: 50 }, + ports: { + groups: { in: group, out: group }, + items: [{ id: 'in1', group: 'in' }, { id: 'out1', group: 'out' }] + } + }); + const el2 = new joint.shapes.standard.Rectangle({ id: 'b', size: { width: 100, height: 40 }}); + const el3 = new joint.shapes.standard.Rectangle({ id: 'c', size: { width: 100, height: 40 }}); + const link1 = new joint.shapes.standard.Link({ source: { id: 'b' }, target: { id: 'a', port: 'in1' }}); + const link2 = new joint.shapes.standard.Link({ source: { id: 'a', port: 'out1' }, target: { id: 'c' }}); + + graph.resetCells([el1, el2, el3, link1, link2]); + + await joint.layout.ELK.layout(graph, { + exportElement: ({ element, elkNode }) => { + if (element.hasPorts()) elkNode.layoutOptions['elk.portConstraints'] = 'FIXED_SIDE'; + }, + exportPort: ({ portId, elkPort }) => { + elkPort.layoutOptions['elk.port.side'] = (portId === 'in1') ? 'WEST' : 'SOUTH'; + } + }); + + // Each port's center sits on its border, in the middle of that side. + assert.deepEqual(el1.portProp('in1', ['position', 'args']), { x: 0, y: 25 }); + assert.deepEqual(el1.portProp('out1', ['position', 'args']), { x: 65, y: 50 }); + }); + + QUnit.test('should keep a port\'s rendered position stable across repeated `layout()` calls', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + // A non-zero port `size` is essential here - it's what the center/top-left mismatch + // this test guards against gets applied to (a zero-sized port can't reveal it). + const el1 = new joint.shapes.standard.Rectangle({ + id: 'a', + size: { width: 100, height: 100 }, + ports: { + groups: { + out: { position: 'right', size: { width: 12, height: 12 }} + }, + items: [{ id: 'out1', group: 'out' }] + } + }); + const el2 = new joint.shapes.standard.Rectangle({ + id: 'b', + size: { width: 100, height: 100 }, + ports: { + groups: { + in: { position: 'left', size: { width: 12, height: 12 }} + }, + items: [{ id: 'in1', group: 'in' }] + } + }); + const link = new joint.shapes.standard.Link({ + source: { id: 'a', port: 'out1' }, + target: { id: 'b', port: 'in1' } + }); + + graph.resetCells([el1, el2, link]); + + await joint.layout.ELK.layout(graph); + const firstPosition = el1.getPortRelativePosition('out1'); + + // Laying out the same, already laid out graph again should not move the + // port any further - each call is independent, not cumulative. + await joint.layout.ELK.layout(graph); + const secondPosition = el1.getPortRelativePosition('out1'); + + assert.equal(secondPosition.x, firstPosition.x); + assert.equal(secondPosition.y, firstPosition.y); + }); + + QUnit.test('should let exportElement read a node\'s own custom property into its computed layoutOptions', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const parent = new joint.shapes.standard.Rectangle({ + id: 'parent', + size: { width: 10, height: 10 }, + // A plain custom property - this package has no built-in name/convention for + // one, `exportElement` (below) reads it explicitly. + elkLayoutOptions: { 'elk.padding': '[top=40,left=20,bottom=20,right=20]' } + }); + const child = new joint.shapes.standard.Rectangle({ id: 'child', size: { width: 50, height: 50 }}); + parent.embed(child); + + graph.resetCells([parent, child]); + + const { elkGraph } = await joint.layout.ELK.layout(graph, { + exportElement: ({ element, elkNode }) => { + Object.assign(elkNode.layoutOptions, element.get('elkLayoutOptions')); + } + }); + + const parentNode = elkGraph.children.find((node) => node.id === 'parent'); + assert.equal(parentNode.layoutOptions['elk.padding'], '[top=40,left=20,bottom=20,right=20]'); + }); + + QUnit.test('should let exportLinkLabel read a link label\'s own custom property into its computed layoutOptions', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ id: 'a', size: { width: 100, height: 100 }}); + const el2 = new joint.shapes.standard.Rectangle({ id: 'b', size: { width: 100, height: 100 }}); + const link = new joint.shapes.standard.Link({ + source: { id: 'a' }, + target: { id: 'b' }, + labels: [{ size: { width: 40, height: 20 }, elkLayoutOptions: { 'elk.edgeLabels.inline': 'false' }}] + }); + + graph.resetCells([el1, el2, link]); + + const { elkGraph } = await joint.layout.ELK.layout(graph, { + exportLinkLabel: ({ link, labelIndex, elkEdgeLabel }) => { + Object.assign(elkEdgeLabel.layoutOptions, link.label(labelIndex).elkLayoutOptions); + } + }); + + const [elkEdge] = elkGraph.edges; + // The label's own value wins over the package's inline default. + assert.equal(elkEdge.labels[0].layoutOptions['elk.edgeLabels.inline'], 'false'); + + // The label's own raw JSON is unaffected - reading it in `exportLinkLabel` doesn't + // write anything back. + assert.deepEqual(link.get('labels')[0].elkLayoutOptions, { 'elk.edgeLabels.inline': 'false' }); + }); + + QUnit.test('should place a link label inline by default - `exportLinkLabel` can opt one out', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ id: 'a', size: { width: 100, height: 100 }}); + const el2 = new joint.shapes.standard.Rectangle({ id: 'b', size: { width: 100, height: 100 }}); + const link = new joint.shapes.standard.Link({ + source: { id: 'a' }, + target: { id: 'b' }, + labels: [{ size: { width: 40, height: 20 }}] + }); + + graph.resetCells([el1, el2, link]); + + const { elkGraph } = await joint.layout.ELK.layout(graph); + assert.equal(elkGraph.edges[0].labels[0].layoutOptions['elk.edgeLabels.inline'], 'true'); + + const { elkGraph: optedOutElkGraph } = await joint.layout.ELK.layout(graph, { + exportLinkLabel: ({ elkEdgeLabel }) => { + elkEdgeLabel.layoutOptions['elk.edgeLabels.inline'] = 'false'; + } + }); + assert.equal(optedOutElkGraph.edges[0].labels[0].layoutOptions['elk.edgeLabels.inline'], 'false'); + }); + + QUnit.test('should let exportLinkLabel read a link\'s `defaultLabel`-inherited custom property for every label', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ id: 'a', size: { width: 100, height: 100 }}); + const el2 = new joint.shapes.standard.Rectangle({ id: 'b', size: { width: 100, height: 100 }}); + const link = new joint.shapes.standard.Link({ + source: { id: 'a' }, + target: { id: 'b' }, + defaultLabel: { + size: { width: 40, height: 20 }, + elkLayoutOptions: { 'elk.edgeLabels.inline': 'false' } + }, + // Neither label sets its own `elkLayoutOptions` - both fall back to + // `defaultLabel`'s, merged in by `Link#getComputedLabels()` (`@joint/core`) + // which `exportLinkLabel` indexes into with `labelIndex` below. + labels: [{}, {}] + }); + + graph.resetCells([el1, el2, link]); + + const { elkGraph } = await joint.layout.ELK.layout(graph, { + exportLinkLabel: ({ link, labelIndex, elkEdgeLabel }) => { + Object.assign(elkEdgeLabel.layoutOptions, link.getComputedLabels()[labelIndex].elkLayoutOptions); + } + }); + + const [elkEdge] = elkGraph.edges; + assert.equal(elkEdge.labels[0].layoutOptions['elk.edgeLabels.inline'], 'false'); + assert.equal(elkEdge.labels[1].layoutOptions['elk.edgeLabels.inline'], 'false'); + }); + + QUnit.test('should let exportPort read a port group\'s own custom property into its computed layoutOptions', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ + id: 'a', + size: { width: 100, height: 100 }, + ports: { + groups: { + out: { position: 'right', elkLayoutOptions: { 'elk.port.side': 'WEST' }} + }, + items: [{ id: 'out1', group: 'out' }] + } + }); + + graph.resetCells([el1]); + + const { elkGraph } = await joint.layout.ELK.layout(graph, { + exportElement: ({ elkNode }) => { + elkNode.layoutOptions['elk.portConstraints'] = 'FIXED_SIDE'; + }, + exportPort: ({ element, portId, elkPort }) => { + const { group } = element.getPort(portId); + const groupOptions = element.prop(['ports', 'groups', group, 'elkLayoutOptions']); + Object.assign(elkPort.layoutOptions, groupOptions); + } + }); + + const elkNode = elkGraph.children.find((node) => node.id === 'a'); + const elkPort = elkNode.ports.find((port) => port.id === 'a:out1'); + // The group's own `elk.port.side` wins over what `right` would otherwise compute. + assert.equal(elkPort.layoutOptions['elk.port.side'], 'WEST'); + }); + + QUnit.test('should size a port label via exportPortLabel, from the port\'s (or its group\'s) `label.size`', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ + id: 'a', + size: { width: 100, height: 100 }, + ports: { + groups: { + out: { position: 'right', label: { size: { width: 99, height: 22 }}} + }, + items: [ + { id: 'out1', group: 'out' }, + { id: 'out2', group: 'out', label: { size: { width: 55, height: 11 }}} + ] + } + }); + + graph.resetCells([el1]); + + const { elkGraph } = await joint.layout.ELK.layout(graph, { + // `portProp(id, 'label/size')` only reads the port's own item data, with no + // group fallback - `getPortMetrics` resolves it the same way `dia.Element` + // itself does for rendering (group first, item overriding it). + exportPortLabel: ({ element, portId, elkPortLabel }) => { + const { width, height } = element.getPortMetrics(portId).labelSize; + elkPortLabel.width = width; + elkPortLabel.height = height; + } + }); + + const elkNode = elkGraph.children.find((node) => node.id === 'a'); + const [out1Label] = elkNode.ports.find((port) => port.id === 'a:out1').labels; + const [out2Label] = elkNode.ports.find((port) => port.id === 'a:out2').labels; + + // `out1` has no `label.size` of its own - falls back to its group's. + assert.equal(out1Label.width, 99); + assert.equal(out1Label.height, 22); + // `out2`'s own `label.size` overrides its group's. + assert.equal(out2Label.width, 55); + assert.equal(out2Label.height, 11); + }); + + QUnit.test('should drop only that port\'s label when exportPortLabel returns false', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ + id: 'a', + size: { width: 100, height: 100 }, + ports: { + groups: { out: { position: 'right' }}, + items: [{ id: 'out1', group: 'out' }, { id: 'out2', group: 'out' }] + } + }); + + graph.resetCells([el1]); + + const { elkGraph } = await joint.layout.ELK.layout(graph, { + exportPortLabel: ({ portId, elkPortLabel }) => { + // Sized either way - `false` drops it regardless. + elkPortLabel.width = 40; + elkPortLabel.height = 20; + if (portId === 'out1') return false; + } + }); + + const elkNode = elkGraph.children.find((node) => node.id === 'a'); + assert.deepEqual(elkNode.ports.find((port) => port.id === 'a:out1').labels, []); + assert.equal(elkNode.ports.find((port) => port.id === 'a:out2').labels.length, 1); + }); + + QUnit.test('should cover an edge label ELK kept room for beside the nodes', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ id: 'a', size: { width: 100, height: 100 }}); + const el2 = new joint.shapes.standard.Rectangle({ id: 'b', size: { width: 100, height: 100 }}); + const link = new joint.shapes.standard.Link({ + source: { id: 'a' }, + target: { id: 'b' }, + // Far taller than the nodes it runs between, so ELK lays the graph out around + // the label and the union of the nodes is no longer the whole layout. + labels: [{ size: { width: 40, height: 400 }}] + }); + graph.resetCells([el1, el2, link]); + + const { bbox, elkGraph } = await joint.layout.ELK.layout(graph); + + // `elk.json.edgeCoords: 'ROOT'` - the label is placed in the root's coordinates. + const [{ x, y, width, height }] = elkGraph.edges[0].labels; + const labelRect = new joint.g.Rect(x, y, width, height); + const elementsRect = graph.getCellsBBox([el1, el2]); + assert.notOk(elementsRect.containsRect(labelRect), 'the label is outside the elements'); + assert.ok(bbox.containsRect(labelRect), `${bbox.toString()} contains ${labelRect.toString()}`); + assert.ok(bbox.containsRect(elementsRect)); + }); + + QUnit.test('should return a zero-size bbox for an empty graph', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + + const { bbox, elkGraph } = await joint.layout.ELK.layout(graph); + + assert.equal(bbox.width, 0); + assert.equal(bbox.height, 0); + assert.deepEqual(elkGraph.children, []); + }); + + QUnit.test('should apply the layout in a single `layout` batch', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ id: 'a', size: { width: 100, height: 100 }}); + graph.resetCells([el1]); + + const batches = []; + graph.on('batch:start', ({ batchName }) => batches.push(`start:${batchName}`)); + graph.on('batch:stop', ({ batchName }) => batches.push(`stop:${batchName}`)); + + await joint.layout.ELK.layout(graph); + + assert.deepEqual(batches, ['start:layout', 'stop:layout']); + assert.notOk(graph.hasActiveBatch()); + }); + + QUnit.test('should close the batch when an import callback throws', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ id: 'a', size: { width: 100, height: 100 }}); + graph.resetCells([el1]); + + const error = new Error('setElementAttributes failed'); + await assert.rejects(joint.layout.ELK.layout(graph, { + setElementAttributes: () => { throw error; } + }), error); + + assert.notOk(graph.hasActiveBatch()); + }); + + QUnit.test('should place a port on the border of the node ELK laid out, whatever size the element has', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ + id: 'a', + size: { width: 100, height: 100 }, + ports: { groups: { out: { position: 'right' }}, items: [{ id: 'p', group: 'out' }] } + }); + const el2 = new joint.shapes.standard.Rectangle({ id: 'b', size: { width: 50, height: 50 }}); + const link = new joint.shapes.standard.Link({ source: { id: 'a', port: 'p' }, target: { id: 'b' }}); + + graph.resetCells([el1, el2, link]); + + // `exportElement` lays the element out as if it were bigger - the port belongs on + // the border of that box, in the frame the caller asked for, not on the element's. + const { elkGraph } = await joint.layout.ELK.layout(graph, { + exportElement: ({ element, elkNode }) => { + if (element.id !== 'a') return; + elkNode.width = 500; + elkNode.height = 400; + elkNode.layoutOptions['elk.portConstraints'] = 'FREE'; + } + }); + + const elkNode = elkGraph.children.find((node) => node.id === 'a'); + const [elkPort] = elkNode.ports; + const { x, y } = el1.portProp('p', 'position/args'); + + // The center is moved onto the border - by half the port, and no further. + assert.equal(x, Math.min(elkPort.x + elkPort.width / 2, elkNode.width)); + assert.equal(y, Math.min(elkPort.y + elkPort.height / 2, elkNode.height)); + }); + + QUnit.test('should size a node around the labels `exportElement` added to it', async(assert) => { + + const layOut = async(addLabel) => { + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ id: 'a', size: { width: 20, height: 10 }}); + const el2 = new joint.shapes.standard.Rectangle({ id: 'b', size: { width: 50, height: 50 }}); + graph.resetCells([el1, el2]); + + const { elkGraph } = await joint.layout.ELK.layout(graph, { + exportElement: ({ element, elkNode }) => { + if (element.id !== 'a') return; + elkNode.layoutOptions['elk.nodeSize.constraints'] = 'NODE_LABELS MINIMUM_SIZE'; + elkNode.layoutOptions['elk.nodeLabels.placement'] = 'INSIDE V_CENTER H_CENTER'; + // Only the size is the caller's to set - ELK skips a label with no + // text, so the package supplies its own placeholder. + if (addLabel) elkNode.labels = [{ width: 200, height: 50, layoutOptions: {}}]; + } + }); + + return elkGraph.children.find((node) => node.id === 'a'); + }; + + const withoutLabel = await layOut(false); + const withLabel = await layOut(true); + + assert.ok(withLabel.width > withoutLabel.width, `${withLabel.width} > ${withoutLabel.width}`); + assert.ok(withLabel.width >= 200, `${withLabel.width} fits the 200 wide label`); + assert.ok(withLabel.height >= 50, `${withLabel.height} fits the 50 high label`); + }); + + QUnit.test('should reject two ports of one element whose ids differ only in type', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + // Two distinct ports for JointJS, one and the same id for ELK, which identifies + // everything by a string. + const el1 = new joint.shapes.standard.Rectangle({ + id: 'a', + size: { width: 100, height: 100 }, + ports: { + groups: { right: { position: 'right' }}, + items: [{ id: 5, group: 'right' }, { id: '5', group: 'right' }] + } + }); + const el2 = new joint.shapes.standard.Rectangle({ id: 'b', size: { width: 100, height: 100 }}); + + graph.resetCells([el1, el2]); + + assert.equal(el1.getPorts().length, 2); + await assert.rejects(joint.layout.ELK.layout(graph), /collides/); + }); + + QUnit.test('should not nest an element under a container whose id differs from its `parent` only in type', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const container = new joint.shapes.standard.Rectangle({ id: '5', size: { width: 200, height: 200 }}); + const child = new joint.shapes.standard.Rectangle({ id: 'child', size: { width: 50, height: 50 }}); + const other = new joint.shapes.standard.Rectangle({ id: 'other', size: { width: 50, height: 50 }}); + + graph.resetCells([container, child, other]); + // The number 5, not the string - a cell id JointJS keeps apart from `'5'`. + child.set('parent', 5); + + const { elkGraph } = await joint.layout.ELK.layout(graph); + + const elkContainer = elkGraph.children.find((node) => node.id === '5'); + assert.notOk(elkContainer.children && elkContainer.children.length); + // Its parent is not among the laid out elements, so it is a top-level node. + assert.ok(elkGraph.children.some((node) => node.id === 'child')); + }); + + QUnit.test('should skip a port removed while the layout was running', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ + id: 'a', + size: { width: 100, height: 100 }, + ports: { groups: { right: { position: 'right' }}, items: [{ id: 'gone', group: 'right' }] } + }); + const el2 = new joint.shapes.standard.Rectangle({ id: 'b', size: { width: 100, height: 100 }}); + + graph.resetCells([el1, el2]); + + const pending = joint.layout.ELK.layout(graph); + // The ports were read when the layout started - this one is gone by the time its + // result comes back, and `portProp()` throws for a port the element does not have. + el1.removePort('gone'); + + await pending; + + // The import ran to the end rather than stopping at the port. + assert.notDeepEqual(el2.position().toJSON(), { x: 0, y: 0 }); + }); + + QUnit.test('should apply a size ELK computed for a leaf, and take it back when ELK stops computing one', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const tiles = [40, 50, 60, 70].map((width, index) => new joint.shapes.standard.Rectangle({ + id: `t${index}`, + size: { width, height: 30 + (index % 3) * 20 } + })); + graph.resetCells(tiles); + + // Like the `layout-elk-rectpacking-ts` example: every tile goes to ELK at the size + // it started with, so stretching compounds from that rather than from the last run. + const baseSizes = tiles.map((tile) => tile.size()); + const exportElement = ({ element, elkNode }) => { + const { width, height } = baseSizes[tiles.indexOf(element)]; + elkNode.width = width; + elkNode.height = height; + }; + const pack = (strategy) => joint.layout.ELK.layout(graph, { + exportElement, + elkLayoutOptions: { + 'elk.algorithm': 'rectpacking', + 'elk.rectpacking.whiteSpaceElimination.strategy': strategy + } + }); + const sizes = () => tiles.map((tile) => tile.size()); + + // White space elimination stretches the tiles - the package applies that. + await pack('EQUAL_BETWEEN_STRUCTURES'); + const stretched = sizes(); + assert.notDeepEqual(stretched, baseSizes); + + // And takes it back when ELK no longer stretches them: the size that matters is + // the one ELK laid the node out at, not whether it differs from the one it was + // given - `exportElement` sends the base size either way. + await pack('NONE'); + assert.deepEqual(sizes(), baseSizes); + }); + + QUnit.test('should not resize an element ELK laid out at the size it already has', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ id: 'a', size: { width: 80, height: 40 }}); + const el2 = new joint.shapes.standard.Rectangle({ id: 'b', size: { width: 50, height: 50 }}); + const link = new joint.shapes.standard.Link({ source: { id: 'a' }, target: { id: 'b' }}); + graph.resetCells([el1, el2, link]); + + const sized = []; + await joint.layout.ELK.layout(graph, { + setElementAttributes: ({ element, attributes }) => { + if ('size' in attributes) sized.push(element.id); + element.set(attributes); + } + }); + + assert.deepEqual(el1.size(), { width: 80, height: 40 }); + assert.deepEqual(el2.size(), { width: 50, height: 50 }); + // `attributes` carries a size only for a node ELK laid out at a different one. + assert.deepEqual(sized, []); + }); + + QUnit.test('should shrink a container back to its content under an algorithm that keeps a given size', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + // Far bigger than its content needs - as a folder is after the tiles inside it + // shrink, or after they are repacked into fewer rows. + const container = new joint.shapes.standard.Rectangle({ id: 'c', size: { width: 800, height: 600 }}); + const tiles = [0, 1, 2].map((index) => new joint.shapes.standard.Rectangle({ + id: `t${index}`, + size: { width: 60, height: 40 } + })); + + graph.resetCells([container, ...tiles]); + tiles.forEach((tile) => container.embed(tile)); + + // `rectpacking` keeps the size a node is given, unlike `layered` - a container + // sent its own size would never shrink again. + const { elkGraph } = await joint.layout.ELK.layout(graph, { + elkLayoutOptions: { 'elk.algorithm': 'rectpacking' }, + exportElement: ({ element, elkNode }) => { + if (element.id === 'c') elkNode.layoutOptions['elk.algorithm'] = 'rectpacking'; + } + }); + + const elkContainer = elkGraph.children.find((node) => node.id === 'c'); + assert.equal(elkContainer.children.length, 3); + assert.ok(container.size().width < 800, `${container.size().width} < 800`); + assert.ok(container.size().height < 600, `${container.size().height} < 600`); + // Still big enough for what is inside it. + tiles.forEach((tile) => { + assert.ok(container.getBBox().containsRect(tile.getBBox()), `${tile.id} fits`); + }); + }); + + QUnit.test('should lay out an element whose port ids are numbers', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ + id: 'a', + size: { width: 100, height: 100 }, + // `@joint/core` types a port id as a string, but a number is valid - and + // `portProp()` looks one up with `===`, so a stringified id finds nothing. + ports: { groups: { right: { position: 'right' }}, items: [{ id: 5, group: 'right' }] } + }); + const el2 = new joint.shapes.standard.Rectangle({ id: 'b', size: { width: 100, height: 100 }}); + const link = new joint.shapes.standard.Link({ source: { id: 'a', port: 5 }, target: { id: 'b' }}); + + graph.resetCells([el1, el2, link]); + + const { elkGraph } = await joint.layout.ELK.layout(graph); + + assert.deepEqual(elkGraph.edges[0].sources, ['a:5']); + // The import ran to the end - an element laid out after the ports was positioned. + assert.notDeepEqual(el2.position().toJSON(), { x: 0, y: 0 }); + }); + + QUnit.test('should leave out a link connected to another link', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ id: 'a', size: { width: 50, height: 50 }}); + const el2 = new joint.shapes.standard.Rectangle({ id: 'b', size: { width: 50, height: 50 }}); + const el3 = new joint.shapes.standard.Rectangle({ id: 'c', size: { width: 50, height: 50 }}); + const link = new joint.shapes.standard.Link({ id: 'ab', source: { id: 'a' }, target: { id: 'b' }}); + // `getSourceElement()` walks through a chain of links - this one must not be taken + // for a link between `c` and `b`. + const toLink = new joint.shapes.standard.Link({ id: 'toLink', source: { id: 'c' }, target: { id: 'ab' }}); + + graph.resetCells([el1, el2, el3, link, toLink]); + + const { elkGraph } = await joint.layout.ELK.layout(graph); + + assert.deepEqual(elkGraph.edges.map((edge) => edge.id), ['ab']); + assert.notOk(toLink.source().anchor); + assert.notOk(toLink.target().anchor); + assert.notOk(toLink.vertices().length); + }); + + QUnit.test('should anchor a link end on the element when it references a port the element does not have', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ + id: 'a', + size: { width: 50, height: 50 }, + ports: { items: [{ id: 'out' }] } + }); + const el2 = new joint.shapes.standard.Rectangle({ id: 'b', size: { width: 50, height: 50 }}); + const link = new joint.shapes.standard.Link({ id: 'ab', source: { id: 'a', port: 'gone' }, target: { id: 'b' }}); + + graph.resetCells([el1, el2, link]); + + const { elkGraph } = await joint.layout.ELK.layout(graph); + + // A dangling port reference would make ELK reject the whole graph. + assert.deepEqual(elkGraph.edges[0].sources, ['a']); + // JointJS renders such an end against the element's bbox - the anchor matches. + assert.ok(link.source().anchor); + }); + + QUnit.test('should keep a link label\'s other position settings', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ id: 'a', size: { width: 50, height: 50 }}); + const el2 = new joint.shapes.standard.Rectangle({ id: 'b', size: { width: 50, height: 50 }}); + const link = new joint.shapes.standard.Link({ id: 'ab', source: { id: 'a' }, target: { id: 'b' }}); + link.labels([{ + size: { width: 40, height: 20 }, + position: { distance: 0.5, angle: 45, args: { keepGradient: true }} + }]); + + graph.resetCells([el1, el2, link]); + + await joint.layout.ELK.layout(graph); + + const { position } = link.label(0); + // Only the label's place along the link is ELK's to decide. + assert.equal(position.angle, 45); + assert.deepEqual(position.args, { keepGradient: true }); + assert.ok('distance' in position); + assert.ok('offset' in position); + }); + + QUnit.test('should measure a link end\'s anchor from the position ELK computed, not the one the element has', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ id: 'a', size: { width: 50, height: 50 }, position: { x: 400, y: 400 }}); + const el2 = new joint.shapes.standard.Rectangle({ id: 'b', size: { width: 50, height: 50 }, position: { x: 700, y: 700 }}); + const link = new joint.shapes.standard.Link({ id: 'ab', source: { id: 'a' }, target: { id: 'b' }}); + + graph.resetCells([el1, el2, link]); + + // A `setElementAttributes` is free to defer the position (the README suggests a + // `transition()`), so the element still sits where it was while links are imported. + const { elkGraph } = await joint.layout.ELK.layout(graph, { + setElementAttributes: () => { /* applied later */ } + }); + + assert.deepEqual(el1.position().toJSON(), { x: 400, y: 400 }); + + const node = elkGraph.children.find((child) => child.id === 'a'); + const { startPoint } = elkGraph.edges[0].sections[0]; + const { args } = link.source().anchor; + + assert.equal(args.dx, startPoint.x - node.x); + assert.equal(args.dy, startPoint.y - node.y); + }); + + QUnit.test('should hand the signal to any `WorkerElk`, not only to `createWorkerElk()`\'s own', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ id: 'a', size: { width: 50, height: 50 }}); + graph.resetCells([el1]); + + const controller = new AbortController(); + let received; + // An implementation of the interface that is not our class - `instanceof` would + // miss it and the signal would never reach `layout()`. + const elk = { + layout: (elkGraph, options) => { + received = options; + return Promise.resolve(elkGraph); + }, + terminate: () => {} + }; + + await joint.layout.ELK.layout(graph, { elk, signal: controller.signal }); + + assert.strictEqual(received.signal, controller.signal); + }); + + QUnit.module('given a `signal`', () => { + + const isAbortError = (error) => error instanceof DOMException && error.name === 'AbortError'; + const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + + const createGraph = () => { + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ id: 'a', size: { width: 100, height: 100 }}); + const el2 = new joint.shapes.standard.Rectangle({ id: 'b', size: { width: 100, height: 100 }}); + const link = new joint.shapes.standard.Link({ source: { id: 'a' }, target: { id: 'b' }}); + graph.resetCells([el1, el2, link]); + return { graph, el1, el2 }; + }; + + QUnit.test('should reject without laying out anything when already aborted', async(assert) => { + + const { graph, el1, el2 } = createGraph(); + const exportElement = () => assert.ok(false, 'nothing is exported'); + + await assert.rejects(joint.layout.ELK.layout(graph, { signal: AbortSignal.abort(), exportElement }), isAbortError); + + await wait(50); + assert.ok(joint.g.intersection.exists(el1.getBBox(), el2.getBBox())); + }); + + QUnit.test('should reject with the signal\'s reason, and apply nothing, when aborted during the layout', async(assert) => { + + const { graph, el1, el2 } = createGraph(); + const controller = new AbortController(); + const reason = new Error('graph changed'); + + const result = joint.layout.ELK.layout(graph, { signal: controller.signal }); + controller.abort(reason); + + await assert.rejects(result, reason); + // ELK's own result (on the main thread, it can't be stopped) is ignored. + await wait(100); + assert.ok(joint.g.intersection.exists(el1.getBBox(), el2.getBBox())); + }); + + QUnit.test('should reject with the signal\'s reason even when it is `null`', async(assert) => { + + const { graph } = createGraph(); + const controller = new AbortController(); + + const result = joint.layout.ELK.layout(graph, { signal: controller.signal }); + controller.abort(null); + + let error = 'not rejected'; + try { + await result; + } catch (reason) { + error = reason; + } + assert.strictEqual(error, null); + }); + + QUnit.test('should apply the layout when the signal is not aborted', async(assert) => { + + const { graph, el1, el2 } = createGraph(); + + await joint.layout.ELK.layout(graph, { signal: new AbortController().signal }); + + assert.notOk(joint.g.intersection.exists(el1.getBBox(), el2.getBBox())); + }); + + QUnit.test('should reject when aborted during the layout of a custom `elk` instance', async(assert) => { + + const { graph, el1, el2 } = createGraph(); + const controller = new AbortController(); + + const result = joint.layout.ELK.layout(graph, { elk: new window.ELK(), signal: controller.signal }); + controller.abort(); + + await assert.rejects(result, isAbortError); + await wait(100); + assert.ok(joint.g.intersection.exists(el1.getBBox(), el2.getBBox())); + }); + }); + + QUnit.module('given a list of cells', () => { + + const rect = (id, x = 500, y = 500) => new joint.shapes.standard.Rectangle({ id, size: { width: 50, height: 50 }, position: { x, y }}); + const edge = (id, source, target) => new joint.shapes.standard.Link({ id, source: { id: source }, target: { id: target }}); + const ids = (items) => (items || []).map((item) => item.id); + + QUnit.test('should lay out only the given elements and links - a link only if both its ends are given too', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const [a, b, c] = [rect('a'), rect('b'), rect('c', 1000, 1000)]; + const ab = edge('ab', 'a', 'b'); + const bc = edge('bc', 'b', 'c'); + const ac = edge('ac', 'a', 'c'); + graph.resetCells([a, b, c, ab, bc, ac]); + + const { elkGraph } = await joint.layout.ELK.layout([a, b, ab, bc]); + + assert.deepEqual(ids(elkGraph.children), ['a', 'b']); + // `bc` is given, but `c` isn't - `ac` isn't given at all. + assert.deepEqual(ids(elkGraph.edges), ['ab']); + // What's left out is left alone. + assert.deepEqual(c.position().toJSON(), { x: 1000, y: 1000 }); + assert.notOk(bc.vertices().length); + assert.notOk(ac.vertices().length); + }); + + QUnit.test('should take the elements and the links from one list, whatever their order in it', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const [a, b, c] = [rect('a'), rect('b'), rect('c')]; + const ab = edge('ab', 'a', 'b'); + const bc = edge('bc', 'b', 'c'); + graph.resetCells([a, b, c, ab, bc]); + + // `bc` is listed, but `c` isn't. + const { elkGraph: withElements } = await joint.layout.ELK.layout([b, a, ab, bc]); + assert.deepEqual(ids(withElements.children), ['b', 'a']); + assert.deepEqual(ids(withElements.edges), ['ab']); + + // A link may be listed before the elements it connects. + const { elkGraph: withLinks } = await joint.layout.ELK.layout([bc, a, b, c]); + assert.deepEqual(ids(withLinks.children), ['a', 'b', 'c']); + assert.deepEqual(ids(withLinks.edges), ['bc']); + }); + + QUnit.test('should follow the given order for the top-level elements and links', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const [a, b, c] = [rect('a'), rect('b'), rect('c')]; + const ab = edge('ab', 'a', 'b'); + const bc = edge('bc', 'b', 'c'); + graph.resetCells([a, b, c, ab, bc]); + + const { elkGraph } = await joint.layout.ELK.layout([c, a, b, bc, ab]); + + assert.deepEqual(ids(elkGraph.children), ['c', 'a', 'b']); + assert.deepEqual(ids(elkGraph.edges), ['bc', 'ab']); + }); + + QUnit.test('should follow the given order (and selection) for a container\'s children and edges', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const parent = rect('parent'); + const [a, b, c] = [rect('a'), rect('b'), rect('c')]; + const ab = edge('ab', 'a', 'b'); + const ba = edge('ba', 'b', 'a'); + graph.resetCells([parent, a, b, c, ab, ba]); + parent.embed([a, b, c]); + + // `c` is embedded in `parent` but isn't given. + const { elkGraph } = await joint.layout.ELK.layout([parent, b, a, ba, ab]); + + const [elkParent] = elkGraph.children; + assert.deepEqual(ids(elkGraph.children), ['parent']); + assert.deepEqual(ids(elkParent.children), ['b', 'a']); + assert.deepEqual(ids(elkParent.edges), ['ba', 'ab']); + // `parent` is still sized by ELK to fit what's given of its content. + assert.ok(parent.getBBox().containsRect(a.getBBox())); + assert.ok(parent.getBBox().containsRect(b.getBBox())); + }); + + QUnit.test('should lay out an element whose parent isn\'t given as a top-level one', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const parent = rect('parent', 0, 0); + const [a, b] = [rect('a'), rect('b')]; + const ab = edge('ab', 'a', 'b'); + graph.resetCells([parent, a, b, ab]); + parent.embed([a, b]); + const parentBBox = parent.getBBox(); + + const { elkGraph } = await joint.layout.ELK.layout([a, b, ab]); + + assert.deepEqual(ids(elkGraph.children), ['a', 'b']); + assert.deepEqual(ids(elkGraph.edges), ['ab']); + assert.notOk(elkGraph.children[0].children); + // The parent, not given itself, isn't resized or moved. + assert.ok(parent.getBBox().equals(parentBBox)); + }); + + QUnit.test('should lay out nothing given no elements', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const [a, b] = [rect('a'), rect('b', 600, 600)]; + const ab = edge('ab', 'a', 'b'); + graph.resetCells([a, b, ab]); + + const { bbox, elkGraph } = await joint.layout.ELK.layout([]); + + assert.ok(bbox.equals(new joint.g.Rect(0, 0, 0, 0))); + assert.deepEqual(elkGraph.children, []); + assert.deepEqual(elkGraph.edges, []); + assert.deepEqual(a.position().toJSON(), { x: 500, y: 500 }); + assert.notOk(ab.vertices().length); + }); + + QUnit.test('should lay out a cell listed more than once only once', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const [a, b] = [rect('a'), rect('b')]; + const ab = edge('ab', 'a', 'b'); + graph.resetCells([a, b, ab]); + + const { elkGraph } = await joint.layout.ELK.layout([a, a, b, ab, ab]); + + assert.deepEqual(ids(elkGraph.children), ['a', 'b']); + assert.deepEqual(ids(elkGraph.edges), ['ab']); + }); + + QUnit.test('should lay out the elements inside a container that is in no graph', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const [a, b] = [rect('a'), rect('b')]; + // Only there to group `a` and `b` for the layout - never added to the graph, + // so its children come from their own `parent`, not from the graph. + const container = rect('container'); + graph.resetCells([a, b]); + a.set('parent', 'container'); + b.set('parent', 'container'); + + const { elkGraph } = await joint.layout.ELK.layout([a, b, container]); + + assert.deepEqual(ids(elkGraph.children), ['container']); + assert.deepEqual(ids(elkGraph.children[0].children), ['a', 'b']); + }); + + QUnit.test('should batch on the graph the cells are in, whichever of them is in none', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const [a, b] = [rect('a'), rect('b')]; + const container = rect('container'); + graph.resetCells([a, b]); + a.set('parent', 'container'); + b.set('parent', 'container'); + + const batches = []; + graph.on('batch:start', ({ batchName }) => batches.push(`start:${batchName}`)); + graph.on('batch:stop', ({ batchName }) => batches.push(`stop:${batchName}`)); + + await joint.layout.ELK.layout([a, b, container]); + + assert.deepEqual(batches, ['start:layout', 'stop:layout']); + assert.notOk(graph.hasActiveBatch()); + }); + + QUnit.test('should reject two cells whose ids differ only in type', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + // Distinct cells for JointJS, one and the same id for ELK, which identifies + // everything by a string. + const numeric = new joint.shapes.standard.Rectangle({ id: 5, size: { width: 50, height: 50 }}); + const textual = new joint.shapes.standard.Rectangle({ id: '5', size: { width: 50, height: 50 }}); + graph.resetCells([numeric, textual]); + + assert.equal(graph.getCells().length, 2); + await assert.rejects(joint.layout.ELK.layout(graph), /collides/); + }); + + }); + + QUnit.test('should drop an element (and its subtree) when exportElement returns false', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const parent = new joint.shapes.standard.Rectangle({ id: 'parent', size: { width: 10, height: 10 }}); + const child = new joint.shapes.standard.Rectangle({ id: 'child', size: { width: 50, height: 50 }}); + const other = new joint.shapes.standard.Rectangle({ id: 'other', size: { width: 50, height: 50 }}); + const link = new joint.shapes.standard.Link({ source: { id: 'child' }, target: { id: 'other' }}); + parent.embed(child); + + graph.resetCells([parent, child, other, link]); + + const { elkGraph } = await joint.layout.ELK.layout(graph, { + exportElement: ({ element }) => element.id !== 'parent' + }); + + // Neither `parent` nor its embedded `child` (dropped along with it) made it in - + // and, since `child` never did, the link connected to it wasn't routed either. + assert.notOk(elkGraph.children.some((node) => node.id === 'parent' || node.id === 'child')); + assert.deepEqual(elkGraph.edges, []); + }); + + QUnit.test('should lay out a container whose embeds exportElement all dropped as a leaf, with its own size', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const container = new joint.shapes.standard.Rectangle({ id: 'container', size: { width: 300, height: 200 }}); + const child = new joint.shapes.standard.Rectangle({ id: 'child', size: { width: 50, height: 50 }}); + const other = new joint.shapes.standard.Rectangle({ id: 'other', size: { width: 100, height: 100 }}); + const link = new joint.shapes.standard.Link({ source: { id: 'container' }, target: { id: 'other' }}); + container.embed(child); + + graph.resetCells([container, child, other, link]); + + const { elkGraph } = await joint.layout.ELK.layout(graph, { + exportElement: ({ element }) => element.id !== 'child' + }); + + const elkNode = elkGraph.children.find((node) => node.id === 'container'); + assert.notOk(elkNode.children && elkNode.children.length); + assert.equal(elkNode.width, 300); + assert.equal(elkNode.height, 200); + // ELK made room for it - and it kept its size. + assert.deepEqual(container.size(), { width: 300, height: 200 }); + assert.notOk(joint.g.intersection.exists(container.getBBox(), other.getBBox())); + }); + + QUnit.test('should keep the size `exportElement` set on a container whose embeds it dropped, and the element\'s own for the rest', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const container = new joint.shapes.standard.Rectangle({ id: 'container', size: { width: 300, height: 200 }}); + const child = new joint.shapes.standard.Rectangle({ id: 'child', size: { width: 50, height: 50 }}); + const other = new joint.shapes.standard.Rectangle({ id: 'other', size: { width: 100, height: 100 }}); + const link = new joint.shapes.standard.Link({ source: { id: 'container' }, target: { id: 'other' }}); + container.embed(child); + + graph.resetCells([container, child, other, link]); + + const { elkGraph } = await joint.layout.ELK.layout(graph, { + exportElement: ({ element, elkNode }) => { + if (element.id === 'child') return false; + // Only one of the two dimensions - the other is the element's own. + if (element.id === 'container') elkNode.width = 500; + } + }); + + const elkNode = elkGraph.children.find((node) => node.id === 'container'); + assert.equal(elkNode.width, 500); + assert.equal(elkNode.height, 200); + // A node ELK thinks is zero high gets no room, and its neighbour moves into it. + assert.notOk(joint.g.intersection.exists(container.getBBox(), other.getBBox())); + }); + + QUnit.test('should keep ELK ids unique whatever the cell and port ids', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const size = { width: 100, height: 100 }; + // Element `a`'s port `p` would share its ELK id with element `a:p`, and element + // `root` with the root node, without escaping. + const a = new joint.shapes.standard.Rectangle({ + id: 'a', + size, + ports: { groups: { in: { position: 'left' }}, items: [{ id: 'p', group: 'in' }] } + }); + const ap = new joint.shapes.standard.Rectangle({ id: 'a:p', size }); + const root = new joint.shapes.standard.Rectangle({ id: 'root', size }); + const b = new joint.shapes.standard.Rectangle({ id: 'b\\', size }); + const toPort = new joint.shapes.standard.Link({ id: 'l1', source: { id: 'b\\' }, target: { id: 'a', port: 'p' }}); + const toElement = new joint.shapes.standard.Link({ id: 'l:2', source: { id: 'b\\' }, target: { id: 'a:p' }}); + const toRoot = new joint.shapes.standard.Link({ id: 'l3', source: { id: 'b\\' }, target: { id: 'root' }}); + + graph.resetCells([a, ap, root, b, toPort, toElement, toRoot]); + + const { elkGraph } = await joint.layout.ELK.layout(graph); + + const nodeIds = elkGraph.children.map((node) => node.id); + assert.deepEqual(nodeIds, ['a', 'a\\:p', 'root', 'b\\\\']); + assert.deepEqual(elkGraph.children[0].ports.map((port) => port.id), ['a:p']); + assert.notOk(nodeIds.includes(elkGraph.id)); + + const targetsByEdgeId = {}; + elkGraph.edges.forEach((edge) => { targetsByEdgeId[edge.id] = edge.targets; }); + assert.deepEqual(targetsByEdgeId, { l1: ['a:p'], 'l\\:2': ['a\\:p'], l3: ['root'] }); + + // Every element laid out where ELK put it, and every link routed - the one to the + // element (not the port) anchored on it. + const elements = [a, ap, root, b]; + elements.forEach((el1, i) => elements.slice(i + 1).forEach((el2) => { + assert.notOk(joint.g.intersection.exists(el1.getBBox(), el2.getBBox()), `${el1.id} / ${el2.id}`); + })); + assert.ok(toElement.target().anchor); + assert.notOk(toPort.target().anchor); + }); + + QUnit.test('should drop only that port when exportPort returns false - and the links connected to it', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ + id: 'a', + size: { width: 100, height: 100 }, + ports: { + groups: { out: { position: 'right' }}, + items: [{ id: 'out1', group: 'out' }, { id: 'out2', group: 'out' }] + } + }); + const el2 = new joint.shapes.standard.Rectangle({ id: 'b', size: { width: 100, height: 100 }}); + const onDroppedPort = new joint.shapes.standard.Link({ id: 'dropped', source: { id: 'a', port: 'out1' }, target: { id: 'b' }}); + const onKeptPort = new joint.shapes.standard.Link({ id: 'kept', source: { id: 'a', port: 'out2' }, target: { id: 'b' }}); + + graph.resetCells([el1, el2, onDroppedPort, onKeptPort]); + + const { elkGraph } = await joint.layout.ELK.layout(graph, { + exportPort: ({ portId }) => (portId === 'out1') ? false : undefined + }); + + const elkNode = elkGraph.children.find((node) => node.id === 'a'); + assert.deepEqual(elkNode.ports.map((port) => port.id), ['a:out2']); + + // Only the link on the port that is still in the ELK graph is laid out. + assert.deepEqual(elkGraph.edges.map((edge) => edge.id), ['kept']); + assert.deepEqual(elkGraph.edges[0].sources, ['a:out2']); + + // The one left out keeps its route, and no anchor is written onto its end. + assert.notOk(onDroppedPort.vertices().length); + assert.notOk(onDroppedPort.source().anchor); + }); + + QUnit.test('should drop a link when exportLink returns false - it is not routed at all', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ id: 'a', size: { width: 100, height: 100 }}); + const el2 = new joint.shapes.standard.Rectangle({ id: 'b', size: { width: 100, height: 100 }}); + const link = new joint.shapes.standard.Link({ source: { id: 'a' }, target: { id: 'b' }}); + + graph.resetCells([el1, el2, link]); + + const { elkGraph } = await joint.layout.ELK.layout(graph, { + exportLink: () => false + }); + + assert.deepEqual(elkGraph.edges, []); + assert.notOk(link.vertices().length); + }); + + QUnit.test('should drop only that label when exportLinkLabel returns false', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ id: 'a', size: { width: 100, height: 100 }}); + const el2 = new joint.shapes.standard.Rectangle({ id: 'b', size: { width: 100, height: 100 }}); + const link = new joint.shapes.standard.Link({ + source: { id: 'a' }, + target: { id: 'b' }, + labels: [{ size: { width: 40, height: 20 }}] + }); + + graph.resetCells([el1, el2, link]); + + const { elkGraph } = await joint.layout.ELK.layout(graph, { + exportLinkLabel: () => false + }); + + const [elkEdge] = elkGraph.edges; + assert.deepEqual(elkEdge.labels, []); + }); + + QUnit.test('should apply each laid out label to its own link label when exportLinkLabel drops another', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ id: 'a', size: { width: 100, height: 100 }}); + const el2 = new joint.shapes.standard.Rectangle({ id: 'b', size: { width: 100, height: 100 }}); + const link = new joint.shapes.standard.Link({ + source: { id: 'a' }, + target: { id: 'b' }, + labels: [ + { position: 0.25, size: { width: 40, height: 20 }}, + { position: 0.75, size: { width: 40, height: 20 }} + ] + }); + + graph.resetCells([el1, el2, link]); + + const { elkGraph } = await joint.layout.ELK.layout(graph, { + exportLinkLabel: ({ labelIndex }) => (labelIndex === 0 ? false : undefined) + }); + + const [elkEdge] = elkGraph.edges; + assert.equal(elkEdge.labels.length, 1); + assert.equal(elkEdge.labels[0].id, `${link.id}:labels:1`); + // The dropped label is left untouched - the laid out one goes to the second label. + assert.equal(link.label(0).position, 0.25); + assert.equal(typeof link.label(1).position, 'object'); + assert.equal(typeof link.label(1).position.distance, 'number'); + }); +}); + +QUnit.module('createWorkerElk()', (hooks) => { + + const WORKER_URL = '/base/node_modules/elkjs/lib/elk-worker.min.js'; + // A script that doesn't exist - e.g. one a bundler didn't emit - fails to load. + const MISSING_WORKER_URL = '/base/missing-elk-worker.js'; + + // Every worker started by `createWorker()` below, and how many messages they've sent back. + let startedWorkers; + let workerMessageCount; + // Instances to terminate after each test. + let elks; + + hooks.beforeEach(() => { + startedWorkers = []; + workerMessageCount = 0; + elks = []; + }); + + hooks.afterEach(() => { + elks.forEach((elk) => elk.terminate()); + }); + + const createElk = (getUrl = () => WORKER_URL) => { + const elk = joint.layout.ELK.createWorkerElk(() => { + const worker = new Worker(getUrl()); + worker.addEventListener('message', () => workerMessageCount++); + startedWorkers.push(worker); + return worker; + }); + elks.push(elk); + return elk; + }; + + const createGraph = () => { + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ id: 'a', size: { width: 100, height: 100 }}); + const el2 = new joint.shapes.standard.Rectangle({ id: 'b', size: { width: 100, height: 100 }}); + const link = new joint.shapes.standard.Link({ source: { id: 'a' }, target: { id: 'b' }}); + graph.resetCells([el1, el2, link]); + return { graph, el1, el2 }; + }; + + const isLaidOut = ({ el1, el2 }) => !joint.g.intersection.exists(el1.getBBox(), el2.getBBox()); + + QUnit.test('should lay out in a worker - started on the first layout, then shared', async(assert) => { + + const elk = createElk(); + assert.equal(startedWorkers.length, 0); + + const first = createGraph(); + await joint.layout.ELK.layout(first.graph, { elk }); + + assert.equal(startedWorkers.length, 1); + // The layout came from the worker. + assert.ok(workerMessageCount > 0); + assert.ok(isLaidOut(first)); + + const messageCount = workerMessageCount; + const second = createGraph(); + await joint.layout.ELK.layout(second.graph, { elk }); + assert.equal(startedWorkers.length, 1); + assert.ok(workerMessageCount > messageCount); + assert.ok(isLaidOut(second)); + }); + + QUnit.test('should terminate the worker busy with an aborted layout - a new one takes over the layouts still waiting', async(assert) => { + + const elk = createElk(); + const aborted = createGraph(); + const waiting = createGraph(); + const controller = new AbortController(); + + const abortedResult = joint.layout.ELK.layout(aborted.graph, { elk, signal: controller.signal }); + const waitingResult = joint.layout.ELK.layout(waiting.graph, { elk }); + controller.abort(); + + await assert.rejects(abortedResult, (error) => error.name === 'AbortError'); + await waitingResult; + + assert.equal(startedWorkers.length, 2); + assert.notOk(isLaidOut(aborted)); + assert.ok(isLaidOut(waiting)); + }); + + QUnit.test('should keep the worker when a layout still waiting its turn is aborted', async(assert) => { + + const elk = createElk(); + const busy = createGraph(); + const aborted = createGraph(); + const controller = new AbortController(); + + const busyResult = joint.layout.ELK.layout(busy.graph, { elk }); + const abortedResult = joint.layout.ELK.layout(aborted.graph, { elk, signal: controller.signal }); + controller.abort(); + + await assert.rejects(abortedResult, (error) => error.name === 'AbortError'); + await busyResult; + + assert.equal(startedWorkers.length, 1); + assert.ok(isLaidOut(busy)); + assert.notOk(isLaidOut(aborted)); + }); + + QUnit.test('should reject a layout the worker crashes during - a new worker takes over the layouts still waiting', async(assert) => { + + const elk = createElk(); + const crashed = createGraph(); + const waiting = createGraph(); + + const crashedResult = joint.layout.ELK.layout(crashed.graph, { elk }); + const waitingResult = joint.layout.ELK.layout(waiting.graph, { elk }); + // Loaded by now - e.g. out of memory. + await new Promise((resolve) => startedWorkers[0].addEventListener('message', resolve, { once: true })); + startedWorkers[0].dispatchEvent(new ErrorEvent('error', { message: 'out of memory' })); + + await assert.rejects(crashedResult, /the ELK worker crashed during the layout \(out of memory\)/); + await waitingResult; + + assert.equal(startedWorkers.length, 2); + assert.notOk(isLaidOut(crashed)); + assert.ok(isLaidOut(waiting)); + }); + + QUnit.test('should reject every layout when the worker fails to load - the next one starts a new worker', async(assert) => { + + let url = MISSING_WORKER_URL; + const elk = createElk(() => url); + const first = createGraph(); + const second = createGraph(); + + // Handled right away - both reject while the other is still pending. + const rejected = Promise.all([ + assert.rejects(joint.layout.ELK.layout(first.graph, { elk }), /the ELK worker failed to load/), + assert.rejects(joint.layout.ELK.layout(second.graph, { elk }), /the ELK worker failed to load/) + ]); + await rejected; + assert.notOk(isLaidOut(first)); + assert.notOk(isLaidOut(second)); + + // E.g. the script is served by now. + url = WORKER_URL; + const next = createGraph(); + await joint.layout.ELK.layout(next.graph, { elk }); + assert.equal(startedWorkers.length, 2); + assert.ok(isLaidOut(next)); + }); + + QUnit.test('should reject a layout when no worker can be started', async(assert) => { + + const error = new Error('blocked by CSP'); + const elk = joint.layout.ELK.createWorkerElk(() => { throw error; }); + const { graph, el1, el2 } = createGraph(); + + await assert.rejects(joint.layout.ELK.layout(graph, { elk }), error); + assert.notOk(isLaidOut({ el1, el2 })); + }); + + QUnit.test('should reject the layouts not settled yet when terminated - a later layout starts a new worker', async(assert) => { + + const elk = createElk(); + const terminated = createGraph(); + + const terminatedResult = joint.layout.ELK.layout(terminated.graph, { elk }); + elk.terminate(); + + await assert.rejects(terminatedResult, /the ELK worker was terminated/); + assert.notOk(isLaidOut(terminated)); + + const next = createGraph(); + await joint.layout.ELK.layout(next.graph, { elk }); + assert.equal(startedWorkers.length, 2); + assert.ok(isLaidOut(next)); + }); + // Settles as `promise` does, or with 'pending' if it hasn't within `ms` - so a layout + // left pending fails its test instead of hanging the suite. + const settledWithin = (promise, ms) => Promise.race([ + promise.then(() => 'resolved', (error) => `rejected: ${error.message}`), + new Promise((resolve) => setTimeout(() => resolve('pending'), ms)) + ]); + + QUnit.test('should not blame a crash during a layout aborted while waiting on the layout waiting behind it', async(assert) => { + + const elk = createElk(); + const controller = new AbortController(); + + const first = createGraph(); + const firstResult = joint.layout.ELK.layout(first.graph, { elk }); + const abortedResult = joint.layout.ELK.layout(createGraph().graph, { elk, signal: controller.signal }); + controller.abort(); + await assert.rejects(abortedResult, (error) => error.name === 'AbortError'); + await firstResult; + + // The worker is busy with the aborted layout now (its result ignored) - `waiting` + // waits behind it, and the crash is no fault of its own. + const waiting = createGraph(); + const waitingResult = joint.layout.ELK.layout(waiting.graph, { elk }); + startedWorkers[0].dispatchEvent(new ErrorEvent('error', { message: 'out of memory' })); + + assert.equal(await settledWithin(waitingResult, 5000), 'resolved'); + assert.equal(startedWorkers.length, 2); + assert.ok(isLaidOut(waiting)); + }); + + QUnit.test('should reject a layout that can\'t be posted to the worker - without holding up the others', async(assert) => { + + const elk = createElk(); + // Started and loaded. + await joint.layout.ELK.layout(createGraph().graph, { elk }); + + // A function can't be cloned into the worker. + const unpostable = createGraph(); + await assert.rejects(joint.layout.ELK.layout(unpostable.graph, { + elk, + exportElement: ({ elkNode }) => { + elkNode.layoutOptions['elk.custom'] = () => {}; + } + }), (error) => error.name === 'DataCloneError'); + assert.notOk(isLaidOut(unpostable)); + + // Aborting the layout the worker is busy with still terminates it - a new worker + // takes over the one waiting. + const controller = new AbortController(); + const abortedResult = joint.layout.ELK.layout(createGraph().graph, { elk, signal: controller.signal }); + const waiting = createGraph(); + const waitingResult = joint.layout.ELK.layout(waiting.graph, { elk }); + controller.abort(); + await assert.rejects(abortedResult, (error) => error.name === 'AbortError'); + assert.equal(await settledWithin(waitingResult, 5000), 'resolved'); + assert.equal(startedWorkers.length, 2); + assert.ok(isLaidOut(waiting)); + + // A crash rejects the layout the worker is busy with - and a new worker takes over + // the one waiting (the layout that couldn't be posted isn't re-posted either). + const crashedResult = joint.layout.ELK.layout(createGraph().graph, { elk }); + const afterCrash = createGraph(); + const afterCrashResult = joint.layout.ELK.layout(afterCrash.graph, { elk }); + startedWorkers[1].dispatchEvent(new ErrorEvent('error', { message: 'out of memory' })); + await assert.rejects(crashedResult, /the ELK worker crashed during the layout/); + assert.equal(await settledWithin(afterCrashResult, 5000), 'resolved'); + assert.equal(startedWorkers.length, 3); + assert.ok(isLaidOut(afterCrash)); + }); +}); diff --git a/packages/joint-layout-elk/tsconfig.cjs.json b/packages/joint-layout-elk/tsconfig.cjs.json new file mode 100644 index 0000000000..e6c9d3bc2e --- /dev/null +++ b/packages/joint-layout-elk/tsconfig.cjs.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "CommonJS", + "moduleResolution": "node", + "outDir": "dist/cjs", + "declarationDir": "dist/cjs", + "allowImportingTsExtensions": false, + "noEmit": false + } +} diff --git a/packages/joint-layout-elk/tsconfig.esm.json b/packages/joint-layout-elk/tsconfig.esm.json new file mode 100644 index 0000000000..ad8c30b3c2 --- /dev/null +++ b/packages/joint-layout-elk/tsconfig.esm.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "node", + "outDir": "dist/esm", + "declarationDir": "dist/esm" + } +} diff --git a/packages/joint-layout-elk/tsconfig.json b/packages/joint-layout-elk/tsconfig.json new file mode 100644 index 0000000000..86a8d24b48 --- /dev/null +++ b/packages/joint-layout-elk/tsconfig.json @@ -0,0 +1,33 @@ +{ + "compilerOptions": { + "target": "ES2019", + "moduleResolution": "bundler", + "module": "ESNext", + "declaration": true, + "declarationMap": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "importHelpers": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "inlineSources": true, + "inlineSourceMap": true, + "lib": [ + "ES2019", + "dom" + ] + }, + "exclude": [ + "node_modules", + "**/dist" + ], + "include": [ + "./src/**/*.mts" + ] +} diff --git a/yarn.lock b/yarn.lock index b8fe3b8093..3d3869e0f2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6348,20 +6348,51 @@ __metadata: languageName: unknown linkType: soft -"@joint/demo-layout-elk-ts@workspace:examples/layout-elk-ts": +"@joint/demo-layout-elk-containers-and-ports-ts@workspace:examples/layout-elk-containers-and-ports-ts": version: 0.0.0-use.local - resolution: "@joint/demo-layout-elk-ts@workspace:examples/layout-elk-ts" + resolution: "@joint/demo-layout-elk-containers-and-ports-ts@workspace:examples/layout-elk-containers-and-ports-ts" dependencies: "@joint/core": "workspace:^" - css-loader: "npm:3.5.3" - elkjs: "npm:^0.11.0" - sass-loader: "npm:8.0.2" - style-loader: "npm:1.2.1" - ts-loader: "npm:^9.2.5" + "@joint/layout-elk": "workspace:^" + sass: "npm:^1.93.2" typescript: "npm:5.8.2" - webpack: "npm:5.98.0" - webpack-cli: "npm:6.0.1" - webpack-dev-server: "npm:5.2.0" + vite: "npm:^7.3.1" + languageName: unknown + linkType: soft + +"@joint/demo-layout-elk-defaults-ts@workspace:examples/layout-elk-defaults-ts": + version: 0.0.0-use.local + resolution: "@joint/demo-layout-elk-defaults-ts@workspace:examples/layout-elk-defaults-ts" + dependencies: + "@joint/core": "workspace:^" + "@joint/layout-elk": "workspace:^" + sass: "npm:^1.93.2" + typescript: "npm:5.8.2" + vite: "npm:^7.3.1" + languageName: unknown + linkType: soft + +"@joint/demo-layout-elk-dependency-graph-ts@workspace:examples/layout-elk-dependency-graph-ts": + version: 0.0.0-use.local + resolution: "@joint/demo-layout-elk-dependency-graph-ts@workspace:examples/layout-elk-dependency-graph-ts" + dependencies: + "@joint/core": "workspace:^" + "@joint/layout-elk": "workspace:^" + sass: "npm:^1.93.2" + typescript: "npm:5.8.2" + vite: "npm:^7.3.1" + languageName: unknown + linkType: soft + +"@joint/demo-layout-elk-rectpacking-ts@workspace:examples/layout-elk-rectpacking-ts": + version: 0.0.0-use.local + resolution: "@joint/demo-layout-elk-rectpacking-ts@workspace:examples/layout-elk-rectpacking-ts" + dependencies: + "@joint/core": "workspace:^" + "@joint/layout-elk": "workspace:^" + sass: "npm:^1.93.2" + typescript: "npm:5.8.2" + vite: "npm:^7.3.1" languageName: unknown linkType: soft @@ -6710,6 +6741,31 @@ __metadata: languageName: unknown linkType: soft +"@joint/layout-elk@workspace:^, @joint/layout-elk@workspace:packages/joint-layout-elk": + version: 0.0.0-use.local + resolution: "@joint/layout-elk@workspace:packages/joint-layout-elk" + dependencies: + "@joint/core": "workspace:~" + "@joint/eslint-config": "workspace:*" + "@rollup/plugin-node-resolve": "npm:^16.0.1" + "@rollup/plugin-terser": "npm:^0.4.4" + "@rollup/plugin-typescript": "npm:^12.1.1" + concurrently: "npm:^9.2.0" + elkjs: "npm:0.12.0" + eslint: "npm:9.39.2" + karma: "npm:^6.4.2" + karma-chrome-launcher: "npm:^3.2.0" + karma-coverage: "npm:^2.2.1" + karma-qunit: "npm:^4.1.2" + karma-sourcemap-loader: "npm:^0.4.0" + puppeteer: "npm:24.22.0" + qunit: "npm:^2.24.1" + rollup: "npm:4.36.0" + rollup-plugin-banner2: "npm:^1.2.2" + typescript: "npm:^5.7.3" + languageName: unknown + linkType: soft + "@joint/layout-msagl@workspace:^, @joint/layout-msagl@workspace:packages/joint-layout-msagl": version: 0.0.0-use.local resolution: "@joint/layout-msagl@workspace:packages/joint-layout-msagl" @@ -14185,6 +14241,15 @@ __metadata: languageName: node linkType: hard +"chokidar@npm:^5.0.0": + version: 5.0.0 + resolution: "chokidar@npm:5.0.0" + dependencies: + readdirp: "npm:^5.0.0" + checksum: 10/a1c2a4ee6ee81ba6409712c295a47be055fb9de1186dfbab33c1e82f28619de962ba02fc5f9d433daaedc96c35747460d8b2079ac2907de2c95e3f7cce913113 + languageName: node + linkType: hard + "chownr@npm:^1.1.1": version: 1.1.4 resolution: "chownr@npm:1.1.4" @@ -15761,10 +15826,10 @@ __metadata: languageName: node linkType: hard -"elkjs@npm:^0.11.0": - version: 0.11.0 - resolution: "elkjs@npm:0.11.0" - checksum: 10/afc7bf05b2d40c21cce4654ed383fb6c97c12a200edf00ac01762a9fa177d6784a0ef752f2f4356bec041a13a05aac9ac31f66ee990ffa0ded31f0575ae93177 +"elkjs@npm:0.12.0": + version: 0.12.0 + resolution: "elkjs@npm:0.12.0" + checksum: 10/84337ad8d3800e7aa2951bd2feeb54bbef9eb653bf0abadad372c32b8ab44f9ec761f9f06d13ad31c42639e548bc39f556c90fa45837cbabe2941ebafdefd21d languageName: node linkType: hard @@ -19582,6 +19647,13 @@ __metadata: languageName: node linkType: hard +"immutable@npm:^5.1.5": + version: 5.1.9 + resolution: "immutable@npm:5.1.9" + checksum: 10/dc61ea6f79897320a048f193dec703dff25ad7bb633c3a0294677d2f258764c88da06e7c2b53d1aa51b6657c98d9f1c7341f49735b9b802fe6e834122cf78dcd + languageName: node + linkType: hard + "import-fresh@npm:^3.2.1, import-fresh@npm:^3.3.0": version: 3.3.1 resolution: "import-fresh@npm:3.3.1" @@ -25816,6 +25888,13 @@ __metadata: languageName: node linkType: hard +"readdirp@npm:^5.0.0": + version: 5.1.1 + resolution: "readdirp@npm:5.1.1" + checksum: 10/4a6a61272c6db332db361166b42be34ce8480542073061f04f405ed2f703e517e90dfafe33843f647e91d20090c575aee04a823e9ed9d9d9a01ec3603ca45a8b + languageName: node + linkType: hard + "readdirp@npm:~3.6.0": version: 3.6.0 resolution: "readdirp@npm:3.6.0" @@ -27144,6 +27223,23 @@ __metadata: languageName: node linkType: hard +"sass@npm:^1.93.2": + version: 1.105.1 + resolution: "sass@npm:1.105.1" + dependencies: + "@parcel/watcher": "npm:^2.4.1" + chokidar: "npm:^5.0.0" + immutable: "npm:^5.1.5" + source-map-js: "npm:>=0.6.2 <2.0.0" + dependenciesMeta: + "@parcel/watcher": + optional: true + bin: + sass: sass.js + checksum: 10/772b8943117cb9badaa1a3d3dda888acc456de859820ab7ac401d03e095f8d88de1e6a39dc969baa850efbbc6ed1a5e4eeb288d0ae4d0d7d99b6efffcf9ec050 + languageName: node + linkType: hard + "sax@npm:>=0.6.0, sax@npm:^1.2.4": version: 1.4.4 resolution: "sax@npm:1.4.4"