From b01e8a55668c128877b11063904d4adb4454ffef Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Wed, 2 Sep 2026 10:08:06 +0200 Subject: [PATCH 01/75] start --- packages/joint-layout-elk/.gitignore | 4 + packages/joint-layout-elk/CHANGELOG.md | 1 + packages/joint-layout-elk/LICENSE | 376 ++++++++++++++++++++ packages/joint-layout-elk/README.md | 156 ++++++++ packages/joint-layout-elk/SECURITY.md | 12 + packages/joint-layout-elk/coverage.json | 8 + packages/joint-layout-elk/eslint.config.mjs | 22 ++ packages/joint-layout-elk/karma.conf.js | 64 ++++ packages/joint-layout-elk/package.json | 95 +++++ packages/joint-layout-elk/rollup.config.mjs | 93 +++++ packages/joint-layout-elk/src/defaults.mts | 76 ++++ packages/joint-layout-elk/src/index.mts | 2 + packages/joint-layout-elk/src/layout.mts | 198 +++++++++++ packages/joint-layout-elk/src/types.mts | 102 ++++++ packages/joint-layout-elk/test/index.html | 18 + packages/joint-layout-elk/test/index.js | 107 ++++++ packages/joint-layout-elk/tsconfig.cjs.json | 11 + packages/joint-layout-elk/tsconfig.esm.json | 9 + packages/joint-layout-elk/tsconfig.json | 32 ++ 19 files changed, 1386 insertions(+) create mode 100644 packages/joint-layout-elk/.gitignore create mode 100644 packages/joint-layout-elk/CHANGELOG.md create mode 100644 packages/joint-layout-elk/LICENSE create mode 100644 packages/joint-layout-elk/README.md create mode 100644 packages/joint-layout-elk/SECURITY.md create mode 100644 packages/joint-layout-elk/coverage.json create mode 100644 packages/joint-layout-elk/eslint.config.mjs create mode 100644 packages/joint-layout-elk/karma.conf.js create mode 100644 packages/joint-layout-elk/package.json create mode 100644 packages/joint-layout-elk/rollup.config.mjs create mode 100644 packages/joint-layout-elk/src/defaults.mts create mode 100644 packages/joint-layout-elk/src/index.mts create mode 100644 packages/joint-layout-elk/src/layout.mts create mode 100644 packages/joint-layout-elk/src/types.mts create mode 100644 packages/joint-layout-elk/test/index.html create mode 100644 packages/joint-layout-elk/test/index.js create mode 100644 packages/joint-layout-elk/tsconfig.cjs.json create mode 100644 packages/joint-layout-elk/tsconfig.esm.json create mode 100644 packages/joint-layout-elk/tsconfig.json 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..e9b53693af --- /dev/null +++ b/packages/joint-layout-elk/README.md @@ -0,0 +1,156 @@ +# 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 fully 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 computes a layout and writes the result (positions, link vertices, anchors and label positions) back onto the graph. It does not keep the graph laid out afterwards. + +## 🚀 Quick Start + +### Installation + +```bash +npm install @joint/layout-elk elkjs +``` + +`elkjs` is a peer dependency - install it alongside this package. + +### 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, { + layoutOptions: { + 'elk.algorithm': 'layered', + 'elk.direction': 'RIGHT', + 'elk.edgeRouting': 'ORTHOGONAL' + } +}); +``` + +## 📖 API Reference + +### `layout(graph, options?): Promise` + +- `graph`: `dia.Graph` - the graph to lay out. Only top-level elements (elements that are not embedded in another element) are laid out; embedded elements and ports are not supported yet (see Caveats below). +- `options?`: `Options` - Layout configuration (see below) + +```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, debugging) +} +``` + +### Options Interface + +```ts +type GetSizeCallback = (element: dia.Element) => dia.Size; +type SetPositionCallback = (element: dia.Element, position: dia.Point) => void; +type SetVerticesCallback = (link: dia.Link, vertices: dia.Point[]) => void; +type SetAnchorCallback = (link: dia.Link, element: dia.Element, point: dia.Point, endType: 'source' | 'target') => void; +type SetLabelsCallback = (link: dia.Link, labelBBox: dia.BBox, points: dia.Point[], labelIndex: number) => void; +type NodeOptionsCallback = (element: dia.Element) => ElkLayoutOptions | undefined; +type EdgeOptionsCallback = (link: dia.Link) => ElkLayoutOptions | undefined; + +interface Options { + // A custom ELK instance, e.g. one configured to run inside a Web Worker. + elk?: ELK; // Default: a shared, main-thread instance (`elkjs/lib/elk.bundled.js`) + // ELK layout options, passed through to ELK unmodified. + layoutOptions?: ElkLayoutOptions; // Default: { 'elk.algorithm': 'layered' } + // Whether to account for link labels during layout and position them afterwards. + edgeLabels?: boolean; // Default: true + // Element sizing callback + getSize?: GetSizeCallback; // Default: element.size() + // Callbacks for customizing how the layout is applied + setPosition?: SetPositionCallback; // Default: element.position(x, y) + setVertices?: boolean | SetVerticesCallback; // Default: true + setAnchor?: boolean | SetAnchorCallback; // Default: true + setLabels?: boolean | SetLabelsCallback; // Default: true + // Per-cell escape hatches into ELK's option space + nodeOptions?: NodeOptionsCallback; + edgeOptions?: EdgeOptionsCallback; +} +``` + +## 🎯 Examples + +### Running ELK in a Web Worker + +`elkjs` supports Web Workers natively. Pass your own `ELK` instance, configured with a `workerUrl` - the consumer controls bundling, since bundlers need the literal worker URL at the call site: + +```ts +import ELK from 'elkjs/lib/elk-api.js'; +import { layout } from '@joint/layout-elk'; + +const elk = new ELK({ + workerUrl: new URL('elkjs/lib/elk-worker.min.js', import.meta.url).href +}); + +// The instance can be reused across calls. +await layout(graph, { elk }); + +// Call this yourself once the instance is no longer needed. +elk.terminateWorker(); +``` + +With no `elk` option, the package creates a default instance running on the main thread (`elkjs/lib/elk.bundled.js`). The public API is identical in both modes. + +### Per-cell ELK options + +```ts +layout(graph, { + nodeOptions: (element) => ({ 'partitioning.partition': `${element.get('layer')}` }), + layoutOptions: { + 'elk.algorithm': 'layered', + 'elk.partitioning.activate': 'true' + } +}); +``` + +### Animated transitions + +```ts +import { util } from '@joint/core'; + +layout(graph, { + setPosition: (element, position) => { + element.transition('position', position, { + duration: 500, + timingFunction: util.timing.cubic, + valueFunction: util.interpolate.object + }); + } +}); +``` + +## ⚠️ Caveats & Known Limitations + +- **Flat graphs only** - embedded elements (clusters) and ports are not supported yet. Elements that are embedded in another element are skipped by the layout; links connected to a port are treated as connected to the port's element. +- **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 (behind `edgeLabels`). +- **Asynchronous** - unlike `@joint/layout-directed-graph`, `layout()` returns a `Promise`, since `elkjs` computes layouts asynchronously (and, optionally, inside a Web Worker). +- **ID handling** - ELK requires string ids; element and link ids are converted with `` `${id}` `` internally, but never written back to the graph. + +## 📄 License + +[Mozilla Public License 2.0](https://www.mozilla.org/en-US/MPL/2.0/) + +This package depends on [`elkjs`](https://github.com/kieler/elkjs), which is licensed under the [Eclipse Public License 2.0](https://github.com/kieler/elkjs/blob/master/LICENSE.md). It is kept as a peer dependency and is never bundled with this package. + +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..886418530c --- /dev/null +++ b/packages/joint-layout-elk/karma.conf.js @@ -0,0 +1,64 @@ +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', + 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..0522fc8eeb --- /dev/null +++ b/packages/joint-layout-elk/package.json @@ -0,0 +1,95 @@ +{ + "name": "@joint/layout-elk", + "title": "JointJS ELK Layout", + "version": "0.1.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" + } + }, + "files": [ + "dist", + "SECURITY.md", + "README.md", + "LICENSE", + "CHANGELOG.md" + ], + "dependencies": { + "@joint/core": "workspace:~" + }, + "peerDependencies": { + "elkjs": "^0.11.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", + "elkjs": "^0.11.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..0a3cfe365f --- /dev/null +++ b/packages/joint-layout-elk/rollup.config.mjs @@ -0,0 +1,93 @@ +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']; + +const external = [ + '@joint/core', + 'elkjs/lib/elk.bundled.js', + 'elkjs/lib/elk-api.js' +]; + +const globals = { + '@joint/core': 'joint', + 'elkjs/lib/elk.bundled.js': 'ELK', + 'elkjs/lib/elk-api.js': 'ELK' +}; + +export default [ + { + input, + external, + output: [ + { + file: 'dist/umd/index.js', + format: 'umd', + name: 'joint.layout.ELK', + extend: true, + globals, + plugins: [ + banner(() => bannerText) + ] + }, + { + file: 'dist/umd/index.min.js', + format: 'umd', + name: 'joint.layout.ELK', + extend: true, + globals, + plugins: [ + terser({ format: { ascii_only: true }}), + banner(() => bannerText) + ] + }, + ], + plugins: [ + 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, + output: [ + { + file: 'build/test/index.js', + format: 'umd', + name: 'joint.layout.ELK', + extend: true, + globals, + sourcemap: true + } + ], + plugins: [ + 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/defaults.mts b/packages/joint-layout-elk/src/defaults.mts new file mode 100644 index 0000000000..6708504710 --- /dev/null +++ b/packages/joint-layout-elk/src/defaults.mts @@ -0,0 +1,76 @@ +import { type dia, g } from '@joint/core'; +import { type ElkLayoutOptions, type Options } from './types.mjs'; + +/** Zero-config default: a layered (Sugiyama-style) layout. */ +export const DEFAULT_LAYOUT_OPTIONS: ElkLayoutOptions = { + 'elk.algorithm': 'layered' +}; + +export const DEFAULT_LABEL_SIZE: dia.Size = { + width: 50, + height: 20 +}; + +export const defaultOptions: Required> = { + edgeLabels: true, + getSize, + setPosition, + setVertices: true, + setAnchor: true, + setLabels: true, + nodeOptions, + edgeOptions +}; + +// --- Default Callbacks + +function getSize(element: dia.Element): dia.Size { + return element.size(); +} + +function setPosition(element: dia.Element, position: dia.Point) { + element.position(position.x, position.y); +} + +function nodeOptions(): ElkLayoutOptions | undefined { + return undefined; +} + +function edgeOptions(): ElkLayoutOptions | undefined { + return undefined; +} + +export function setVertices(link: dia.Link, vertices: dia.Point[]) { + link.vertices(vertices); +} + +export function setAnchor(link: dia.Link, element: dia.Element, point: dia.Point, endType: 'source' | 'target') { + const delta = element.getRelativePointFromAbsolute(point); + link.prop(`${endType}/anchor`, { + name: 'topLeft', + args: { + dx: delta.x, + dy: delta.y, + useModelGeometry: true + } + }); +} + +export function setLabels(link: dia.Link, labelBBox: dia.BBox, points: dia.Point[], labelIndex: number) { + + const polyline = new g.Polyline(points); + + const { x, y, width, height } = labelBBox; + const center = new g.Point(x + width / 2, y + height / 2); + + const distance = polyline.closestPointLength(center); + // Get the tangent at the closest point to calculate the offset + const tangent = polyline.tangentAtLength(distance); + + link.label(labelIndex, { + position: { + distance, + offset: tangent ? tangent.pointOffset(center) : 0 + } + }); +} diff --git a/packages/joint-layout-elk/src/index.mts b/packages/joint-layout-elk/src/index.mts new file mode 100644 index 0000000000..8b3af4c90b --- /dev/null +++ b/packages/joint-layout-elk/src/index.mts @@ -0,0 +1,2 @@ +export { layout } from './layout.mjs'; +export type { Options, LayoutResult } from './types.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..8f93954a73 --- /dev/null +++ b/packages/joint-layout-elk/src/layout.mts @@ -0,0 +1,198 @@ +import { type dia, g, util } from '@joint/core'; +import ElkConstructor from 'elkjs/lib/elk.bundled.js'; +import { + type ELK, + type ElkNode, + type ElkExtendedEdge, + type ElkLabel, + type ElkLayoutOptions, + type Options, + type LayoutResult +} from './types.mjs'; +import { DEFAULT_LAYOUT_OPTIONS, DEFAULT_LABEL_SIZE, defaultOptions, setVertices, setAnchor, setLabels } from './defaults.mjs'; + +const LAYOUT_BATCH_NAME = 'layout'; +// ELK ignores labels with no text. +const ELK_LABEL_TEXT = '-'; +const ELK_INLINE_LABEL_OPTIONS = { 'edgeLabels.inline': 'true' }; + +let defaultElk: ELK | undefined; + +function getDefaultElk(): ELK { + if (!defaultElk) { + defaultElk = new ElkConstructor(); + } + return defaultElk; +} + +interface ElkGraphData { + elkGraph: ElkNode; + elementsById: Map; + linksById: Map; +} + +export async function layout(graph: dia.Graph, opt?: Options): Promise { + + const options = util.defaults({}, opt || {}, defaultOptions) as Required>; + const layoutOptions = util.defaults({}, opt?.layoutOptions || {}, DEFAULT_LAYOUT_OPTIONS) as ElkLayoutOptions; + const elk = opt?.elk || getDefaultElk(); + + const { elkGraph, elementsById, linksById } = toElkGraph(graph, options, layoutOptions); + + const result = await elk.layout(elkGraph) as ElkNode; + + graph.startBatch(LAYOUT_BATCH_NAME); + applyElkLayout(result, elementsById, linksById, options); + graph.stopBatch(LAYOUT_BATCH_NAME); + + return { + bbox: getBBox(result), + elkGraph: result + }; +} + +/** + * Converts a flat JointJS graph (top-level elements only - embedded elements, + * clusters and ports are not supported yet) to an ELK graph structure. + */ +function toElkGraph( + graph: dia.Graph, + options: Required>, + layoutOptions: ElkLayoutOptions +): ElkGraphData { + + const elementsById = new Map(); + const linksById = new Map(); + + const children: ElkNode[] = graph.getElements() + .filter((element) => !element.parent()) + .map((element) => { + const id = `${element.id}`; + elementsById.set(id, element); + + const { width, height } = options.getSize(element); + return { + id, + width, + height, + layoutOptions: options.nodeOptions(element) + }; + }); + + const edges: ElkExtendedEdge[] = []; + + graph.getLinks().forEach((link) => { + const sourceElement = link.getSourceElement(); + const targetElement = link.getTargetElement(); + // Links not connected to two elements (e.g. connected to a point or + // to another link) are not part of the layout. + if (!sourceElement || !targetElement) return; + if (!elementsById.has(`${sourceElement.id}`) || !elementsById.has(`${targetElement.id}`)) return; + + const id = `${link.id}`; + linksById.set(id, link); + + const edge: ElkExtendedEdge = { + id, + sources: [`${sourceElement.id}`], + targets: [`${targetElement.id}`], + layoutOptions: options.edgeOptions(link) + }; + + if (options.edgeLabels) { + const labels = link.labels(); + if (labels.length > 0) { + edge.labels = labels.map((label): ElkLabel => { + const { width, height } = label.size || DEFAULT_LABEL_SIZE; + return { + // Some text is required, otherwise ELK ignores the label. + text: ELK_LABEL_TEXT, + width, + height, + // Place the label directly on the edge (and allocate space for it). + layoutOptions: ELK_INLINE_LABEL_OPTIONS + }; + }); + } + } + + edges.push(edge); + }); + + const elkGraph: ElkNode = { + id: 'root', + layoutOptions, + children, + edges + }; + + return { elkGraph, elementsById, linksById }; +} + +/** + * Applies an ELK layout result back onto the JointJS graph. + */ +function applyElkLayout( + elkGraph: ElkNode, + elementsById: Map, + linksById: Map, + options: Required> +): void { + + (elkGraph.children || []).forEach((node) => { + const element = elementsById.get(node.id); + if (!element) return; + + options.setPosition(element, { x: node.x || 0, y: node.y || 0 }); + }); + + (elkGraph.edges || []).forEach((edge) => { + const link = linksById.get(edge.id); + if (!link) return; + + const [section] = edge.sections || []; + if (!section) return; + + const { startPoint, endPoint, bendPoints = [] } = section; + + if (options.setVertices) { + if (util.isFunction(options.setVertices)) { + (options.setVertices as unknown as typeof setVertices)(link, bendPoints); + } else { + setVertices(link, bendPoints); + } + } + + if (options.setAnchor) { + const sourceElement = link.getSourceElement(); + const targetElement = link.getTargetElement(); + if (util.isFunction(options.setAnchor)) { + const setAnchorFn = options.setAnchor as unknown as typeof setAnchor; + if (sourceElement) setAnchorFn(link, sourceElement, startPoint, 'source'); + if (targetElement) setAnchorFn(link, targetElement, endPoint, 'target'); + } else { + if (sourceElement) setAnchor(link, sourceElement, startPoint, 'source'); + if (targetElement) setAnchor(link, targetElement, endPoint, 'target'); + } + } + + if (options.edgeLabels && options.setLabels && edge.labels && edge.labels.length > 0) { + const points = [startPoint, ...bendPoints, endPoint]; + const setLabelsFn = util.isFunction(options.setLabels) + ? options.setLabels as unknown as typeof setLabels + : setLabels; + edge.labels.forEach((label, labelIndex) => { + const { x = 0, y = 0, width = 0, height = 0 } = label; + setLabelsFn(link, { x, y, width, height }, points, labelIndex); + }); + } + }); +} + +/** + * Tight bounding box of the top-level nodes in an ELK layout result. + */ +function getBBox(elkGraph: ElkNode): g.Rect { + const rects = (elkGraph.children || []).map((node) => new g.Rect(node.x || 0, node.y || 0, node.width || 0, node.height || 0)); + return g.Rect.fromRectUnion(...rects) || new g.Rect(0, 0, 0, 0); +} diff --git a/packages/joint-layout-elk/src/types.mts b/packages/joint-layout-elk/src/types.mts new file mode 100644 index 0000000000..75824ec63f --- /dev/null +++ b/packages/joint-layout-elk/src/types.mts @@ -0,0 +1,102 @@ +import type { dia, g } from '@joint/core'; +import type { + ELK, + ElkNode, + ElkExtendedEdge, + ElkLabel, + LayoutOptions as ElkLayoutOptions +} from 'elkjs/lib/elk-api.js'; + +export type { ELK, ElkNode, ElkExtendedEdge, ElkLabel, ElkLayoutOptions }; + +type GetSizeCallback = (element: dia.Element) => dia.Size; +type SetPositionCallback = (element: dia.Element, position: dia.Point) => void; +type SetVerticesCallback = (link: dia.Link, vertices: dia.Point[]) => void; +type SetAnchorCallback = (link: dia.Link, element: dia.Element, point: dia.Point, endType: 'source' | 'target') => void; +type SetLabelsCallback = (link: dia.Link, labelBBox: dia.BBox, points: dia.Point[], labelIndex: number) => void; +type NodeOptionsCallback = (element: dia.Element) => ElkLayoutOptions | undefined; +type EdgeOptionsCallback = (link: dia.Link) => ElkLayoutOptions | undefined; + +/** + * Layout configuration options. + */ +export interface Options { + /** + * A custom ELK instance, e.g. one configured to run inside a Web Worker. + * The instance is not terminated by the package - call `elk.terminateWorker()` + * yourself when it is no longer needed. + * @defaultValue a shared, main-thread instance (`elkjs/lib/elk.bundled.js`) + * @example + * import ELK from 'elkjs/lib/elk-api.js'; + * const elk = new ELK({ workerUrl: new URL('elkjs/lib/elk-worker.min.js', import.meta.url).href }); + * layout(graph, { elk }); + */ + elk?: ELK; + /** + * ELK layout options, passed through to ELK unmodified. + * @see https://eclipse.dev/elk/reference/options.html + * @defaultValue `{ 'elk.algorithm': 'layered' }` + */ + layoutOptions?: ElkLayoutOptions; + /** + * Whether to account for link labels during layout and position them + * along the routed link afterwards. + * @defaultValue true + */ + edgeLabels?: boolean; + /** + * Returns the element's size used during layout. + * @defaultValue element.size() + */ + getSize?: GetSizeCallback; + /** + * Applies a new position to an element after layout. + * @defaultValue element.position(x, y) + * @example + * setPosition: (el, pos) => el.position(pos.x, pos.y) + */ + setPosition?: SetPositionCallback; + /** + * Sets vertices on a link from the bend points of the ELK edge section. + * @remarks When set to `true`, the built-in vertices setter is used. Provide a function to customize. + * @defaultValue true + * @example + * setVertices: (link, vertices) => link.vertices(vertices) + */ + setVertices?: boolean | SetVerticesCallback; + /** + * Sets a link's anchor at either source or target, based on the start/end + * point of the ELK edge section. + * @remarks When set to `true`, the built-in `topLeft` anchor is used. Provide a function to customize. + * @defaultValue true + */ + setAnchor?: boolean | SetAnchorCallback; + /** + * Sets a link label's position, based on the ELK edge label. + * Only takes effect when `edgeLabels` is enabled. + * @remarks When set to `true`, the built-in label setter is used. Provide a function to customize. + * @defaultValue true + * @example + * setLabels: (link, labelBBox, points, labelIndex) => link.label(labelIndex, { + * position: { distance: 0, offset: 0 } + * }); + */ + setLabels?: boolean | SetLabelsCallback; + /** + * Per-element ELK layout options, merged into the generated ELK node. + * @example + * nodeOptions: (element) => ({ 'partitioning.partition': element.get('layer') }) + */ + nodeOptions?: NodeOptionsCallback; + /** + * Per-link ELK layout options, merged into the generated ELK edge. + */ + edgeOptions?: EdgeOptionsCallback; +} + +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; +} 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..df1a784e66 --- /dev/null +++ b/packages/joint-layout-elk/test/index.js @@ -0,0 +1,107 @@ +QUnit.module('sanity check', () => { + QUnit.test('should load', assert => { + assert.ok(typeof joint.layout.ELK !== 'undefined'); + assert.ok(typeof joint.layout.ELK.layout === 'function'); + }); +}); + +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, { + layoutOptions: { + '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 ignore embedded elements', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const parent = new joint.shapes.standard.Rectangle({ id: 'parent', size: { width: 200, height: 200 }, position: { x: 10, y: 10 }}); + const child = new joint.shapes.standard.Rectangle({ id: 'child', size: { width: 50, height: 50 }, position: { x: 60, y: 60 }}); + parent.embed(child); + + graph.resetCells([parent, child]); + + await joint.layout.ELK.layout(graph); + + const position = child.position(); + assert.equal(position.x, 60); + assert.equal(position.y, 60); + }); +}); 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..0fe9e0c65d --- /dev/null +++ b/packages/joint-layout-elk/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "target": "ES2019", + "moduleResolution": "node", + "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" + ] +} From a04e4a5f9bff35e5885df66643beff1dbf44084e Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Mon, 7 Sep 2026 11:32:29 +0200 Subject: [PATCH 02/75] feat(joint-layout-elk): support hierarchical containers, ports and per-port ELK options - Elements embedded in another element are laid out as a container - the parent is resized (and positioned) by ELK to fit its content, at any nesting depth. Cross-container links are filed under their lowest common ancestor per ELK's hierarchical-edge convention. - Ports route edges to/from the position JointJS itself already computes for them (`elk.portConstraints: FIXED_POS`); the new `positionPorts` option lets ELK reposition/reorder them instead. - Add a `portOptions` callback for per-port ELK layout options, alongside the existing `nodeOptions`/`edgeOptions`. - Default to `elk.hierarchyHandling: INCLUDE_CHILDREN` so edges crossing a container's boundary are accounted for during layout. - Migrate the `layout-elk-ts` example to the package's `layout()` entry point instead of hand-rolled ELK export/import. --- .changeset/layout-elk-containers.md | 5 + .changeset/layout-elk-port-options.md | 5 + .changeset/layout-elk-ports.md | 5 + examples/layout-elk-ts/package.json | 3 +- examples/layout-elk-ts/src/index.ts | 276 +++++---------------- examples/layout-elk-ts/tsconfig.json | 5 +- examples/layout-elk-ts/webpack.config.js | 6 + packages/joint-layout-elk/README.md | 34 ++- packages/joint-layout-elk/package.json | 7 +- packages/joint-layout-elk/src/defaults.mts | 76 ------ packages/joint-layout-elk/src/export.mts | 253 +++++++++++++++++++ packages/joint-layout-elk/src/import.mts | 237 ++++++++++++++++++ packages/joint-layout-elk/src/index.mts | 3 +- packages/joint-layout-elk/src/layout.mts | 247 ++++++------------ packages/joint-layout-elk/src/types.mts | 102 -------- packages/joint-layout-elk/test/index.js | 174 ++++++++++++- 16 files changed, 851 insertions(+), 587 deletions(-) create mode 100644 .changeset/layout-elk-containers.md create mode 100644 .changeset/layout-elk-port-options.md create mode 100644 .changeset/layout-elk-ports.md delete mode 100644 packages/joint-layout-elk/src/defaults.mts create mode 100644 packages/joint-layout-elk/src/export.mts create mode 100644 packages/joint-layout-elk/src/import.mts delete mode 100644 packages/joint-layout-elk/src/types.mts diff --git a/.changeset/layout-elk-containers.md b/.changeset/layout-elk-containers.md new file mode 100644 index 0000000000..920359e517 --- /dev/null +++ b/.changeset/layout-elk-containers.md @@ -0,0 +1,5 @@ +--- +"@joint/layout-elk": minor +--- + +layout.ELK - lay out elements embedded in another element as a container, resized (and positioned) by ELK to fit their content diff --git a/.changeset/layout-elk-port-options.md b/.changeset/layout-elk-port-options.md new file mode 100644 index 0000000000..aab8541047 --- /dev/null +++ b/.changeset/layout-elk-port-options.md @@ -0,0 +1,5 @@ +--- +"@joint/layout-elk": minor +--- + +layout.ELK - add a `portOptions` callback for per-port ELK layout options, alongside the existing `nodeOptions`/`edgeOptions` diff --git a/.changeset/layout-elk-ports.md b/.changeset/layout-elk-ports.md new file mode 100644 index 0000000000..d9389e14a3 --- /dev/null +++ b/.changeset/layout-elk-ports.md @@ -0,0 +1,5 @@ +--- +"@joint/layout-elk": minor +--- + +layout.ELK - route edges to/from an element's ports, kept at the position JointJS itself already computes for them unless `positionPorts` is enabled diff --git a/examples/layout-elk-ts/package.json b/examples/layout-elk-ts/package.json index 1706be066c..8335c9f058 100644 --- a/examples/layout-elk-ts/package.json +++ b/examples/layout-elk-ts/package.json @@ -19,7 +19,8 @@ }, "dependencies": { "@joint/core": "workspace:^", - "elkjs": "^0.11.0" + "@joint/layout-elk": "workspace:^", + "elkjs": "0.12.0" }, "devDependencies": { "css-loader": "3.5.3", diff --git a/examples/layout-elk-ts/src/index.ts b/examples/layout-elk-ts/src/index.ts index 551512f4c5..4ae09f05a9 100644 --- a/examples/layout-elk-ts/src/index.ts +++ b/examples/layout-elk-ts/src/index.ts @@ -1,12 +1,9 @@ -import { dia, shapes, util, g } from '@joint/core'; +import { dia, shapes, g } from '@joint/core'; +import { layout } from '@joint/layout-elk'; import ELK from 'elkjs/lib/elk-api.js'; -import type { ElkNode, ElkExtendedEdge, ElkLabel } from 'elkjs/lib/elk-api.d.ts'; 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; @@ -42,12 +39,64 @@ const init = () => { // Generate JointJS cells from example data generateCells(dependenciesJSON, graph); - // Perform ELK layout + // Run ELK in a Web Worker, via the `@joint/layout-elk` package 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, + layoutOptions: { + /** + * 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 @@ -206,215 +255,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/tsconfig.json b/examples/layout-elk-ts/tsconfig.json index 39951cb836..7ecb0809ec 100644 --- a/examples/layout-elk-ts/tsconfig.json +++ b/examples/layout-elk-ts/tsconfig.json @@ -1,7 +1,8 @@ { "compilerOptions": { - "module": "commonjs", - "target": "es5", + "module": "ES6", + "moduleResolution": "node", + "target": "es6", "noImplicitAny": false, "sourceMap": false, "outDir": "./build", diff --git a/examples/layout-elk-ts/webpack.config.js b/examples/layout-elk-ts/webpack.config.js index 525bf11dc1..7b10ca335d 100644 --- a/examples/layout-elk-ts/webpack.config.js +++ b/examples/layout-elk-ts/webpack.config.js @@ -13,6 +13,12 @@ module.exports = { mode: 'development', module: { rules: [ + { + test: /\.m?js/, + resolve: { + fullySpecified: false, + }, + }, { test: /\.ts$/, loader: 'ts-loader' }, { test: /\.s[ac]ss$/i, diff --git a/packages/joint-layout-elk/README.md b/packages/joint-layout-elk/README.md index e9b53693af..7bdc1b4c26 100644 --- a/packages/joint-layout-elk/README.md +++ b/packages/joint-layout-elk/README.md @@ -11,11 +11,9 @@ This library fully depends on [JointJS](https://github.com/clientio/joint) (*>=4 ### Installation ```bash -npm install @joint/layout-elk elkjs +npm install @joint/layout-elk ``` -`elkjs` is a peer dependency - install it alongside this package. - ### Basic Usage ```ts @@ -48,7 +46,7 @@ const { bbox } = await layout(graph, { ### `layout(graph, options?): Promise` -- `graph`: `dia.Graph` - the graph to lay out. Only top-level elements (elements that are not embedded in another element) are laid out; embedded elements and ports are not supported yet (see Caveats below). +- `graph`: `dia.Graph` - the graph to lay out. Elements embedded in another element are laid out as a container - the parent is resized (and positioned) by ELK to fit its content, at any nesting depth. By default, ports are laid out at the position JointJS itself already computes for them (via the element's port groups) - ELK only uses that position to route edges to/from them; pass `positionPorts: true` to let ELK reposition them instead (see below). - `options?`: `Options` - Layout configuration (see below) ```ts @@ -67,7 +65,9 @@ type SetVerticesCallback = (link: dia.Link, vertices: dia.Point[]) => void; type SetAnchorCallback = (link: dia.Link, element: dia.Element, point: dia.Point, endType: 'source' | 'target') => void; type SetLabelsCallback = (link: dia.Link, labelBBox: dia.BBox, points: dia.Point[], labelIndex: number) => void; type NodeOptionsCallback = (element: dia.Element) => ElkLayoutOptions | undefined; +type PortOptionsCallback = (port: dia.Element.Port, element: dia.Element) => ElkLayoutOptions | undefined; type EdgeOptionsCallback = (link: dia.Link) => ElkLayoutOptions | undefined; +type SetPortPositionCallback = (element: dia.Element, portId: string, position: dia.Point) => void; interface Options { // A custom ELK instance, e.g. one configured to run inside a Web Worker. @@ -76,6 +76,9 @@ interface Options { layoutOptions?: ElkLayoutOptions; // Default: { 'elk.algorithm': 'layered' } // Whether to account for link labels during layout and position them afterwards. edgeLabels?: boolean; // Default: true + // Whether to let ELK reposition (and reorder) ports itself, instead of keeping + // them at their JointJS-computed position - see "Letting ELK position ports" below. + positionPorts?: boolean | { setPortPosition?: SetPortPositionCallback }; // Default: false // Element sizing callback getSize?: GetSizeCallback; // Default: element.size() // Callbacks for customizing how the layout is applied @@ -85,6 +88,7 @@ interface Options { setLabels?: boolean | SetLabelsCallback; // Default: true // Per-cell escape hatches into ELK's option space nodeOptions?: NodeOptionsCallback; + portOptions?: PortOptionsCallback; edgeOptions?: EdgeOptionsCallback; } ``` @@ -124,6 +128,24 @@ layout(graph, { }); ``` +### Letting ELK position ports + +By default, ports stay exactly where JointJS's own port groups already place them - ELK only uses that position to route edges. Pass `positionPorts: true` to let ELK freely reposition (and reorder) ports along their element instead, e.g. to minimize edge crossings: + +```ts +layout(graph, { positionPorts: true }); +``` + +This only takes visible effect for a port whose group renders it at a plain `x`/`y` (the `'absolute'` position) - `layout()` switches every port-bearing group to that position for you (its `attrs`/`markup`/`label` are left untouched), so this works regardless of how the group was originally configured (`'left'`, `'right'`, a custom callback, ...). To customize how a computed position is applied instead of the default `element.portProp(portId, ['position', 'args'], position)`, pass an object: + +```ts +layout(graph, { + positionPorts: { + setPortPosition: (element, portId, position) => element.portProp(portId, ['position', 'args'], position) + } +}); +``` + ### Animated transitions ```ts @@ -142,8 +164,8 @@ layout(graph, { ## ⚠️ Caveats & Known Limitations -- **Flat graphs only** - embedded elements (clusters) and ports are not supported yet. Elements that are embedded in another element are skipped by the layout; links connected to a port are treated as connected to the port's element. - **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 (behind `edgeLabels`). +- **Ports keep their JointJS-computed position by default** - `layout()` only tells ELK where they already are (`elk.portConstraints: FIXED_POS`), so edges route to/from the exact spot the element's port groups place them at. Opt into ELK repositioning them with `positionPorts` (see above). - **Asynchronous** - unlike `@joint/layout-directed-graph`, `layout()` returns a `Promise`, since `elkjs` computes layouts asynchronously (and, optionally, inside a Web Worker). - **ID handling** - ELK requires string ids; element and link ids are converted with `` `${id}` `` internally, but never written back to the graph. @@ -151,6 +173,6 @@ layout(graph, { [Mozilla Public License 2.0](https://www.mozilla.org/en-US/MPL/2.0/) -This package depends on [`elkjs`](https://github.com/kieler/elkjs), which is licensed under the [Eclipse Public License 2.0](https://github.com/kieler/elkjs/blob/master/LICENSE.md). It is kept as a peer dependency and is never bundled with this package. +This package depends on [`elkjs`](https://github.com/kieler/elkjs), which is licensed under the [Eclipse Public License 2.0](https://github.com/kieler/elkjs/blob/master/LICENSE.md). It is installed automatically as a regular dependency, but is kept external to (never inlined into) this package's own UMD build. Copyright © 2013-2026 client IO diff --git a/packages/joint-layout-elk/package.json b/packages/joint-layout-elk/package.json index 0522fc8eeb..9ee0e017dd 100644 --- a/packages/joint-layout-elk/package.json +++ b/packages/joint-layout-elk/package.json @@ -57,10 +57,8 @@ "CHANGELOG.md" ], "dependencies": { - "@joint/core": "workspace:~" - }, - "peerDependencies": { - "elkjs": "^0.11.0" + "@joint/core": "workspace:~", + "elkjs": "0.12.0" }, "devDependencies": { "@joint/eslint-config": "workspace:*", @@ -68,7 +66,6 @@ "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^12.1.1", "concurrently": "^9.2.0", - "elkjs": "^0.11.0", "eslint": "9.39.2", "karma": "^6.4.2", "karma-chrome-launcher": "^3.2.0", diff --git a/packages/joint-layout-elk/src/defaults.mts b/packages/joint-layout-elk/src/defaults.mts deleted file mode 100644 index 6708504710..0000000000 --- a/packages/joint-layout-elk/src/defaults.mts +++ /dev/null @@ -1,76 +0,0 @@ -import { type dia, g } from '@joint/core'; -import { type ElkLayoutOptions, type Options } from './types.mjs'; - -/** Zero-config default: a layered (Sugiyama-style) layout. */ -export const DEFAULT_LAYOUT_OPTIONS: ElkLayoutOptions = { - 'elk.algorithm': 'layered' -}; - -export const DEFAULT_LABEL_SIZE: dia.Size = { - width: 50, - height: 20 -}; - -export const defaultOptions: Required> = { - edgeLabels: true, - getSize, - setPosition, - setVertices: true, - setAnchor: true, - setLabels: true, - nodeOptions, - edgeOptions -}; - -// --- Default Callbacks - -function getSize(element: dia.Element): dia.Size { - return element.size(); -} - -function setPosition(element: dia.Element, position: dia.Point) { - element.position(position.x, position.y); -} - -function nodeOptions(): ElkLayoutOptions | undefined { - return undefined; -} - -function edgeOptions(): ElkLayoutOptions | undefined { - return undefined; -} - -export function setVertices(link: dia.Link, vertices: dia.Point[]) { - link.vertices(vertices); -} - -export function setAnchor(link: dia.Link, element: dia.Element, point: dia.Point, endType: 'source' | 'target') { - const delta = element.getRelativePointFromAbsolute(point); - link.prop(`${endType}/anchor`, { - name: 'topLeft', - args: { - dx: delta.x, - dy: delta.y, - useModelGeometry: true - } - }); -} - -export function setLabels(link: dia.Link, labelBBox: dia.BBox, points: dia.Point[], labelIndex: number) { - - const polyline = new g.Polyline(points); - - const { x, y, width, height } = labelBBox; - const center = new g.Point(x + width / 2, y + height / 2); - - const distance = polyline.closestPointLength(center); - // Get the tangent at the closest point to calculate the offset - const tangent = polyline.tangentAtLength(distance); - - link.label(labelIndex, { - position: { - distance, - offset: tangent ? tangent.pointOffset(center) : 0 - } - }); -} diff --git a/packages/joint-layout-elk/src/export.mts b/packages/joint-layout-elk/src/export.mts new file mode 100644 index 0000000000..a7966e3955 --- /dev/null +++ b/packages/joint-layout-elk/src/export.mts @@ -0,0 +1,253 @@ +import type { dia } from '@joint/core'; +import type { + ElkNode, + ElkPort, + LayoutOptions as ElkLayoutOptions, + ElkExtendedEdge, + ElkLabel +} from 'elkjs'; + +export const DEFAULT_LABEL_SIZE: dia.Size = { + width: 50, + height: 20 +}; + +// ELK ignores labels with no text. +const ELK_LABEL_TEXT = '-'; +const ELK_INLINE_LABEL_OPTIONS = { 'edgeLabels.inline': 'true' }; +// Ports are positioned by JointJS (via the element's port groups), not by ELK - +// this tells ELK to treat the coordinates we give it as final. +const ELK_FIXED_PORTS_OPTIONS = { 'elk.portConstraints': 'FIXED_POS' }; +// With `positionPorts`, ELK is free to reposition (and reorder) ports itself. +const ELK_FREE_PORTS_OPTIONS = { 'elk.portConstraints': 'FREE' }; + +type GetSizeCallback = (element: dia.Element) => dia.Size; +type NodeOptionsCallback = (element: dia.Element) => ElkLayoutOptions | undefined; +type PortOptionsCallback = (port: dia.Element.Port, element: dia.Element) => ElkLayoutOptions | undefined; +type EdgeOptionsCallback = (link: dia.Link) => ElkLayoutOptions | undefined; + +export interface ElkGraphPort { + element: dia.Element; + portId: string; +} + +export interface ElkGraphData { + elkGraph: ElkNode; + elementsById: Map; + linksById: Map; + portsById: Map; +} + +export interface ExportGraphOptions { + /** + * Specify custom logic to determine the element's size used during layout + * instead of the default `element.size()`. Not called for elements that + * have embedded elements - their size is computed by ELK to fit their content. + */ + getSize?: GetSizeCallback; + /** + * Per-element ELK layout options, merged into the generated ELK node. + * @example + * nodeOptions: (element) => ({ 'partitioning.partition': element.get('layer') }) + */ + nodeOptions?: NodeOptionsCallback; + /** + * Per-port ELK layout options, merged into the generated ELK port. + * @example + * portOptions: (port) => ({ 'port.side': port.group === 'in' ? 'WEST' : 'EAST' }) + */ + portOptions?: PortOptionsCallback; + /** + * Per-link ELK layout options, merged into the generated ELK edge. + */ + edgeOptions?: EdgeOptionsCallback; + /** + * Whether to account for link labels during layout and position them + * along the routed link afterwards. + * @defaultValue true + */ + edgeLabels?: boolean; + /** + * Whether to let ELK reposition (and reorder) ports along their element, + * instead of keeping them at the position JointJS itself already computed + * for them. The new positions are written back onto the graph - see the + * `positionPorts` option in `ImportLayoutOptions`. + * @defaultValue false + */ + positionPorts?: boolean; +} + +const getSize: GetSizeCallback = (element) => { + return element.size(); +}; + +const nodeOptions: NodeOptionsCallback = (_element) => { + return undefined; +}; + +const portOptions: PortOptionsCallback = (_port, _element) => { + return undefined; +}; + +const edgeOptions: EdgeOptionsCallback = (_link) => { + return undefined; +}; + +/** + * Builds the ELK ports for an element's JointJS ports, starting out at the + * position JointJS itself has already computed for them (via the element's + * port groups). Whether ELK is free to move them from there, or has to treat + * that position as final, is controlled by the node's own `elk.portConstraints` + * (see `ELK_FIXED_PORTS_OPTIONS`/`ELK_FREE_PORTS_OPTIONS` in `buildElkNode`). + */ +function buildPorts( + element: dia.Element, + portOptionsFn: PortOptionsCallback, + portsById: Map +): ElkPort[] | undefined { + if (!element.hasPorts()) return undefined; + + return element.getPorts().map((port): ElkPort => { + const portId = `${port.id}`; + const elkPortId = `${element.id}:${portId}`; + portsById.set(elkPortId, { element, portId }); + + const { x, y, width, height } = element.getPortRelativeRect(portId); + return { + id: elkPortId, + x, + y, + width, + height, + layoutOptions: portOptionsFn(port, element) + }; + }); +} + +/** + * Converts a JointJS graph (elements, their embedded elements, ports and the + * links between them) to an ELK graph structure. + */ +export function exportGraph( + graph: dia.Graph, + options: ExportGraphOptions, + layoutOptions: ElkLayoutOptions +): ElkGraphData { + + const getSizeFn = options.getSize ?? getSize; + const nodeOptionsFn = options.nodeOptions ?? nodeOptions; + const portOptionsFn = options.portOptions ?? portOptions; + const edgeOptionsFn = options.edgeOptions ?? edgeOptions; + const portConstraintsOptions = (options.positionPorts) ? ELK_FREE_PORTS_OPTIONS : ELK_FIXED_PORTS_OPTIONS; + + const elementsById = new Map(); + const linksById = new Map(); + const portsById = new 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. + const edgeContainersById = new Map(); + + function buildElkNode(element: dia.Element): ElkNode { + const id = `${element.id}`; + elementsById.set(id, element); + + const ports = buildPorts(element, portOptionsFn, portsById); + const customOptions = nodeOptionsFn(element); + + const embeds = element.getEmbeddedCells() + .filter((cell): cell is dia.Element => cell.isElement()); + + if (embeds.length > 0) { + // A container - its size is computed by ELK to fit its (recursively laid out) content. + const children = embeds.map(buildElkNode); + const node: ElkNode = { id, children, ports, layoutOptions: customOptions }; + edgeContainersById.set(id, node.edges = []); + return node; + } + + const { width, height } = getSizeFn(element); + return { + id, + width, + height, + ports, + layoutOptions: (ports) ? { ...portConstraintsOptions, ...customOptions } : customOptions + }; + } + + const children: ElkNode[] = graph.getElements() + .filter((element) => !element.parent()) + .map(buildElkNode); + + const elkGraph: ElkNode = { + id: 'root', + layoutOptions, + children, + edges: [] + }; + edgeContainersById.set(undefined, elkGraph.edges as ElkExtendedEdge[]); + + // The lowest common ancestor of an element and itself/an ancestor is the element's parent chain - + // this returns that chain, ordered from the outermost ancestor to the immediate parent. + function getAncestorPath(element: dia.Element): string[] { + return element.getAncestors().reverse().map((cell) => `${cell.id}`); + } + + 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; + } + + graph.getLinks().forEach((link) => { + const sourceElement = link.getSourceElement(); + const targetElement = link.getTargetElement(); + // Links not connected to two elements (e.g. connected to a point or + // to another link) are not part of the layout. + if (!sourceElement || !targetElement) return; + if (!elementsById.has(`${sourceElement.id}`) || !elementsById.has(`${targetElement.id}`)) return; + + const id = `${link.id}`; + linksById.set(id, link); + + const sourcePort = link.source().port; + const targetPort = link.target().port; + + const edge: ElkExtendedEdge = { + id, + sources: [(sourcePort) ? `${sourceElement.id}:${sourcePort}` : `${sourceElement.id}`], + targets: [(targetPort) ? `${targetElement.id}:${targetPort}` : `${targetElement.id}`], + layoutOptions: edgeOptionsFn(link) + }; + + if (options.edgeLabels) { + const labels = link.labels(); + if (labels.length > 0) { + edge.labels = labels.map((label): ElkLabel => { + const { width, height } = label.size || DEFAULT_LABEL_SIZE; + return { + // Some text is required, otherwise ELK ignores the label. + text: ELK_LABEL_TEXT, + width, + height, + // Place the label directly on the edge (and allocate space for it). + layoutOptions: ELK_INLINE_LABEL_OPTIONS + }; + }); + } + } + + 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 is a container + // that has already been registered in `edgeContainersById` by the time links are processed. + (edges as ElkExtendedEdge[]).push(edge); + }); + + 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..e6f848a962 --- /dev/null +++ b/packages/joint-layout-elk/src/import.mts @@ -0,0 +1,237 @@ +import { type dia, g } from '@joint/core'; +import type { ElkNode, ElkExtendedEdge, ElkPoint } from 'elkjs'; +import type { ElkGraphPort } from './export.mjs'; + +type SetPositionCallback = (element: dia.Element, position: dia.Point) => void; +type SetVerticesCallback = (link: dia.Link, vertices: dia.Point[]) => void; +type SetAnchorCallback = (link: dia.Link, element: dia.Element, point: dia.Point, endType: 'source' | 'target') => void; +type SetLabelsCallback = (link: dia.Link, labelBBox: dia.BBox, points: dia.Point[], labelIndex: number) => void; +type SetPortPositionCallback = (element: dia.Element, portId: string, position: dia.Point) => void; + +export interface EdgeLabelsOptions { + /** + * Sets a link label's position, based on the ELK edge label. + * Only takes effect when `edgeLabels` is enabled. + * @example + * setLabels: (link, labelBBox, points, labelIndex) => link.label(labelIndex, { + * position: { distance: 0, offset: 0 } + * }); + */ + setLabels?: SetLabelsCallback; +} + +export interface PortPositionsOptions { + /** + * Sets a port's position, based on the ELK port's layout result. + * Only takes effect when `positionPorts` is enabled. + * @example + * setPortPosition: (element, portId, position) => element.portProp(portId, ['position', 'args'], position) + */ + setPortPosition?: SetPortPositionCallback; +} + +export interface ImportLayoutOptions { + /** + * Specify a function to use when setting a new position to an element after layout + * instead of the default `element.position(x, y)`. + * @example + * setPosition: (el, pos) => el.position(pos.x, pos.y) + */ + setPosition?: SetPositionCallback; + /** + * Specify a function to use when setting new vertices to a link after layout + * instead of the default `link.vertices(vertices)`. + * @example + * setVertices: (link, vertices) => link.vertices(vertices) + */ + setVertices?: SetVerticesCallback; + /** + * Specify a function to use when setting a link's anchor at either source or target, based on the start/end + * point of the ELK edge section instead of the default `topLeft` anchor. Not called for a link end that is + * connected to a port - ports keep the anchor JointJS already gives them. + * @example + * setAnchor: (link, element, point, endType) => { + * const delta = element.getRelativePointFromAbsolute(point); + * link.prop(`${endType}/anchor`, { + * name: 'topLeft', + * args: { + * dx: delta.x, + * dy: delta.y, + * useModelGeometry: true + * } + * }); + * } + */ + setAnchor?: SetAnchorCallback; + /** + * Whether to account for link labels during layout and position them + * along the routed link afterwards. + * @defaultValue true + */ + edgeLabels?: boolean | EdgeLabelsOptions; + /** + * Whether to let ELK reposition (and reorder) ports along their element, + * instead of keeping them at the position JointJS itself already computed + * for them. When enabled, every port's owning group is switched to an + * `'absolute'` position (preserving its `attrs`/`markup`/`label`) so the + * position ELK computed for it can be applied. + * @defaultValue false + */ + positionPorts?: boolean | PortPositionsOptions; +} + +const defaultSetPosition = (element: dia.Element, position: dia.Point) => { + element.position(position.x, position.y); +}; + +const defaultSetVertices = (link: dia.Link, vertices: dia.Point[]) => { + link.vertices(vertices); +}; + +const defaultSetAnchor = (link: dia.Link, element: dia.Element, point: dia.Point, endType: 'source' | 'target') => { + const delta = element.getRelativePointFromAbsolute(point); + link.prop(`${endType}/anchor`, { + name: 'topLeft', + args: { + dx: delta.x, + dy: delta.y, + useModelGeometry: true + } + }); +}; + +const defaultSetPortPosition = (element: dia.Element, portId: string, position: dia.Point) => { + const { group } = element.getPort(portId); + if (group !== undefined) { + // Every port ends up with a computed position (all of an element's ports are + // exported), so switching the whole group to `'absolute'` is safe here - it + // only replaces the group's `position`, leaving its `attrs`/`markup`/`label` intact. + element.prop(['ports', 'groups', group, 'position'], { name: 'absolute' }); + } + element.portProp(portId, ['position', 'args'], position); +}; + +const defaultSetLabels = (link: dia.Link, labelBBox: dia.BBox, points: dia.Point[], labelIndex: number) => { + + const polyline = new g.Polyline(points); + + const { x, y, width, height } = labelBBox; + const center = new g.Point(x + width / 2, y + height / 2); + + const distance = polyline.closestPointLength(center); + // Get the tangent at the closest point to calculate the offset + const tangent = polyline.tangentAtLength(distance); + + link.label(labelIndex, { + position: { + distance, + offset: tangent ? tangent.pointOffset(center) : 0 + } + }); +}; + +/** + * Applies an ELK layout result back onto the JointJS graph. + */ +export function importLayout( + elkGraph: ElkNode, + elementsById: Map, + linksById: Map, + portsById: Map, + options: ImportLayoutOptions +): void { + + const setPositionFn = options.setPosition ?? defaultSetPosition; + const setVerticesFn = options.setVertices ?? defaultSetVertices; + const setAnchorFn = options.setAnchor ?? defaultSetAnchor; + + let setPortPositionFn: SetPortPositionCallback | undefined; + if (options.positionPorts) { + setPortPositionFn = defaultSetPortPosition; + if (typeof options.positionPorts === 'object') { + setPortPositionFn = options.positionPorts.setPortPosition ?? defaultSetPortPosition; + } + } + + // ELK positions a node's children (and routes a node's own edges) relative to that + // node's own origin - `containerX`/`containerY` accumulate the offset needed to turn + // those relative coordinates into graph-absolute ones as we walk down the hierarchy. + const toAbsolute = (point: ElkPoint, containerX: number, containerY: number): dia.Point => ({ + x: containerX + point.x, + y: containerY + point.y + }); + + function importEdges(edges: ElkExtendedEdge[] | undefined, containerX: number, containerY: number): void { + (edges || []).forEach((edge) => { + const link = linksById.get(edge.id); + if (!link) return; + + const [section] = edge.sections || []; + if (!section) return; + + const { startPoint, endPoint, bendPoints = [] } = section; + + setVerticesFn(link, bendPoints.map((point) => toAbsolute(point, containerX, containerY))); + + const sourceElement = link.getSourceElement() as dia.Element; + const targetElement = link.getTargetElement() as dia.Element; + + // A port-connected end already has the anchor JointJS itself computed for that + // port (the same position ELK was told to route to) - it does not need overriding. + if (!link.source().port) { + setAnchorFn(link, sourceElement, toAbsolute(startPoint, containerX, containerY), 'source'); + } + if (!link.target().port) { + setAnchorFn(link, targetElement, toAbsolute(endPoint, containerX, containerY), 'target'); + } + + if (options.edgeLabels && edge.labels && edge.labels.length > 0) { + let setLabelsFn = defaultSetLabels; + + if (typeof options.edgeLabels === 'object') { + setLabelsFn = options.edgeLabels.setLabels ?? defaultSetLabels; + } + + const points = [startPoint, ...bendPoints, endPoint] + .map((point) => toAbsolute(point, containerX, containerY)); + edge.labels.forEach((label, labelIndex) => { + const { x = 0, y = 0, width = 0, height = 0 } = label; + setLabelsFn(link, { x: containerX + x, y: containerY + y, width, height }, points, labelIndex); + }); + } + }); + } + + function importNode(node: ElkNode, containerX: number, containerY: number): void { + const x = containerX + (node.x || 0); + const y = containerY + (node.y || 0); + + const element = elementsById.get(node.id); + if (element) { + setPositionFn(element, { x, y }); + if (node.children && node.children.length > 0) { + // A container - ELK computed its size to fit its (recursively laid out) content. + element.resize(node.width || 0, node.height || 0); + } + } + + if (setPortPositionFn && node.ports) { + node.ports.forEach((port) => { + const found = portsById.get(port.id); + if (!found) return; + + // A port's position is relative to its own element, same as `node.x`/`node.y` + // above - it does not need the `containerX`/`containerY` offset. + const centerX = (port.x || 0) + (port.width || 0) / 2; + const centerY = (port.y || 0) + (port.height || 0) / 2; + setPortPositionFn(found.element, found.portId, { x: centerX, y: centerY }); + }); + } + + (node.children || []).forEach((child) => importNode(child, x, y)); + importEdges(node.edges, x, y); + } + + (elkGraph.children || []).forEach((node) => importNode(node, 0, 0)); + importEdges(elkGraph.edges, 0, 0); +} diff --git a/packages/joint-layout-elk/src/index.mts b/packages/joint-layout-elk/src/index.mts index 8b3af4c90b..718c2d085d 100644 --- a/packages/joint-layout-elk/src/index.mts +++ b/packages/joint-layout-elk/src/index.mts @@ -1,2 +1 @@ -export { layout } from './layout.mjs'; -export type { Options, LayoutResult } from './types.mjs'; +export { layout, Options, LayoutResult } from './layout.mjs'; diff --git a/packages/joint-layout-elk/src/layout.mts b/packages/joint-layout-elk/src/layout.mts index 8f93954a73..ab5a929d3c 100644 --- a/packages/joint-layout-elk/src/layout.mts +++ b/packages/joint-layout-elk/src/layout.mts @@ -1,23 +1,80 @@ -import { type dia, g, util } from '@joint/core'; +import { util, g } from '@joint/core'; import ElkConstructor from 'elkjs/lib/elk.bundled.js'; -import { - type ELK, - type ElkNode, - type ElkExtendedEdge, - type ElkLabel, - type ElkLayoutOptions, - type Options, - type LayoutResult -} from './types.mjs'; -import { DEFAULT_LAYOUT_OPTIONS, DEFAULT_LABEL_SIZE, defaultOptions, setVertices, setAnchor, setLabels } from './defaults.mjs'; +import { importLayout } from './import.mjs'; +import { exportGraph } from './export.mjs'; + +import type { ExportGraphOptions } from './export.mjs'; +import type { EdgeLabelsOptions, ImportLayoutOptions, PortPositionsOptions } from './import.mjs'; +import type { dia } from '@joint/core'; +import type { ELK, ElkNode, LayoutOptions as ElkLayoutOptions } from 'elkjs'; const LAYOUT_BATCH_NAME = 'layout'; -// ELK ignores labels with no text. -const ELK_LABEL_TEXT = '-'; -const ELK_INLINE_LABEL_OPTIONS = { 'edgeLabels.inline': 'true' }; + +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' +}; + +const DEFAULT_OPTIONS: Options = { + edgeLabels: true, + batchName: LAYOUT_BATCH_NAME, +}; let defaultElk: ELK | undefined; +/** + * Layout configuration options. + */ +export interface Options extends Omit, Omit { + /** + * A custom ELK instance, e.g. one configured to run inside a Web Worker. + * The instance is not terminated by the package - call `elk.terminateWorker()` + * yourself when it is no longer needed. + * @defaultValue a shared, main-thread instance (`elkjs/lib/elk.bundled.js`) + * @example + * import ELK from 'elkjs/lib/elk-api.js'; + * const elk = new ELK({ workerUrl: new URL('elkjs/lib/elk-worker.min.js', import.meta.url).href }); + * layout(graph, { elk }); + */ + elk?: 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' }` + */ + layoutOptions?: ElkLayoutOptions; + /** + * Whether to account for link labels during layout and position them + * along the routed link afterwards. + * @defaultValue true + */ + edgeLabels?: boolean | EdgeLabelsOptions; + /** + * Whether to let ELK reposition (and reorder) ports along their element, + * instead of keeping them at the position JointJS itself already computed + * for them. When enabled, every port's owning group is switched to an + * `'absolute'` position (preserving its `attrs`/`markup`/`label`) so the + * position ELK computed for it can be applied. + * @defaultValue false + */ + positionPorts?: boolean | PortPositionsOptions; + /** + * A name for the layout batch, which can be used to group multiple layout operations together. + * @defaultValue 'layout' + */ + batchName?: string; +} + +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; +} + function getDefaultElk(): ELK { if (!defaultElk) { defaultElk = new ElkConstructor(); @@ -25,24 +82,26 @@ function getDefaultElk(): ELK { return defaultElk; } -interface ElkGraphData { - elkGraph: ElkNode; - elementsById: Map; - linksById: Map; +/** + * Tight bounding box of the top-level nodes in an ELK layout result. + */ +export function getBBox(elkGraph: ElkNode): g.Rect { + const rects = (elkGraph.children || []).map((node) => new g.Rect(node.x || 0, node.y || 0, node.width || 0, node.height || 0)); + return g.Rect.fromRectUnion(...rects) || new g.Rect(0, 0, 0, 0); } export async function layout(graph: dia.Graph, opt?: Options): Promise { - const options = util.defaults({}, opt || {}, defaultOptions) as Required>; + const options = util.defaults({}, opt || {}, DEFAULT_OPTIONS) as Options; const layoutOptions = util.defaults({}, opt?.layoutOptions || {}, DEFAULT_LAYOUT_OPTIONS) as ElkLayoutOptions; const elk = opt?.elk || getDefaultElk(); - const { elkGraph, elementsById, linksById } = toElkGraph(graph, options, layoutOptions); + const { elkGraph, elementsById, linksById, portsById } = exportGraph(graph, options as ExportGraphOptions, layoutOptions); const result = await elk.layout(elkGraph) as ElkNode; graph.startBatch(LAYOUT_BATCH_NAME); - applyElkLayout(result, elementsById, linksById, options); + importLayout(result, elementsById, linksById, portsById, options); graph.stopBatch(LAYOUT_BATCH_NAME); return { @@ -50,149 +109,3 @@ export async function layout(graph: dia.Graph, opt?: Options): Promise>, - layoutOptions: ElkLayoutOptions -): ElkGraphData { - - const elementsById = new Map(); - const linksById = new Map(); - - const children: ElkNode[] = graph.getElements() - .filter((element) => !element.parent()) - .map((element) => { - const id = `${element.id}`; - elementsById.set(id, element); - - const { width, height } = options.getSize(element); - return { - id, - width, - height, - layoutOptions: options.nodeOptions(element) - }; - }); - - const edges: ElkExtendedEdge[] = []; - - graph.getLinks().forEach((link) => { - const sourceElement = link.getSourceElement(); - const targetElement = link.getTargetElement(); - // Links not connected to two elements (e.g. connected to a point or - // to another link) are not part of the layout. - if (!sourceElement || !targetElement) return; - if (!elementsById.has(`${sourceElement.id}`) || !elementsById.has(`${targetElement.id}`)) return; - - const id = `${link.id}`; - linksById.set(id, link); - - const edge: ElkExtendedEdge = { - id, - sources: [`${sourceElement.id}`], - targets: [`${targetElement.id}`], - layoutOptions: options.edgeOptions(link) - }; - - if (options.edgeLabels) { - const labels = link.labels(); - if (labels.length > 0) { - edge.labels = labels.map((label): ElkLabel => { - const { width, height } = label.size || DEFAULT_LABEL_SIZE; - return { - // Some text is required, otherwise ELK ignores the label. - text: ELK_LABEL_TEXT, - width, - height, - // Place the label directly on the edge (and allocate space for it). - layoutOptions: ELK_INLINE_LABEL_OPTIONS - }; - }); - } - } - - edges.push(edge); - }); - - const elkGraph: ElkNode = { - id: 'root', - layoutOptions, - children, - edges - }; - - return { elkGraph, elementsById, linksById }; -} - -/** - * Applies an ELK layout result back onto the JointJS graph. - */ -function applyElkLayout( - elkGraph: ElkNode, - elementsById: Map, - linksById: Map, - options: Required> -): void { - - (elkGraph.children || []).forEach((node) => { - const element = elementsById.get(node.id); - if (!element) return; - - options.setPosition(element, { x: node.x || 0, y: node.y || 0 }); - }); - - (elkGraph.edges || []).forEach((edge) => { - const link = linksById.get(edge.id); - if (!link) return; - - const [section] = edge.sections || []; - if (!section) return; - - const { startPoint, endPoint, bendPoints = [] } = section; - - if (options.setVertices) { - if (util.isFunction(options.setVertices)) { - (options.setVertices as unknown as typeof setVertices)(link, bendPoints); - } else { - setVertices(link, bendPoints); - } - } - - if (options.setAnchor) { - const sourceElement = link.getSourceElement(); - const targetElement = link.getTargetElement(); - if (util.isFunction(options.setAnchor)) { - const setAnchorFn = options.setAnchor as unknown as typeof setAnchor; - if (sourceElement) setAnchorFn(link, sourceElement, startPoint, 'source'); - if (targetElement) setAnchorFn(link, targetElement, endPoint, 'target'); - } else { - if (sourceElement) setAnchor(link, sourceElement, startPoint, 'source'); - if (targetElement) setAnchor(link, targetElement, endPoint, 'target'); - } - } - - if (options.edgeLabels && options.setLabels && edge.labels && edge.labels.length > 0) { - const points = [startPoint, ...bendPoints, endPoint]; - const setLabelsFn = util.isFunction(options.setLabels) - ? options.setLabels as unknown as typeof setLabels - : setLabels; - edge.labels.forEach((label, labelIndex) => { - const { x = 0, y = 0, width = 0, height = 0 } = label; - setLabelsFn(link, { x, y, width, height }, points, labelIndex); - }); - } - }); -} - -/** - * Tight bounding box of the top-level nodes in an ELK layout result. - */ -function getBBox(elkGraph: ElkNode): g.Rect { - const rects = (elkGraph.children || []).map((node) => new g.Rect(node.x || 0, node.y || 0, node.width || 0, node.height || 0)); - return g.Rect.fromRectUnion(...rects) || new g.Rect(0, 0, 0, 0); -} diff --git a/packages/joint-layout-elk/src/types.mts b/packages/joint-layout-elk/src/types.mts deleted file mode 100644 index 75824ec63f..0000000000 --- a/packages/joint-layout-elk/src/types.mts +++ /dev/null @@ -1,102 +0,0 @@ -import type { dia, g } from '@joint/core'; -import type { - ELK, - ElkNode, - ElkExtendedEdge, - ElkLabel, - LayoutOptions as ElkLayoutOptions -} from 'elkjs/lib/elk-api.js'; - -export type { ELK, ElkNode, ElkExtendedEdge, ElkLabel, ElkLayoutOptions }; - -type GetSizeCallback = (element: dia.Element) => dia.Size; -type SetPositionCallback = (element: dia.Element, position: dia.Point) => void; -type SetVerticesCallback = (link: dia.Link, vertices: dia.Point[]) => void; -type SetAnchorCallback = (link: dia.Link, element: dia.Element, point: dia.Point, endType: 'source' | 'target') => void; -type SetLabelsCallback = (link: dia.Link, labelBBox: dia.BBox, points: dia.Point[], labelIndex: number) => void; -type NodeOptionsCallback = (element: dia.Element) => ElkLayoutOptions | undefined; -type EdgeOptionsCallback = (link: dia.Link) => ElkLayoutOptions | undefined; - -/** - * Layout configuration options. - */ -export interface Options { - /** - * A custom ELK instance, e.g. one configured to run inside a Web Worker. - * The instance is not terminated by the package - call `elk.terminateWorker()` - * yourself when it is no longer needed. - * @defaultValue a shared, main-thread instance (`elkjs/lib/elk.bundled.js`) - * @example - * import ELK from 'elkjs/lib/elk-api.js'; - * const elk = new ELK({ workerUrl: new URL('elkjs/lib/elk-worker.min.js', import.meta.url).href }); - * layout(graph, { elk }); - */ - elk?: ELK; - /** - * ELK layout options, passed through to ELK unmodified. - * @see https://eclipse.dev/elk/reference/options.html - * @defaultValue `{ 'elk.algorithm': 'layered' }` - */ - layoutOptions?: ElkLayoutOptions; - /** - * Whether to account for link labels during layout and position them - * along the routed link afterwards. - * @defaultValue true - */ - edgeLabels?: boolean; - /** - * Returns the element's size used during layout. - * @defaultValue element.size() - */ - getSize?: GetSizeCallback; - /** - * Applies a new position to an element after layout. - * @defaultValue element.position(x, y) - * @example - * setPosition: (el, pos) => el.position(pos.x, pos.y) - */ - setPosition?: SetPositionCallback; - /** - * Sets vertices on a link from the bend points of the ELK edge section. - * @remarks When set to `true`, the built-in vertices setter is used. Provide a function to customize. - * @defaultValue true - * @example - * setVertices: (link, vertices) => link.vertices(vertices) - */ - setVertices?: boolean | SetVerticesCallback; - /** - * Sets a link's anchor at either source or target, based on the start/end - * point of the ELK edge section. - * @remarks When set to `true`, the built-in `topLeft` anchor is used. Provide a function to customize. - * @defaultValue true - */ - setAnchor?: boolean | SetAnchorCallback; - /** - * Sets a link label's position, based on the ELK edge label. - * Only takes effect when `edgeLabels` is enabled. - * @remarks When set to `true`, the built-in label setter is used. Provide a function to customize. - * @defaultValue true - * @example - * setLabels: (link, labelBBox, points, labelIndex) => link.label(labelIndex, { - * position: { distance: 0, offset: 0 } - * }); - */ - setLabels?: boolean | SetLabelsCallback; - /** - * Per-element ELK layout options, merged into the generated ELK node. - * @example - * nodeOptions: (element) => ({ 'partitioning.partition': element.get('layer') }) - */ - nodeOptions?: NodeOptionsCallback; - /** - * Per-link ELK layout options, merged into the generated ELK edge. - */ - edgeOptions?: EdgeOptionsCallback; -} - -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; -} diff --git a/packages/joint-layout-elk/test/index.js b/packages/joint-layout-elk/test/index.js index df1a784e66..62afdb0e6b 100644 --- a/packages/joint-layout-elk/test/index.js +++ b/packages/joint-layout-elk/test/index.js @@ -89,19 +89,179 @@ QUnit.module('layout()', () => { assert.ok(label.position && typeof label.position.distance === 'number'); }); - QUnit.test('should ignore embedded elements', async(assert) => { + 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: 200, height: 200 }, position: { x: 10, y: 10 }}); - const child = new joint.shapes.standard.Rectangle({ id: 'child', size: { width: 50, height: 50 }, position: { x: 60, y: 60 }}); + 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]); + 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 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 portOptions 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, { + portOptions: (port, element) => { + seen.push([port.id, element.id]); + return undefined; + } + }); + + assert.deepEqual(seen, [['out1', 'a']]); + }); + + 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' }] + } + }); + + graph.resetCells([el1]); await joint.layout.ELK.layout(graph); - const position = child.position(); - assert.equal(position.x, 60); - assert.equal(position.y, 60); + // Untouched - still the original group config, not switched to 'absolute'. + assert.equal(el1.prop(['ports', 'groups', 'out', 'position']), 'right'); + }); + + QUnit.test('should let ELK position ports when `positionPorts` is enabled', 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, { positionPorts: true }); + + // The group's position is switched to 'absolute' so the ELK-computed position applies. + assert.equal(el1.prop(['ports', 'groups', 'out', 'position', 'name']), 'absolute'); + assert.equal(el2.prop(['ports', 'groups', 'in', 'position', 'name']), 'absolute'); + + 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); }); }); From 6ea64c0ec1d366d65472a5f26b5dea78ed9bd90d Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Mon, 7 Sep 2026 11:32:42 +0200 Subject: [PATCH 03/75] example(layout-elk): add containers, ports and width-restriction demo A fixed (non-random) system diagram exercising the new @joint/layout-elk capabilities: three containers, eight services connected through ports (three of which opt into `positionPorts` so ELK orders them to minimize crossings), and `elk.aspectRatio`/`elk.layered.wrapping.strategy` tuned to keep the drawing within a soft 1000-unit width budget. --- .../layout-elk-containers-ports-ts/.gitignore | 3 + .../layout-elk-containers-ports-ts/README.md | 24 ++ .../layout-elk-containers-ports-ts/index.html | 22 + .../package.json | 40 ++ .../src/index.ts | 387 ++++++++++++++++++ .../src/styles.scss | 48 +++ .../tsconfig.json | 12 + .../webpack.config.js | 39 ++ 8 files changed, 575 insertions(+) create mode 100644 examples/layout-elk-containers-ports-ts/.gitignore create mode 100644 examples/layout-elk-containers-ports-ts/README.md create mode 100644 examples/layout-elk-containers-ports-ts/index.html create mode 100644 examples/layout-elk-containers-ports-ts/package.json create mode 100644 examples/layout-elk-containers-ports-ts/src/index.ts create mode 100644 examples/layout-elk-containers-ports-ts/src/styles.scss create mode 100644 examples/layout-elk-containers-ports-ts/tsconfig.json create mode 100644 examples/layout-elk-containers-ports-ts/webpack.config.js diff --git a/examples/layout-elk-containers-ports-ts/.gitignore b/examples/layout-elk-containers-ports-ts/.gitignore new file mode 100644 index 0000000000..69c575d17f --- /dev/null +++ b/examples/layout-elk-containers-ports-ts/.gitignore @@ -0,0 +1,3 @@ +build/ +dist/ +node_modules/ diff --git a/examples/layout-elk-containers-ports-ts/README.md b/examples/layout-elk-containers-ports-ts/README.md new file mode 100644 index 0000000000..2e24d4a4cb --- /dev/null +++ b/examples/layout-elk-containers-ports-ts/README.md @@ -0,0 +1,24 @@ +# JointJS ELK Containers & Ports Demo + +A fixed (non-random), small system diagram laid out automatically with `@joint/layout-elk`: two containers ("Frontend", "Backend"), each grouping a couple of services that connect through ports - including a link that crosses from one container into the other. + +## 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 start +``` + +## 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-ports-ts/index.html b/examples/layout-elk-containers-ports-ts/index.html new file mode 100644 index 0000000000..1b4b313ef3 --- /dev/null +++ b/examples/layout-elk-containers-ports-ts/index.html @@ -0,0 +1,22 @@ + + + + + + + + ELK Containers & Ports Layout | JointJS + + + +
+ Zoom Out + Zoom In +
+
+ + + + + diff --git a/examples/layout-elk-containers-ports-ts/package.json b/examples/layout-elk-containers-ports-ts/package.json new file mode 100644 index 0000000000..bde7c5dbe8 --- /dev/null +++ b/examples/layout-elk-containers-ports-ts/package.json @@ -0,0 +1,40 @@ +{ + "name": "@joint/demo-layout-elk-containers-ports-ts", + "version": "4.3.1", + "description": "JointJS - ELK Layout Containers & Ports Demo", + "main": "dist/bundle.js", + "homepage": "https://jointjs.com", + "author": { + "name": "client IO", + "url": "https://client.io" + }, + "license": "MPL-2.0", + "private": true, + "installConfig": { + "hoistingLimits": "workspaces" + }, + "scripts": { + "start": "webpack-dev-server", + "build": "webpack" + }, + "dependencies": { + "@joint/core": "workspace:^", + "@joint/layout-elk": "workspace:^", + "elkjs": "0.12.0" + }, + "devDependencies": { + "css-loader": "3.5.3", + "sass-loader": "8.0.2", + "style-loader": "1.2.1", + "ts-loader": "^9.2.5", + "typescript": "5.8.2", + "webpack": "5.98.0", + "webpack-cli": "6.0.1", + "webpack-dev-server": "5.2.0" + }, + "volta": { + "node": "22.14.0", + "npm": "11.2.0", + "yarn": "4.18.0" + } +} diff --git a/examples/layout-elk-containers-ports-ts/src/index.ts b/examples/layout-elk-containers-ports-ts/src/index.ts new file mode 100644 index 0000000000..c91a3cb118 --- /dev/null +++ b/examples/layout-elk-containers-ports-ts/src/index.ts @@ -0,0 +1,387 @@ +import { dia, shapes } from '@joint/core'; +import { layout } from '@joint/layout-elk'; +import ELK from 'elkjs/lib/elk-api.js'; +import './styles.scss'; + +const ELK_DIRECTION = 'RIGHT'; +const CONTAINER_PADDING = '[top=40,left=20,bottom=20,right=20]'; +const PORT_SIZE = { width: 12, height: 12 }; +// Soft cap on the overall drawing width - ELK wraps layers onto additional +// rows (rather than growing ever wider) once it would otherwise be exceeded. +// It is a target for ELK's wrapping heuristic, not a hard guarantee. +const ELK_MAX_WIDTH = 1000; + +interface ContainerDef { + id: string; + label: string; +} + +interface NodeDef { + id: string; + label: string; + parent: string; + color: string; + inPorts: string[]; + outPorts: string[]; + // Whether this node is one of the few "hub" nodes that let ELK decide + // where its ports go (`positionPorts`), instead of keeping them exactly + // where JointJS's own port groups already place them. + autoPorts?: boolean; +} + +interface LinkDef { + source: string; + sourcePort: string; + target: string; + targetPort: string; + label: string; +} + +// A fixed (non-random) system diagram: three containers grouping eight +// services that communicate over ports, including links that cross container +// boundaries. Three "hub" nodes (Load Balancer, API Gateway, Auth Service) +// have several ports on the same side and opt into `positionPorts`, so ELK +// orders them to minimize crossings; every other node keeps its ports +// exactly where JointJS's own port groups already place them. +const CONTAINERS: ContainerDef[] = [ + { id: 'frontend', label: 'Frontend' }, + { id: 'backend', label: 'Backend' }, + { id: 'observability', label: 'Observability' } +]; + +const NODES: NodeDef[] = [ + { id: 'webui', label: 'Web UI', parent: 'frontend', color: '#F8FCDA', inPorts: [], outPorts: ['out'] }, + { id: 'mobileui', label: 'Mobile UI', parent: 'frontend', color: '#F8FCDA', inPorts: [], outPorts: ['out'] }, + { id: 'lb', label: 'Load Balancer', parent: 'frontend', color: '#E3E9C2', inPorts: ['in1', 'in2'], outPorts: ['out'], autoPorts: true }, + { id: 'gateway', label: 'API Gateway', parent: 'frontend', color: '#E3E9C2', inPorts: ['in'], outPorts: ['out1', 'out2'], autoPorts: true }, + { id: 'auth', label: 'Auth Service', parent: 'backend', color: '#F9FBB2', inPorts: ['in'], outPorts: ['out1', 'out2'], autoPorts: true }, + { id: 'cache', label: 'Cache', parent: 'backend', color: '#F9FBB2', inPorts: ['in'], outPorts: ['out'] }, + { id: 'db', label: 'Database', parent: 'backend', color: '#C89F9C', inPorts: ['in'], outPorts: [] }, + { id: 'logger', label: 'Logger', parent: 'observability', color: '#D9D2E9', inPorts: ['in1', 'in2'], outPorts: [] } +]; + +const LINKS: LinkDef[] = [ + { source: 'webui', sourcePort: 'out', target: 'lb', targetPort: 'in1', label: 'request' }, + { source: 'mobileui', sourcePort: 'out', target: 'lb', targetPort: 'in2', label: 'request' }, + { source: 'lb', sourcePort: 'out', target: 'gateway', targetPort: 'in', label: 'route' }, + { source: 'gateway', sourcePort: 'out1', target: 'auth', targetPort: 'in', label: 'authenticate' }, + { source: 'gateway', sourcePort: 'out2', target: 'logger', targetPort: 'in1', label: 'log' }, + { source: 'auth', sourcePort: 'out1', target: 'cache', targetPort: 'in', label: 'lookup' }, + { source: 'auth', sourcePort: 'out2', target: 'logger', targetPort: 'in2', label: 'log' }, + { source: 'cache', sourcePort: 'out', target: 'db', targetPort: 'in', label: 'query' } +]; + +const NODES_BY_ID = new Map(NODES.map((def) => [def.id, def])); + +const init = () => { + + // Create JointJS graph and paper + const graph = new dia.Graph({}, { cellNamespace: shapes }); + const paper = new dia.Paper({ + model: graph, + cellViewNamespace: shapes, + width: 1200, + height: 700, + gridSize: 1, + interactive: false, + async: true, + frozen: true, + defaultConnectionPoint: { + name: 'anchor' + }, + defaultConnector: { + name: 'straight', + args: { + cornerType: 'cubic', + cornerRadius: 5 + } + } + }); + document.getElementById('canvas')!.appendChild(paper.el); + addZoomAndPanListeners(paper); + + // Generate JointJS cells from the fixed example data + graph.resetCells(generateCells()); + + // Run ELK in a Web Worker, via the `@joint/layout-elk` package + const elk = new ELK({ + workerUrl: '../node_modules/elkjs/lib/elk-worker.js', + }); + + layout(graph, { + elk, + layoutOptions: { + /** + * 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', + + /** + * Distance between edge labels and the edge itself. + * A number value as a string. + */ + 'elk.spacing.edgeLabel': '4', + + /** + * 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, to keep this + * particular graph within `ELK_MAX_WIDTH` (see the check below). + */ + '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', + }, + // Let ELK reposition ports for the "hub" nodes only (see `nodeOptions` + // below, which opts every other node back out on a per-node basis). + positionPorts: true, + nodeOptions: (element) => { + // Reserve extra top padding inside containers, so children don't + // overlap the container's title label. + if (element.getEmbeddedCells().length > 0) { + return { 'elk.padding': CONTAINER_PADDING }; + } + const def = NODES_BY_ID.get(`${element.id}`); + if (def && !def.autoPorts) { + // Keep this node's ports exactly where JointJS placed them. + return { 'elk.portConstraints': 'FIXED_POS' }; + } + // Leave unset - the `positionPorts` default (`FREE`) applies. + return undefined; + } + }).then(() => { + paper.unfreeze(); + zoom(paper, 1); + + // `elk.aspectRatio` only targets the placement of nodes - the drawing's + // actual width (checked here on the rendered content, wrap-around + // routing included) is a target for ELK's wrapping heuristic, not a + // hard guarantee, so flag it during development if it is ever missed. + const contentWidth = paper.getContentBBox({ useModelGeometry: true }).width; + if (contentWidth > ELK_MAX_WIDTH) { + console.warn(`ELK layout is ${contentWidth} units wide, over the ${ELK_MAX_WIDTH} unit target.`); + } + }).catch((error) => { + 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) + ); + }); +} + +/** + * Create a dashed, semi-transparent container element - its final size and + * position are computed by ELK to fit whatever gets embedded into it. + */ +function createContainer(def: ContainerDef): dia.Element { + return new shapes.standard.Rectangle({ + id: def.id, + size: { width: 100, height: 100 }, + attrs: { + body: { + fill: '#EEF3F1', + stroke: '#7C9C92', + strokeWidth: 2, + strokeDasharray: '6,3', + rx: 8, + ry: 8 + }, + label: { + text: def.label, + x: 12, + y: 10, + textAnchor: 'start', + textVerticalAnchor: 'top', + fontWeight: 'bold', + fontSize: 13, + fill: '#3E5C53', + fontFamily: 'Arial, helvetica, sans-serif' + } + } + }); +} + +/** + * Create a rectangle element with the given 'in' (left) and 'out' (right) + * ports. A node tall enough to fit whichever side has more ports. + */ +function createNode(def: NodeDef): dia.Element { + const items: dia.Element.Port[] = [ + ...def.inPorts.map((portId): dia.Element.Port => ({ + id: portId, + group: 'in', + size: PORT_SIZE, + attrs: { text: { text: portId } } + })), + ...def.outPorts.map((portId): dia.Element.Port => ({ + id: portId, + group: 'out', + size: PORT_SIZE, + attrs: { text: { text: portId } } + })) + ]; + + const maxPortsPerSide = Math.max(def.inPorts.length, def.outPorts.length, 1); + const height = 50 + (maxPortsPerSide - 1) * 30; + + return new shapes.standard.Rectangle({ + id: def.id, + size: { width: 130, height }, + attrs: { + body: { + fill: def.color, + stroke: (def.autoPorts) ? '#B85C38' : '#333', + strokeWidth: (def.autoPorts) ? 3 : 2, + rx: 5, + ry: 5 + }, + label: { + text: def.label, + fill: '#333', + fontSize: 13, + fontFamily: 'Arial, helvetica, sans-serif' + } + }, + ports: { + groups: { + in: { + position: 'left', + attrs: { + circle: { r: 6, fill: '#FFFFFF', stroke: '#333', strokeWidth: 2 }, + text: { fontSize: 10, fill: '#555' } + }, + // 'top' (rather than 'left') keeps the label clear of the link + // labels routed horizontally between closely-spaced ports. + label: { position: { name: 'top' } } + }, + out: { + position: 'right', + attrs: { + circle: { r: 6, fill: '#FFFFFF', stroke: '#333', strokeWidth: 2 }, + text: { fontSize: 10, fill: '#555' } + }, + label: { position: { name: 'top' } } + } + }, + items + } + }); +} + +/** + * Create a link between two nodes, connected via the given ports, with a + * label describing the interaction. + */ +function createLink(def: LinkDef): dia.Link { + return new shapes.standard.Link({ + source: { id: def.source, port: def.sourcePort }, + target: { id: def.target, port: def.targetPort }, + labels: [{ + size: { width: 80, height: 20 }, + attrs: { + text: { + text: def.label, + fontSize: 11, + fontFamily: 'Arial, helvetica, sans-serif', + fill: '#333' + }, + rect: { + ref: null, + x: 'calc(x - calc(w / 2))', + y: 'calc(y - calc(h / 2))', + width: 'calc(w)', + height: 'calc(h)', + fill: '#FFB7C3', + strokeWidth: 1, + stroke: '#333' + }, + }, + position: 0.5 + }] + }); +} + +/** + * Build the fixed set of cells: the containers, the nodes embedded into + * them, and the links between the nodes. + */ +function generateCells(): dia.Cell[] { + const containers = CONTAINERS.map(createContainer); + const containersById = new Map(containers.map((container) => [`${container.id}`, container])); + + const nodes = NODES.map((def) => { + const node = createNode(def); + containersById.get(def.parent)!.embed(node); + return node; + }); + + const links = LINKS.map(createLink); + + return [...containers, ...nodes, ...links]; +} + +init(); diff --git a/examples/layout-elk-containers-ports-ts/src/styles.scss b/examples/layout-elk-containers-ports-ts/src/styles.scss new file mode 100644 index 0000000000..e712102c7b --- /dev/null +++ b/examples/layout-elk-containers-ports-ts/src/styles.scss @@ -0,0 +1,48 @@ + +html, body { + margin: 0; + padding: 0; +} + +#canvas { + position: absolute; + margin-top: 50px; + margin-left: 20px; + border: 1px solid #E2E2E2; + background-color: #F3F7F6; + 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: #FFFFFF; + border: 1px solid #E0E0E0; + border-radius: 16px; + text-align: center; + font-family: sans-serif; + font-size: 12px; + padding: 6px 12px; + letter-spacing: 0.25px; + color: #222222; + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + margin: 0 2px; + + &:hover { + background: #F7F8F9; + } +} diff --git a/examples/layout-elk-containers-ports-ts/tsconfig.json b/examples/layout-elk-containers-ports-ts/tsconfig.json new file mode 100644 index 0000000000..7ecb0809ec --- /dev/null +++ b/examples/layout-elk-containers-ports-ts/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "module": "ES6", + "moduleResolution": "node", + "target": "es6", + "noImplicitAny": false, + "sourceMap": false, + "outDir": "./build", + "resolveJsonModule": true, + "esModuleInterop": true + } +} diff --git a/examples/layout-elk-containers-ports-ts/webpack.config.js b/examples/layout-elk-containers-ports-ts/webpack.config.js new file mode 100644 index 0000000000..7b10ca335d --- /dev/null +++ b/examples/layout-elk-containers-ports-ts/webpack.config.js @@ -0,0 +1,39 @@ +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: /\.m?js/, + resolve: { + fullySpecified: false, + }, + }, + { test: /\.ts$/, loader: 'ts-loader' }, + { + test: /\.s[ac]ss$/i, + use: [ + 'style-loader', + 'css-loader', + 'sass-loader', + ], + }, + ], + }, + devServer: { + static: { + directory: __dirname, + }, + compress: true, + }, +}; From 044ce022c9d927442d2c7b69dbe82f4d3eb7416b Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Mon, 7 Sep 2026 11:34:53 +0200 Subject: [PATCH 04/75] docs(changeset): add 'new package' changeset for @joint/layout-elk The package itself (README, build config, initial layout()) isn't covered by an earlier changeset - this PR is its first appearance on master, so the changelog should read as an introduction, not a bump. --- .changeset/layout-elk-new-package.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/layout-elk-new-package.md 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 From 43141df3770a17964bdedb5fbe2c5a999e5f9220 Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Tue, 8 Sep 2026 11:55:38 +0200 Subject: [PATCH 05/75] up --- .changeset/layout-elk-containers.md | 5 - .changeset/layout-elk-port-options.md | 5 - .changeset/layout-elk-ports.md | 5 - .../src/example.ts | 175 +++++++++ .../src/index.ts | 336 ++++-------------- .../src/shapes.ts | 156 ++++++++ packages/joint-layout-elk/src/export.mts | 4 +- packages/joint-layout-elk/src/index.mts | 4 +- packages/joint-layout-elk/src/layout.mts | 10 +- 9 files changed, 409 insertions(+), 291 deletions(-) delete mode 100644 .changeset/layout-elk-containers.md delete mode 100644 .changeset/layout-elk-port-options.md delete mode 100644 .changeset/layout-elk-ports.md create mode 100644 examples/layout-elk-containers-ports-ts/src/example.ts create mode 100644 examples/layout-elk-containers-ports-ts/src/shapes.ts diff --git a/.changeset/layout-elk-containers.md b/.changeset/layout-elk-containers.md deleted file mode 100644 index 920359e517..0000000000 --- a/.changeset/layout-elk-containers.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@joint/layout-elk": minor ---- - -layout.ELK - lay out elements embedded in another element as a container, resized (and positioned) by ELK to fit their content diff --git a/.changeset/layout-elk-port-options.md b/.changeset/layout-elk-port-options.md deleted file mode 100644 index aab8541047..0000000000 --- a/.changeset/layout-elk-port-options.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@joint/layout-elk": minor ---- - -layout.ELK - add a `portOptions` callback for per-port ELK layout options, alongside the existing `nodeOptions`/`edgeOptions` diff --git a/.changeset/layout-elk-ports.md b/.changeset/layout-elk-ports.md deleted file mode 100644 index d9389e14a3..0000000000 --- a/.changeset/layout-elk-ports.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@joint/layout-elk": minor ---- - -layout.ELK - route edges to/from an element's ports, kept at the position JointJS itself already computes for them unless `positionPorts` is enabled diff --git a/examples/layout-elk-containers-ports-ts/src/example.ts b/examples/layout-elk-containers-ports-ts/src/example.ts new file mode 100644 index 0000000000..9fbd5d5b35 --- /dev/null +++ b/examples/layout-elk-containers-ports-ts/src/example.ts @@ -0,0 +1,175 @@ +import { dia } from '@joint/core'; + +// A fixed (non-random) system diagram: three containers grouping eight +// services that communicate over ports, including links that cross container +// boundaries. Every plain `example.Service` has exactly one 'in' and one +// 'out' port; the four "hub" services (Load Balancer, API Gateway, Auth +// Service, Logger) are `example.HubService` instead, with a custom number of +// ports - highlighted, and the only ones that opt into `positionPorts` so +// ELK orders their ports to minimize crossings (see `index.ts`). +export const graphJSON: dia.Graph.JSON = { + cells: [ + // Containers + { + id: 'frontend', + type: 'example.Container', + attrs: { label: { text: 'Frontend' } }, + embeds: ['webui', 'mobileui', 'lb', 'gateway'] + }, + { + id: 'backend', + type: 'example.Container', + attrs: { label: { text: 'Backend' } }, + embeds: ['auth', 'cache', 'db'] + }, + { + id: 'observability', + type: 'example.Container', + attrs: { label: { text: 'Observability' } }, + embeds: ['logger'] + }, + + // Frontend + { + id: 'webui', + type: 'example.Service', + parent: 'frontend', + attrs: { body: { fill: '#F8FCDA' }, label: { text: 'Web UI' } } + }, + { + id: 'mobileui', + type: 'example.Service', + parent: 'frontend', + attrs: { body: { fill: '#F8FCDA' }, label: { text: 'Mobile UI' } } + }, + { + id: 'lb', + type: 'example.HubService', + parent: 'frontend', + size: { width: 130, height: 80 }, + attrs: { body: { fill: '#E3E9C2' }, 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: 'frontend', + size: { width: 130, height: 80 }, + attrs: { body: { fill: '#E3E9C2' }, 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' } } } + ] + } + }, + + // Backend + { + id: 'auth', + type: 'example.HubService', + parent: 'backend', + size: { width: 130, height: 80 }, + attrs: { body: { fill: '#F9FBB2' }, 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: 'backend', + attrs: { body: { fill: '#F9FBB2' }, label: { text: 'Cache' } } + }, + { + id: 'db', + type: 'example.Service', + parent: 'backend', + attrs: { body: { fill: '#C89F9C' }, label: { text: 'Database' } } + }, + + // Observability + { + id: 'logger', + type: 'example.HubService', + parent: 'observability', + size: { width: 130, height: 80 }, + attrs: { body: { fill: '#D9D2E9' }, label: { text: 'Logger' } }, + 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: 'logger', port: 'in1' }, + labels: [{ attrs: { text: { text: 'log' } } }] + }, + { + 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: 'logger', port: 'in2' }, + labels: [{ attrs: { text: { text: 'log' } } }] + }, + { + id: 'l8', + type: 'example.InteractionLink', + source: { id: 'cache', port: 'out' }, + target: { id: 'db', port: 'in' }, + labels: [{ attrs: { text: { text: 'query' } } }] + } + ] +}; diff --git a/examples/layout-elk-containers-ports-ts/src/index.ts b/examples/layout-elk-containers-ports-ts/src/index.ts index c91a3cb118..4a84710fe5 100644 --- a/examples/layout-elk-containers-ports-ts/src/index.ts +++ b/examples/layout-elk-containers-ports-ts/src/index.ts @@ -1,94 +1,40 @@ import { dia, shapes } from '@joint/core'; -import { layout } from '@joint/layout-elk'; +import { ElkLayoutOptions, layout } from '@joint/layout-elk'; import ELK from 'elkjs/lib/elk-api.js'; +import { graphJSON } from './example'; +import { Container, HubService, InteractionLink, Service } from './shapes'; import './styles.scss'; const ELK_DIRECTION = 'RIGHT'; const CONTAINER_PADDING = '[top=40,left=20,bottom=20,right=20]'; -const PORT_SIZE = { width: 12, height: 12 }; // Soft cap on the overall drawing width - ELK wraps layers onto additional // rows (rather than growing ever wider) once it would otherwise be exceeded. // It is a target for ELK's wrapping heuristic, not a hard guarantee. const ELK_MAX_WIDTH = 1000; -interface ContainerDef { - id: string; - label: string; -} - -interface NodeDef { - id: string; - label: string; - parent: string; - color: string; - inPorts: string[]; - outPorts: string[]; - // Whether this node is one of the few "hub" nodes that let ELK decide - // where its ports go (`positionPorts`), instead of keeping them exactly - // where JointJS's own port groups already place them. - autoPorts?: boolean; -} - -interface LinkDef { - source: string; - sourcePort: string; - target: string; - targetPort: string; - label: string; -} - -// A fixed (non-random) system diagram: three containers grouping eight -// services that communicate over ports, including links that cross container -// boundaries. Three "hub" nodes (Load Balancer, API Gateway, Auth Service) -// have several ports on the same side and opt into `positionPorts`, so ELK -// orders them to minimize crossings; every other node keeps its ports -// exactly where JointJS's own port groups already place them. -const CONTAINERS: ContainerDef[] = [ - { id: 'frontend', label: 'Frontend' }, - { id: 'backend', label: 'Backend' }, - { id: 'observability', label: 'Observability' } -]; - -const NODES: NodeDef[] = [ - { id: 'webui', label: 'Web UI', parent: 'frontend', color: '#F8FCDA', inPorts: [], outPorts: ['out'] }, - { id: 'mobileui', label: 'Mobile UI', parent: 'frontend', color: '#F8FCDA', inPorts: [], outPorts: ['out'] }, - { id: 'lb', label: 'Load Balancer', parent: 'frontend', color: '#E3E9C2', inPorts: ['in1', 'in2'], outPorts: ['out'], autoPorts: true }, - { id: 'gateway', label: 'API Gateway', parent: 'frontend', color: '#E3E9C2', inPorts: ['in'], outPorts: ['out1', 'out2'], autoPorts: true }, - { id: 'auth', label: 'Auth Service', parent: 'backend', color: '#F9FBB2', inPorts: ['in'], outPorts: ['out1', 'out2'], autoPorts: true }, - { id: 'cache', label: 'Cache', parent: 'backend', color: '#F9FBB2', inPorts: ['in'], outPorts: ['out'] }, - { id: 'db', label: 'Database', parent: 'backend', color: '#C89F9C', inPorts: ['in'], outPorts: [] }, - { id: 'logger', label: 'Logger', parent: 'observability', color: '#D9D2E9', inPorts: ['in1', 'in2'], outPorts: [] } -]; - -const LINKS: LinkDef[] = [ - { source: 'webui', sourcePort: 'out', target: 'lb', targetPort: 'in1', label: 'request' }, - { source: 'mobileui', sourcePort: 'out', target: 'lb', targetPort: 'in2', label: 'request' }, - { source: 'lb', sourcePort: 'out', target: 'gateway', targetPort: 'in', label: 'route' }, - { source: 'gateway', sourcePort: 'out1', target: 'auth', targetPort: 'in', label: 'authenticate' }, - { source: 'gateway', sourcePort: 'out2', target: 'logger', targetPort: 'in1', label: 'log' }, - { source: 'auth', sourcePort: 'out1', target: 'cache', targetPort: 'in', label: 'lookup' }, - { source: 'auth', sourcePort: 'out2', target: 'logger', targetPort: 'in2', label: 'log' }, - { source: 'cache', sourcePort: 'out', target: 'db', targetPort: 'in', label: 'query' } -]; - -const NODES_BY_ID = new Map(NODES.map((def) => [def.id, def])); +const cellNamespace = { + ...shapes, + example: { + Container, + Service, + HubService, + InteractionLink + } +}; const init = () => { // Create JointJS graph and paper - const graph = new dia.Graph({}, { cellNamespace: shapes }); + const graph = new dia.Graph({}, { cellNamespace }); const paper = new dia.Paper({ model: graph, - cellViewNamespace: shapes, + cellViewNamespace: cellNamespace, width: 1200, height: 700, gridSize: 1, interactive: false, async: true, frozen: true, - defaultConnectionPoint: { - name: 'anchor' - }, defaultConnector: { name: 'straight', args: { @@ -100,62 +46,66 @@ const init = () => { document.getElementById('canvas')!.appendChild(paper.el); addZoomAndPanListeners(paper); - // Generate JointJS cells from the fixed example data - graph.resetCells(generateCells()); + // 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 }); // Run ELK in a Web Worker, via the `@joint/layout-elk` package const elk = new ELK({ workerUrl: '../node_modules/elkjs/lib/elk-worker.js', }); + 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', + + /** + * Distance between edge labels and the edge itself. + * A number value as a string. + */ + 'elk.spacing.edgeLabel': '4', + + /** + * 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, to keep this + * particular graph within `ELK_MAX_WIDTH` (see the check below). + */ + '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', + } + layout(graph, { elk, - layoutOptions: { - /** - * 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', - - /** - * Distance between edge labels and the edge itself. - * A number value as a string. - */ - 'elk.spacing.edgeLabel': '4', - - /** - * 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, to keep this - * particular graph within `ELK_MAX_WIDTH` (see the check below). - */ - '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', - }, + elkLayoutOptions, // Let ELK reposition ports for the "hub" nodes only (see `nodeOptions` // below, which opts every other node back out on a per-node basis). positionPorts: true, @@ -165,8 +115,7 @@ const init = () => { if (element.getEmbeddedCells().length > 0) { return { 'elk.padding': CONTAINER_PADDING }; } - const def = NODES_BY_ID.get(`${element.id}`); - if (def && !def.autoPorts) { + if (!(element instanceof HubService)) { // Keep this node's ports exactly where JointJS placed them. return { 'elk.portConstraints': 'FIXED_POS' }; } @@ -233,155 +182,4 @@ function addZoomAndPanListeners(paper: dia.Paper): void { }); } -/** - * Create a dashed, semi-transparent container element - its final size and - * position are computed by ELK to fit whatever gets embedded into it. - */ -function createContainer(def: ContainerDef): dia.Element { - return new shapes.standard.Rectangle({ - id: def.id, - size: { width: 100, height: 100 }, - attrs: { - body: { - fill: '#EEF3F1', - stroke: '#7C9C92', - strokeWidth: 2, - strokeDasharray: '6,3', - rx: 8, - ry: 8 - }, - label: { - text: def.label, - x: 12, - y: 10, - textAnchor: 'start', - textVerticalAnchor: 'top', - fontWeight: 'bold', - fontSize: 13, - fill: '#3E5C53', - fontFamily: 'Arial, helvetica, sans-serif' - } - } - }); -} - -/** - * Create a rectangle element with the given 'in' (left) and 'out' (right) - * ports. A node tall enough to fit whichever side has more ports. - */ -function createNode(def: NodeDef): dia.Element { - const items: dia.Element.Port[] = [ - ...def.inPorts.map((portId): dia.Element.Port => ({ - id: portId, - group: 'in', - size: PORT_SIZE, - attrs: { text: { text: portId } } - })), - ...def.outPorts.map((portId): dia.Element.Port => ({ - id: portId, - group: 'out', - size: PORT_SIZE, - attrs: { text: { text: portId } } - })) - ]; - - const maxPortsPerSide = Math.max(def.inPorts.length, def.outPorts.length, 1); - const height = 50 + (maxPortsPerSide - 1) * 30; - - return new shapes.standard.Rectangle({ - id: def.id, - size: { width: 130, height }, - attrs: { - body: { - fill: def.color, - stroke: (def.autoPorts) ? '#B85C38' : '#333', - strokeWidth: (def.autoPorts) ? 3 : 2, - rx: 5, - ry: 5 - }, - label: { - text: def.label, - fill: '#333', - fontSize: 13, - fontFamily: 'Arial, helvetica, sans-serif' - } - }, - ports: { - groups: { - in: { - position: 'left', - attrs: { - circle: { r: 6, fill: '#FFFFFF', stroke: '#333', strokeWidth: 2 }, - text: { fontSize: 10, fill: '#555' } - }, - // 'top' (rather than 'left') keeps the label clear of the link - // labels routed horizontally between closely-spaced ports. - label: { position: { name: 'top' } } - }, - out: { - position: 'right', - attrs: { - circle: { r: 6, fill: '#FFFFFF', stroke: '#333', strokeWidth: 2 }, - text: { fontSize: 10, fill: '#555' } - }, - label: { position: { name: 'top' } } - } - }, - items - } - }); -} - -/** - * Create a link between two nodes, connected via the given ports, with a - * label describing the interaction. - */ -function createLink(def: LinkDef): dia.Link { - return new shapes.standard.Link({ - source: { id: def.source, port: def.sourcePort }, - target: { id: def.target, port: def.targetPort }, - labels: [{ - size: { width: 80, height: 20 }, - attrs: { - text: { - text: def.label, - fontSize: 11, - fontFamily: 'Arial, helvetica, sans-serif', - fill: '#333' - }, - rect: { - ref: null, - x: 'calc(x - calc(w / 2))', - y: 'calc(y - calc(h / 2))', - width: 'calc(w)', - height: 'calc(h)', - fill: '#FFB7C3', - strokeWidth: 1, - stroke: '#333' - }, - }, - position: 0.5 - }] - }); -} - -/** - * Build the fixed set of cells: the containers, the nodes embedded into - * them, and the links between the nodes. - */ -function generateCells(): dia.Cell[] { - const containers = CONTAINERS.map(createContainer); - const containersById = new Map(containers.map((container) => [`${container.id}`, container])); - - const nodes = NODES.map((def) => { - const node = createNode(def); - containersById.get(def.parent)!.embed(node); - return node; - }); - - const links = LINKS.map(createLink); - - return [...containers, ...nodes, ...links]; -} - init(); diff --git a/examples/layout-elk-containers-ports-ts/src/shapes.ts b/examples/layout-elk-containers-ports-ts/src/shapes.ts new file mode 100644 index 0000000000..d218e268a1 --- /dev/null +++ b/examples/layout-elk-containers-ports-ts/src/shapes.ts @@ -0,0 +1,156 @@ +import { shapes, util } from '@joint/core'; + +const PORT_SIZE = { width: 12, height: 12 }; +const PORT_ATTRS = { + circle: { r: 6, fill: '#FFFFFF', stroke: '#333', strokeWidth: 2 }, + text: { fontSize: 10, fill: '#555' } +}; +// 'top' (rather than 'left'/'right') keeps a port's label clear of the link +// labels routed horizontally between closely-spaced ports. +const PORT_LABEL = { + position: { + name: 'outside', + args: { + offset: 4, + y: 10 + }, + } +}; + +/** + * A dashed, semi-transparent container - 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 }, + attrs: { + body: { + fill: '#EEF3F1', + stroke: '#7C9C92', + strokeWidth: 2, + strokeDasharray: '6,3', + rx: 8, + ry: 8 + }, + label: { + x: 12, + y: 10, + textAnchor: 'start', + textVerticalAnchor: 'top', + fontWeight: 'bold', + fontSize: 13, + fill: '#3E5C53', + fontFamily: 'Arial, helvetica, sans-serif' + } + } + }, super.defaults); + } +} + +/** + * A service node with exactly one 'in' (left) and one 'out' (right) port, + * always - only `fill` and label `text` are 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: { + stroke: '#333', + strokeWidth: 2, + rx: 5, + ry: 5 + }, + label: { + fill: '#333', + fontSize: 13, + fontFamily: 'Arial, helvetica, sans-serif' + } + }, + ports: { + groups: { + in: { + position: 'left', + size: PORT_SIZE, + attrs: PORT_ATTRS, + label: PORT_LABEL + }, + out: { + position: 'right', + size: PORT_SIZE, + attrs: PORT_ATTRS, + label: PORT_LABEL + } + }, + items: [ + { id: 'in', group: 'in', attrs: { text: { text: 'in' } } }, + { id: 'out', group: 'out', attrs: { text: { text: 'out' } } } + ] + } + }, super.defaults); + } +} + +/** + * A service with a custom (per-instance) number of ports - `ports.items` + * always comes from the instance, replacing `Service`'s fixed pair. Also + * highlighted with a thicker, colored stroke to signal that it's one of the + * few nodes whose ports ELK is free to reposition (`positionPorts`), instead + * of keeping them where JointJS placed them. + */ +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: { + stroke: '#B85C38', + strokeWidth: 3 + } + } + }, super.defaults()); + } +} + +/** + * A link with a labelled, pill-shaped 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', + labels: [{ + size: { width: 80, height: 20 }, + attrs: { + text: { + fontSize: 11, + fontFamily: 'Arial, helvetica, sans-serif', + fill: '#333' + }, + rect: { + ref: null, + x: 'calc(x - calc(w / 2))', + y: 'calc(y - calc(h / 2))', + width: 'calc(w)', + height: 'calc(h)', + fill: '#FFB7C3', + strokeWidth: 1, + stroke: '#333' + } + }, + position: 0.5 + }] + }, super.defaults); + } +} diff --git a/packages/joint-layout-elk/src/export.mts b/packages/joint-layout-elk/src/export.mts index a7966e3955..c2f1412a0e 100644 --- a/packages/joint-layout-elk/src/export.mts +++ b/packages/joint-layout-elk/src/export.mts @@ -131,7 +131,7 @@ function buildPorts( export function exportGraph( graph: dia.Graph, options: ExportGraphOptions, - layoutOptions: ElkLayoutOptions + elkLayoutOptions: ElkLayoutOptions ): ElkGraphData { const getSizeFn = options.getSize ?? getSize; @@ -181,7 +181,7 @@ export function exportGraph( const elkGraph: ElkNode = { id: 'root', - layoutOptions, + layoutOptions: elkLayoutOptions, children, edges: [] }; diff --git a/packages/joint-layout-elk/src/index.mts b/packages/joint-layout-elk/src/index.mts index 718c2d085d..54a6f57f13 100644 --- a/packages/joint-layout-elk/src/index.mts +++ b/packages/joint-layout-elk/src/index.mts @@ -1 +1,3 @@ -export { layout, Options, LayoutResult } from './layout.mjs'; +export * from './layout.mjs'; +export * from './import.mjs'; +export * from './export.mjs'; diff --git a/packages/joint-layout-elk/src/layout.mts b/packages/joint-layout-elk/src/layout.mts index ab5a929d3c..4dabcd07b5 100644 --- a/packages/joint-layout-elk/src/layout.mts +++ b/packages/joint-layout-elk/src/layout.mts @@ -8,6 +8,8 @@ import type { EdgeLabelsOptions, ImportLayoutOptions, PortPositionsOptions } fro import type { dia } from '@joint/core'; import type { ELK, ElkNode, LayoutOptions as ElkLayoutOptions } from 'elkjs'; +export { ElkLayoutOptions }; + const LAYOUT_BATCH_NAME = 'layout'; const DEFAULT_LAYOUT_OPTIONS: ElkLayoutOptions = { @@ -45,7 +47,7 @@ export interface Options extends Omit new g.Rect(node.x || 0, node.y || 0, node.width || 0, node.height || 0)); return g.Rect.fromRectUnion(...rects) || new g.Rect(0, 0, 0, 0); } @@ -93,10 +95,10 @@ export function getBBox(elkGraph: ElkNode): g.Rect { export async function layout(graph: dia.Graph, opt?: Options): Promise { const options = util.defaults({}, opt || {}, DEFAULT_OPTIONS) as Options; - const layoutOptions = util.defaults({}, opt?.layoutOptions || {}, DEFAULT_LAYOUT_OPTIONS) as ElkLayoutOptions; + const elkLayoutOptions = util.defaults({}, opt?.elkLayoutOptions || {}, DEFAULT_LAYOUT_OPTIONS) as ElkLayoutOptions; const elk = opt?.elk || getDefaultElk(); - const { elkGraph, elementsById, linksById, portsById } = exportGraph(graph, options as ExportGraphOptions, layoutOptions); + const { elkGraph, elementsById, linksById, portsById } = exportGraph(graph, options as ExportGraphOptions, elkLayoutOptions); const result = await elk.layout(elkGraph) as ElkNode; From d9fceb48be0cef5a0b68f6acf96fbb71187d5f67 Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Tue, 8 Sep 2026 15:59:58 +0200 Subject: [PATCH 06/75] port and portLabels wip --- .../src/index.ts | 10 +-- .../src/shapes.ts | 67 ++++++++++++--- .../webpack.config.js | 1 + packages/joint-layout-elk/src/export.mts | 65 +++++++++++--- packages/joint-layout-elk/src/import.mts | 84 +++++++++++++++---- packages/joint-layout-elk/src/layout.mts | 18 +++- 6 files changed, 198 insertions(+), 47 deletions(-) diff --git a/examples/layout-elk-containers-ports-ts/src/index.ts b/examples/layout-elk-containers-ports-ts/src/index.ts index 4a84710fe5..e58f10a05e 100644 --- a/examples/layout-elk-containers-ports-ts/src/index.ts +++ b/examples/layout-elk-containers-ports-ts/src/index.ts @@ -106,20 +106,16 @@ const init = () => { layout(graph, { elk, elkLayoutOptions, - // Let ELK reposition ports for the "hub" nodes only (see `nodeOptions` - // below, which opts every other node back out on a per-node basis). + // Let ELK reposition (and reorder) every port in the diagram, instead + // of keeping them where JointJS's own port groups first placed them. positionPorts: true, + positionPortLabels: true, nodeOptions: (element) => { // Reserve extra top padding inside containers, so children don't // overlap the container's title label. if (element.getEmbeddedCells().length > 0) { return { 'elk.padding': CONTAINER_PADDING }; } - if (!(element instanceof HubService)) { - // Keep this node's ports exactly where JointJS placed them. - return { 'elk.portConstraints': 'FIXED_POS' }; - } - // Leave unset - the `positionPorts` default (`FREE`) applies. return undefined; } }).then(() => { diff --git a/examples/layout-elk-containers-ports-ts/src/shapes.ts b/examples/layout-elk-containers-ports-ts/src/shapes.ts index d218e268a1..6cf56ceefe 100644 --- a/examples/layout-elk-containers-ports-ts/src/shapes.ts +++ b/examples/layout-elk-containers-ports-ts/src/shapes.ts @@ -2,18 +2,48 @@ import { shapes, util } from '@joint/core'; const PORT_SIZE = { width: 12, height: 12 }; const PORT_ATTRS = { - circle: { r: 6, fill: '#FFFFFF', stroke: '#333', strokeWidth: 2 }, - text: { fontSize: 10, fill: '#555' } + circle: { + r: 6, + cx: 6, + cy: 6, + fill: '#FFFFFF', + stroke: '#333', + strokeWidth: 2 + }, + text: { + fontSize: 10, + fill: '#555' + } }; -// 'top' (rather than 'left'/'right') keeps a port's label clear of the link -// labels routed horizontally between closely-spaced ports. + const PORT_LABEL = { position: { - name: 'outside', - args: { - offset: 4, - y: 10 - }, + name: 'outside' + }, + size: { + width: 15, + height: 10 + }, +}; + +// 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: 14 }; +const HUB_PORT_MARKUP = [{ + tagName: 'rect', + selector: 'rect' +}]; +const HUB_PORT_ATTRS = { + rect: { + width: HUB_PORT_SIZE.width, + height: HUB_PORT_SIZE.height, + fill: '#FFFFFF', + stroke: '#B85C38', + strokeWidth: 2 + }, + text: { + fontSize: 10, + fill: '#555' } }; @@ -101,9 +131,8 @@ export class Service extends shapes.standard.Rectangle { /** * A service with a custom (per-instance) number of ports - `ports.items` * always comes from the instance, replacing `Service`'s fixed pair. Also - * highlighted with a thicker, colored stroke to signal that it's one of the - * few nodes whose ports ELK is free to reposition (`positionPorts`), instead - * of keeping them where JointJS placed them. + * highlighted with a thicker, colored stroke, to stand out as a hub with + * several ports fanning in/out on the same side. */ export class HubService extends Service { defaults() { @@ -117,6 +146,20 @@ export class HubService extends Service { stroke: '#B85C38', strokeWidth: 3 } + }, + 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()); } diff --git a/examples/layout-elk-containers-ports-ts/webpack.config.js b/examples/layout-elk-containers-ports-ts/webpack.config.js index 7b10ca335d..410b983af5 100644 --- a/examples/layout-elk-containers-ports-ts/webpack.config.js +++ b/examples/layout-elk-containers-ports-ts/webpack.config.js @@ -11,6 +11,7 @@ module.exports = { publicPath: '/dist/', }, mode: 'development', + devtool: 'source-map', module: { rules: [ { diff --git a/packages/joint-layout-elk/src/export.mts b/packages/joint-layout-elk/src/export.mts index c2f1412a0e..3a584b6388 100644 --- a/packages/joint-layout-elk/src/export.mts +++ b/packages/joint-layout-elk/src/export.mts @@ -19,7 +19,7 @@ const ELK_INLINE_LABEL_OPTIONS = { 'edgeLabels.inline': 'true' }; // this tells ELK to treat the coordinates we give it as final. const ELK_FIXED_PORTS_OPTIONS = { 'elk.portConstraints': 'FIXED_POS' }; // With `positionPorts`, ELK is free to reposition (and reorder) ports itself. -const ELK_FREE_PORTS_OPTIONS = { 'elk.portConstraints': 'FREE' }; +const ELK_FREE_PORTS_OPTIONS = { 'elk.portConstraints': 'FIXED_SIDE' }; type GetSizeCallback = (element: dia.Element) => dia.Size; type NodeOptionsCallback = (element: dia.Element) => ElkLayoutOptions | undefined; @@ -75,6 +75,14 @@ export interface ExportGraphOptions { * @defaultValue false */ positionPorts?: boolean; + /** + * Whether to let ELK reposition port labels along their port, instead of keeping + * them at the position JointJS itself already computed for them. The new + * positions are written back onto the graph - see the `positionPortLabels` + * option in `ImportLayoutOptions`. + * @defaultValue false + */ + positionPortLabels?: boolean; } const getSize: GetSizeCallback = (element) => { @@ -103,7 +111,8 @@ const edgeOptions: EdgeOptionsCallback = (_link) => { function buildPorts( element: dia.Element, portOptionsFn: PortOptionsCallback, - portsById: Map + portsById: Map, + positionPortLabels: boolean ): ElkPort[] | undefined { if (!element.hasPorts()) return undefined; @@ -112,15 +121,45 @@ function buildPorts( const elkPortId = `${element.id}:${portId}`; portsById.set(elkPortId, { element, portId }); - const { x, y, width, height } = element.getPortRelativeRect(portId); - return { + const portLayoutOptions: ElkPort = { id: elkPortId, - x, - y, - width, - height, - layoutOptions: portOptionsFn(port, element) + layoutOptions: portOptionsFn(port, element) || {} }; + + if (port.group) { + const groupDef = element.prop(`ports/groups/${port.group}`); + if (groupDef && groupDef.position) { + // ELK's `port.side` is a string, not a number, so we have to map JointJS's + // numeric group positions to the corresponding string values. + const side = (groupDef.position === 'left') ? 'WEST' + : (groupDef.position === 'right') ? 'EAST' + : (groupDef.position === 'top') ? 'NORTH' + : (groupDef.position === 'bottom') ? 'SOUTH' + : undefined; + if (side) { + portLayoutOptions.layoutOptions!['port.side'] = side; + } + } + if (positionPortLabels && groupDef?.label) { + const { width, height } = groupDef.label.size || DEFAULT_LABEL_SIZE; + portLayoutOptions.labels = [{ + // Some text is required, otherwise ELK ignores the label. + text: ELK_LABEL_TEXT, + width, + height, + layoutOptions: {} + }]; + } + } + + const { x, y, width, height } = element.getPortRelativeRect(portId); + portLayoutOptions.x = x; + portLayoutOptions.y = y; + portLayoutOptions.width = width; + portLayoutOptions.height = height; + portLayoutOptions.layoutOptions!['port.borderOffset'] = (-width / 2).toString(); + + return portLayoutOptions; }); } @@ -151,7 +190,7 @@ export function exportGraph( const id = `${element.id}`; elementsById.set(id, element); - const ports = buildPorts(element, portOptionsFn, portsById); + const ports = buildPorts(element, portOptionsFn, portsById, !!options.positionPortLabels); const customOptions = nodeOptionsFn(element); const embeds = element.getEmbeddedCells() @@ -171,7 +210,11 @@ export function exportGraph( width, height, ports, - layoutOptions: (ports) ? { ...portConstraintsOptions, ...customOptions } : customOptions + layoutOptions: (ports) ? { + ...portConstraintsOptions, + 'portLabels.placement': 'OUTSIDE', + ...customOptions + } : customOptions }; } diff --git a/packages/joint-layout-elk/src/import.mts b/packages/joint-layout-elk/src/import.mts index e6f848a962..ef47994619 100644 --- a/packages/joint-layout-elk/src/import.mts +++ b/packages/joint-layout-elk/src/import.mts @@ -7,6 +7,7 @@ type SetVerticesCallback = (link: dia.Link, vertices: dia.Point[]) => void; type SetAnchorCallback = (link: dia.Link, element: dia.Element, point: dia.Point, endType: 'source' | 'target') => void; type SetLabelsCallback = (link: dia.Link, labelBBox: dia.BBox, points: dia.Point[], labelIndex: number) => void; type SetPortPositionCallback = (element: dia.Element, portId: string, position: dia.Point) => void; +type SetPortLabelPositionCallback = (element: dia.Element, portId: string, position: dia.Point) => void; export interface EdgeLabelsOptions { /** @@ -30,6 +31,16 @@ export interface PortPositionsOptions { setPortPosition?: SetPortPositionCallback; } +export interface PortLabelPositionsOptions { + /** + * Sets a port label's position, based on the ELK port label's layout result. + * Only takes effect when `positionPortLabels` is enabled. + * @example + * setPortLabelPosition: (element, portId, position) => element.portProp(portId, ['label', 'position', 'args'], position) + */ + setPortLabelPosition?: SetPortLabelPositionCallback; +} + export interface ImportLayoutOptions { /** * Specify a function to use when setting a new position to an element after layout @@ -78,6 +89,15 @@ export interface ImportLayoutOptions { * @defaultValue false */ positionPorts?: boolean | PortPositionsOptions; + /** + * Whether to let ELK reposition port labels along their port, instead of keeping + * them at the position JointJS itself already computed for them (via the port + * group's `label`). When enabled, every port's owning group's label is switched + * to a `'manual'` position (preserving its `attrs`/`markup`) so the position ELK + * computed for it can be applied. + * @defaultValue false + */ + positionPortLabels?: boolean | PortLabelPositionsOptions; } const defaultSetPosition = (element: dia.Element, position: dia.Point) => { @@ -111,6 +131,18 @@ const defaultSetPortPosition = (element: dia.Element, portId: string, position: element.portProp(portId, ['position', 'args'], position); }; +const defaultSetPortLabelPosition = (element: dia.Element, portId: string, position: dia.Point) => { + const { group } = element.getPort(portId); + if (group !== undefined) { + // Every positioned port label ends up with a computed position (only ports whose + // group defines a `label` are exported, but all of those are), so switching the + // whole group's label to `'manual'` is safe here - it only replaces the group + // label's `position`, leaving its `attrs`/`markup` intact. + element.prop(['ports', 'groups', group, 'label', 'position'], { name: 'manual' }); + } + element.portProp(portId, ['label', 'position', 'args'], position); +}; + const defaultSetLabels = (link: dia.Link, labelBBox: dia.BBox, points: dia.Point[], labelIndex: number) => { const polyline = new g.Polyline(points); @@ -148,11 +180,30 @@ export function importLayout( let setPortPositionFn: SetPortPositionCallback | undefined; if (options.positionPorts) { setPortPositionFn = defaultSetPortPosition; + if (typeof options.positionPorts === 'object') { setPortPositionFn = options.positionPorts.setPortPosition ?? defaultSetPortPosition; } } + let setPortLabelPositionFn: SetPortLabelPositionCallback | undefined; + if (options.positionPortLabels) { + setPortLabelPositionFn = defaultSetPortLabelPosition; + + if (typeof options.positionPortLabels === 'object') { + setPortLabelPositionFn = options.positionPortLabels.setPortLabelPosition ?? defaultSetPortLabelPosition; + } + } + + let setLabelsFn: SetLabelsCallback | undefined; + if (options.edgeLabels) { + setLabelsFn = defaultSetLabels; + + if (typeof options.edgeLabels === 'object') { + setLabelsFn = options.edgeLabels.setLabels ?? defaultSetLabels; + } + } + // ELK positions a node's children (and routes a node's own edges) relative to that // node's own origin - `containerX`/`containerY` accumulate the offset needed to turn // those relative coordinates into graph-absolute ones as we walk down the hierarchy. @@ -185,18 +236,12 @@ export function importLayout( setAnchorFn(link, targetElement, toAbsolute(endPoint, containerX, containerY), 'target'); } - if (options.edgeLabels && edge.labels && edge.labels.length > 0) { - let setLabelsFn = defaultSetLabels; - - if (typeof options.edgeLabels === 'object') { - setLabelsFn = options.edgeLabels.setLabels ?? defaultSetLabels; - } - + if (setLabelsFn && edge.labels && edge.labels.length > 0) { const points = [startPoint, ...bendPoints, endPoint] .map((point) => toAbsolute(point, containerX, containerY)); edge.labels.forEach((label, labelIndex) => { const { x = 0, y = 0, width = 0, height = 0 } = label; - setLabelsFn(link, { x: containerX + x, y: containerY + y, width, height }, points, labelIndex); + setLabelsFn?.(link, { x: containerX + x, y: containerY + y, width, height }, points, labelIndex); }); } }); @@ -215,16 +260,27 @@ export function importLayout( } } - if (setPortPositionFn && node.ports) { + if ((setPortPositionFn || setPortLabelPositionFn) && node.ports) { node.ports.forEach((port) => { const found = portsById.get(port.id); if (!found) return; - // A port's position is relative to its own element, same as `node.x`/`node.y` - // above - it does not need the `containerX`/`containerY` offset. - const centerX = (port.x || 0) + (port.width || 0) / 2; - const centerY = (port.y || 0) + (port.height || 0) / 2; - setPortPositionFn(found.element, found.portId, { x: centerX, y: centerY }); + if (setPortPositionFn) { + setPortPositionFn(found.element, found.portId, { + x: port.x || 0, + y: port.y || 0 + }); + } + + if (setPortLabelPositionFn) { + const [label] = port.labels || []; + if (label) { + setPortLabelPositionFn(found.element, found.portId, { + x: label.x || 0, + y: label.y || 0 + }); + } + } }); } diff --git a/packages/joint-layout-elk/src/layout.mts b/packages/joint-layout-elk/src/layout.mts index 4dabcd07b5..ba8510738a 100644 --- a/packages/joint-layout-elk/src/layout.mts +++ b/packages/joint-layout-elk/src/layout.mts @@ -4,7 +4,7 @@ import { importLayout } from './import.mjs'; import { exportGraph } from './export.mjs'; import type { ExportGraphOptions } from './export.mjs'; -import type { EdgeLabelsOptions, ImportLayoutOptions, PortPositionsOptions } from './import.mjs'; +import type { EdgeLabelsOptions, ImportLayoutOptions, PortPositionsOptions, PortLabelPositionsOptions } from './import.mjs'; import type { dia } from '@joint/core'; import type { ELK, ElkNode, LayoutOptions as ElkLayoutOptions } from 'elkjs'; @@ -17,7 +17,10 @@ const DEFAULT_LAYOUT_OPTIONS: ElkLayoutOptions = { // 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' + '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', }; const DEFAULT_OPTIONS: Options = { @@ -30,7 +33,7 @@ let defaultElk: ELK | undefined; /** * Layout configuration options. */ -export interface Options extends Omit, Omit { +export interface Options extends Omit, Omit { /** * A custom ELK instance, e.g. one configured to run inside a Web Worker. * The instance is not terminated by the package - call `elk.terminateWorker()` @@ -63,6 +66,15 @@ export interface Options extends Omit Date: Tue, 8 Sep 2026 16:52:12 +0200 Subject: [PATCH 07/75] full types wip --- .../src/shapes.ts | 6 +- packages/joint-layout-elk/src/elkOptions.mts | 1762 +++++++++++++++++ packages/joint-layout-elk/src/export.mts | 22 +- packages/joint-layout-elk/src/import.mts | 3 +- packages/joint-layout-elk/src/index.mts | 1 + packages/joint-layout-elk/src/layout.mts | 7 +- 6 files changed, 1782 insertions(+), 19 deletions(-) create mode 100644 packages/joint-layout-elk/src/elkOptions.mts diff --git a/examples/layout-elk-containers-ports-ts/src/shapes.ts b/examples/layout-elk-containers-ports-ts/src/shapes.ts index 6cf56ceefe..1a6596ac6f 100644 --- a/examples/layout-elk-containers-ports-ts/src/shapes.ts +++ b/examples/layout-elk-containers-ports-ts/src/shapes.ts @@ -19,11 +19,7 @@ const PORT_ATTRS = { const PORT_LABEL = { position: { name: 'outside' - }, - size: { - width: 15, - height: 10 - }, + } }; // Square ports (rather than `PORT_ATTRS`' circles) set `HubService` apart as diff --git a/packages/joint-layout-elk/src/elkOptions.mts b/packages/joint-layout-elk/src/elkOptions.mts new file mode 100644 index 0000000000..0fcd8de724 --- /dev/null +++ b/packages/joint-layout-elk/src/elkOptions.mts @@ -0,0 +1,1762 @@ +import type { + ElkNode as RawElkNode, + ElkPort as RawElkPort, + ElkExtendedEdge as RawElkExtendedEdge, + ElkLabel as RawElkLabel +} from 'elkjs'; + +export interface NodeElkLayoutOptions { + // Falls back to a plain string for any ELK option beyond this package's Core/Layered coverage. + [key: string]: string | undefined; + /** + * Alignment of the selected node relative to other nodes; the exact meaning depends on the used + * algorithm. + * @defaultValue 'AUTOMATIC' + */ + 'elk.alignment'?: Alignment; + /** + * Determines whether separate layout runs are triggered for different compound nodes in a + * hierarchical graph. Setting a node's hierarchy handling to `INCLUDE_CHILDREN` will lay out that + * node and all of its descendants in a single layout run, until a descendant is encountered which + * has its hierarchy handling set to `SEPARATE_CHILDREN`. In general, `SEPARATE_CHILDREN` will + * ensure that a new layout run is triggered for a node with that setting. Including multiple + * levels of hierarchy in a single layout run may allow cross-hierarchical edges to be laid out + * properly. If the root node is set to `INHERIT` (or not set at all), the default behavior is + * `SEPARATE_CHILDREN`. + * @defaultValue 'INHERIT' + */ + 'elk.hierarchyHandling'?: HierarchyHandling; + /** + * The padding to be left to a parent element's border when placing child elements. This can also + * serve as an output option of a layout algorithm if node size calculation is setup appropriately. + * @defaultValue `new ElkPadding(12)` + */ + 'elk.padding'?: string; + /** + * Spacing between pairs of ports of the same node. + * @defaultValue '10' + */ + 'elk.spacing.portPort'?: `${number}`; + /** + * Allows to specify individual spacing values for graph elements that shall be different from the + * value specified for the element's parent. + */ + 'elk.spacing.individual'?: string; + /** + * Partition to which the node belongs. This requires Layout Partitioning to be active. Nodes with + * lower partition IDs will appear to the left of nodes with higher partition IDs (assuming a + * left-to-right layout direction). + */ + 'elk.partitioning.partition'?: `${number}`; + /** + * Hints for where node labels are to be placed; if empty, the node label's position is not + * modified. + * @defaultValue `NodeLabelPlacement.fixed` + */ + 'elk.nodeLabels.placement'?: string; + /** + * Defines the default port distribution for a node. May be overridden for each side individually. + * @defaultValue 'DISTRIBUTED' + */ + 'elk.portAlignment.default'?: PortAlignment; + /** + * Defines how ports on the northern side are placed, overriding the node's general port alignment. + */ + 'elk.portAlignment.north'?: PortAlignment; + /** + * Defines how ports on the southern side are placed, overriding the node's general port alignment. + */ + 'elk.portAlignment.south'?: PortAlignment; + /** + * Defines how ports on the western side are placed, overriding the node's general port alignment. + */ + 'elk.portAlignment.west'?: PortAlignment; + /** + * Defines how ports on the eastern side are placed, overriding the node's general port alignment. + */ + 'elk.portAlignment.east'?: PortAlignment; + /** + * Defines constraints of the position of the ports of a node. + * @defaultValue 'UNDEFINED' + */ + 'elk.portConstraints'?: PortConstraints; + /** + * The position of a node, port, or label. This is used by the 'Fixed Layout' algorithm to specify + * a pre-defined position. + */ + 'elk.position'?: string; + /** + * Defines the priority of an object; its meaning depends on the specific layout algorithm and the + * context where it is used. + */ + 'elk.priority'?: `${number}`; + /** + * What should be taken into account when calculating a node's size. Empty size constraints specify + * that a node's size is already fixed and should not be changed. + * @defaultValue `EnumSet.noneOf(SizeConstraint)` + */ + 'elk.nodeSize.constraints'?: string; + /** + * Options modifying the behavior of the size constraints set on a node. Each member of the set + * specifies something that should be taken into account when calculating node sizes. The empty set + * corresponds to no further modifications. + * @defaultValue `EnumSet.of(SizeOptions.DEFAULT_MINIMUM_SIZE)` + */ + 'elk.nodeSize.options'?: string; + /** + * The minimal size to which a node can be reduced. + * @defaultValue `new KVector(0, 0)` + */ + 'elk.nodeSize.minimum'?: string; + /** + * Whether the node should be regarded as a comment box instead of a regular node. In that case its + * placement should be similar to how labels are handled. Any edges incident to a comment box + * specify to which graph elements the comment is related. + * @defaultValue 'false' + */ + 'elk.commentBox'?: 'true' | 'false'; + /** + * Whether the node should be handled as a hypernode. + * @defaultValue 'false' + */ + 'elk.hypernode'?: 'true' | 'false'; + /** + * Margins define additional space around the actual bounds of a graph element. For instance, ports + * or labels being placed on the outside of a node's border might introduce such a margin. The + * margin is used to guarantee non-overlap of other graph elements with those ports or labels. + * @defaultValue `new ElkMargin()` + */ + 'elk.margins'?: string; + /** + * No layout is done for the associated element. This is used to mark parts of a diagram to avoid + * their inclusion in the layout graph, or to mark parts of the layout graph to prevent layout + * engines from processing them. If you wish to exclude the contents of a compound node from + * automatic layout, while the node itself is still considered on its own layer, use the 'Fixed + * Layout' algorithm for that node. + * @defaultValue 'false' + */ + 'elk.noLayout'?: 'true' | 'false'; + /** + * Decides on a placement method for port labels; if empty, the node label's position is not + * modified. + * @defaultValue `PortLabelPlacement.outside` + */ + 'elk.portLabels.placement'?: string; + /** + * Use 'portLabels.placement': NEXT_TO_PORT_OF_POSSIBLE. + * @defaultValue 'false' + */ + 'elk.portLabels.nextToPortIfPossible'?: 'true' | 'false'; + /** + * If this option is true (default), the labels of a port will be treated as a group when it comes + * to centering them next to their port. If this option is false, only the first label will be + * centered next to the port, with the others being placed below. This only applies to labels of + * eastern and western ports and will have no effect if labels are not placed next to their port. + * @defaultValue 'true' + */ + 'elk.portLabels.treatAsGroup'?: 'true' | 'false'; + /** + * The scaling factor to be applied to the corresponding node in recursive layout. It causes the + * corresponding node's size to be adjusted, and its ports and labels to be sized and placed + * accordingly after the layout of that node has been determined (and before the node itself and + * its siblings are arranged). The scaling is not reverted afterwards, so the resulting layout + * graph contains the adjusted size and position data. This option is currently not supported if + * 'Layout Hierarchy' is set. + * @defaultValue '1' + */ + 'elk.scaleFactor'?: `${number}`; + /** + * The size approximator to be used to set sizes of hierarchical nodes during topdown layout. The + * default value is null, which results in nodes keeping whatever size is defined for them e.g. + * through parent parallel node or by manually setting the size. + * @defaultValue `null` + */ + 'elk.topdown.sizeApproximator'?: string; + /** + * The fixed size of a hierarchical node when using topdown layout. If this value is set on a + * parallel node it applies to its children, when set on a hierarchical node it applies to the node + * itself. + * @defaultValue '150' + */ + 'elk.topdown.hierarchicalNodeWidth'?: `${number}`; + /** + * The fixed aspect ratio of a hierarchical node when using topdown layout. Default is 1/sqrt(2). + * If this value is set on a parallel node it applies to its children, when set on a hierarchical + * node it applies to the node itself. + * @defaultValue '1.414' + */ + 'elk.topdown.hierarchicalNodeAspectRatio'?: `${number}`; + /** + * The different node types used for topdown layout. If the node type is set to + * `TopdownNodeTypes.PARALLEL_NODE` the algorithm must be set to a `TopdownLayoutProvider` such as + * `TopdownPacking`. The `nodeSize.fixedGraphSize` option is technically only required for + * hierarchical nodes. + * @defaultValue `null` + */ + 'elk.topdown.nodeType'?: TopdownNodeTypes; + /** + * Whether this node allows to route self loops inside of it instead of around it. If set to true, + * this will make the node a compound node if it isn't already, and will require the layout + * algorithm to support compound nodes with hierarchical ports. + * @defaultValue 'false' + */ + 'elk.insideSelfLoops.activate'?: 'true' | 'false'; + /** + * Determines a constraint on the placement of the node regarding the layering. + * @defaultValue 'NONE' + */ + 'elk.layered.layering.layerConstraint'?: LayerConstraint; + /** + * Allows to set a constraint regarding the layer placement of a node. Let i be the value of teh + * constraint. Assumed the drawing has n layers and i < n. If set to i, it expresses that the node + * should be placed in i-th layer. Should i>=n be true then the node is placed in the last layer of + * the drawing. Note that this option is not part of any of ELK Layered's default configurations + * but is only evaluated as part of the `InteractiveLayeredGraphVisitor`, which must be applied + * manually or used via the `DiagramLayoutEngine. + * @defaultValue `null` + */ + 'elk.layered.layering.layerChoiceConstraint'?: `${number}`; + /** + * Layer identifier that was calculated by ELK Layered for a node. This is only generated if + * interactiveLayot or generatePositionAndLayerIds is set. + * @defaultValue '-1' + */ + 'elk.layered.layering.layerId'?: `${number}`; + /** + * Allows to set a constraint which specifies of which node the current node is the predecessor. If + * set to 's' then the node is the predecessor of 's' and is in the same layer + * @defaultValue `null` + */ + 'elk.layered.crossingMinimization.inLayerPredOf'?: string; + /** + * Allows to set a constraint which specifies of which node the current node is the successor. If + * set to 's' then the node is the successor of 's' and is in the same layer + * @defaultValue `null` + */ + 'elk.layered.crossingMinimization.inLayerSuccOf'?: string; + /** + * Allows to set a constraint regarding the position placement of a node in a layer. Assumed the + * layer in which the node placed includes n other nodes and i < n. If set to i, it expresses that + * the node should be placed at the i-th position. Should i>=n be true then the node is placed at + * the last position in the layer. Note that this option is not part of any of ELK Layered's + * default configurations but is only evaluated as part of the `InteractiveLayeredGraphVisitor`, + * which must be applied manually or used via the `DiagramLayoutEngine. + * @defaultValue `null` + */ + 'elk.layered.crossingMinimization.positionChoiceConstraint'?: `${number}`; + /** + * Position within a layer that was determined by ELK Layered for a node. This is only generated if + * interactiveLayot or generatePositionAndLayerIds is set. + * @defaultValue '-1' + */ + 'elk.layered.crossingMinimization.positionId'?: `${number}`; + /** + * Aims at shorter and straighter edges. Two configurations are possible: (a) allow ports to move + * freely on the side they are assigned to (the order is always defined beforehand), (b) + * additionally allow to enlarge a node wherever it helps. If this option is not configured for a + * node, the 'nodeFlexibility.default' value is used, which is specified for the node's parent. + */ + 'elk.layered.nodePlacement.networkSimplex.nodeFlexibility'?: NodeFlexibility; + /** + * Alter the distribution of the loops around the node. It only takes effect for + * PortConstraints.FREE. + * @defaultValue 'NORTH' + */ + 'elk.layered.edgeRouting.selfLoopDistribution'?: SelfLoopDistributionStrategy; + /** + * Alter the ordering of the loops they can either be stacked or sequenced. It only takes effect + * for PortConstraints.FREE. + * @defaultValue 'STACKED' + */ + 'elk.layered.edgeRouting.selfLoopOrdering'?: SelfLoopOrderingStrategy; + /** + * Use a heuristic to decide whether or not to actually perform the layer split with the goal of + * minimizing the total edge length. This option only works when layerSplit is set to 2. The + * property can be set to the nodes in a layer, which then applies the property for the layer. If + * any node sets the value to true, then the value is set to true for the entire layer. + * @defaultValue 'false' + */ + 'elk.layered.layerUnzipping.minimizeEdgeLength'?: 'true' | 'false'; + /** + * Defines the number of sublayers to split a layer into. The property can be set to the nodes in a + * layer, which then applies the property for the layer. If multiple nodes set the value to + * different values, then the lowest value is chosen. + * @defaultValue '2' + */ + 'elk.layered.layerUnzipping.layerSplit'?: `${number}`; + /** + * If set to true, nodes will always be placed in the first sublayer after a long edge when using + * the ALTERNATING strategy. Otherwise long edge dummies are treated the same as regular nodes. The + * default value is true. The property can be set to the nodes in a layer, which then applies the + * property for the layer. If any node sets the value to false, then the value is set to false for + * the entire layer. + * @defaultValue 'true' + */ + 'elk.layered.layerUnzipping.resetOnLongEdges'?: 'true' | 'false'; + /** + * Set on a node to not set a model order for this node even though it is a real node. + * @defaultValue 'false' + */ + 'elk.layered.considerModelOrder.noModelOrder'?: 'true' | 'false'; + /** + * Used to define partial ordering groups during cycle breaking. A lower group id means that the + * group is sorted before other groups. A group model order of 0 is the default group. + * @defaultValue '0' + */ + 'elk.layered.considerModelOrder.groupModelOrder.cycleBreakingId'?: `${number}`; + /** + * Used to define partial ordering groups during crossing minimization. A lower group id means that + * the group is sorted before other groups. A group model order of 0 is the default group. + * @defaultValue '0' + */ + 'elk.layered.considerModelOrder.groupModelOrder.crossingMinimizationId'?: `${number}`; + /** + * Used to define partial ordering groups during component packing. A lower group id means that the + * group is sorted before other groups. A group model order of 0 is the default group. + * @defaultValue '0' + */ + 'elk.layered.considerModelOrder.groupModelOrder.componentGroupId'?: `${number}`; +} + +export interface EdgeElkLayoutOptions { + // Falls back to a plain string for any ELK option beyond this package's Core/Layered coverage. + [key: string]: string | undefined; + /** + * A fixed list of bend points for the edge. This is used by the 'Fixed Layout' algorithm to + * specify a pre-defined routing for an edge. The vector chain must include the source point, any + * bend points, and the target point, so it must have at least two points. + */ + 'elk.bendPoints'?: string; + /** + * Allows to specify individual spacing values for graph elements that shall be different from the + * value specified for the element's parent. + */ + 'elk.spacing.individual'?: string; + /** + * Defines the priority of an object; its meaning depends on the specific layout algorithm and the + * context where it is used. + */ + 'elk.priority'?: `${number}`; + /** + * This option is not used as option, but as output of the layout algorithms. It is attached to + * edges and determines the points where junction symbols should be drawn in order to represent + * hyperedges with orthogonal routing. Whether such points are computed depends on the chosen + * layout algorithm and edge routing style. The points are put into the vector chain with no + * specific order. + * @defaultValue `new KVectorChain()` + */ + 'elk.junctionPoints'?: string; + /** + * No layout is done for the associated element. This is used to mark parts of a diagram to avoid + * their inclusion in the layout graph, or to mark parts of the layout graph to prevent layout + * engines from processing them. If you wish to exclude the contents of a compound node from + * automatic layout, while the node itself is still considered on its own layer, use the 'Fixed + * Layout' algorithm for that node. + * @defaultValue 'false' + */ + 'elk.noLayout'?: 'true' | 'false'; + /** + * Whether a self loop should be routed inside a node instead of around that node. + * @defaultValue 'false' + */ + 'elk.insideSelfLoops.yo'?: 'true' | 'false'; + /** + * The thickness of an edge. This is a hint on the line width used to draw an edge, possibly + * requiring more space to be reserved for it. + * @defaultValue '1' + */ + 'elk.edge.thickness'?: `${number}`; + /** + * The type of an edge. This is usually used for UML class diagrams, where associations must be + * handled differently from generalizations. + * @defaultValue 'NONE' + */ + 'elk.edge.type'?: EdgeType; + /** + * Defines how important it is to have a certain edge point into the direction of the overall + * layout. This option is evaluated during the cycle breaking phase. + * @defaultValue '0' + */ + 'elk.layered.priority.direction'?: `${number}`; + /** + * Defines how important it is to keep an edge as short as possible. This option is evaluated + * during the layering phase. + * @defaultValue '0' + */ + 'elk.layered.priority.shortness'?: `${number}`; + /** + * Defines how important it is to keep an edge straight, i.e. aligned with one of the two axes. + * This option is evaluated during node placement. + * @defaultValue '0' + */ + 'elk.layered.priority.straightness'?: `${number}`; + /** + * Used to define partial ordering groups during crossing minimization. A lower group id means that + * the group is sorted before other groups. A group model order of 0 is the default group. + * @defaultValue '0' + */ + 'elk.layered.considerModelOrder.groupModelOrder.crossingMinimizationId'?: `${number}`; + /** + * Used to define partial ordering groups during component packing. A lower group id means that the + * group is sorted before other groups. A group model order of 0 is the default group. + * @defaultValue '0' + */ + 'elk.layered.considerModelOrder.groupModelOrder.componentGroupId'?: `${number}`; +} + +export interface PortElkLayoutOptions { + // Falls back to a plain string for any ELK option beyond this package's Core/Layered coverage. + [key: string]: string | undefined; + /** + * Allows to specify individual spacing values for graph elements that shall be different from the + * value specified for the element's parent. + */ + 'elk.spacing.individual'?: string; + /** + * The position of a node, port, or label. This is used by the 'Fixed Layout' algorithm to specify + * a pre-defined position. + */ + 'elk.position'?: string; + /** + * No layout is done for the associated element. This is used to mark parts of a diagram to avoid + * their inclusion in the layout graph, or to mark parts of the layout graph to prevent layout + * engines from processing them. If you wish to exclude the contents of a compound node from + * automatic layout, while the node itself is still considered on its own layer, use the 'Fixed + * Layout' algorithm for that node. + * @defaultValue 'false' + */ + 'elk.noLayout'?: 'true' | 'false'; + /** + * The offset to the port position where connections shall be attached. + */ + 'elk.port.anchor'?: string; + /** + * The index of a port in the fixed order around a node. The order is assumed as clockwise, + * starting with the leftmost port on the top side. This option must be set if 'Port Constraints' + * is set to FIXED_ORDER and no specific positions are given for the ports. Additionally, the + * option 'Port Side' must be defined in this case. + */ + 'elk.port.index'?: `${number}`; + /** + * The side of a node on which a port is situated. This option must be set if 'Port Constraints' is + * set to FIXED_SIDE or FIXED_ORDER and no specific positions are given for the ports. + * @defaultValue 'UNDEFINED' + */ + 'elk.port.side'?: PortSide; + /** + * The offset of ports on the node border. With a positive offset the port is moved outside of the + * node, while with a negative offset the port is moved towards the inside. An offset of 0 means + * that the port is placed directly on the node border, i.e. if the port side is north, the port's + * south border touches the nodes's north border; if the port side is east, the port's west border + * touches the nodes's east border; if the port side is south, the port's north border touches the + * node's south border; if the port side is west, the port's east border touches the node's west + * border. + */ + 'elk.port.borderOffset'?: `${number}`; + /** + * Used to define partial ordering groups during crossing minimization. A lower group id means that + * the group is sorted before other groups. A group model order of 0 is the default group. + * @defaultValue '0' + */ + 'elk.layered.considerModelOrder.groupModelOrder.crossingMinimizationId'?: `${number}`; + /** + * Used to define partial ordering groups during component packing. A lower group id means that the + * group is sorted before other groups. A group model order of 0 is the default group. + * @defaultValue '0' + */ + 'elk.layered.considerModelOrder.groupModelOrder.componentGroupId'?: `${number}`; + /** + * Specifies whether non-flow ports may switch sides if their node's port constraints are either + * FIXED_SIDE or FIXED_ORDER. A non-flow port is a port on a side that is not part of the currently + * configured layout flow. For instance, given a left-to-right layout direction, north and south + * ports would be considered non-flow ports. Further note that the underlying criterium whether to + * switch sides or not solely relies on the minimization of edge crossings. Hence, edge length and + * other aesthetics criteria are not addressed. + * @defaultValue 'false' + */ + 'elk.layered.allowNonFlowPortsToSwitchSides'?: 'true' | 'false'; +} + +export interface LabelElkLayoutOptions { + // Falls back to a plain string for any ELK option beyond this package's Core/Layered coverage. + [key: string]: string | undefined; + /** + * Allows to specify individual spacing values for graph elements that shall be different from the + * value specified for the element's parent. + */ + 'elk.spacing.individual'?: string; + /** + * Hints for where node labels are to be placed; if empty, the node label's position is not + * modified. + * @defaultValue `NodeLabelPlacement.fixed` + */ + 'elk.nodeLabels.placement'?: string; + /** + * The position of a node, port, or label. This is used by the 'Fixed Layout' algorithm to specify + * a pre-defined position. + */ + 'elk.position'?: string; + /** + * Gives a hint on where to put edge labels. + * @defaultValue 'CENTER' + */ + 'elk.edgeLabels.placement'?: EdgeLabelPlacement; + /** + * If true, an edge label is placed directly on its edge. May only apply to center edge labels. + * This kind of label placement is only advisable if the label's rendering is such that it is not + * crossed by its edge and thus stays legible. + * @defaultValue 'false' + */ + 'elk.edgeLabels.inline'?: 'true' | 'false'; + /** + * Font name used for a label. + */ + 'elk.font.name'?: string; + /** + * Font size used for a label. + */ + 'elk.font.size'?: `${number}`; + /** + * Determines the amount of fuzziness to be used when performing softwrapping on labels. The value + * expresses the percent of overhang that is permitted for each line. If the next line would take + * up less space than this threshold, it is appended to the current line instead of being placed in + * a new line. + * @defaultValue '0.0' + */ + 'elk.softwrappingFuzziness'?: `${number}`; + /** + * No layout is done for the associated element. This is used to mark parts of a diagram to avoid + * their inclusion in the layout graph, or to mark parts of the layout graph to prevent layout + * engines from processing them. If you wish to exclude the contents of a compound node from + * automatic layout, while the node itself is still considered on its own layer, use the 'Fixed + * Layout' algorithm for that node. + * @defaultValue 'false' + */ + 'elk.noLayout'?: 'true' | 'false'; + /** + * Determines in which layer center labels of long edges should be placed. + * @defaultValue 'MEDIAN_LAYER' + */ + 'elk.layered.edgeLabels.centerLabelPlacementStrategy'?: CenterEdgeLabelPlacementStrategy; +} + +export interface ElkLayoutOptions { + // Falls back to a plain string for any ELK option beyond this package's Core/Layered coverage. + [key: string]: string | undefined; + /** + * Configures the packing mode used by the `BoxLayoutProvider`. If SIMPLE is not required (neither + * priorities are used nor the interactive mode), GROUP_DEC can improve the packing and decrease + * the area. GROUP_MIXED and GROUP_INC may, in very specific scenarios, work better. + * @defaultValue `BoxLayoutProvider.PackingMode.SIMPLE` + */ + 'elk.box.packingMode'?: string; + /** + * Select a specific layout algorithm. + */ + 'elk.algorithm'?: ElkAlgorithm; + /** + * Alignment of the selected node relative to other nodes; the exact meaning depends on the used + * algorithm. + * @defaultValue 'AUTOMATIC' + */ + 'elk.alignment'?: Alignment; + /** + * The desired aspect ratio of the drawing, that is the quotient of width by height. + */ + 'elk.aspectRatio'?: `${number}`; + /** + * A fixed list of bend points for the edge. This is used by the 'Fixed Layout' algorithm to + * specify a pre-defined routing for an edge. The vector chain must include the source point, any + * bend points, and the target point, so it must have at least two points. + */ + 'elk.bendPoints'?: string; + /** + * Specifies how the content of a node are aligned. Each node can individually control the + * alignment of its contents. I.e. if a node should be aligned top left in its parent node, the + * parent node should specify that option. + * @defaultValue `ContentAlignment.topLeft()` + */ + 'elk.contentAlignment'?: string; + /** + * Whether additional debug information shall be generated. + * @defaultValue 'false' + */ + 'elk.debugMode'?: 'true' | 'false'; + /** + * Overall direction of edges: horizontal (right / left) or vertical (down / up). + * @defaultValue 'UNDEFINED' + */ + 'elk.direction'?: Direction; + /** + * What kind of edge routing style should be applied for the content of a parent node. Algorithms + * may also set this option to single edges in order to mark them as splines. The bend point list + * of edges with this option set to SPLINES must be interpreted as control points for a piecewise + * cubic spline. + * @defaultValue 'UNDEFINED' + */ + 'elk.edgeRouting'?: EdgeRouting; + /** + * If active, nodes are expanded to fill the area of their parent. + * @defaultValue 'false' + */ + 'elk.expandNodes'?: 'true' | 'false'; + /** + * Determines whether separate layout runs are triggered for different compound nodes in a + * hierarchical graph. Setting a node's hierarchy handling to `INCLUDE_CHILDREN` will lay out that + * node and all of its descendants in a single layout run, until a descendant is encountered which + * has its hierarchy handling set to `SEPARATE_CHILDREN`. In general, `SEPARATE_CHILDREN` will + * ensure that a new layout run is triggered for a node with that setting. Including multiple + * levels of hierarchy in a single layout run may allow cross-hierarchical edges to be laid out + * properly. If the root node is set to `INHERIT` (or not set at all), the default behavior is + * `SEPARATE_CHILDREN`. + * @defaultValue 'INHERIT' + */ + 'elk.hierarchyHandling'?: HierarchyHandling; + /** + * The padding to be left to a parent element's border when placing child elements. This can also + * serve as an output option of a layout algorithm if node size calculation is setup appropriately. + * @defaultValue `new ElkPadding(12)` + */ + 'elk.padding'?: string; + /** + * Whether the algorithm should be run in interactive mode for the content of a parent node. What + * this means exactly depends on how the specific algorithm interprets this option. Usually in the + * interactive mode algorithms try to modify the current layout as little as possible. + * @defaultValue 'false' + */ + 'elk.interactive'?: 'true' | 'false'; + /** + * Whether the graph should be changeable interactively and by setting constraints + * @defaultValue 'false' + */ + 'elk.interactiveLayout'?: 'true' | 'false'; + /** + * Node micro layout comprises the computation of node dimensions (if requested), the placement of + * ports and their labels, and the placement of node labels. The functionality is implemented + * independent of any specific layout algorithm and shouldn't have any negative impact on the + * layout algorithm's performance itself. Yet, if any unforeseen behavior occurs, this option + * allows to deactivate the micro layout. + * @defaultValue 'false' + */ + 'elk.omitNodeMicroLayout'?: 'true' | 'false'; + /** + * For layouts transferred into JSON graphs, specify the coordinate system to be used for nodes, + * ports, and labels of nodes and ports. + * @defaultValue 'INHERIT' + */ + 'elk.json.shapeCoords'?: ShapeCoords; + /** + * For layouts transferred into JSON graphs, specify the coordinate system to be used for edge + * route points and edge labels. + * @defaultValue 'INHERIT' + */ + 'elk.json.edgeCoords'?: EdgeCoords; + /** + * Spacing to be preserved between a comment box and other comment boxes connected to the same + * node. The space left between comment boxes of different nodes is controlled by the node-node + * spacing. + * @defaultValue '10' + */ + 'elk.spacing.commentComment'?: `${number}`; + /** + * Spacing to be preserved between a node and its connected comment boxes. The space left between a + * node and the comments of another node is controlled by the node-node spacing. + * @defaultValue '10' + */ + 'elk.spacing.commentNode'?: `${number}`; + /** + * Spacing to be preserved between pairs of connected components. This option is only relevant if + * 'separateConnectedComponents' is activated. + * @defaultValue '20' + */ + 'elk.spacing.componentComponent'?: `${number}`; + /** + * Spacing to be preserved between any two edges. Note that while this can somewhat easily be + * satisfied for the segments of orthogonally drawn edges, it is harder for general polylines or + * splines. + * @defaultValue '10' + */ + 'elk.spacing.edgeEdge'?: `${number}`; + /** + * The minimal distance to be preserved between a label and the edge it is associated with. Note + * that the placement of a label is influenced by the 'edgelabels.placement' option. + * @defaultValue '2' + */ + 'elk.spacing.edgeLabel'?: `${number}`; + /** + * Spacing to be preserved between nodes and edges. + * @defaultValue '10' + */ + 'elk.spacing.edgeNode'?: `${number}`; + /** + * Determines the amount of space to be left between two labels of the same graph element. + * @defaultValue '0' + */ + 'elk.spacing.labelLabel'?: `${number}`; + /** + * Spacing to be preserved between labels and the border of node they are associated with. Note + * that the placement of a label is influenced by the 'nodelabels.placement' option. + * @defaultValue '5' + */ + 'elk.spacing.labelNode'?: `${number}`; + /** + * Horizontal spacing to be preserved between labels and the ports they are associated with. Note + * that the placement of a label is influenced by the 'portlabels.placement' option. + * @defaultValue '1' + */ + 'elk.spacing.labelPortHorizontal'?: `${number}`; + /** + * Vertical spacing to be preserved between labels and the ports they are associated with. Note + * that the placement of a label is influenced by the 'portlabels.placement' option. + * @defaultValue '1' + */ + 'elk.spacing.labelPortVertical'?: `${number}`; + /** + * The minimal distance to be preserved between each two nodes. + * @defaultValue '20' + */ + 'elk.spacing.nodeNode'?: `${number}`; + /** + * Spacing to be preserved between a node and its self loops. + * @defaultValue '10' + */ + 'elk.spacing.nodeSelfLoop'?: `${number}`; + /** + * Spacing between pairs of ports of the same node. + * @defaultValue '10' + */ + 'elk.spacing.portPort'?: `${number}`; + /** + * Allows to specify individual spacing values for graph elements that shall be different from the + * value specified for the element's parent. + */ + 'elk.spacing.individual'?: string; + /** + * Additional space around the sets of ports on each node side. For each side of a node, this + * option can reserve additional space before and after the ports on each side. For example, a top + * spacing of 20 makes sure that the first port on the western and eastern side is 20 units away + * from the northern border. + * @defaultValue `new ElkMargin(0)` + */ + 'elk.spacing.portsSurrounding'?: string; + /** + * Partition to which the node belongs. This requires Layout Partitioning to be active. Nodes with + * lower partition IDs will appear to the left of nodes with higher partition IDs (assuming a + * left-to-right layout direction). + */ + 'elk.partitioning.partition'?: `${number}`; + /** + * Whether to activate partitioned layout. This will allow to group nodes through the Layout + * Partition option. a pair of nodes with different partition indices is then placed such that the + * node with lower index is placed to the left of the other node (with left-to-right layout + * direction). Depending on the layout algorithm, this may only be guaranteed to work if all nodes + * have a layout partition configured, or at least if edges that cross partitions are not part of a + * partition-crossing cycle. + * @defaultValue 'false' + */ + 'elk.partitioning.activate'?: 'true' | 'false'; + /** + * Define padding for node labels that are placed inside of a node. + * @defaultValue `new ElkPadding(5)` + */ + 'elk.nodeLabels.padding'?: string; + /** + * Hints for where node labels are to be placed; if empty, the node label's position is not + * modified. + * @defaultValue `NodeLabelPlacement.fixed` + */ + 'elk.nodeLabels.placement'?: string; + /** + * Defines the default port distribution for a node. May be overridden for each side individually. + * @defaultValue 'DISTRIBUTED' + */ + 'elk.portAlignment.default'?: PortAlignment; + /** + * Defines how ports on the northern side are placed, overriding the node's general port alignment. + */ + 'elk.portAlignment.north'?: PortAlignment; + /** + * Defines how ports on the southern side are placed, overriding the node's general port alignment. + */ + 'elk.portAlignment.south'?: PortAlignment; + /** + * Defines how ports on the western side are placed, overriding the node's general port alignment. + */ + 'elk.portAlignment.west'?: PortAlignment; + /** + * Defines how ports on the eastern side are placed, overriding the node's general port alignment. + */ + 'elk.portAlignment.east'?: PortAlignment; + /** + * Defines constraints of the position of the ports of a node. + * @defaultValue 'UNDEFINED' + */ + 'elk.portConstraints'?: PortConstraints; + /** + * The position of a node, port, or label. This is used by the 'Fixed Layout' algorithm to specify + * a pre-defined position. + */ + 'elk.position'?: string; + /** + * Defines the priority of an object; its meaning depends on the specific layout algorithm and the + * context where it is used. + */ + 'elk.priority'?: `${number}`; + /** + * Seed used for pseudo-random number generators to control the layout algorithm. If the value is + * 0, the seed shall be determined pseudo-randomly (e.g. from the system time). + */ + 'elk.randomSeed'?: `${number}`; + /** + * Whether each connected component should be processed separately. + */ + 'elk.separateConnectedComponents'?: 'true' | 'false'; + /** + * What should be taken into account when calculating a node's size. Empty size constraints specify + * that a node's size is already fixed and should not be changed. + * @defaultValue `EnumSet.noneOf(SizeConstraint)` + */ + 'elk.nodeSize.constraints'?: string; + /** + * Options modifying the behavior of the size constraints set on a node. Each member of the set + * specifies something that should be taken into account when calculating node sizes. The empty set + * corresponds to no further modifications. + * @defaultValue `EnumSet.of(SizeOptions.DEFAULT_MINIMUM_SIZE)` + */ + 'elk.nodeSize.options'?: string; + /** + * The minimal size to which a node can be reduced. + * @defaultValue `new KVector(0, 0)` + */ + 'elk.nodeSize.minimum'?: string; + /** + * By default, the fixed layout provider will enlarge a graph until it is large enough to contain + * its children. If this option is set, it won't do so. + * @defaultValue 'false' + */ + 'elk.nodeSize.fixedGraphSize'?: 'true' | 'false'; + /** + * This option is not used as option, but as output of the layout algorithms. It is attached to + * edges and determines the points where junction symbols should be drawn in order to represent + * hyperedges with orthogonal routing. Whether such points are computed depends on the chosen + * layout algorithm and edge routing style. The points are put into the vector chain with no + * specific order. + * @defaultValue `new KVectorChain()` + */ + 'elk.junctionPoints'?: string; + /** + * Whether the node should be regarded as a comment box instead of a regular node. In that case its + * placement should be similar to how labels are handled. Any edges incident to a comment box + * specify to which graph elements the comment is related. + * @defaultValue 'false' + */ + 'elk.commentBox'?: 'true' | 'false'; + /** + * Gives a hint on where to put edge labels. + * @defaultValue 'CENTER' + */ + 'elk.edgeLabels.placement'?: EdgeLabelPlacement; + /** + * If true, an edge label is placed directly on its edge. May only apply to center edge labels. + * This kind of label placement is only advisable if the label's rendering is such that it is not + * crossed by its edge and thus stays legible. + * @defaultValue 'false' + */ + 'elk.edgeLabels.inline'?: 'true' | 'false'; + /** + * Font name used for a label. + */ + 'elk.font.name'?: string; + /** + * Font size used for a label. + */ + 'elk.font.size'?: `${number}`; + /** + * Whether the node should be handled as a hypernode. + * @defaultValue 'false' + */ + 'elk.hypernode'?: 'true' | 'false'; + /** + * Determines the amount of fuzziness to be used when performing softwrapping on labels. The value + * expresses the percent of overhang that is permitted for each line. If the next line would take + * up less space than this threshold, it is appended to the current line instead of being placed in + * a new line. + * @defaultValue '0.0' + */ + 'elk.softwrappingFuzziness'?: `${number}`; + /** + * Margins define additional space around the actual bounds of a graph element. For instance, ports + * or labels being placed on the outside of a node's border might introduce such a margin. The + * margin is used to guarantee non-overlap of other graph elements with those ports or labels. + * @defaultValue `new ElkMargin()` + */ + 'elk.margins'?: string; + /** + * No layout is done for the associated element. This is used to mark parts of a diagram to avoid + * their inclusion in the layout graph, or to mark parts of the layout graph to prevent layout + * engines from processing them. If you wish to exclude the contents of a compound node from + * automatic layout, while the node itself is still considered on its own layer, use the 'Fixed + * Layout' algorithm for that node. + * @defaultValue 'false' + */ + 'elk.noLayout'?: 'true' | 'false'; + /** + * The offset to the port position where connections shall be attached. + */ + 'elk.port.anchor'?: string; + /** + * The index of a port in the fixed order around a node. The order is assumed as clockwise, + * starting with the leftmost port on the top side. This option must be set if 'Port Constraints' + * is set to FIXED_ORDER and no specific positions are given for the ports. Additionally, the + * option 'Port Side' must be defined in this case. + */ + 'elk.port.index'?: `${number}`; + /** + * The side of a node on which a port is situated. This option must be set if 'Port Constraints' is + * set to FIXED_SIDE or FIXED_ORDER and no specific positions are given for the ports. + * @defaultValue 'UNDEFINED' + */ + 'elk.port.side'?: PortSide; + /** + * The offset of ports on the node border. With a positive offset the port is moved outside of the + * node, while with a negative offset the port is moved towards the inside. An offset of 0 means + * that the port is placed directly on the node border, i.e. if the port side is north, the port's + * south border touches the nodes's north border; if the port side is east, the port's west border + * touches the nodes's east border; if the port side is south, the port's north border touches the + * node's south border; if the port side is west, the port's east border touches the node's west + * border. + */ + 'elk.port.borderOffset'?: `${number}`; + /** + * Decides on a placement method for port labels; if empty, the node label's position is not + * modified. + * @defaultValue `PortLabelPlacement.outside` + */ + 'elk.portLabels.placement'?: string; + /** + * Use 'portLabels.placement': NEXT_TO_PORT_OF_POSSIBLE. + * @defaultValue 'false' + */ + 'elk.portLabels.nextToPortIfPossible'?: 'true' | 'false'; + /** + * If this option is true (default), the labels of a port will be treated as a group when it comes + * to centering them next to their port. If this option is false, only the first label will be + * centered next to the port, with the others being placed below. This only applies to labels of + * eastern and western ports and will have no effect if labels are not placed next to their port. + * @defaultValue 'true' + */ + 'elk.portLabels.treatAsGroup'?: 'true' | 'false'; + /** + * The scaling factor to be applied to the corresponding node in recursive layout. It causes the + * corresponding node's size to be adjusted, and its ports and labels to be sized and placed + * accordingly after the layout of that node has been determined (and before the node itself and + * its siblings are arranged). The scaling is not reverted afterwards, so the resulting layout + * graph contains the adjusted size and position data. This option is currently not supported if + * 'Layout Hierarchy' is set. + * @defaultValue '1' + */ + 'elk.scaleFactor'?: `${number}`; + /** + * The width of the area occupied by the laid out children of a node. + */ + 'elk.childAreaWidth'?: `${number}`; + /** + * The height of the area occupied by the laid out children of a node. + */ + 'elk.childAreaHeight'?: `${number}`; + /** + * Turns topdown layout on and off. If this option is enabled, hierarchical layout will be computed + * first for the root node and then for its children recursively. Layouts are then scaled down to + * fit the area provided by their parents. Graphs must follow a certain structure for topdown + * layout to work properly. `TopdownNodeTypes.PARALLEL_NODE` nodes must have children of type + * `TopdownNodeTypes.HIERARCHICAL_NODE` and must define `topdown.hierarchicalNodeWidth` and + * `topdown.hierarchicalNodeAspectRatio` for their children. Furthermore they need to be laid out + * using an algorithm that is a `TopdownLayoutProvider`. Hierarchical nodes can also be parents of + * other hierarchical nodes and can optionally use a `TopdownSizeApproximator` to dynamically set + * sizes during topdown layout. In this case `topdown.hierarchicalNodeWidth` and + * `topdown.hierarchicalNodeAspectRatio` should be set on the node itself rather than the parent. + * The values are then used by the size approximator as base values. Hierarchical nodes require the + * layout option `nodeSize.fixedGraphSize` to be true to prevent the algorithm used there from + * resizing the hierarchical node. This option is not supported if 'Hierarchy Handling' is set to + * 'INCLUDE_CHILDREN' + * @defaultValue 'false' + */ + 'elk.topdownLayout'?: 'true' | 'false'; + /** + * Defines the number of categories to use for the FIXED_INTEGER_RATIO_BOXES size approximator. + * @defaultValue '3' + */ + 'elk.topdown.sizeCategories'?: `${number}`; + /** + * When determining the graph size for the size categorisation, this value determines how many + * times a node containing children is weighted more than a simple node. For example setting this + * value to four would result in a graph containing a simple node and a hierarchical node to be + * counted as having a size of five. + * @defaultValue '4' + */ + 'elk.topdown.sizeCategoriesHierarchicalNodeWeight'?: `${number}`; + /** + * The scaling factor to be applied to the nodes laid out within the node in recursive topdown + * layout. The difference to 'Scale Factor' is that the node itself is not scaled. This value has + * to be set on hierarchical nodes. + * @defaultValue '1' + */ + 'elk.topdown.scaleFactor'?: `${number}`; + /** + * The size approximator to be used to set sizes of hierarchical nodes during topdown layout. The + * default value is null, which results in nodes keeping whatever size is defined for them e.g. + * through parent parallel node or by manually setting the size. + * @defaultValue `null` + */ + 'elk.topdown.sizeApproximator'?: string; + /** + * The fixed size of a hierarchical node when using topdown layout. If this value is set on a + * parallel node it applies to its children, when set on a hierarchical node it applies to the node + * itself. + * @defaultValue '150' + */ + 'elk.topdown.hierarchicalNodeWidth'?: `${number}`; + /** + * The fixed aspect ratio of a hierarchical node when using topdown layout. Default is 1/sqrt(2). + * If this value is set on a parallel node it applies to its children, when set on a hierarchical + * node it applies to the node itself. + * @defaultValue '1.414' + */ + 'elk.topdown.hierarchicalNodeAspectRatio'?: `${number}`; + /** + * The different node types used for topdown layout. If the node type is set to + * `TopdownNodeTypes.PARALLEL_NODE` the algorithm must be set to a `TopdownLayoutProvider` such as + * `TopdownPacking`. The `nodeSize.fixedGraphSize` option is technically only required for + * hierarchical nodes. + * @defaultValue `null` + */ + 'elk.topdown.nodeType'?: TopdownNodeTypes; + /** + * Determines the upper limit for the topdown scale factor. The default value is 1.0 which ensures + * that nested children never end up appearing larger than their parents in terms of unit sizes + * such as the font size. If the limit is larger, nodes will fully utilize the available space, but + * it is counteriniuitive for inner nodes to have a larger scale than outer nodes. + * @defaultValue '1' + */ + 'elk.topdown.scaleCap'?: `${number}`; + /** + * Whether this node allows to route self loops inside of it instead of around it. If set to true, + * this will make the node a compound node if it isn't already, and will require the layout + * algorithm to support compound nodes with hierarchical ports. + * @defaultValue 'false' + */ + 'elk.insideSelfLoops.activate'?: 'true' | 'false'; + /** + * Whether a self loop should be routed inside a node instead of around that node. + * @defaultValue 'false' + */ + 'elk.insideSelfLoops.yo'?: 'true' | 'false'; + /** + * The thickness of an edge. This is a hint on the line width used to draw an edge, possibly + * requiring more space to be reserved for it. + * @defaultValue '1' + */ + 'elk.edge.thickness'?: `${number}`; + /** + * The type of an edge. This is usually used for UML class diagrams, where associations must be + * handled differently from generalizations. + * @defaultValue 'NONE' + */ + 'elk.edge.type'?: EdgeType; + /** + * Whether the shift from the old layout to the new computed layout shall be animated. + * @defaultValue 'true' + */ + 'elk.animate'?: 'true' | 'false'; + /** + * Factor for computation of animation time. The higher the value, the longer the animation time. + * If the value is 0, the resulting time is always equal to the minimum defined by 'Minimal + * Animation Time'. + * @defaultValue '100' + */ + 'elk.animTimeFactor'?: `${number}`; + /** + * Whether the hierarchy levels on the path from the selected element to the root of the diagram + * shall be included in the layout process. + * @defaultValue 'false' + */ + 'elk.layoutAncestors'?: 'true' | 'false'; + /** + * The maximal time for animations, in milliseconds. + * @defaultValue '4000' + */ + 'elk.maxAnimTime'?: `${number}`; + /** + * The minimal time for animations, in milliseconds. + * @defaultValue '400' + */ + 'elk.minAnimTime'?: `${number}`; + /** + * Whether a progress bar shall be displayed during layout computations. + * @defaultValue 'false' + */ + 'elk.progressBar'?: 'true' | 'false'; + /** + * Whether the graph shall be validated before any layout algorithm is applied. If this option is + * enabled and at least one error is found, the layout process is aborted and a message is shown to + * the user. + * @defaultValue 'false' + */ + 'elk.validateGraph'?: 'true' | 'false'; + /** + * Whether layout options shall be validated before any layout algorithm is applied. If this option + * is enabled and at least one error is found, the layout process is aborted and a message is shown + * to the user. + * @defaultValue 'true' + */ + 'elk.validateOptions'?: 'true' | 'false'; + /** + * Whether the zoom level shall be set to view the whole diagram after layout. + * @defaultValue 'false' + */ + 'elk.zoomToFit'?: 'true' | 'false'; + /** + * Strategy for cycle breaking. Cycle breaking looks for cycles in the graph and determines which + * edges to reverse to break the cycles. Reversed edges will end up pointing to the opposite + * direction of regular edges (that is, reversed edges will point left if edges usually point + * right). + * @defaultValue 'GREEDY' + */ + 'elk.layered.cycleBreaking.strategy'?: CycleBreakingStrategy; + /** + * Strategy for node layering. + * @defaultValue 'NETWORK_SIMPLEX' + */ + 'elk.layered.layering.strategy'?: LayeringStrategy; + /** + * Determines a constraint on the placement of the node regarding the layering. + * @defaultValue 'NONE' + */ + 'elk.layered.layering.layerConstraint'?: LayerConstraint; + /** + * Allows to set a constraint regarding the layer placement of a node. Let i be the value of teh + * constraint. Assumed the drawing has n layers and i < n. If set to i, it expresses that the node + * should be placed in i-th layer. Should i>=n be true then the node is placed in the last layer of + * the drawing. Note that this option is not part of any of ELK Layered's default configurations + * but is only evaluated as part of the `InteractiveLayeredGraphVisitor`, which must be applied + * manually or used via the `DiagramLayoutEngine. + * @defaultValue `null` + */ + 'elk.layered.layering.layerChoiceConstraint'?: `${number}`; + /** + * Layer identifier that was calculated by ELK Layered for a node. This is only generated if + * interactiveLayot or generatePositionAndLayerIds is set. + * @defaultValue '-1' + */ + 'elk.layered.layering.layerId'?: `${number}`; + /** + * Defines a loose upper bound on the width of the MinWidth layerer. If set to '-1' multiple values + * are tested and the best result is selected. + * @defaultValue '4' + */ + 'elk.layered.layering.minWidth.upperBoundOnWidth'?: `${number}`; + /** + * Multiplied with Upper Bound On Width for defining an upper bound on the width of layers which + * haven't been determined yet, but whose maximum width had been (roughly) estimated by the + * MinWidth algorithm. Compensates for too high estimations. If set to '-1' multiple values are + * tested and the best result is selected. + * @defaultValue '2' + */ + 'elk.layered.layering.minWidth.upperLayerEstimationScalingFactor'?: `${number}`; + /** + * Reduces number of dummy nodes after layering phase (if possible). + * @defaultValue 'NONE' + */ + 'elk.layered.layering.nodePromotion.strategy'?: NodePromotionStrategy; + /** + * Limits the number of iterations for node promotion. + * @defaultValue '0' + */ + 'elk.layered.layering.nodePromotion.maxIterations'?: `${number}`; + /** + * The maximum number of nodes allowed per layer. + * @defaultValue 'MAX_VALUE' + */ + 'elk.layered.layering.coffmanGraham.layerBound'?: `${number}`; + /** + * Strategy for crossing minimization. + * @defaultValue 'LAYER_SWEEP' + */ + 'elk.layered.crossingMinimization.strategy'?: CrossingMinimizationStrategy; + /** + * The node order given by the model does not change to produce a better layout. E.g. if node A is + * before node B in the model this is not changed during crossing minimization. This assumes that + * the node model order is already respected before crossing minimization. This can be achieved by + * setting considerModelOrder.strategy to NODES_AND_EDGES. + * @defaultValue 'false' + */ + 'elk.layered.crossingMinimization.forceNodeModelOrder'?: 'true' | 'false'; + /** + * How likely it is to use cross-hierarchy (1) vs bottom-up (-1). + * @defaultValue '0.1' + */ + 'elk.layered.crossingMinimization.hierarchicalSweepiness'?: `${number}`; + /** + * By default it is decided automatically if the greedy switch is activated or not. The decision is + * based on whether the size of the input graph (without dummy nodes) is smaller than the value of + * this option. A '0' enforces the activation. + * @defaultValue '40' + */ + 'elk.layered.crossingMinimization.greedySwitch.activationThreshold'?: `${number}`; + /** + * Greedy Switch strategy for crossing minimization. The greedy switch heuristic is executed after + * the regular crossing minimization as a post-processor. Note that if 'hierarchyHandling' is set + * to 'INCLUDE_CHILDREN', the 'greedySwitchHierarchical.type' option must be used. + * @defaultValue 'TWO_SIDED' + */ + 'elk.layered.crossingMinimization.greedySwitch.type'?: GreedySwitchType; + /** + * Activates the greedy switch heuristic in case hierarchical layout is used. The differences to + * the non-hierarchical case (see 'greedySwitch.type') are: 1) greedy switch is inactive by + * default, 3) only the option value set on the node at which hierarchical layout starts is + * relevant, and 2) if it's activated by the user, it properly addresses hierarchy-crossing edges. + * @defaultValue 'OFF' + */ + 'elk.layered.crossingMinimization.greedySwitchHierarchical.type'?: GreedySwitchType; + /** + * Preserves the order of nodes within a layer but still minimizes crossings between edges + * connecting long edge dummies. Derives the desired order from positions specified by the + * 'org.eclipse.elk.position' layout option. Requires a crossing minimization strategy that is able + * to process 'in-layer' constraints. + * @defaultValue 'false' + */ + 'elk.layered.crossingMinimization.semiInteractive'?: 'true' | 'false'; + /** + * Allows to set a constraint which specifies of which node the current node is the predecessor. If + * set to 's' then the node is the predecessor of 's' and is in the same layer + * @defaultValue `null` + */ + 'elk.layered.crossingMinimization.inLayerPredOf'?: string; + /** + * Allows to set a constraint which specifies of which node the current node is the successor. If + * set to 's' then the node is the successor of 's' and is in the same layer + * @defaultValue `null` + */ + 'elk.layered.crossingMinimization.inLayerSuccOf'?: string; + /** + * Allows to set a constraint regarding the position placement of a node in a layer. Assumed the + * layer in which the node placed includes n other nodes and i < n. If set to i, it expresses that + * the node should be placed at the i-th position. Should i>=n be true then the node is placed at + * the last position in the layer. Note that this option is not part of any of ELK Layered's + * default configurations but is only evaluated as part of the `InteractiveLayeredGraphVisitor`, + * which must be applied manually or used via the `DiagramLayoutEngine. + * @defaultValue `null` + */ + 'elk.layered.crossingMinimization.positionChoiceConstraint'?: `${number}`; + /** + * Position within a layer that was determined by ELK Layered for a node. This is only generated if + * interactiveLayot or generatePositionAndLayerIds is set. + * @defaultValue '-1' + */ + 'elk.layered.crossingMinimization.positionId'?: `${number}`; + /** + * Strategy for node placement. + * @defaultValue 'BRANDES_KOEPF' + */ + 'elk.layered.nodePlacement.strategy'?: NodePlacementStrategy; + /** + * Favor straight edges over a balanced node placement. The default behavior is determined + * automatically based on the used 'edgeRouting'. For an orthogonal style it is set to true, for + * all other styles to false. + */ + 'elk.layered.nodePlacement.favorStraightEdges'?: 'true' | 'false'; + /** + * Specifies whether the Brandes Koepf node placer tries to increase the number of straight edges + * at the expense of diagram size. There is a subtle difference to the 'favorStraightEdges' option, + * which decides whether a balanced placement of the nodes is desired, or not. In bk terms this + * means combining the four alignments into a single balanced one, or not. This option on the other + * hand tries to straighten additional edges during the creation of each of the four alignments. + * @defaultValue 'IMPROVE_STRAIGHTNESS' + */ + 'elk.layered.nodePlacement.bk.edgeStraightening'?: EdgeStraighteningStrategy; + /** + * Tells the BK node placer to use a certain alignment (out of its four) instead of the one + * producing the smallest height, or the combination of all four. + * @defaultValue 'NONE' + */ + 'elk.layered.nodePlacement.bk.fixedAlignment'?: FixedAlignment; + /** + * Dampens the movement of nodes to keep the diagram from getting too large. + * @defaultValue '0.3' + */ + 'elk.layered.nodePlacement.linearSegments.deflectionDampening'?: `${number}`; + /** + * Aims at shorter and straighter edges. Two configurations are possible: (a) allow ports to move + * freely on the side they are assigned to (the order is always defined beforehand), (b) + * additionally allow to enlarge a node wherever it helps. If this option is not configured for a + * node, the 'nodeFlexibility.default' value is used, which is specified for the node's parent. + */ + 'elk.layered.nodePlacement.networkSimplex.nodeFlexibility'?: NodeFlexibility; + /** + * Default value of the 'nodeFlexibility' option for the children of a hierarchical node. + * @defaultValue 'NONE' + */ + 'elk.layered.nodePlacement.networkSimplex.nodeFlexibility.default'?: NodeFlexibility; + /** + * Run a second node placement algorithm after the initial network simplex with node flexibility. + * In the second run, node flexibility is disabled and a different node placer can be used. The + * node sizes determined by the node flexibility option are used as fixed node sizes in the second + * run. If set to null (default) no second run is performed. + * @defaultValue `null` + */ + 'elk.layered.nodePlacement.networkSimplex.nodeFlexibility.recomputeNodePlacement'?: NodePlacementStrategy; + /** + * Specifies the way control points are assembled for each individual edge. CONSERVATIVE ensures + * that edges are properly routed around the nodes but feels rather orthogonal at times. SLOPPY + * uses fewer control points to obtain curvier edge routes but may result in edges overlapping + * nodes. + * @defaultValue 'SLOPPY' + */ + 'elk.layered.edgeRouting.splines.mode'?: SplineRoutingMode; + /** + * Spacing factor for routing area between layers when using sloppy spline routing. + * @defaultValue '0.2' + */ + 'elk.layered.edgeRouting.splines.sloppy.layerSpacingFactor'?: `${number}`; + /** + * Width of the strip to the left and to the right of each layer where the polyline edge router is + * allowed to refrain from ensuring that edges are routed horizontally. This prevents awkward bend + * points for nodes that extent almost to the edge of their layer. + * @defaultValue '2.0' + */ + 'elk.layered.edgeRouting.polyline.slopedEdgeZoneWidth'?: `${number}`; + /** + * Alter the distribution of the loops around the node. It only takes effect for + * PortConstraints.FREE. + * @defaultValue 'NORTH' + */ + 'elk.layered.edgeRouting.selfLoopDistribution'?: SelfLoopDistributionStrategy; + /** + * Alter the ordering of the loops they can either be stacked or sequenced. It only takes effect + * for PortConstraints.FREE. + * @defaultValue 'STACKED' + */ + 'elk.layered.edgeRouting.selfLoopOrdering'?: SelfLoopOrderingStrategy; + /** + * An optional base value for all other layout options of the 'spacing' group. It can be used to + * conveniently alter the overall 'spaciousness' of the drawing. Whenever an explicit value is set + * for the other layout options, this base value will have no effect. The base value is not + * inherited, i.e. it must be set for each hierarchical node. + */ + 'elk.layered.spacing.baseValue'?: `${number}`; + /** + * The spacing to be preserved between nodes and edges that are routed next to the node's layer. + * For the spacing between nodes and edges that cross the node's layer 'spacing.edgeNode' is used. + * @defaultValue '10' + */ + 'elk.layered.spacing.edgeNodeBetweenLayers'?: `${number}`; + /** + * Spacing to be preserved between pairs of edges that are routed between the same pair of layers. + * Note that 'spacing.edgeEdge' is used for the spacing between pairs of edges crossing the same + * layer. + * @defaultValue '10' + */ + 'elk.layered.spacing.edgeEdgeBetweenLayers'?: `${number}`; + /** + * The spacing to be preserved between any pair of nodes of two adjacent layers. Note that + * 'spacing.nodeNode' is used for the spacing between nodes within the layer itself. + * @defaultValue '20' + */ + 'elk.layered.spacing.nodeNodeBetweenLayers'?: `${number}`; + /** + * Defines how important it is to have a certain edge point into the direction of the overall + * layout. This option is evaluated during the cycle breaking phase. + * @defaultValue '0' + */ + 'elk.layered.priority.direction'?: `${number}`; + /** + * Defines how important it is to keep an edge as short as possible. This option is evaluated + * during the layering phase. + * @defaultValue '0' + */ + 'elk.layered.priority.shortness'?: `${number}`; + /** + * Defines how important it is to keep an edge straight, i.e. aligned with one of the two axes. + * This option is evaluated during node placement. + * @defaultValue '0' + */ + 'elk.layered.priority.straightness'?: `${number}`; + /** + * Specifies whether and how post-process compaction is applied. + * @defaultValue 'NONE' + */ + 'elk.layered.compaction.postCompaction.strategy'?: GraphCompactionStrategy; + /** + * Specifies whether and how post-process compaction is applied. + * @defaultValue 'SCANLINE' + */ + 'elk.layered.compaction.postCompaction.constraints'?: ConstraintCalculationStrategy; + /** + * Tries to further compact components (disconnected sub-graphs). + * @defaultValue 'false' + */ + 'elk.layered.compaction.connectedComponents'?: 'true' | 'false'; + /** + * Makes room around high degree nodes to place leafs and trees. + * @defaultValue 'false' + */ + 'elk.layered.highDegreeNodes.treatment'?: 'true' | 'false'; + /** + * Whether a node is considered to have a high degree. + * @defaultValue '16' + */ + 'elk.layered.highDegreeNodes.threshold'?: `${number}`; + /** + * Maximum height of a subtree connected to a high degree node to be moved to separate layers. + * @defaultValue '5' + */ + 'elk.layered.highDegreeNodes.treeHeight'?: `${number}`; + /** + * For certain graphs and certain prescribed drawing areas it may be desirable to split the laid + * out graph into chunks that are placed side by side. The edges that connect different chunks are + * 'wrapped' around from the end of one chunk to the start of the other chunk. The points between + * the chunks are referred to as 'cuts'. + * @defaultValue 'OFF' + */ + 'elk.layered.wrapping.strategy'?: WrappingStrategy; + /** + * To visually separate edges that are wrapped from regularly routed edges an additional spacing + * value can be specified in form of this layout option. The spacing is added to the regular + * edgeNode spacing. + * @defaultValue '10' + */ + 'elk.layered.wrapping.additionalEdgeSpacing'?: `${number}`; + /** + * At times and for certain types of graphs the executed wrapping may produce results that are + * consistently biased in the same fashion: either wrapping to often or to rarely. This factor can + * be used to correct the bias. Internally, it is simply multiplied with the 'aspect ratio' layout + * option. + * @defaultValue '1.0' + */ + 'elk.layered.wrapping.correctionFactor'?: `${number}`; + /** + * The strategy by which the layer indexes are determined at which the layering crumbles into + * chunks. + * @defaultValue 'MSD' + */ + 'elk.layered.wrapping.cutting.strategy'?: CuttingStrategy; + /** + * Allows the user to specify her own cuts for a certain graph. + */ + 'elk.layered.wrapping.cutting.cuts'?: string; + /** + * The MSD cutting strategy starts with an initial guess on the number of chunks the graph should + * be split into. The freedom specifies how much the strategy may deviate from this guess. E.g. if + * an initial number of 3 is computed, a freedom of 1 allows 2, 3, and 4 cuts. + * @defaultValue '1' + */ + 'elk.layered.wrapping.cutting.msd.freedom'?: `${number}`; + /** + * When wrapping graphs, one can specify indices that are not allowed as split points. The + * validification strategy makes sure every computed split point is allowed. + * @defaultValue 'GREEDY' + */ + 'elk.layered.wrapping.validify.strategy'?: ValidifyStrategy; + /** + */ + 'elk.layered.wrapping.validify.forbiddenIndices'?: string; + /** + * For general graphs it is important that not too many edges wrap backwards. Thus a compromise + * between evenly-distributed cuts and the total number of cut edges is sought. + * @defaultValue 'true' + */ + 'elk.layered.wrapping.multiEdge.improveCuts'?: 'true' | 'false'; + /** + * @defaultValue '2.0' + */ + 'elk.layered.wrapping.multiEdge.distancePenalty'?: `${number}`; + /** + * The initial wrapping is performed in a very simple way. As a consequence, edges that wrap from + * one chunk to another may be unnecessarily long. Activating this option tries to shorten such + * edges. + * @defaultValue 'true' + */ + 'elk.layered.wrapping.multiEdge.improveWrappedEdges'?: 'true' | 'false'; + /** + * The strategy to use for unzipping a layer into multiple sublayers while maintaining the existing + * ordering of nodes and edges after crossing minimization. The default value is 'NONE'. + * @defaultValue 'NONE' + */ + 'elk.layered.layerUnzipping.strategy'?: LayerUnzippingStrategy; + /** + * Use a heuristic to decide whether or not to actually perform the layer split with the goal of + * minimizing the total edge length. This option only works when layerSplit is set to 2. The + * property can be set to the nodes in a layer, which then applies the property for the layer. If + * any node sets the value to true, then the value is set to true for the entire layer. + * @defaultValue 'false' + */ + 'elk.layered.layerUnzipping.minimizeEdgeLength'?: 'true' | 'false'; + /** + * Defines the number of sublayers to split a layer into. The property can be set to the nodes in a + * layer, which then applies the property for the layer. If multiple nodes set the value to + * different values, then the lowest value is chosen. + * @defaultValue '2' + */ + 'elk.layered.layerUnzipping.layerSplit'?: `${number}`; + /** + * If set to true, nodes will always be placed in the first sublayer after a long edge when using + * the ALTERNATING strategy. Otherwise long edge dummies are treated the same as regular nodes. The + * default value is true. The property can be set to the nodes in a layer, which then applies the + * property for the layer. If any node sets the value to false, then the value is set to false for + * the entire layer. + * @defaultValue 'true' + */ + 'elk.layered.layerUnzipping.resetOnLongEdges'?: 'true' | 'false'; + /** + * Method to decide on edge label sides. + * @defaultValue 'SMART_DOWN' + */ + 'elk.layered.edgeLabels.sideSelection'?: EdgeLabelSideSelection; + /** + * Determines in which layer center labels of long edges should be placed. + * @defaultValue 'MEDIAN_LAYER' + */ + 'elk.layered.edgeLabels.centerLabelPlacementStrategy'?: CenterEdgeLabelPlacementStrategy; + /** + * Preserves the order of nodes and edges in the model file if this does not lead to additional + * edge crossings. Depending on the strategy this is not always possible since the node and edge + * order might be conflicting. + * @defaultValue 'NONE' + */ + 'elk.layered.considerModelOrder.strategy'?: OrderingStrategy; + /** + * If disabled the port order of output ports is derived from the edge order and input ports are + * ordered by their incoming connections. If enabled all ports are ordered by the port model order. + * @defaultValue 'false' + */ + 'elk.layered.considerModelOrder.portModelOrder'?: 'true' | 'false'; + /** + * Set on a node to not set a model order for this node even though it is a real node. + * @defaultValue 'false' + */ + 'elk.layered.considerModelOrder.noModelOrder'?: 'true' | 'false'; + /** + * If set to NONE the usual ordering strategy (by cumulative node priority and size of nodes) is + * used. INSIDE_PORT_SIDES orders the components with external ports only inside the groups with + * the same port side. FORCE_MODEL_ORDER enforces the mode order on components. This option might + * produce bad alignments and sub optimal drawings in terms of used area since the ordering should + * be respected. + * @defaultValue 'NONE' + */ + 'elk.layered.considerModelOrder.components'?: ComponentOrderingStrategy; + /** + * Indicates whether long edges are sorted under, over, or equal to nodes that have no connection + * to a previous layer in a left-to-right or right-to-left layout. Under and over changes to right + * and left in a vertical layout. + * @defaultValue 'DUMMY_NODE_OVER' + */ + 'elk.layered.considerModelOrder.longEdgeStrategy'?: LongEdgeOrderingStrategy; + /** + * Indicates with what percentage (1 for 100%) violations of the node model order are weighted + * against the crossings e.g. a value of 0.5 means two model order violations are as important as + * on edge crossing. This allows some edge crossings in favor of preserving the model order. It is + * advised to set this value to a very small positive value (e.g. 0.001) to have minimal crossing + * and a optimal node order. Defaults to no influence (0). + * @defaultValue '0' + */ + 'elk.layered.considerModelOrder.crossingCounterNodeInfluence'?: `${number}`; + /** + * Indicates with what percentage (1 for 100%) violations of the port model order are weighted + * against the crossings e.g. a value of 0.5 means two model order violations are as important as + * on edge crossing. This allows some edge crossings in favor of preserving the model order. It is + * advised to set this value to a very small positive value (e.g. 0.001) to have minimal crossing + * and a optimal port order. Defaults to no influence (0). + * @defaultValue '0' + */ + 'elk.layered.considerModelOrder.crossingCounterPortInfluence'?: `${number}`; + /** + * Used to define partial ordering groups during cycle breaking. A lower group id means that the + * group is sorted before other groups. A group model order of 0 is the default group. + * @defaultValue '0' + */ + 'elk.layered.considerModelOrder.groupModelOrder.cycleBreakingId'?: `${number}`; + /** + * Used to define partial ordering groups during crossing minimization. A lower group id means that + * the group is sorted before other groups. A group model order of 0 is the default group. + * @defaultValue '0' + */ + 'elk.layered.considerModelOrder.groupModelOrder.crossingMinimizationId'?: `${number}`; + /** + * Used to define partial ordering groups during component packing. A lower group id means that the + * group is sorted before other groups. A group model order of 0 is the default group. + * @defaultValue '0' + */ + 'elk.layered.considerModelOrder.groupModelOrder.componentGroupId'?: `${number}`; + /** + * Determines how to count ordering violations during cycle breaking. NONE: They do not count. + * ENFORCED: A group with a higher model order is before a node with a smaller. MODEL_ORDER: The + * model order counts instead of the model order group id ordering. + * @defaultValue 'ONLY_WITHIN_GROUP' + */ + 'elk.layered.considerModelOrder.groupModelOrder.cbGroupOrderStrategy'?: GroupOrderStrategy; + /** + * The model order group id for which should be preferred as a source if possible. + */ + 'elk.layered.considerModelOrder.groupModelOrder.cbPreferredSourceId'?: `${number}`; + /** + * The model order group id for which should be preferred as a target if possible. + */ + 'elk.layered.considerModelOrder.groupModelOrder.cbPreferredTargetId'?: `${number}`; + /** + * Determines how to count ordering violations during crossing minimization. NONE: They do not + * count. ENFORCED: A group with a lower id is before a group with a higher id. MODEL_ORDER: The + * model order counts instead of the model order group id ordering. + * @defaultValue 'ONLY_WITHIN_GROUP' + */ + 'elk.layered.considerModelOrder.groupModelOrder.cmGroupOrderStrategy'?: GroupOrderStrategy; + /** + * Holds all group ids which are enforcing their order during crossing minimization strategies. + * E.g. if only groups 2 and -1 (default) enforce their ordering. Other groups e.g. the group of + * timer nodes can be ordered arbitrarily if it helps and the mentioned groups may not change their + * order. + * @defaultValue `#[1, 2, 6, 7, 10, 11]` + */ + 'elk.layered.considerModelOrder.groupModelOrder.cmEnforcedGroupOrders'?: string; + /** + * Specifies how drawings of the same graph with different layout directions compare to each other: + * either a natural reading direction is preserved or the drawings are rotated versions of each + * other. + * @defaultValue 'READING_DIRECTION' + */ + 'elk.layered.directionCongruency'?: DirectionCongruency; + /** + * Whether feedback edges should be highlighted by routing around the nodes. + * @defaultValue 'false' + */ + 'elk.layered.feedbackEdges'?: 'true' | 'false'; + /** + * Determines which point of a node is considered by interactive layout phases. + * @defaultValue 'CENTER' + */ + 'elk.layered.interactiveReferencePoint'?: InteractiveReferencePoint; + /** + * Edges that have no ports are merged so they touch the connected nodes at the same points. When + * this option is disabled, one port is created for each edge directly connected to a node. When it + * is enabled, all such incoming edges share an input port, and all outgoing edges share an output + * port. + * @defaultValue 'false' + */ + 'elk.layered.mergeEdges'?: 'true' | 'false'; + /** + * If hierarchical layout is active, hierarchy-crossing edges use as few hierarchical ports as + * possible. They are broken by the algorithm, with hierarchical ports inserted as required. + * Usually, one such port is created for each edge at each hierarchy crossing point. With this + * option set to true, we try to create as few hierarchical ports as possible in the process. In + * particular, all edges that form a hyperedge can share a port. + * @defaultValue 'true' + */ + 'elk.layered.mergeHierarchyEdges'?: 'true' | 'false'; + /** + * Specifies whether non-flow ports may switch sides if their node's port constraints are either + * FIXED_SIDE or FIXED_ORDER. A non-flow port is a port on a side that is not part of the currently + * configured layout flow. For instance, given a left-to-right layout direction, north and south + * ports would be considered non-flow ports. Further note that the underlying criterium whether to + * switch sides or not solely relies on the minimization of edge crossings. Hence, edge length and + * other aesthetics criteria are not addressed. + * @defaultValue 'false' + */ + 'elk.layered.allowNonFlowPortsToSwitchSides'?: 'true' | 'false'; + /** + * Only relevant for nodes with FIXED_SIDE port constraints. Determines the way a node's ports are + * distributed on the sides of a node if their order is not prescribed. The option is set on parent + * nodes. + * @defaultValue 'INPUT_ORDER' + */ + 'elk.layered.portSortingStrategy'?: PortSortingStrategy; + /** + * How much effort should be spent to produce a nice layout. + * @defaultValue '7' + */ + 'elk.layered.thoroughness'?: `${number}`; + /** + * Adds bend points even if an edge does not change direction. If true, each long edge dummy will + * contribute a bend point to its edges and hierarchy-crossing edges will always get a bend point + * where they cross hierarchy boundaries. By default, bend points are only added where an edge + * changes direction. + * @defaultValue 'false' + */ + 'elk.layered.unnecessaryBendpoints'?: 'true' | 'false'; + /** + * If enabled position id and layer id are generated, which are usually only used internally when + * setting the interactiveLayout option. This option should be specified on the root node. + * @defaultValue 'false' + */ + 'elk.layered.generatePositionAndLayerIds'?: 'true' | 'false'; +} + +// 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'; + +// 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/export.mts b/packages/joint-layout-elk/src/export.mts index 3a584b6388..d33249cdec 100644 --- a/packages/joint-layout-elk/src/export.mts +++ b/packages/joint-layout-elk/src/export.mts @@ -2,10 +2,14 @@ import type { dia } from '@joint/core'; import type { ElkNode, ElkPort, - LayoutOptions as ElkLayoutOptions, ElkExtendedEdge, - ElkLabel -} from 'elkjs'; + ElkLabel, + ElkLayoutOptions, + NodeElkLayoutOptions, + PortElkLayoutOptions, + EdgeElkLayoutOptions, + LabelElkLayoutOptions +} from './elkOptions.mjs'; export const DEFAULT_LABEL_SIZE: dia.Size = { width: 50, @@ -14,17 +18,17 @@ export const DEFAULT_LABEL_SIZE: dia.Size = { // ELK ignores labels with no text. const ELK_LABEL_TEXT = '-'; -const ELK_INLINE_LABEL_OPTIONS = { 'edgeLabels.inline': 'true' }; +const ELK_INLINE_LABEL_OPTIONS: LabelElkLayoutOptions = { 'edgeLabels.inline': 'true' }; // Ports are positioned by JointJS (via the element's port groups), not by ELK - // this tells ELK to treat the coordinates we give it as final. -const ELK_FIXED_PORTS_OPTIONS = { 'elk.portConstraints': 'FIXED_POS' }; +const ELK_FIXED_PORTS_OPTIONS: NodeElkLayoutOptions = { 'elk.portConstraints': 'FIXED_POS' }; // With `positionPorts`, ELK is free to reposition (and reorder) ports itself. -const ELK_FREE_PORTS_OPTIONS = { 'elk.portConstraints': 'FIXED_SIDE' }; +const ELK_FREE_PORTS_OPTIONS: NodeElkLayoutOptions = { 'elk.portConstraints': 'FIXED_SIDE' }; type GetSizeCallback = (element: dia.Element) => dia.Size; -type NodeOptionsCallback = (element: dia.Element) => ElkLayoutOptions | undefined; -type PortOptionsCallback = (port: dia.Element.Port, element: dia.Element) => ElkLayoutOptions | undefined; -type EdgeOptionsCallback = (link: dia.Link) => ElkLayoutOptions | undefined; +type NodeOptionsCallback = (element: dia.Element) => NodeElkLayoutOptions | undefined; +type PortOptionsCallback = (port: dia.Element.Port, element: dia.Element) => PortElkLayoutOptions | undefined; +type EdgeOptionsCallback = (link: dia.Link) => EdgeElkLayoutOptions | undefined; export interface ElkGraphPort { element: dia.Element; diff --git a/packages/joint-layout-elk/src/import.mts b/packages/joint-layout-elk/src/import.mts index ef47994619..c749b4c907 100644 --- a/packages/joint-layout-elk/src/import.mts +++ b/packages/joint-layout-elk/src/import.mts @@ -1,5 +1,6 @@ import { type dia, g } from '@joint/core'; -import type { ElkNode, ElkExtendedEdge, ElkPoint } from 'elkjs'; +import type { ElkPoint } from 'elkjs'; +import type { ElkNode, ElkExtendedEdge } from './elkOptions.mjs'; import type { ElkGraphPort } from './export.mjs'; type SetPositionCallback = (element: dia.Element, position: dia.Point) => void; diff --git a/packages/joint-layout-elk/src/index.mts b/packages/joint-layout-elk/src/index.mts index 54a6f57f13..d9f9eb6d28 100644 --- a/packages/joint-layout-elk/src/index.mts +++ b/packages/joint-layout-elk/src/index.mts @@ -1,3 +1,4 @@ export * from './layout.mjs'; export * from './import.mjs'; export * from './export.mjs'; +export * from './elkOptions.mjs'; diff --git a/packages/joint-layout-elk/src/layout.mts b/packages/joint-layout-elk/src/layout.mts index ba8510738a..0d07d470dc 100644 --- a/packages/joint-layout-elk/src/layout.mts +++ b/packages/joint-layout-elk/src/layout.mts @@ -5,10 +5,9 @@ import { exportGraph } from './export.mjs'; import type { ExportGraphOptions } from './export.mjs'; import type { EdgeLabelsOptions, ImportLayoutOptions, PortPositionsOptions, PortLabelPositionsOptions } from './import.mjs'; +import type { ElkLayoutOptions, ElkNode } from './elkOptions.mjs'; import type { dia } from '@joint/core'; -import type { ELK, ElkNode, LayoutOptions as ElkLayoutOptions } from 'elkjs'; - -export { ElkLayoutOptions }; +import type { ELK, ElkNode as RawElkNode } from 'elkjs'; const LAYOUT_BATCH_NAME = 'layout'; @@ -112,7 +111,7 @@ export async function layout(graph: dia.Graph, opt?: Options): Promise Date: Tue, 8 Sep 2026 17:11:12 +0200 Subject: [PATCH 08/75] estimate port label size --- packages/joint-layout-elk/src/export.mts | 111 ++++++++++++++++++----- 1 file changed, 86 insertions(+), 25 deletions(-) diff --git a/packages/joint-layout-elk/src/export.mts b/packages/joint-layout-elk/src/export.mts index d33249cdec..722f7cc7c5 100644 --- a/packages/joint-layout-elk/src/export.mts +++ b/packages/joint-layout-elk/src/export.mts @@ -18,6 +18,12 @@ export const DEFAULT_LABEL_SIZE: dia.Size = { // ELK ignores labels with no text. const ELK_LABEL_TEXT = '-'; +// Used to estimate a port label's size from its text (see `getPortLabelSize`) when +// no explicit size is given - a rough, DOM-free approximation, not a real measurement. +const DEFAULT_FONT_SIZE = 16; +const AVERAGE_CHAR_WIDTH_RATIO = 0.6; +const LINE_HEIGHT_RATIO = 1.2; + const ELK_INLINE_LABEL_OPTIONS: LabelElkLayoutOptions = { 'edgeLabels.inline': 'true' }; // Ports are positioned by JointJS (via the element's port groups), not by ELK - // this tells ELK to treat the coordinates we give it as final. @@ -26,6 +32,7 @@ const ELK_FIXED_PORTS_OPTIONS: NodeElkLayoutOptions = { 'elk.portConstraints': ' const ELK_FREE_PORTS_OPTIONS: NodeElkLayoutOptions = { 'elk.portConstraints': 'FIXED_SIDE' }; type GetSizeCallback = (element: dia.Element) => dia.Size; +type GetPortLabelSizeCallback = (port: dia.Element.Port, element: dia.Element) => dia.Size; type NodeOptionsCallback = (element: dia.Element) => NodeElkLayoutOptions | undefined; type PortOptionsCallback = (port: dia.Element.Port, element: dia.Element) => PortElkLayoutOptions | undefined; type EdgeOptionsCallback = (link: dia.Link) => EdgeElkLayoutOptions | undefined; @@ -49,6 +56,14 @@ export interface ExportGraphOptions { * have embedded elements - their size is computed by ELK to fit their content. */ getSize?: GetSizeCallback; + /** + * Specify custom logic to determine a port's label size, used when `positionPortLabels` + * is enabled, instead of the default - the port's own `label.size`, falling back to its + * group's `label.size`, falling back to an estimate from the label's text and font size + * (`attrs.text.text`/`attrs.text.fontSize`, the latter defaulting to 16 if not set), and + * finally to `DEFAULT_LABEL_SIZE` if there is no text either. + */ + getPortLabelSize?: GetPortLabelSizeCallback; /** * Per-element ELK layout options, merged into the generated ELK node. * @example @@ -93,6 +108,44 @@ const getSize: GetSizeCallback = (element) => { return element.size(); }; +/** + * A rough, DOM-free approximation of a text's rendered size - not a real measurement + * (that would need a live SVG document, see `util.breakText` in `@joint/core`), just + * enough to give ELK a sane amount of space to reserve for a port label. + */ +function estimateTextSize(text: string, fontSize: number): dia.Size { + return { + width: Math.ceil(text.length * fontSize * AVERAGE_CHAR_WIDTH_RATIO), + height: Math.ceil(fontSize * LINE_HEIGHT_RATIO) + }; +} + +const getPortLabelSize: GetPortLabelSizeCallback = (port, element) => { + // `label.size` isn't part of the officially typed `dia.Element.Port`/`PortGroup.label` + // shape, but JointJS reads it off both at render time if present - a port's own size + // takes precedence over its group's, same as JointJS resolves every other port/group + // property (`attrs`, `markup`, ...). + const portLabelSize = (port.label as { size?: dia.Size } | undefined)?.size; + if (portLabelSize) { + return portLabelSize; + } + const groupDef = port.group && element.prop(`ports/groups/${port.group}`); + if (groupDef?.label?.size) { + return groupDef.label.size; + } + + // No explicit size anywhere - estimate one from the label's actual text (again, + // the port's own `attrs` take precedence over its group's) instead of resorting + // straight away to `DEFAULT_LABEL_SIZE`. + const text = port.attrs?.text?.text ?? groupDef?.attrs?.text?.text; + if (text) { + const fontSize = parseFloat(port.attrs?.text?.fontSize ?? groupDef?.attrs?.text?.fontSize); + return estimateTextSize(text, isNaN(fontSize) ? DEFAULT_FONT_SIZE : fontSize); + } + + return DEFAULT_LABEL_SIZE; +}; + const nodeOptions: NodeOptionsCallback = (_element) => { return undefined; }; @@ -115,6 +168,7 @@ const edgeOptions: EdgeOptionsCallback = (_link) => { function buildPorts( element: dia.Element, portOptionsFn: PortOptionsCallback, + getPortLabelSizeFn: GetPortLabelSizeCallback, portsById: Map, positionPortLabels: boolean ): ElkPort[] | undefined { @@ -130,30 +184,36 @@ function buildPorts( layoutOptions: portOptionsFn(port, element) || {} }; - if (port.group) { - const groupDef = element.prop(`ports/groups/${port.group}`); - if (groupDef && groupDef.position) { - // ELK's `port.side` is a string, not a number, so we have to map JointJS's - // numeric group positions to the corresponding string values. - const side = (groupDef.position === 'left') ? 'WEST' - : (groupDef.position === 'right') ? 'EAST' - : (groupDef.position === 'top') ? 'NORTH' - : (groupDef.position === 'bottom') ? 'SOUTH' - : undefined; - if (side) { - portLayoutOptions.layoutOptions!['port.side'] = side; - } - } - if (positionPortLabels && groupDef?.label) { - const { width, height } = groupDef.label.size || DEFAULT_LABEL_SIZE; - portLayoutOptions.labels = [{ - // Some text is required, otherwise ELK ignores the label. - text: ELK_LABEL_TEXT, - width, - height, - layoutOptions: {} - }]; - } + const groupDef = port.group && element.prop(`ports/groups/${port.group}`); + + // A port's side is always determined by its group - JointJS has no way for an + // individual port to sit on a different side than the rest of its group - so a + // grouped port takes its group's `position`; an ungrouped one falls back to + // JointJS's own default side ('left'), the same side it actually renders on. + const positionName = (port.group) ? groupDef?.position : 'left'; + // ELK's `port.side` is a string, not a number, so we have to map JointJS's + // named port positions to the corresponding string values. + const side = (positionName === 'left') ? 'WEST' + : (positionName === 'right') ? 'EAST' + : (positionName === 'top') ? 'NORTH' + : (positionName === 'bottom') ? 'SOUTH' + : undefined; + if (side) { + portLayoutOptions.layoutOptions!['port.side'] = side; + } + + // A port's own `label` (if it has one) always takes precedence over its group's - + // same as JointJS itself resolves it (see `getPortLabelSizeFn`) - so either one is + // enough to warrant reserving/positioning a label for this port. + if (positionPortLabels && (port.label || groupDef?.label)) { + const { width, height } = getPortLabelSizeFn(port, element); + portLayoutOptions.labels = [{ + // Some text is required, otherwise ELK ignores the label. + text: ELK_LABEL_TEXT, + width, + height, + layoutOptions: {} + }]; } const { x, y, width, height } = element.getPortRelativeRect(portId); @@ -178,6 +238,7 @@ export function exportGraph( ): ElkGraphData { const getSizeFn = options.getSize ?? getSize; + const getPortLabelSizeFn = options.getPortLabelSize ?? getPortLabelSize; const nodeOptionsFn = options.nodeOptions ?? nodeOptions; const portOptionsFn = options.portOptions ?? portOptions; const edgeOptionsFn = options.edgeOptions ?? edgeOptions; @@ -194,7 +255,7 @@ export function exportGraph( const id = `${element.id}`; elementsById.set(id, element); - const ports = buildPorts(element, portOptionsFn, portsById, !!options.positionPortLabels); + const ports = buildPorts(element, portOptionsFn, getPortLabelSizeFn, portsById, !!options.positionPortLabels); const customOptions = nodeOptionsFn(element); const embeds = element.getEmbeddedCells() From 46496727fdbeb9f2c9397d5ad6cf6b9a07db9764 Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Tue, 8 Sep 2026 18:11:47 +0200 Subject: [PATCH 09/75] refactor --- .../src/index.ts | 21 +-- .../src/ElkLayoutController.mts | 154 ++++++++++++++++++ packages/joint-layout-elk/src/index.mts | 5 +- packages/joint-layout-elk/src/layout.mts | 124 -------------- 4 files changed, 166 insertions(+), 138 deletions(-) create mode 100644 packages/joint-layout-elk/src/ElkLayoutController.mts delete mode 100644 packages/joint-layout-elk/src/layout.mts diff --git a/examples/layout-elk-containers-ports-ts/src/index.ts b/examples/layout-elk-containers-ports-ts/src/index.ts index e58f10a05e..c50ee882c8 100644 --- a/examples/layout-elk-containers-ports-ts/src/index.ts +++ b/examples/layout-elk-containers-ports-ts/src/index.ts @@ -1,6 +1,5 @@ import { dia, shapes } from '@joint/core'; -import { ElkLayoutOptions, layout } from '@joint/layout-elk'; -import ELK from 'elkjs/lib/elk-api.js'; +import { ElkLayoutOptions, ElkLayoutController } from '@joint/layout-elk'; import { graphJSON } from './example'; import { Container, HubService, InteractionLink, Service } from './shapes'; import './styles.scss'; @@ -51,11 +50,6 @@ const init = () => { // index, instead of the default behavior of replacing them outright. graph.fromJSON(graphJSON, { mergeArrays: true }); - // Run ELK in a Web Worker, via the `@joint/layout-elk` package - const elk = new ELK({ - workerUrl: '../node_modules/elkjs/lib/elk-worker.js', - }); - const elkLayoutOptions: ElkLayoutOptions = { /** * Overall direction of the layout. @@ -103,9 +97,9 @@ const init = () => { 'elk.layered.wrapping.strategy': 'MULTI_EDGE', } - layout(graph, { - elk, - elkLayoutOptions, + const layoutController = new ElkLayoutController({ + graph, + workerUrl: '../node_modules/elkjs/lib/elk-worker.js', // Let ELK reposition (and reorder) every port in the diagram, instead // of keeping them where JointJS's own port groups first placed them. positionPorts: true, @@ -117,8 +111,11 @@ const init = () => { return { 'elk.padding': CONTAINER_PADDING }; } return undefined; - } - }).then(() => { + }, + elkLayoutOptions + }); + + layoutController.layout().then(() => { paper.unfreeze(); zoom(paper, 1); diff --git a/packages/joint-layout-elk/src/ElkLayoutController.mts b/packages/joint-layout-elk/src/ElkLayoutController.mts new file mode 100644 index 0000000000..21cd78d075 --- /dev/null +++ b/packages/joint-layout-elk/src/ElkLayoutController.mts @@ -0,0 +1,154 @@ +import ElkConstructor from 'elkjs/lib/elk.bundled.js'; +import { util, g } from '@joint/core'; + +import type { dia } from '@joint/core'; +import type { ELK, LayoutOptions, ElkNode as RawElkNode } from 'elkjs'; +import type { ElkLayoutOptions, ElkNode } from './elkOptions.mjs'; +import type { EdgeLabelsOptions, ImportLayoutOptions, PortLabelPositionsOptions, PortPositionsOptions } from './import.mjs'; +import { exportGraph, importLayout, type ExportGraphOptions } from './index.mjs'; + +const LAYOUT_BATCH_NAME = 'layout'; + +const DEFAULT_OPTIONS: Partial = { + edgeLabels: true, + batchName: LAYOUT_BATCH_NAME, +}; + +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', +}; + +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; +} + +export interface ElkLayoutControllerOptions extends + Omit, + Omit { + + /** + * The graph to lay out. Fixed for the controller's lifetime. + */ + graph: dia.Graph; + /** + * A URL for `elkjs`'s Web Worker script, to run layout off the main thread. + * Fixed for the controller's lifetime - the underlying ELK instance is only + * ever created once, in the constructor. + * @example + * new ElkLayoutController({ graph, workerUrl: new URL('elkjs/lib/elk-worker.min.js', import.meta.url).href }); + */ + workerUrl?: string; + + /** + * ELK layout options, passed through to ELK unmodified. + * @see https://eclipse.dev/elk/reference/options.html + * @defaultValue `{ 'elk.algorithm': 'layered', 'elk.hierarchyHandling': 'INCLUDE_CHILDREN' }` + */ + elkLayoutOptions?: ElkLayoutOptions; + /** + * Whether to account for link labels during layout and position them + * along the routed link afterwards. + * @defaultValue true + */ + edgeLabels?: boolean | EdgeLabelsOptions; + /** + * Whether to let ELK reposition (and reorder) ports along their element, + * instead of keeping them at the position JointJS itself already computed + * for them. When enabled, every port's owning group is switched to an + * `'absolute'` position (preserving its `attrs`/`markup`/`label`) so the + * position ELK computed for it can be applied. + * @defaultValue false + */ + positionPorts?: boolean | PortPositionsOptions; + /** + * Whether to let ELK reposition port labels along their port, instead of keeping + * them at the position JointJS itself already computed for them (via the port + * group's `label`). When enabled, every port's owning group's label is switched + * to a `'manual'` position (preserving its `attrs`/`markup`) so the position ELK + * computed for it can be applied. + * @defaultValue false + */ + positionPortLabels?: boolean | PortLabelPositionsOptions; + /** + * A name for the layout batch, which can be used to group multiple layout operations together. + * @defaultValue 'layout' + */ + batchName?: string; +} + +/** + * Options that can be supplied to a single `layout()` call, supplementing (not + * replacing) the controller's own options - see `ElkLayoutControllerOptions` - + * for just that one run. Everything except `graph`/`workerUrl` (fixed for the + * controller's lifetime - see their docs) can be overridden this way; `elkLayoutOptions` + * given here is merged on top of the controller's own, rather than replacing it outright. + */ +export type ElkLayoutRunOptions = Omit; + +export class ElkLayoutController { + + private elkInstance: ELK; + private graph: dia.Graph; + private options: ElkLayoutControllerOptions; + + constructor(options: ElkLayoutControllerOptions) { + if (options.workerUrl) { + this.elkInstance = new ElkConstructor({ + workerUrl: options.workerUrl, + algorithms: ['layered'], + defaultLayoutOptions: DEFAULT_LAYOUT_OPTIONS as LayoutOptions + }); + } else { + this.elkInstance = new ElkConstructor({ + algorithms: ['layered'], + defaultLayoutOptions: DEFAULT_LAYOUT_OPTIONS as LayoutOptions + }); + } + + this.graph = options.graph; + + this.options = util.defaults({}, options || {}, DEFAULT_OPTIONS) as ElkLayoutControllerOptions; + } + + private getBBox(elkGraph: ElkNode): g.Rect { + const rects = (elkGraph.children || []).map((node) => new g.Rect(node.x || 0, node.y || 0, node.width || 0, node.height || 0)); + return g.Rect.fromRectUnion(...rects) || new g.Rect(0, 0, 0, 0); + } + + public async layout(options?: ElkLayoutRunOptions): Promise { + // Options given here supplement (rather than replace) the controller's own for + // this run only - `this.options` itself is left untouched for the next call. + const runOptions = util.defaults({}, options || {}, this.options) as ElkLayoutRunOptions; + const elkLayoutOptions = util.defaults( + {}, + runOptions.elkLayoutOptions || {}, + DEFAULT_LAYOUT_OPTIONS + ) as ElkLayoutOptions; + const elk = this.elkInstance; + const batchName = runOptions.batchName || LAYOUT_BATCH_NAME; + + const { elkGraph, elementsById, linksById, portsById } = exportGraph(this.graph, runOptions as ExportGraphOptions, elkLayoutOptions); + + const result = await elk.layout(elkGraph as unknown as RawElkNode) as ElkNode; + + this.graph.startBatch(batchName); + importLayout(result, elementsById, linksById, portsById, runOptions); + this.graph.stopBatch(batchName); + + return { + bbox: this.getBBox(result), + elkGraph: result + }; + } + +} diff --git a/packages/joint-layout-elk/src/index.mts b/packages/joint-layout-elk/src/index.mts index d9f9eb6d28..c84321e098 100644 --- a/packages/joint-layout-elk/src/index.mts +++ b/packages/joint-layout-elk/src/index.mts @@ -1,4 +1,5 @@ -export * from './layout.mjs'; +export * from './ElkLayoutController.mjs'; export * from './import.mjs'; export * from './export.mjs'; -export * from './elkOptions.mjs'; + +export type * from './elkOptions.mjs'; diff --git a/packages/joint-layout-elk/src/layout.mts b/packages/joint-layout-elk/src/layout.mts deleted file mode 100644 index 0d07d470dc..0000000000 --- a/packages/joint-layout-elk/src/layout.mts +++ /dev/null @@ -1,124 +0,0 @@ -import { util, g } from '@joint/core'; -import ElkConstructor from 'elkjs/lib/elk.bundled.js'; -import { importLayout } from './import.mjs'; -import { exportGraph } from './export.mjs'; - -import type { ExportGraphOptions } from './export.mjs'; -import type { EdgeLabelsOptions, ImportLayoutOptions, PortPositionsOptions, PortLabelPositionsOptions } from './import.mjs'; -import type { ElkLayoutOptions, ElkNode } from './elkOptions.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', -}; - -const DEFAULT_OPTIONS: Options = { - edgeLabels: true, - batchName: LAYOUT_BATCH_NAME, -}; - -let defaultElk: ELK | undefined; - -/** - * Layout configuration options. - */ -export interface Options extends Omit, Omit { - /** - * A custom ELK instance, e.g. one configured to run inside a Web Worker. - * The instance is not terminated by the package - call `elk.terminateWorker()` - * yourself when it is no longer needed. - * @defaultValue a shared, main-thread instance (`elkjs/lib/elk.bundled.js`) - * @example - * import ELK from 'elkjs/lib/elk-api.js'; - * const elk = new ELK({ workerUrl: new URL('elkjs/lib/elk-worker.min.js', import.meta.url).href }); - * layout(graph, { elk }); - */ - elk?: 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' }` - */ - elkLayoutOptions?: ElkLayoutOptions; - /** - * Whether to account for link labels during layout and position them - * along the routed link afterwards. - * @defaultValue true - */ - edgeLabels?: boolean | EdgeLabelsOptions; - /** - * Whether to let ELK reposition (and reorder) ports along their element, - * instead of keeping them at the position JointJS itself already computed - * for them. When enabled, every port's owning group is switched to an - * `'absolute'` position (preserving its `attrs`/`markup`/`label`) so the - * position ELK computed for it can be applied. - * @defaultValue false - */ - positionPorts?: boolean | PortPositionsOptions; - /** - * Whether to let ELK reposition port labels along their port, instead of keeping - * them at the position JointJS itself already computed for them (via the port - * group's `label`). When enabled, every port's owning group's label is switched - * to a `'manual'` position (preserving its `attrs`/`markup`) so the position ELK - * computed for it can be applied. - * @defaultValue false - */ - positionPortLabels?: boolean | PortLabelPositionsOptions; - /** - * A name for the layout batch, which can be used to group multiple layout operations together. - * @defaultValue 'layout' - */ - batchName?: string; -} - -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; -} - -function getDefaultElk(): ELK { - if (!defaultElk) { - defaultElk = new ElkConstructor(); - } - return defaultElk; -} - -/** - * Tight bounding box of the top-level nodes in an ELK layout result. - */ -function getBBox(elkGraph: ElkNode): g.Rect { - const rects = (elkGraph.children || []).map((node) => new g.Rect(node.x || 0, node.y || 0, node.width || 0, node.height || 0)); - return g.Rect.fromRectUnion(...rects) || new g.Rect(0, 0, 0, 0); -} - -export async function layout(graph: dia.Graph, opt?: Options): Promise { - - const options = util.defaults({}, opt || {}, DEFAULT_OPTIONS) as Options; - const elkLayoutOptions = util.defaults({}, opt?.elkLayoutOptions || {}, DEFAULT_LAYOUT_OPTIONS) as ElkLayoutOptions; - const elk = opt?.elk || getDefaultElk(); - - const { elkGraph, elementsById, linksById, portsById } = exportGraph(graph, options as ExportGraphOptions, elkLayoutOptions); - - const result = await elk.layout(elkGraph as unknown as RawElkNode) as ElkNode; - - graph.startBatch(LAYOUT_BATCH_NAME); - importLayout(result, elementsById, linksById, portsById, options); - graph.stopBatch(LAYOUT_BATCH_NAME); - - return { - bbox: getBBox(result), - elkGraph: result - }; -} From 18515089dbe53d8c893623e4eaf5f57f70ead1a2 Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Tue, 8 Sep 2026 18:57:20 +0200 Subject: [PATCH 10/75] up --- examples/layout-elk-containers-ports-ts/src/index.ts | 11 +++-------- examples/layout-elk-containers-ports-ts/src/shapes.ts | 4 ++-- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/examples/layout-elk-containers-ports-ts/src/index.ts b/examples/layout-elk-containers-ports-ts/src/index.ts index c50ee882c8..4ca3bb2b32 100644 --- a/examples/layout-elk-containers-ports-ts/src/index.ts +++ b/examples/layout-elk-containers-ports-ts/src/index.ts @@ -68,6 +68,7 @@ const init = () => { * A number value as a string. */ 'elk.layered.spacing.nodeNodeBetweenLayers': '40', + 'elk.edgeLabels.inline': 'true', /** * Edge routing style. @@ -75,26 +76,20 @@ const init = () => { */ 'elk.edgeRouting': 'ORTHOGONAL', - /** - * Distance between edge labels and the edge itself. - * A number value as a string. - */ - 'elk.spacing.edgeLabel': '4', - /** * 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, to keep this * particular graph within `ELK_MAX_WIDTH` (see the check below). */ - 'elk.aspectRatio': '1.2', - + 'elk.aspectRatio': '1.4', /** * 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', + 'elk.layered.priority.direction': '40' } const layoutController = new ElkLayoutController({ diff --git a/examples/layout-elk-containers-ports-ts/src/shapes.ts b/examples/layout-elk-containers-ports-ts/src/shapes.ts index 1a6596ac6f..562cd9ed79 100644 --- a/examples/layout-elk-containers-ports-ts/src/shapes.ts +++ b/examples/layout-elk-containers-ports-ts/src/shapes.ts @@ -169,7 +169,7 @@ export class InteractionLink extends shapes.standard.Link { defaults() { return util.defaultsDeep({ type: 'example.InteractionLink', - labels: [{ + defaultLabel: { size: { width: 80, height: 20 }, attrs: { text: { @@ -189,7 +189,7 @@ export class InteractionLink extends shapes.standard.Link { } }, position: 0.5 - }] + } }, super.defaults); } } From 3a103894a3ea01afb84372cb7605454758a6d1b3 Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Thu, 10 Sep 2026 10:04:57 +0200 Subject: [PATCH 11/75] wip --- .../src/example.ts | 2 +- .../src/index.ts | 19 ++------ .../src/shapes.ts | 4 +- .../webpack.config.js | 1 - packages/joint-layout-elk/README.md | 19 +++++--- .../src/ElkLayoutController.mts | 15 ++++--- packages/joint-layout-elk/src/export.mts | 28 +++++++----- packages/joint-layout-elk/src/import.mts | 45 +++++++++++++++---- packages/joint-layout-elk/test/index.js | 2 +- packages/joint-layout-elk/tsconfig.json | 3 +- 10 files changed, 82 insertions(+), 56 deletions(-) diff --git a/examples/layout-elk-containers-ports-ts/src/example.ts b/examples/layout-elk-containers-ports-ts/src/example.ts index 9fbd5d5b35..3754b9ed4d 100644 --- a/examples/layout-elk-containers-ports-ts/src/example.ts +++ b/examples/layout-elk-containers-ports-ts/src/example.ts @@ -162,7 +162,7 @@ export const graphJSON: dia.Graph.JSON = { type: 'example.InteractionLink', source: { id: 'auth', port: 'out2' }, target: { id: 'logger', port: 'in2' }, - labels: [{ attrs: { text: { text: 'log' } } }] + //labels: [{ attrs: { text: { text: 'log' } } }] }, { id: 'l8', diff --git a/examples/layout-elk-containers-ports-ts/src/index.ts b/examples/layout-elk-containers-ports-ts/src/index.ts index 4ca3bb2b32..a6893c848b 100644 --- a/examples/layout-elk-containers-ports-ts/src/index.ts +++ b/examples/layout-elk-containers-ports-ts/src/index.ts @@ -6,10 +6,6 @@ import './styles.scss'; const ELK_DIRECTION = 'RIGHT'; const CONTAINER_PADDING = '[top=40,left=20,bottom=20,right=20]'; -// Soft cap on the overall drawing width - ELK wraps layers onto additional -// rows (rather than growing ever wider) once it would otherwise be exceeded. -// It is a target for ELK's wrapping heuristic, not a hard guarantee. -const ELK_MAX_WIDTH = 1000; const cellNamespace = { ...shapes, @@ -68,7 +64,6 @@ const init = () => { * A number value as a string. */ 'elk.layered.spacing.nodeNodeBetweenLayers': '40', - 'elk.edgeLabels.inline': 'true', /** * Edge routing style. @@ -82,7 +77,7 @@ const init = () => { * onto. Tuned, together with the spacing above, to keep this * particular graph within `ELK_MAX_WIDTH` (see the check below). */ - 'elk.aspectRatio': '1.4', + 'elk.aspectRatio': '1.2', /** * Wraps layers onto additional rows, connected by dedicated * "wrap" edges, instead of growing a single row indefinitely. @@ -94,10 +89,10 @@ const init = () => { const layoutController = new ElkLayoutController({ graph, - workerUrl: '../node_modules/elkjs/lib/elk-worker.js', + workerUrl: '../node_modules/elkjs/lib/elk-worker.js' , // Let ELK reposition (and reorder) every port in the diagram, instead // of keeping them where JointJS's own port groups first placed them. - positionPorts: true, + positionPorts: 'fixed-side', positionPortLabels: true, nodeOptions: (element) => { // Reserve extra top padding inside containers, so children don't @@ -114,14 +109,6 @@ const init = () => { paper.unfreeze(); zoom(paper, 1); - // `elk.aspectRatio` only targets the placement of nodes - the drawing's - // actual width (checked here on the rendered content, wrap-around - // routing included) is a target for ELK's wrapping heuristic, not a - // hard guarantee, so flag it during development if it is ever missed. - const contentWidth = paper.getContentBBox({ useModelGeometry: true }).width; - if (contentWidth > ELK_MAX_WIDTH) { - console.warn(`ELK layout is ${contentWidth} units wide, over the ${ELK_MAX_WIDTH} unit target.`); - } }).catch((error) => { console.error('ELK layout error:', error.message); }); diff --git a/examples/layout-elk-containers-ports-ts/src/shapes.ts b/examples/layout-elk-containers-ports-ts/src/shapes.ts index 562cd9ed79..c21b95488c 100644 --- a/examples/layout-elk-containers-ports-ts/src/shapes.ts +++ b/examples/layout-elk-containers-ports-ts/src/shapes.ts @@ -11,7 +11,7 @@ const PORT_ATTRS = { strokeWidth: 2 }, text: { - fontSize: 10, + fontSize: 14, fill: '#555' } }; @@ -38,7 +38,7 @@ const HUB_PORT_ATTRS = { strokeWidth: 2 }, text: { - fontSize: 10, + fontSize: 14, fill: '#555' } }; diff --git a/examples/layout-elk-containers-ports-ts/webpack.config.js b/examples/layout-elk-containers-ports-ts/webpack.config.js index 410b983af5..7b10ca335d 100644 --- a/examples/layout-elk-containers-ports-ts/webpack.config.js +++ b/examples/layout-elk-containers-ports-ts/webpack.config.js @@ -11,7 +11,6 @@ module.exports = { publicPath: '/dist/', }, mode: 'development', - devtool: 'source-map', module: { rules: [ { diff --git a/packages/joint-layout-elk/README.md b/packages/joint-layout-elk/README.md index 7bdc1b4c26..c202db6444 100644 --- a/packages/joint-layout-elk/README.md +++ b/packages/joint-layout-elk/README.md @@ -46,7 +46,7 @@ const { bbox } = await layout(graph, { ### `layout(graph, options?): Promise` -- `graph`: `dia.Graph` - the graph to lay out. Elements embedded in another element are laid out as a container - the parent is resized (and positioned) by ELK to fit its content, at any nesting depth. By default, ports are laid out at the position JointJS itself already computes for them (via the element's port groups) - ELK only uses that position to route edges to/from them; pass `positionPorts: true` to let ELK reposition them instead (see below). +- `graph`: `dia.Graph` - the graph to lay out. Elements embedded in another element are laid out as a container - the parent is resized (and positioned) by ELK to fit its content, at any nesting depth. By default (`positionPorts: 'fixed'`), ports are laid out at the position JointJS itself already computes for them (via the element's port groups) - ELK only uses that position to route edges to/from them; pass `positionPorts: 'fixed-side'` or `'free'` to let ELK reposition them instead (see below). - `options?`: `Options` - Layout configuration (see below) ```ts @@ -68,6 +68,10 @@ type NodeOptionsCallback = (element: dia.Element) => ElkLayoutOptions | undefine type PortOptionsCallback = (port: dia.Element.Port, element: dia.Element) => ElkLayoutOptions | undefined; type EdgeOptionsCallback = (link: dia.Link) => ElkLayoutOptions | undefined; type SetPortPositionCallback = (element: dia.Element, portId: string, position: dia.Point) => void; +// - 'fixed': ports stay exactly where JointJS's own port groups place them (`FIXED_POS`). +// - 'fixed-side': ELK may reposition/reorder ports along their group's assigned side (`FIXED_SIDE`). +// - 'free': ELK may reposition ports anywhere around their element (`FREE`). +type PortPositionsMode = 'fixed' | 'fixed-side' | 'free'; interface Options { // A custom ELK instance, e.g. one configured to run inside a Web Worker. @@ -76,9 +80,9 @@ interface Options { layoutOptions?: ElkLayoutOptions; // Default: { 'elk.algorithm': 'layered' } // Whether to account for link labels during layout and position them afterwards. edgeLabels?: boolean; // Default: true - // Whether to let ELK reposition (and reorder) ports itself, instead of keeping + // How freely ELK may reposition (and reorder) ports itself, instead of keeping // them at their JointJS-computed position - see "Letting ELK position ports" below. - positionPorts?: boolean | { setPortPosition?: SetPortPositionCallback }; // Default: false + positionPorts?: PortPositionsMode | { mode?: PortPositionsMode; setPortPosition?: SetPortPositionCallback }; // Default: 'fixed' // Element sizing callback getSize?: GetSizeCallback; // Default: element.size() // Callbacks for customizing how the layout is applied @@ -130,17 +134,18 @@ layout(graph, { ### Letting ELK position ports -By default, ports stay exactly where JointJS's own port groups already place them - ELK only uses that position to route edges. Pass `positionPorts: true` to let ELK freely reposition (and reorder) ports along their element instead, e.g. to minimize edge crossings: +By default (`positionPorts: 'fixed'`), ports stay exactly where JointJS's own port groups already place them - ELK only uses that position to route edges. Pass `positionPorts: 'fixed-side'` to let ELK reposition (and reorder) ports along the side their group already assigns them to, e.g. to minimize edge crossings, or `'free'` to let it place them on any side: ```ts -layout(graph, { positionPorts: true }); +layout(graph, { positionPorts: 'fixed-side' }); ``` -This only takes visible effect for a port whose group renders it at a plain `x`/`y` (the `'absolute'` position) - `layout()` switches every port-bearing group to that position for you (its `attrs`/`markup`/`label` are left untouched), so this works regardless of how the group was originally configured (`'left'`, `'right'`, a custom callback, ...). To customize how a computed position is applied instead of the default `element.portProp(portId, ['position', 'args'], position)`, pass an object: +This only takes visible effect for a port whose group renders it at a plain `x`/`y` (the `'absolute'` position) - `layout()` switches every port-bearing group to that position for you (its `attrs`/`markup`/`label` are left untouched), so this works regardless of how the group was originally configured (`'left'`, `'right'`, a custom callback, ...). To customize how a computed position is applied instead of the default `element.portProp(portId, ['position', 'args'], position)`, pass an object (`mode` defaults to `'fixed'` here too, so it still needs to be given explicitly): ```ts layout(graph, { positionPorts: { + mode: 'fixed-side', setPortPosition: (element, portId, position) => element.portProp(portId, ['position', 'args'], position) } }); @@ -165,7 +170,7 @@ layout(graph, { ## ⚠️ Caveats & Known Limitations - **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 (behind `edgeLabels`). -- **Ports keep their JointJS-computed position by default** - `layout()` only tells ELK where they already are (`elk.portConstraints: FIXED_POS`), so edges route to/from the exact spot the element's port groups place them at. Opt into ELK repositioning them with `positionPorts` (see above). +- **Ports keep their JointJS-computed position by default** (`positionPorts: 'fixed'`) - `layout()` only tells ELK where they already are (`elk.portConstraints: FIXED_POS`), so edges route to/from the exact spot the element's port groups place them at. Opt into ELK repositioning them with `positionPorts: 'fixed-side'`/`'free'` (see above). - **Asynchronous** - unlike `@joint/layout-directed-graph`, `layout()` returns a `Promise`, since `elkjs` computes layouts asynchronously (and, optionally, inside a Web Worker). - **ID handling** - ELK requires string ids; element and link ids are converted with `` `${id}` `` internally, but never written back to the graph. diff --git a/packages/joint-layout-elk/src/ElkLayoutController.mts b/packages/joint-layout-elk/src/ElkLayoutController.mts index 21cd78d075..8f28897342 100644 --- a/packages/joint-layout-elk/src/ElkLayoutController.mts +++ b/packages/joint-layout-elk/src/ElkLayoutController.mts @@ -4,7 +4,7 @@ import { util, g } from '@joint/core'; import type { dia } from '@joint/core'; import type { ELK, LayoutOptions, ElkNode as RawElkNode } from 'elkjs'; import type { ElkLayoutOptions, ElkNode } from './elkOptions.mjs'; -import type { EdgeLabelsOptions, ImportLayoutOptions, PortLabelPositionsOptions, PortPositionsOptions } from './import.mjs'; +import type { EdgeLabelsOptions, ImportLayoutOptions, PortLabelPositionsOptions, PortPositionsMode, PortPositionsOptions } from './import.mjs'; import { exportGraph, importLayout, type ExportGraphOptions } from './index.mjs'; const LAYOUT_BATCH_NAME = 'layout'; @@ -62,14 +62,15 @@ export interface ElkLayoutControllerOptions extends */ edgeLabels?: boolean | EdgeLabelsOptions; /** - * Whether to let ELK reposition (and reorder) ports along their element, + * How freely ELK may reposition (and reorder) ports along their element, * instead of keeping them at the position JointJS itself already computed - * for them. When enabled, every port's owning group is switched to an - * `'absolute'` position (preserving its `attrs`/`markup`/`label`) so the - * position ELK computed for it can be applied. - * @defaultValue false + * for them - see `PortPositionsMode`. When set to anything other than + * `'fixed'`, every port's owning group is switched to an `'absolute'` + * position (preserving its `attrs`/`markup`/`label`) so the position ELK + * computed for it can be applied. + * @defaultValue 'fixed' */ - positionPorts?: boolean | PortPositionsOptions; + positionPorts?: PortPositionsMode | PortPositionsOptions; /** * Whether to let ELK reposition port labels along their port, instead of keeping * them at the position JointJS itself already computed for them (via the port diff --git a/packages/joint-layout-elk/src/export.mts b/packages/joint-layout-elk/src/export.mts index 722f7cc7c5..80da486126 100644 --- a/packages/joint-layout-elk/src/export.mts +++ b/packages/joint-layout-elk/src/export.mts @@ -1,4 +1,6 @@ import type { dia } from '@joint/core'; +import { getPortPositionsMode, type PortPositionsMode, type PortPositionsOptions } from './import.mjs'; + import type { ElkNode, ElkPort, @@ -25,11 +27,13 @@ const AVERAGE_CHAR_WIDTH_RATIO = 0.6; const LINE_HEIGHT_RATIO = 1.2; const ELK_INLINE_LABEL_OPTIONS: LabelElkLayoutOptions = { 'edgeLabels.inline': 'true' }; -// Ports are positioned by JointJS (via the element's port groups), not by ELK - -// this tells ELK to treat the coordinates we give it as final. -const ELK_FIXED_PORTS_OPTIONS: NodeElkLayoutOptions = { 'elk.portConstraints': 'FIXED_POS' }; -// With `positionPorts`, ELK is free to reposition (and reorder) ports itself. -const ELK_FREE_PORTS_OPTIONS: NodeElkLayoutOptions = { 'elk.portConstraints': 'FIXED_SIDE' }; +// Maps a `PortPositionsMode` onto the corresponding `elk.portConstraints` value - +// see `PortPositionsMode` for what each mode means. +const ELK_PORT_CONSTRAINTS_BY_MODE: Record = { + 'fixed': { 'elk.portConstraints': 'FIXED_POS' }, + 'fixed-side': { 'elk.portConstraints': 'FIXED_SIDE' }, + 'free': { 'elk.portConstraints': 'FREE' }, +}; type GetSizeCallback = (element: dia.Element) => dia.Size; type GetPortLabelSizeCallback = (port: dia.Element.Port, element: dia.Element) => dia.Size; @@ -87,13 +91,13 @@ export interface ExportGraphOptions { */ edgeLabels?: boolean; /** - * Whether to let ELK reposition (and reorder) ports along their element, + * How freely ELK may reposition (and reorder) ports along their element, * instead of keeping them at the position JointJS itself already computed - * for them. The new positions are written back onto the graph - see the - * `positionPorts` option in `ImportLayoutOptions`. - * @defaultValue false + * for them - see `PortPositionsMode`. Any new positions are written back + * onto the graph - see the `positionPorts` option in `ImportLayoutOptions`. + * @defaultValue 'fixed' */ - positionPorts?: boolean; + positionPorts?: PortPositionsMode | PortPositionsOptions; /** * Whether to let ELK reposition port labels along their port, instead of keeping * them at the position JointJS itself already computed for them. The new @@ -163,7 +167,7 @@ const edgeOptions: EdgeOptionsCallback = (_link) => { * position JointJS itself has already computed for them (via the element's * port groups). Whether ELK is free to move them from there, or has to treat * that position as final, is controlled by the node's own `elk.portConstraints` - * (see `ELK_FIXED_PORTS_OPTIONS`/`ELK_FREE_PORTS_OPTIONS` in `buildElkNode`). + * (see `ELK_PORT_CONSTRAINTS_BY_MODE` in `buildElkNode`). */ function buildPorts( element: dia.Element, @@ -242,7 +246,7 @@ export function exportGraph( const nodeOptionsFn = options.nodeOptions ?? nodeOptions; const portOptionsFn = options.portOptions ?? portOptions; const edgeOptionsFn = options.edgeOptions ?? edgeOptions; - const portConstraintsOptions = (options.positionPorts) ? ELK_FREE_PORTS_OPTIONS : ELK_FIXED_PORTS_OPTIONS; + const portConstraintsOptions = ELK_PORT_CONSTRAINTS_BY_MODE[getPortPositionsMode(options.positionPorts)]; const elementsById = new Map(); const linksById = new Map(); diff --git a/packages/joint-layout-elk/src/import.mts b/packages/joint-layout-elk/src/import.mts index c749b4c907..37b4136022 100644 --- a/packages/joint-layout-elk/src/import.mts +++ b/packages/joint-layout-elk/src/import.mts @@ -22,16 +22,44 @@ export interface EdgeLabelsOptions { setLabels?: SetLabelsCallback; } +/** + * Controls how freely ELK may reposition a port along its element - maps directly + * onto ELK's own `elk.portConstraints` (see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portConstraints.html): + * - `'fixed'` (default) - the port stays exactly where JointJS's own port groups + * already place it; ELK only uses that position to route edges to/from it + * (`FIXED_POS`). + * - `'fixed-side'` - ELK may reposition (and reorder) the port along the side its + * group already assigns it to, e.g. to minimize edge crossings (`FIXED_SIDE`). + * - `'free'` - ELK may reposition the port anywhere around its element, including + * onto a different side than its group's (`FREE`). + */ +export type PortPositionsMode = 'fixed' | 'fixed-side' | 'free'; + export interface PortPositionsOptions { + /** + * How freely ELK may reposition the port. + * @defaultValue 'fixed' + */ + mode?: PortPositionsMode; /** * Sets a port's position, based on the ELK port's layout result. - * Only takes effect when `positionPorts` is enabled. + * Only takes effect when `mode` is not `'fixed'`. * @example * setPortPosition: (element, portId, position) => element.portProp(portId, ['position', 'args'], position) */ setPortPosition?: SetPortPositionCallback; } +/** + * Resolves the effective `PortPositionsMode` for a `positionPorts` option - a plain + * mode string, an options object with an optional `mode` (defaulting to `'fixed'`), + * or `undefined` (also `'fixed'`). + */ +export function getPortPositionsMode(positionPorts: PortPositionsMode | PortPositionsOptions | undefined): PortPositionsMode { + if (typeof positionPorts === 'string') return positionPorts; + return positionPorts?.mode ?? 'fixed'; +} + export interface PortLabelPositionsOptions { /** * Sets a port label's position, based on the ELK port label's layout result. @@ -82,14 +110,15 @@ export interface ImportLayoutOptions { */ edgeLabels?: boolean | EdgeLabelsOptions; /** - * Whether to let ELK reposition (and reorder) ports along their element, + * How freely ELK may reposition (and reorder) ports along their element, * instead of keeping them at the position JointJS itself already computed - * for them. When enabled, every port's owning group is switched to an - * `'absolute'` position (preserving its `attrs`/`markup`/`label`) so the - * position ELK computed for it can be applied. - * @defaultValue false + * for them - see `PortPositionsMode`. When set to anything other than + * `'fixed'`, every port's owning group is switched to an `'absolute'` + * position (preserving its `attrs`/`markup`/`label`) so the position ELK + * computed for it can be applied. + * @defaultValue 'fixed' */ - positionPorts?: boolean | PortPositionsOptions; + positionPorts?: PortPositionsMode | PortPositionsOptions; /** * Whether to let ELK reposition port labels along their port, instead of keeping * them at the position JointJS itself already computed for them (via the port @@ -179,7 +208,7 @@ export function importLayout( const setAnchorFn = options.setAnchor ?? defaultSetAnchor; let setPortPositionFn: SetPortPositionCallback | undefined; - if (options.positionPorts) { + if (getPortPositionsMode(options.positionPorts) !== 'fixed') { setPortPositionFn = defaultSetPortPosition; if (typeof options.positionPorts === 'object') { diff --git a/packages/joint-layout-elk/test/index.js b/packages/joint-layout-elk/test/index.js index 62afdb0e6b..a4315698d0 100644 --- a/packages/joint-layout-elk/test/index.js +++ b/packages/joint-layout-elk/test/index.js @@ -249,7 +249,7 @@ QUnit.module('layout()', () => { graph.resetCells([el1, el2, link]); - await joint.layout.ELK.layout(graph, { positionPorts: true }); + await joint.layout.ELK.layout(graph, { positionPorts: 'fixed-side' }); // The group's position is switched to 'absolute' so the ELK-computed position applies. assert.equal(el1.prop(['ports', 'groups', 'out', 'position', 'name']), 'absolute'); diff --git a/packages/joint-layout-elk/tsconfig.json b/packages/joint-layout-elk/tsconfig.json index 0fe9e0c65d..86a8d24b48 100644 --- a/packages/joint-layout-elk/tsconfig.json +++ b/packages/joint-layout-elk/tsconfig.json @@ -1,7 +1,8 @@ { "compilerOptions": { "target": "ES2019", - "moduleResolution": "node", + "moduleResolution": "bundler", + "module": "ESNext", "declaration": true, "declarationMap": true, "strict": true, From 853dfcbdbbda90c56e4c986c19cbf2d671323ec1 Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Thu, 10 Sep 2026 12:08:24 +0200 Subject: [PATCH 12/75] interactive wip --- examples/layout-elk-interactive-ts/README.md | 24 ++ examples/layout-elk-interactive-ts/index.html | 27 +++ .../layout-elk-interactive-ts/package.json | 40 ++++ .../layout-elk-interactive-ts/src/index.ts | 206 ++++++++++++++++++ .../layout-elk-interactive-ts/src/styles.scss | 60 +++++ .../layout-elk-interactive-ts/tsconfig.json | 12 + .../webpack.config.js | 39 ++++ packages/joint-layout-elk/README.md | 19 ++ .../src/ElkLayoutController.mts | 37 ++++ packages/joint-layout-elk/src/export.mts | 18 +- 10 files changed, 478 insertions(+), 4 deletions(-) create mode 100644 examples/layout-elk-interactive-ts/README.md create mode 100644 examples/layout-elk-interactive-ts/index.html create mode 100644 examples/layout-elk-interactive-ts/package.json create mode 100644 examples/layout-elk-interactive-ts/src/index.ts create mode 100644 examples/layout-elk-interactive-ts/src/styles.scss create mode 100644 examples/layout-elk-interactive-ts/tsconfig.json create mode 100644 examples/layout-elk-interactive-ts/webpack.config.js diff --git a/examples/layout-elk-interactive-ts/README.md b/examples/layout-elk-interactive-ts/README.md new file mode 100644 index 0000000000..14a1c8a185 --- /dev/null +++ b/examples/layout-elk-interactive-ts/README.md @@ -0,0 +1,24 @@ +# JointJS ELK Interactive Layout Demo + +A small tree, laid out automatically with `@joint/layout-elk`. Click "+ Add Element" to attach a new element to a random existing one and re-run the layout - with the "Interactive layout" toggle checked, only the new element gets positioned, and the rest of the graph stays where it already is. Uncheck it to see a from-scratch layout reshuffle the whole graph instead. + +## 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 start +``` + +## 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-interactive-ts/index.html b/examples/layout-elk-interactive-ts/index.html new file mode 100644 index 0000000000..830c4b6468 --- /dev/null +++ b/examples/layout-elk-interactive-ts/index.html @@ -0,0 +1,27 @@ + + + + + + + + ELK Interactive Layout | JointJS + + + +
+ Zoom Out + Zoom In + + Add Element + +
+
+ + + + + diff --git a/examples/layout-elk-interactive-ts/package.json b/examples/layout-elk-interactive-ts/package.json new file mode 100644 index 0000000000..f8274d0f47 --- /dev/null +++ b/examples/layout-elk-interactive-ts/package.json @@ -0,0 +1,40 @@ +{ + "name": "@joint/demo-layout-elk-interactive-ts", + "version": "4.3.1", + "description": "JointJS - ELK Interactive Layout Demo", + "main": "dist/bundle.js", + "homepage": "https://jointjs.com", + "author": { + "name": "client IO", + "url": "https://client.io" + }, + "license": "MPL-2.0", + "private": true, + "installConfig": { + "hoistingLimits": "workspaces" + }, + "scripts": { + "start": "webpack-dev-server", + "build": "webpack" + }, + "dependencies": { + "@joint/core": "workspace:^", + "@joint/layout-elk": "workspace:^", + "elkjs": "0.12.0" + }, + "devDependencies": { + "css-loader": "3.5.3", + "sass-loader": "8.0.2", + "style-loader": "1.2.1", + "ts-loader": "^9.2.5", + "typescript": "5.8.2", + "webpack": "5.98.0", + "webpack-cli": "6.0.1", + "webpack-dev-server": "5.2.0" + }, + "volta": { + "node": "22.14.0", + "npm": "11.2.0", + "yarn": "4.18.0" + } +} diff --git a/examples/layout-elk-interactive-ts/src/index.ts b/examples/layout-elk-interactive-ts/src/index.ts new file mode 100644 index 0000000000..67c1b894fd --- /dev/null +++ b/examples/layout-elk-interactive-ts/src/index.ts @@ -0,0 +1,206 @@ +import { dia, shapes, g } from '@joint/core'; +import { ElkLayoutOptions, ElkLayoutController } from '@joint/layout-elk'; +import './styles.scss'; + +const ELK_DIRECTION = 'RIGHT'; +const NODE_SIZE = { width: 100, height: 40 }; +const COLORS = ['#F8FCDA', '#E3E9C2', '#F9FBB2', '#C89F9C']; + +// A small seed graph - a couple of branches, so there's a choice of where a +// newly added element can be attached. +const SEED_LINKS: Array<[string, string]> = [ + ['n1', 'n2'], + ['n1', 'n3'], + ['n2', 'n4'], + ['n2', 'n5'], + ['n3', 'n6'], +]; + +const SEED_NODE_IDS = new Set(SEED_LINKS.flat()); +let nextId = SEED_NODE_IDS.size + 1; +let zoomLevel = 1; + +const init = () => { + + // Create JointJS graph and paper + const graph = new dia.Graph({}, { cellNamespace: shapes }); + const paper = new dia.Paper({ + model: graph, + cellViewNamespace: shapes, + width: 1200, + height: 700, + gridSize: 1, + interactive: false, + async: true, + frozen: true, + defaultConnectionPoint: { + name: 'anchor' + }, + defaultConnector: { + name: 'straight', + args: { + cornerType: 'cubic', + cornerRadius: 5 + } + } + }); + document.getElementById('canvas')!.appendChild(paper.el); + addZoomAndPanListeners(paper); + + const elkLayoutOptions: ElkLayoutOptions = { + 'elk.direction': ELK_DIRECTION, + 'elk.spacing.nodeNode': '30', + 'elk.layered.spacing.nodeNodeBetweenLayers': '60', + 'elk.edgeRouting': 'ORTHOGONAL', + }; + + const layoutController = new ElkLayoutController({ + graph, + elkLayoutOptions + }); + + // Seed the graph with a small, already laid out tree. + const cells: dia.Cell[] = []; + SEED_LINKS.forEach(([sourceId, targetId]) => { + if (!graph.getCell(sourceId)) cells.push(createElement(sourceId)); + if (!graph.getCell(targetId)) cells.push(createElement(targetId)); + cells.push(createLink(sourceId, targetId)); + }); + graph.resetCells(cells); + + const interactiveToggle = document.getElementById('interactive-toggle') as HTMLInputElement; + + // The very first layout always computes the whole graph from scratch - there is + // nothing to be "interactive" about yet, since no element has a position at all. + layoutController.layout().then(() => { + paper.unfreeze(); + zoom(paper, zoomLevel); + }).catch((error) => { + console.error('ELK layout error:', error.message); + }); + + let isLayingOut = false; + const addElementButton = document.getElementById('add-element') as HTMLElement; + addElementButton.addEventListener('click', () => { + // A `layout()` call reads/writes the graph as it stands at that moment - guard + // against a second click firing (and exporting a half-updated graph) while one + // is still in flight. + if (isLayingOut) return; + isLayingOut = true; + addElementButton.classList.add('toolbar-button-disabled'); + + // Attach the new element to a random one already on the graph. + const elements = graph.getElements(); + const parent = elements[g.random(0, elements.length - 1)]; + const element = createElement(`n${nextId++}`); + // ELK's interactive strategies use an element's *current* position as a hint of + // where to keep it - a brand new element defaults to (0, 0), which reads as "put + // this in the very first layer" and forces everything else to make room for it. + // Starting it off one layer to the right of its parent (`ELK_DIRECTION`) instead + // gives interactive layout a sensible hint, so only `element` itself (not its + // parent's whole layer) needs to move. + element.position(parent.position().x + NODE_SIZE.width + 60, parent.position().y); + + paper.freeze(); + graph.addCells([element, createLink(`${parent.id}`, `${element.id}`)]); + + // With `interactive: true`, every already laid out element (`parent` included) + // keeps roughly its current position - ELK only has to find a spot for `element` + // itself. Uncheck "Interactive layout" to see the whole graph get reshuffled by + // a from-scratch layout instead. Either way, re-fit the viewport afterwards so + // the (possibly larger) diagram stays fully visible. + layoutController.layout({ interactive: interactiveToggle.checked }).then(() => { + paper.unfreeze(); + zoom(paper, zoomLevel); + }).catch((error) => { + console.error('ELK layout error:', error.message); + }).finally(() => { + isLayingOut = false; + addElementButton.classList.remove('toolbar-button-disabled'); + }); + }); +}; + +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 { + + 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) + ); + }); +} + +/** + * Create a rectangle element with the given id. + */ +function createElement(id: dia.Cell.ID): dia.Element { + return new shapes.standard.Rectangle({ + id, + size: NODE_SIZE, + attrs: { + body: { + fill: COLORS[g.random(0, COLORS.length - 1)], + stroke: '#333', + strokeWidth: 2, + rx: 5, + ry: 5 + }, + label: { + text: `${id}`, + fill: '#333', + fontSize: 14, + fontFamily: 'Arial, helvetica, sans-serif' + } + } + }); +} + +/** + * Create a link between sourceId and targetId. + */ +function createLink(sourceId: dia.Cell.ID, targetId: dia.Cell.ID): dia.Link { + return new shapes.standard.Link({ + source: { id: sourceId }, + target: { id: targetId }, + attrs: { + line: { + stroke: '#333', + strokeWidth: 1.5 + } + } + }); +} + +init(); diff --git a/examples/layout-elk-interactive-ts/src/styles.scss b/examples/layout-elk-interactive-ts/src/styles.scss new file mode 100644 index 0000000000..f248d2e7b8 --- /dev/null +++ b/examples/layout-elk-interactive-ts/src/styles.scss @@ -0,0 +1,60 @@ + +html, body { + margin: 0; + padding: 0; +} + +#canvas { + position: absolute; + margin-top: 50px; + margin-left: 20px; + border: 1px solid #E2E2E2; + background-color: #F3F7F6; + overflow: hidden; +} + +.toolbar { + display: flex; + position: fixed; + width: 100%; + top: 10px; + margin-left: 30px; + text-align: center; + justify-content: left; + align-items: center; + z-index: 1; +} + +.toolbar-button { + outline: none; + background: #FFFFFF; + border: 1px solid #E0E0E0; + border-radius: 16px; + text-align: center; + font-family: sans-serif; + font-size: 12px; + padding: 6px 12px; + letter-spacing: 0.25px; + color: #222222; + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + margin: 0 2px; + + &:hover { + background: #F7F8F9; + } +} + +.toolbar-toggle { + display: flex; + align-items: center; + gap: 6px; +} + +.toolbar-button-disabled { + pointer-events: none; + opacity: 0.5; +} diff --git a/examples/layout-elk-interactive-ts/tsconfig.json b/examples/layout-elk-interactive-ts/tsconfig.json new file mode 100644 index 0000000000..7ecb0809ec --- /dev/null +++ b/examples/layout-elk-interactive-ts/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "module": "ES6", + "moduleResolution": "node", + "target": "es6", + "noImplicitAny": false, + "sourceMap": false, + "outDir": "./build", + "resolveJsonModule": true, + "esModuleInterop": true + } +} diff --git a/examples/layout-elk-interactive-ts/webpack.config.js b/examples/layout-elk-interactive-ts/webpack.config.js new file mode 100644 index 0000000000..7b10ca335d --- /dev/null +++ b/examples/layout-elk-interactive-ts/webpack.config.js @@ -0,0 +1,39 @@ +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: /\.m?js/, + resolve: { + fullySpecified: false, + }, + }, + { 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/packages/joint-layout-elk/README.md b/packages/joint-layout-elk/README.md index c202db6444..e0b56e002c 100644 --- a/packages/joint-layout-elk/README.md +++ b/packages/joint-layout-elk/README.md @@ -83,6 +83,10 @@ interface Options { // How freely ELK may reposition (and reorder) ports itself, instead of keeping // them at their JointJS-computed position - see "Letting ELK position ports" below. positionPorts?: PortPositionsMode | { mode?: PortPositionsMode; setPortPosition?: SetPortPositionCallback }; // Default: 'fixed' + // Whether to treat elements' current positions as a starting point and change the + // layout as little as possible from there, instead of computing it from scratch - + // see "Incremental/interactive layout" below. + interactive?: boolean; // Default: false // Element sizing callback getSize?: GetSizeCallback; // Default: element.size() // Callbacks for customizing how the layout is applied @@ -151,6 +155,20 @@ layout(graph, { }); ``` +### Incremental/interactive layout + +By default, every `layout()` call computes the whole graph's layout from scratch. Pass `interactive: true` to instead let ELK treat elements' current positions - already reflected on the graph, e.g. from an earlier `layout()` call - as a starting point, and change the layout as little as possible from there: + +```ts +// Initial layout. +await layout(graph); + +// ... later, after adding one new element/link to the already laid out graph: +await layout(graph, { interactive: true }); +``` + +This is useful for laying out a graph incrementally - e.g. adding one element to an already laid out graph and re-running `layout({ interactive: true })` only positions that new element, instead of reshuffling the whole diagram. It's an approximation, not a guarantee, of the previous layout though - e.g. a layer's own position can still shift to fit its (possibly changed) content - so already laid out elements may still move slightly. + ### Animated transitions ```ts @@ -171,6 +189,7 @@ layout(graph, { - **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 (behind `edgeLabels`). - **Ports keep their JointJS-computed position by default** (`positionPorts: 'fixed'`) - `layout()` only tells ELK where they already are (`elk.portConstraints: FIXED_POS`), so edges route to/from the exact spot the element's port groups place them at. Opt into ELK repositioning them with `positionPorts: 'fixed-side'`/`'free'` (see above). +- **`interactive` approximates the previous layout, it doesn't freeze it** - already laid out elements are not guaranteed to keep their exact position (see "Incremental/interactive layout" above). - **Asynchronous** - unlike `@joint/layout-directed-graph`, `layout()` returns a `Promise`, since `elkjs` computes layouts asynchronously (and, optionally, inside a Web Worker). - **ID handling** - ELK requires string ids; element and link ids are converted with `` `${id}` `` internally, but never written back to the graph. diff --git a/packages/joint-layout-elk/src/ElkLayoutController.mts b/packages/joint-layout-elk/src/ElkLayoutController.mts index 8f28897342..52acec9a9a 100644 --- a/packages/joint-layout-elk/src/ElkLayoutController.mts +++ b/packages/joint-layout-elk/src/ElkLayoutController.mts @@ -23,6 +23,25 @@ const DEFAULT_LAYOUT_OPTIONS: ElkLayoutOptions = { // 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', + // JointJS positions an element by its top-left corner - match that as the point + // `interactive` (see below) compares against an element's previous position. + 'elk.layered.interactiveReferencePoint': 'TOP_LEFT' +}; + +// Applied on top of `DEFAULT_LAYOUT_OPTIONS` (but under the caller's own `elkLayoutOptions`) +// when `interactive` is enabled - see its doc on `ElkLayoutControllerOptions`. +const INTERACTIVE_LAYOUT_OPTIONS: ElkLayoutOptions = { + // Generic hint, respected by every algorithm - see e.g. ELK Force/Stress, which use it to + // skip generating a fresh initial layout and relax from each element's current position + // instead. Not `'layered'`'s primary lever (below), but harmless to set alongside it. + 'elk.interactive': 'true', + // `'layered'`'s own interactivity is per-phase - each of these reads the corresponding + // aspect (edge direction, x/y) straight off an element's current position instead of + // computing it from scratch, so the four are meant to be used together. + 'elk.layered.cycleBreaking.strategy': 'INTERACTIVE', + 'elk.layered.layering.strategy': 'INTERACTIVE', + 'elk.layered.crossingMinimization.strategy': 'INTERACTIVE', + 'elk.layered.nodePlacement.strategy': 'INTERACTIVE', }; export interface LayoutResult { @@ -85,6 +104,23 @@ export interface ElkLayoutControllerOptions extends * @defaultValue 'layout' */ batchName?: string; + /** + * Whether to let ELK treat elements' current positions (as already reflected on the + * graph, e.g. from a previous `layout()` call) as a starting point, and try to change + * the layout as little as possible from there - instead of computing a fresh layout + * from scratch every time. Useful for laying out a graph incrementally, e.g. so that + * adding one element and calling `layout()` again only affects that new element, + * leaving the rest roughly where they already are. + * + * This approximates, rather than guarantees, the previous layout - e.g. a layer's own + * position can still shift to fit its (possibly changed) content - so already laid out + * elements may still move slightly. Applies `elk.interactive` plus, for the default + * `'layered'` algorithm, its own per-phase interactive strategies; give an + * `elkLayoutOptions` of your own to override/turn off any of them individually. + * @defaultValue false + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-interactive.html + */ + interactive?: boolean; } /** @@ -133,6 +169,7 @@ export class ElkLayoutController { const elkLayoutOptions = util.defaults( {}, runOptions.elkLayoutOptions || {}, + (runOptions.interactive) ? INTERACTIVE_LAYOUT_OPTIONS : {}, DEFAULT_LAYOUT_OPTIONS ) as ElkLayoutOptions; const elk = this.elkInstance; diff --git a/packages/joint-layout-elk/src/export.mts b/packages/joint-layout-elk/src/export.mts index 80da486126..37f0cdae92 100644 --- a/packages/joint-layout-elk/src/export.mts +++ b/packages/joint-layout-elk/src/export.mts @@ -255,20 +255,28 @@ export function exportGraph( // used to file each edge under the lowest common ancestor of its source and target. const edgeContainersById = new Map(); - function buildElkNode(element: dia.Element): ElkNode { + // ELK positions a node's children (and routes a node's own edges) relative to that + // node's own origin (see `toAbsolute` in `importLayout`) - `containerX`/`containerY` + // convert an element's own graph-absolute `position()` into that frame, so that an + // element's exported `x`/`y` is always a usable hint of where it currently is, e.g. + // for `interactive` (see `ElkLayoutControllerOptions`) to pick up. + function buildElkNode(element: dia.Element, containerX = 0, containerY = 0): ElkNode { const id = `${element.id}`; elementsById.set(id, element); const ports = buildPorts(element, portOptionsFn, getPortLabelSizeFn, portsById, !!options.positionPortLabels); const customOptions = nodeOptionsFn(element); + const { x: absoluteX, y: absoluteY } = element.position(); + const x = absoluteX - containerX; + const y = absoluteY - containerY; const embeds = element.getEmbeddedCells() .filter((cell): cell is dia.Element => cell.isElement()); if (embeds.length > 0) { // A container - its size is computed by ELK to fit its (recursively laid out) content. - const children = embeds.map(buildElkNode); - const node: ElkNode = { id, children, ports, layoutOptions: customOptions }; + const children = embeds.map((embed) => buildElkNode(embed, absoluteX, absoluteY)); + const node: ElkNode = { id, x, y, children, ports, layoutOptions: customOptions }; edgeContainersById.set(id, node.edges = []); return node; } @@ -276,6 +284,8 @@ export function exportGraph( const { width, height } = getSizeFn(element); return { id, + x, + y, width, height, ports, @@ -289,7 +299,7 @@ export function exportGraph( const children: ElkNode[] = graph.getElements() .filter((element) => !element.parent()) - .map(buildElkNode); + .map((element) => buildElkNode(element)); const elkGraph: ElkNode = { id: 'root', From 268c5e95abbf4eb6f182cd41e415f797917dc65a Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Thu, 10 Sep 2026 13:58:19 +0200 Subject: [PATCH 13/75] refactor to function --- .../src/index.ts | 17 +- .../layout-elk-interactive-ts/src/index.ts | 11 +- packages/joint-layout-elk/src/export.mts | 2 +- packages/joint-layout-elk/src/index.mts | 2 +- .../{ElkLayoutController.mts => layout.mts} | 156 ++++++++---------- 5 files changed, 82 insertions(+), 106 deletions(-) rename packages/joint-layout-elk/src/{ElkLayoutController.mts => layout.mts} (58%) diff --git a/examples/layout-elk-containers-ports-ts/src/index.ts b/examples/layout-elk-containers-ports-ts/src/index.ts index a6893c848b..42ae77749f 100644 --- a/examples/layout-elk-containers-ports-ts/src/index.ts +++ b/examples/layout-elk-containers-ports-ts/src/index.ts @@ -1,5 +1,6 @@ import { dia, shapes } from '@joint/core'; -import { ElkLayoutOptions, ElkLayoutController } from '@joint/layout-elk'; +import { ElkLayoutOptions, layout } from '@joint/layout-elk'; +import ELK from 'elkjs/lib/elk-api.js'; import { graphJSON } from './example'; import { Container, HubService, InteractionLink, Service } from './shapes'; import './styles.scss'; @@ -87,9 +88,13 @@ const init = () => { 'elk.layered.priority.direction': '40' } - const layoutController = new ElkLayoutController({ - graph, - workerUrl: '../node_modules/elkjs/lib/elk-worker.js' , + // Run ELK in a Web Worker, via the `@joint/layout-elk` package. + const elk = new ELK({ + workerUrl: '../node_modules/elkjs/lib/elk-worker.js' + }); + + layout(graph, { + elk, // Let ELK reposition (and reorder) every port in the diagram, instead // of keeping them where JointJS's own port groups first placed them. positionPorts: 'fixed-side', @@ -103,9 +108,7 @@ const init = () => { return undefined; }, elkLayoutOptions - }); - - layoutController.layout().then(() => { + }).then(() => { paper.unfreeze(); zoom(paper, 1); diff --git a/examples/layout-elk-interactive-ts/src/index.ts b/examples/layout-elk-interactive-ts/src/index.ts index 67c1b894fd..29711d6a76 100644 --- a/examples/layout-elk-interactive-ts/src/index.ts +++ b/examples/layout-elk-interactive-ts/src/index.ts @@ -1,5 +1,5 @@ import { dia, shapes, g } from '@joint/core'; -import { ElkLayoutOptions, ElkLayoutController } from '@joint/layout-elk'; +import { ElkLayoutOptions, layout } from '@joint/layout-elk'; import './styles.scss'; const ELK_DIRECTION = 'RIGHT'; @@ -54,11 +54,6 @@ const init = () => { 'elk.edgeRouting': 'ORTHOGONAL', }; - const layoutController = new ElkLayoutController({ - graph, - elkLayoutOptions - }); - // Seed the graph with a small, already laid out tree. const cells: dia.Cell[] = []; SEED_LINKS.forEach(([sourceId, targetId]) => { @@ -72,7 +67,7 @@ const init = () => { // The very first layout always computes the whole graph from scratch - there is // nothing to be "interactive" about yet, since no element has a position at all. - layoutController.layout().then(() => { + layout(graph, { elkLayoutOptions }).then(() => { paper.unfreeze(); zoom(paper, zoomLevel); }).catch((error) => { @@ -109,7 +104,7 @@ const init = () => { // itself. Uncheck "Interactive layout" to see the whole graph get reshuffled by // a from-scratch layout instead. Either way, re-fit the viewport afterwards so // the (possibly larger) diagram stays fully visible. - layoutController.layout({ interactive: interactiveToggle.checked }).then(() => { + layout(graph, { elkLayoutOptions, interactive: interactiveToggle.checked }).then(() => { paper.unfreeze(); zoom(paper, zoomLevel); }).catch((error) => { diff --git a/packages/joint-layout-elk/src/export.mts b/packages/joint-layout-elk/src/export.mts index 37f0cdae92..d12454ed33 100644 --- a/packages/joint-layout-elk/src/export.mts +++ b/packages/joint-layout-elk/src/export.mts @@ -259,7 +259,7 @@ export function exportGraph( // node's own origin (see `toAbsolute` in `importLayout`) - `containerX`/`containerY` // convert an element's own graph-absolute `position()` into that frame, so that an // element's exported `x`/`y` is always a usable hint of where it currently is, e.g. - // for `interactive` (see `ElkLayoutControllerOptions`) to pick up. + // for `interactive` (see `Options` in `layout.mts`) to pick up. function buildElkNode(element: dia.Element, containerX = 0, containerY = 0): ElkNode { const id = `${element.id}`; elementsById.set(id, element); diff --git a/packages/joint-layout-elk/src/index.mts b/packages/joint-layout-elk/src/index.mts index c84321e098..567d250afe 100644 --- a/packages/joint-layout-elk/src/index.mts +++ b/packages/joint-layout-elk/src/index.mts @@ -1,4 +1,4 @@ -export * from './ElkLayoutController.mjs'; +export * from './layout.mjs'; export * from './import.mjs'; export * from './export.mjs'; diff --git a/packages/joint-layout-elk/src/ElkLayoutController.mts b/packages/joint-layout-elk/src/layout.mts similarity index 58% rename from packages/joint-layout-elk/src/ElkLayoutController.mts rename to packages/joint-layout-elk/src/layout.mts index 52acec9a9a..f9f0477e61 100644 --- a/packages/joint-layout-elk/src/ElkLayoutController.mts +++ b/packages/joint-layout-elk/src/layout.mts @@ -1,19 +1,16 @@ -import ElkConstructor from 'elkjs/lib/elk.bundled.js'; import { util, g } from '@joint/core'; +import ElkConstructor from 'elkjs/lib/elk.bundled.js'; +import { importLayout } from './import.mjs'; +import { exportGraph } from './export.mjs'; -import type { dia } from '@joint/core'; -import type { ELK, LayoutOptions, ElkNode as RawElkNode } from 'elkjs'; +import type { ExportGraphOptions } from './export.mjs'; +import type { EdgeLabelsOptions, ImportLayoutOptions, PortPositionsMode, PortPositionsOptions, PortLabelPositionsOptions } from './import.mjs'; import type { ElkLayoutOptions, ElkNode } from './elkOptions.mjs'; -import type { EdgeLabelsOptions, ImportLayoutOptions, PortLabelPositionsOptions, PortPositionsMode, PortPositionsOptions } from './import.mjs'; -import { exportGraph, importLayout, type ExportGraphOptions } from './index.mjs'; +import type { dia } from '@joint/core'; +import type { ELK, ElkNode as RawElkNode } from 'elkjs'; const LAYOUT_BATCH_NAME = 'layout'; -const DEFAULT_OPTIONS: Partial = { - edgeLabels: true, - batchName: LAYOUT_BATCH_NAME, -}; - const DEFAULT_LAYOUT_OPTIONS: ElkLayoutOptions = { 'elk.algorithm': 'layered', // Lay out embedded elements (containers) as part of the same pass as their @@ -29,7 +26,7 @@ const DEFAULT_LAYOUT_OPTIONS: ElkLayoutOptions = { }; // Applied on top of `DEFAULT_LAYOUT_OPTIONS` (but under the caller's own `elkLayoutOptions`) -// when `interactive` is enabled - see its doc on `ElkLayoutControllerOptions`. +// when `interactive` is enabled - see its doc on `Options`. const INTERACTIVE_LAYOUT_OPTIONS: ElkLayoutOptions = { // Generic hint, respected by every algorithm - see e.g. ELK Force/Stress, which use it to // skip generating a fresh initial layout and relax from each element's current position @@ -44,30 +41,31 @@ const INTERACTIVE_LAYOUT_OPTIONS: ElkLayoutOptions = { 'elk.layered.nodePlacement.strategy': 'INTERACTIVE', }; -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; -} +const DEFAULT_OPTIONS: Options = { + edgeLabels: true, + batchName: LAYOUT_BATCH_NAME, +}; + +let defaultElk: ELK | undefined; -export interface ElkLayoutControllerOptions extends +/** + * Layout configuration options. + */ +export interface Options extends Omit, Omit { /** - * The graph to lay out. Fixed for the controller's lifetime. - */ - graph: dia.Graph; - /** - * A URL for `elkjs`'s Web Worker script, to run layout off the main thread. - * Fixed for the controller's lifetime - the underlying ELK instance is only - * ever created once, in the constructor. + * A custom ELK instance, e.g. one configured to run inside a Web Worker. + * The instance is not terminated by the package - call `elk.terminateWorker()` + * yourself when it is no longer needed. + * @defaultValue a shared, main-thread instance (`elkjs/lib/elk.bundled.js`) * @example - * new ElkLayoutController({ graph, workerUrl: new URL('elkjs/lib/elk-worker.min.js', import.meta.url).href }); + * import ELK from 'elkjs/lib/elk-api.js'; + * const elk = new ELK({ workerUrl: new URL('elkjs/lib/elk-worker.min.js', import.meta.url).href }); + * layout(graph, { elk }); */ - workerUrl?: string; - + elk?: ELK; /** * ELK layout options, passed through to ELK unmodified. * @see https://eclipse.dev/elk/reference/options.html @@ -123,70 +121,50 @@ export interface ElkLayoutControllerOptions extends interactive?: boolean; } +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; +} + +function getDefaultElk(): ELK { + if (!defaultElk) { + defaultElk = new ElkConstructor(); + } + return defaultElk; +} + /** - * Options that can be supplied to a single `layout()` call, supplementing (not - * replacing) the controller's own options - see `ElkLayoutControllerOptions` - - * for just that one run. Everything except `graph`/`workerUrl` (fixed for the - * controller's lifetime - see their docs) can be overridden this way; `elkLayoutOptions` - * given here is merged on top of the controller's own, rather than replacing it outright. + * Tight bounding box of the top-level nodes in an ELK layout result. */ -export type ElkLayoutRunOptions = Omit; - -export class ElkLayoutController { - - private elkInstance: ELK; - private graph: dia.Graph; - private options: ElkLayoutControllerOptions; - - constructor(options: ElkLayoutControllerOptions) { - if (options.workerUrl) { - this.elkInstance = new ElkConstructor({ - workerUrl: options.workerUrl, - algorithms: ['layered'], - defaultLayoutOptions: DEFAULT_LAYOUT_OPTIONS as LayoutOptions - }); - } else { - this.elkInstance = new ElkConstructor({ - algorithms: ['layered'], - defaultLayoutOptions: DEFAULT_LAYOUT_OPTIONS as LayoutOptions - }); - } - - this.graph = options.graph; - - this.options = util.defaults({}, options || {}, DEFAULT_OPTIONS) as ElkLayoutControllerOptions; - } +function getBBox(elkGraph: ElkNode): g.Rect { + const rects = (elkGraph.children || []).map((node) => new g.Rect(node.x || 0, node.y || 0, node.width || 0, node.height || 0)); + return g.Rect.fromRectUnion(...rects) || new g.Rect(0, 0, 0, 0); +} - private getBBox(elkGraph: ElkNode): g.Rect { - const rects = (elkGraph.children || []).map((node) => new g.Rect(node.x || 0, node.y || 0, node.width || 0, node.height || 0)); - return g.Rect.fromRectUnion(...rects) || new g.Rect(0, 0, 0, 0); - } +export async function layout(graph: dia.Graph, opt?: Options): Promise { - public async layout(options?: ElkLayoutRunOptions): Promise { - // Options given here supplement (rather than replace) the controller's own for - // this run only - `this.options` itself is left untouched for the next call. - const runOptions = util.defaults({}, options || {}, this.options) as ElkLayoutRunOptions; - const elkLayoutOptions = util.defaults( - {}, - runOptions.elkLayoutOptions || {}, - (runOptions.interactive) ? INTERACTIVE_LAYOUT_OPTIONS : {}, - DEFAULT_LAYOUT_OPTIONS - ) as ElkLayoutOptions; - const elk = this.elkInstance; - const batchName = runOptions.batchName || LAYOUT_BATCH_NAME; - - const { elkGraph, elementsById, linksById, portsById } = exportGraph(this.graph, runOptions as ExportGraphOptions, elkLayoutOptions); - - const result = await elk.layout(elkGraph as unknown as RawElkNode) as ElkNode; - - this.graph.startBatch(batchName); - importLayout(result, elementsById, linksById, portsById, runOptions); - this.graph.stopBatch(batchName); - - return { - bbox: this.getBBox(result), - elkGraph: result - }; - } + const options = util.defaults({}, opt || {}, DEFAULT_OPTIONS) as Options; + const elkLayoutOptions = util.defaults( + {}, + opt?.elkLayoutOptions || {}, + (opt?.interactive) ? INTERACTIVE_LAYOUT_OPTIONS : {}, + DEFAULT_LAYOUT_OPTIONS + ) as ElkLayoutOptions; + const elk = opt?.elk || getDefaultElk(); + const batchName = options.batchName || LAYOUT_BATCH_NAME; + + const { elkGraph, elementsById, linksById, portsById } = exportGraph(graph, options as ExportGraphOptions, elkLayoutOptions); + + const result = await elk.layout(elkGraph as unknown as RawElkNode) as ElkNode; + + graph.startBatch(batchName); + importLayout(result, elementsById, linksById, portsById, options); + graph.stopBatch(batchName); + return { + bbox: getBBox(result), + elkGraph: result + }; } From b21af6ef2dc7afa65119f4ef25b1062bd7e85a3d Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Fri, 11 Sep 2026 13:44:37 +0200 Subject: [PATCH 14/75] wip --- .../layout-elk-interactive-ts/src/index.ts | 11 ++++----- .../webpack.config.js | 1 + packages/joint-layout-elk/src/export.mts | 24 ++++++++++++++++--- packages/joint-layout-elk/src/import.mts | 2 ++ packages/joint-layout-elk/src/layout.mts | 20 +++++++++++----- 5 files changed, 43 insertions(+), 15 deletions(-) diff --git a/examples/layout-elk-interactive-ts/src/index.ts b/examples/layout-elk-interactive-ts/src/index.ts index 29711d6a76..cfc4dbbc64 100644 --- a/examples/layout-elk-interactive-ts/src/index.ts +++ b/examples/layout-elk-interactive-ts/src/index.ts @@ -55,13 +55,11 @@ const init = () => { }; // Seed the graph with a small, already laid out tree. - const cells: dia.Cell[] = []; SEED_LINKS.forEach(([sourceId, targetId]) => { - if (!graph.getCell(sourceId)) cells.push(createElement(sourceId)); - if (!graph.getCell(targetId)) cells.push(createElement(targetId)); - cells.push(createLink(sourceId, targetId)); + if (!graph.getCell(sourceId)) graph.addCell(createElement(sourceId)); + if (!graph.getCell(targetId)) graph.addCell(createElement(targetId)); + graph.addCell(createLink(sourceId, targetId)); }); - graph.resetCells(cells); const interactiveToggle = document.getElementById('interactive-toggle') as HTMLInputElement; @@ -94,7 +92,8 @@ const init = () => { // Starting it off one layer to the right of its parent (`ELK_DIRECTION`) instead // gives interactive layout a sensible hint, so only `element` itself (not its // parent's whole layer) needs to move. - element.position(parent.position().x + NODE_SIZE.width + 60, parent.position().y); + // element.position(parent.position().x + NODE_SIZE.width + 60, parent.position().y); + element.set('new', true); paper.freeze(); graph.addCells([element, createLink(`${parent.id}`, `${element.id}`)]); diff --git a/examples/layout-elk-interactive-ts/webpack.config.js b/examples/layout-elk-interactive-ts/webpack.config.js index 7b10ca335d..410b983af5 100644 --- a/examples/layout-elk-interactive-ts/webpack.config.js +++ b/examples/layout-elk-interactive-ts/webpack.config.js @@ -11,6 +11,7 @@ module.exports = { publicPath: '/dist/', }, mode: 'development', + devtool: 'source-map', module: { rules: [ { diff --git a/packages/joint-layout-elk/src/export.mts b/packages/joint-layout-elk/src/export.mts index d12454ed33..d593bb377d 100644 --- a/packages/joint-layout-elk/src/export.mts +++ b/packages/joint-layout-elk/src/export.mts @@ -282,10 +282,8 @@ export function exportGraph( } const { width, height } = getSizeFn(element); - return { + const elkNode: ElkNode = { id, - x, - y, width, height, ports, @@ -295,6 +293,26 @@ export function exportGraph( ...customOptions } : customOptions }; + + if (!element.get('new')) { + // An already laid out element - give ELK a hint of where it currently is, so + // that `interactive` (see `Options` in `layout.mts`) can try to keep it there. + elkNode.x = x; + elkNode.y = y; + // Also expose it as the `elk.position` layout option - a distinct property from + // the plain `x`/`y` above, read specifically by + // `elk.layered.crossingMinimization.semiInteractive` to derive a *soft* ordering + // constraint between pairs of already-positioned nodes in the same layer, on top + // of whatever crossing-minimizing strategy is otherwise in effect. A node with no + // `elk.position` (e.g. one still marked `new`) is left out of that constraint, so + // it's free to be placed wherever reduces crossings. + elkNode.layoutOptions = { + ...elkNode.layoutOptions, + 'elk.position': `(${x},${y})` + }; + } + + return elkNode; } const children: ElkNode[] = graph.getElements() diff --git a/packages/joint-layout-elk/src/import.mts b/packages/joint-layout-elk/src/import.mts index 37b4136022..a7252b11d6 100644 --- a/packages/joint-layout-elk/src/import.mts +++ b/packages/joint-layout-elk/src/import.mts @@ -288,6 +288,8 @@ export function importLayout( // A container - ELK computed its size to fit its (recursively laid out) content. element.resize(node.width || 0, node.height || 0); } + + element.unset('new'); // remove the "new" flag, if any, so it doesn't stay styled differently } if ((setPortPositionFn || setPortLabelPositionFn) && node.ports) { diff --git a/packages/joint-layout-elk/src/layout.mts b/packages/joint-layout-elk/src/layout.mts index f9f0477e61..b8c74d97a5 100644 --- a/packages/joint-layout-elk/src/layout.mts +++ b/packages/joint-layout-elk/src/layout.mts @@ -19,10 +19,7 @@ const DEFAULT_LAYOUT_OPTIONS: ElkLayoutOptions = { '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', - // JointJS positions an element by its top-left corner - match that as the point - // `interactive` (see below) compares against an element's previous position. - 'elk.layered.interactiveReferencePoint': 'TOP_LEFT' + 'elk.layered.considerModelOrder.portModelOrder': 'true' }; // Applied on top of `DEFAULT_LAYOUT_OPTIONS` (but under the caller's own `elkLayoutOptions`) @@ -32,13 +29,24 @@ const INTERACTIVE_LAYOUT_OPTIONS: ElkLayoutOptions = { // skip generating a fresh initial layout and relax from each element's current position // instead. Not `'layered'`'s primary lever (below), but harmless to set alongside it. 'elk.interactive': 'true', + 'elk.interactiveLayout': 'true', // `'layered'`'s own interactivity is per-phase - each of these reads the corresponding // aspect (edge direction, x/y) straight off an element's current position instead of // computing it from scratch, so the four are meant to be used together. - 'elk.layered.cycleBreaking.strategy': 'INTERACTIVE', + // 'elk.layered.cycleBreaking.strategy': 'INTERACTIVE', 'elk.layered.layering.strategy': 'INTERACTIVE', - 'elk.layered.crossingMinimization.strategy': 'INTERACTIVE', + // NOT `crossingMinimization.strategy: 'INTERACTIVE'` - that variant doesn't minimize + // crossings at all, it just sorts each layer by previous y and calls it done (see + // `InteractiveCrossingMinimizer` upstream). `semiInteractive` instead keeps the real + // crossing-minimizing strategy (`LAYER_SWEEP`, the default) running, and only adds + // *soft* ordering constraints between pairs of already-positioned nodes (read from the + // `elk.position` layout option - see `buildElkNode` in `export.mts`) - so genuinely new + // nodes still get placed to reduce crossings, while nodes that already had a position + // keep their relative order unless the topology actually requires otherwise. + 'elk.layered.crossingMinimization.semiInteractive': 'true', 'elk.layered.nodePlacement.strategy': 'INTERACTIVE', + 'elk.layered.interactiveReferencePoint': 'TOP_LEFT', + 'elk.layered.mergeEdges': 'true', }; const DEFAULT_OPTIONS: Options = { From c5b638a1d8483c633ed114631bca7acd3d1083d7 Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Sun, 13 Sep 2026 20:22:07 +0200 Subject: [PATCH 15/75] wip --- .../src/index.ts | 11 +- .../layout-elk-interactive-ts/src/index.ts | 32 ++-- packages/joint-layout-elk/README.md | 47 ++++-- packages/joint-layout-elk/src/export.mts | 138 +++++++++--------- packages/joint-layout-elk/src/import.mts | 2 - packages/joint-layout-elk/test/index.js | 2 +- 6 files changed, 135 insertions(+), 97 deletions(-) diff --git a/examples/layout-elk-containers-ports-ts/src/index.ts b/examples/layout-elk-containers-ports-ts/src/index.ts index 42ae77749f..17d238e311 100644 --- a/examples/layout-elk-containers-ports-ts/src/index.ts +++ b/examples/layout-elk-containers-ports-ts/src/index.ts @@ -99,13 +99,14 @@ const init = () => { // of keeping them where JointJS's own port groups first placed them. positionPorts: 'fixed-side', positionPortLabels: true, - nodeOptions: (element) => { + nodeOptions: (element, computed) => { // Reserve extra top padding inside containers, so children don't // overlap the container's title label. - if (element.getEmbeddedCells().length > 0) { - return { 'elk.padding': CONTAINER_PADDING }; - } - return undefined; + if (element.getEmbeddedCells().length === 0) return undefined; + return { + ...computed, + layoutOptions: { ...computed.layoutOptions, 'elk.padding': CONTAINER_PADDING } + }; }, elkLayoutOptions }).then(() => { diff --git a/examples/layout-elk-interactive-ts/src/index.ts b/examples/layout-elk-interactive-ts/src/index.ts index cfc4dbbc64..71af90aa11 100644 --- a/examples/layout-elk-interactive-ts/src/index.ts +++ b/examples/layout-elk-interactive-ts/src/index.ts @@ -1,5 +1,5 @@ import { dia, shapes, g } from '@joint/core'; -import { ElkLayoutOptions, layout } from '@joint/layout-elk'; +import { ElkLayoutOptions, layout, NodeLayoutProperties } from '@joint/layout-elk'; import './styles.scss'; const ELK_DIRECTION = 'RIGHT'; @@ -20,6 +20,21 @@ const SEED_NODE_IDS = new Set(SEED_LINKS.flat()); let nextId = SEED_NODE_IDS.size + 1; let zoomLevel = 1; +/** + * A brand new element (see the "Add Element" handler below) has no meaningful position yet - + * strip the position hints `@joint/layout-elk` would otherwise give it, so ELK's interactive + * layering/placement is free to place it based on topology, instead of anchoring it near the + * (0, 0) `element.position()` defaults to. + */ +function nodeOptions(element: dia.Element, computed: NodeLayoutProperties): NodeLayoutProperties | undefined { + if (!element.get('new')) return undefined; + // Actually omit `x`/`y` (not just set them to `undefined`) - elkjs chokes on a + // present-but-`undefined` coordinate instead of treating it as absent. + const { x: _x, y: _y, width, height, layoutOptions: computedLayoutOptions } = computed; + const { 'elk.position': _elkPosition, ...layoutOptions } = computedLayoutOptions ?? {}; + return { width, height, layoutOptions }; +} + const init = () => { // Create JointJS graph and paper @@ -65,7 +80,7 @@ const init = () => { // The very first layout always computes the whole graph from scratch - there is // nothing to be "interactive" about yet, since no element has a position at all. - layout(graph, { elkLayoutOptions }).then(() => { + layout(graph, { elkLayoutOptions, nodeOptions }).then(() => { paper.unfreeze(); zoom(paper, zoomLevel); }).catch((error) => { @@ -86,13 +101,8 @@ const init = () => { const elements = graph.getElements(); const parent = elements[g.random(0, elements.length - 1)]; const element = createElement(`n${nextId++}`); - // ELK's interactive strategies use an element's *current* position as a hint of - // where to keep it - a brand new element defaults to (0, 0), which reads as "put - // this in the very first layer" and forces everything else to make room for it. - // Starting it off one layer to the right of its parent (`ELK_DIRECTION`) instead - // gives interactive layout a sensible hint, so only `element` itself (not its - // parent's whole layer) needs to move. - // element.position(parent.position().x + NODE_SIZE.width + 60, parent.position().y); + // Marks it for `nodeOptions` (above) to strip position hints from, so ELK is free + // to place it based on topology instead of anchoring it near (0, 0). element.set('new', true); paper.freeze(); @@ -103,7 +113,9 @@ const init = () => { // itself. Uncheck "Interactive layout" to see the whole graph get reshuffled by // a from-scratch layout instead. Either way, re-fit the viewport afterwards so // the (possibly larger) diagram stays fully visible. - layout(graph, { elkLayoutOptions, interactive: interactiveToggle.checked }).then(() => { + layout(graph, { elkLayoutOptions, interactive: interactiveToggle.checked, nodeOptions }).then(() => { + // Layout succeeded - `element` now has a real position, so it's no longer "new". + element.unset('new'); paper.unfreeze(); zoom(paper, zoomLevel); }).catch((error) => { diff --git a/packages/joint-layout-elk/README.md b/packages/joint-layout-elk/README.md index e0b56e002c..3861c81bff 100644 --- a/packages/joint-layout-elk/README.md +++ b/packages/joint-layout-elk/README.md @@ -34,7 +34,7 @@ const link = new shapes.standard.Link({ source: { id: 'a' }, target: { id: 'b' } graph.addCells([rect1, rect2, link]); const { bbox } = await layout(graph, { - layoutOptions: { + elkLayoutOptions: { 'elk.algorithm': 'layered', 'elk.direction': 'RIGHT', 'elk.edgeRouting': 'ORTHOGONAL' @@ -64,20 +64,28 @@ type SetPositionCallback = (element: dia.Element, position: dia.Point) => void; type SetVerticesCallback = (link: dia.Link, vertices: dia.Point[]) => void; type SetAnchorCallback = (link: dia.Link, element: dia.Element, point: dia.Point, endType: 'source' | 'target') => void; type SetLabelsCallback = (link: dia.Link, labelBBox: dia.BBox, points: dia.Point[], labelIndex: number) => void; -type NodeOptionsCallback = (element: dia.Element) => ElkLayoutOptions | undefined; -type PortOptionsCallback = (port: dia.Element.Port, element: dia.Element) => ElkLayoutOptions | undefined; -type EdgeOptionsCallback = (link: dia.Link) => ElkLayoutOptions | undefined; type SetPortPositionCallback = (element: dia.Element, portId: string, position: dia.Point) => void; // - 'fixed': ports stay exactly where JointJS's own port groups place them (`FIXED_POS`). // - 'fixed-side': ELK may reposition/reorder ports along their group's assigned side (`FIXED_SIDE`). // - 'free': ELK may reposition ports anywhere around their element (`FREE`). type PortPositionsMode = 'fixed' | 'fixed-side' | 'free'; +// What this package itself would compute for a node/port/edge, and what `nodeOptions`/ +// `portOptions`/`edgeOptions` (see "Adjusting computed node/port/edge properties" below) +// get to inspect and adjust - everything except structural fields (`id`, `sources`/ +// `targets`, `ports`/`children`, which are handled separately). +type NodeLayoutProperties = Pick; +type PortLayoutProperties = Pick; +type EdgeLayoutProperties = Pick; +type NodeOptionsCallback = (element: dia.Element, computed: NodeLayoutProperties) => NodeLayoutProperties | undefined; +type PortOptionsCallback = (port: dia.Element.Port, element: dia.Element, computed: PortLayoutProperties) => PortLayoutProperties | undefined; +type EdgeOptionsCallback = (link: dia.Link, computed: EdgeLayoutProperties) => EdgeLayoutProperties | undefined; + interface Options { // A custom ELK instance, e.g. one configured to run inside a Web Worker. elk?: ELK; // Default: a shared, main-thread instance (`elkjs/lib/elk.bundled.js`) // ELK layout options, passed through to ELK unmodified. - layoutOptions?: ElkLayoutOptions; // Default: { 'elk.algorithm': 'layered' } + elkLayoutOptions?: ElkLayoutOptions; // Default: { 'elk.algorithm': 'layered' } // Whether to account for link labels during layout and position them afterwards. edgeLabels?: boolean; // Default: true // How freely ELK may reposition (and reorder) ports itself, instead of keeping @@ -94,7 +102,8 @@ interface Options { setVertices?: boolean | SetVerticesCallback; // Default: true setAnchor?: boolean | SetAnchorCallback; // Default: true setLabels?: boolean | SetLabelsCallback; // Default: true - // Per-cell escape hatches into ELK's option space + // Escape hatches into ELK's per-node/port/edge option space - see "Adjusting computed + // node/port/edge properties" below. nodeOptions?: NodeOptionsCallback; portOptions?: PortOptionsCallback; edgeOptions?: EdgeOptionsCallback; @@ -124,18 +133,38 @@ elk.terminateWorker(); With no `elk` option, the package creates a default instance running on the main thread (`elkjs/lib/elk.bundled.js`). The public API is identical in both modes. -### Per-cell ELK options +### Adjusting computed node/port/edge properties + +`nodeOptions`/`portOptions`/`edgeOptions` are called with everything this package itself would use for that node/port/edge (its `x`/`y`/`width`/`height` and `layoutOptions`, or `labels` for a port/edge) - not just the element/port/link, so you can build on what's already computed instead of writing it all from scratch. Return `undefined` to leave it as computed; return a value to use that instead (spread the given one and adjust the parts you care about, since **whatever you return replaces the computed value outright** - it isn't merged with it): ```ts layout(graph, { - nodeOptions: (element) => ({ 'partitioning.partition': `${element.get('layer')}` }), - layoutOptions: { + nodeOptions: (element, computed) => ({ + ...computed, + layoutOptions: { ...computed.layoutOptions, 'partitioning.partition': `${element.get('layer')}` } + }), + elkLayoutOptions: { 'elk.algorithm': 'layered', 'elk.partitioning.activate': 'true' } }); ``` +Because the callback sees the computed value, it can also *remove* something this package would otherwise set - e.g. every node's `x`/`y` (and the `elk.position` layout option, used by `elk.layered.crossingMinimization.semiInteractive`) reflect the element's current position, which only makes sense once it actually has one. For a freshly created element (flagged here however you like, e.g. a `new` attribute you set yourself) you'd typically want ELK to place it wherever fits, not anchor it at `(0, 0)`: + +```ts +layout(graph, { + nodeOptions: (element, computed) => { + if (!element.get('new')) return undefined; + // Actually omit `x`/`y` (not just set them to `undefined`) - elkjs treats a + // present-but-`undefined` coordinate differently from an absent one. + const { x: _x, y: _y, width, height, layoutOptions: computedLayoutOptions } = computed; + const { 'elk.position': _elkPosition, ...layoutOptions } = computedLayoutOptions ?? {}; + return { width, height, layoutOptions }; + } +}); +``` + ### Letting ELK position ports By default (`positionPorts: 'fixed'`), ports stay exactly where JointJS's own port groups already place them - ELK only uses that position to route edges. Pass `positionPorts: 'fixed-side'` to let ELK reposition (and reorder) ports along the side their group already assigns them to, e.g. to minimize edge crossings, or `'free'` to let it place them on any side: diff --git a/packages/joint-layout-elk/src/export.mts b/packages/joint-layout-elk/src/export.mts index d593bb377d..61535817f8 100644 --- a/packages/joint-layout-elk/src/export.mts +++ b/packages/joint-layout-elk/src/export.mts @@ -9,7 +9,6 @@ import type { ElkLayoutOptions, NodeElkLayoutOptions, PortElkLayoutOptions, - EdgeElkLayoutOptions, LabelElkLayoutOptions } from './elkOptions.mjs'; @@ -37,9 +36,27 @@ const ELK_PORT_CONSTRAINTS_BY_MODE: Record dia.Size; type GetPortLabelSizeCallback = (port: dia.Element.Port, element: dia.Element) => dia.Size; -type NodeOptionsCallback = (element: dia.Element) => NodeElkLayoutOptions | undefined; -type PortOptionsCallback = (port: dia.Element.Port, element: dia.Element) => PortElkLayoutOptions | undefined; -type EdgeOptionsCallback = (link: dia.Link) => EdgeElkLayoutOptions | undefined; + +/** + * The ELK node properties `nodeOptions` can inspect and adjust - everything about a node + * this package itself computes, except `id` (structural) and `ports`/`children` (built + * separately, from the element's JointJS ports/embeds). + */ +export type NodeLayoutProperties = Pick; +/** + * The ELK port properties `portOptions` can inspect and adjust - everything about a port + * this package itself computes, except `id` (structural). + */ +export type PortLayoutProperties = Pick; +/** + * The ELK edge properties `edgeOptions` can inspect and adjust - everything about an edge + * this package itself computes, except `id`/`sources`/`targets` (structural). + */ +export type EdgeLayoutProperties = Pick; + +type NodeOptionsCallback = (element: dia.Element, computed: NodeLayoutProperties) => NodeLayoutProperties | undefined; +type PortOptionsCallback = (port: dia.Element.Port, element: dia.Element, computed: PortLayoutProperties) => PortLayoutProperties | undefined; +type EdgeOptionsCallback = (link: dia.Link, computed: EdgeLayoutProperties) => EdgeLayoutProperties | undefined; export interface ElkGraphPort { element: dia.Element; @@ -150,15 +167,15 @@ const getPortLabelSize: GetPortLabelSizeCallback = (port, element) => { return DEFAULT_LABEL_SIZE; }; -const nodeOptions: NodeOptionsCallback = (_element) => { +const nodeOptions: NodeOptionsCallback = (_element, _computed) => { return undefined; }; -const portOptions: PortOptionsCallback = (_port, _element) => { +const portOptions: PortOptionsCallback = (_port, _element, _computed) => { return undefined; }; -const edgeOptions: EdgeOptionsCallback = (_link) => { +const edgeOptions: EdgeOptionsCallback = (_link, _computed) => { return undefined; }; @@ -183,12 +200,8 @@ function buildPorts( const elkPortId = `${element.id}:${portId}`; portsById.set(elkPortId, { element, portId }); - const portLayoutOptions: ElkPort = { - id: elkPortId, - layoutOptions: portOptionsFn(port, element) || {} - }; - const groupDef = port.group && element.prop(`ports/groups/${port.group}`); + const layoutOptions: PortElkLayoutOptions = {}; // A port's side is always determined by its group - JointJS has no way for an // individual port to sit on a different side than the rest of its group - so a @@ -203,31 +216,29 @@ function buildPorts( : (positionName === 'bottom') ? 'SOUTH' : undefined; if (side) { - portLayoutOptions.layoutOptions!['port.side'] = side; + layoutOptions['port.side'] = side; } // A port's own `label` (if it has one) always takes precedence over its group's - // same as JointJS itself resolves it (see `getPortLabelSizeFn`) - so either one is // enough to warrant reserving/positioning a label for this port. + let labels: ElkLabel[] | undefined; if (positionPortLabels && (port.label || groupDef?.label)) { - const { width, height } = getPortLabelSizeFn(port, element); - portLayoutOptions.labels = [{ + const { width: labelWidth, height: labelHeight } = getPortLabelSizeFn(port, element); + labels = [{ // Some text is required, otherwise ELK ignores the label. text: ELK_LABEL_TEXT, - width, - height, + width: labelWidth, + height: labelHeight, layoutOptions: {} }]; } const { x, y, width, height } = element.getPortRelativeRect(portId); - portLayoutOptions.x = x; - portLayoutOptions.y = y; - portLayoutOptions.width = width; - portLayoutOptions.height = height; - portLayoutOptions.layoutOptions!['port.borderOffset'] = (-width / 2).toString(); + layoutOptions['port.borderOffset'] = (-width / 2).toString(); - return portLayoutOptions; + const computed: PortLayoutProperties = { x, y, width, height, layoutOptions, labels }; + return { id: elkPortId, ...(portOptionsFn(port, element, computed) ?? computed) }; }); } @@ -257,62 +268,47 @@ export function exportGraph( // ELK positions a node's children (and routes a node's own edges) relative to that // node's own origin (see `toAbsolute` in `importLayout`) - `containerX`/`containerY` - // convert an element's own graph-absolute `position()` into that frame, so that an - // element's exported `x`/`y` is always a usable hint of where it currently is, e.g. - // for `interactive` (see `Options` in `layout.mts`) to pick up. + // convert an element's own graph-absolute `position()` into that frame, so that the + // `x`/`y` handed to `nodeOptions` is always a usable hint of where the element + // currently is. function buildElkNode(element: dia.Element, containerX = 0, containerY = 0): ElkNode { const id = `${element.id}`; elementsById.set(id, element); const ports = buildPorts(element, portOptionsFn, getPortLabelSizeFn, portsById, !!options.positionPortLabels); - const customOptions = nodeOptionsFn(element); const { x: absoluteX, y: absoluteY } = element.position(); const x = absoluteX - containerX; const y = absoluteY - containerY; + const layoutOptions: NodeElkLayoutOptions = (ports) ? { + ...portConstraintsOptions, + 'portLabels.placement': 'OUTSIDE' + } : {}; + // A hint of the element's current position - read directly (as the plain `x`/`y` + // below) by ELK's `interactive` strategies (see `layout.mts`), and via this distinct + // option by `elk.layered.crossingMinimization.semiInteractive`. A brand new element + // has no meaningful position yet - strip this (and `x`/`y`) via `nodeOptions` (e.g. + // based on your own "is this new" convention) to let ELK place it freely instead of + // anchoring it here. + layoutOptions['elk.position'] = `(${x},${y})`; + const embeds = element.getEmbeddedCells() .filter((cell): cell is dia.Element => cell.isElement()); if (embeds.length > 0) { - // A container - its size is computed by ELK to fit its (recursively laid out) content. + // A container - its size is computed by ELK to fit its (recursively laid out) + // content, so `width`/`height` are left for `nodeOptions` to see as `undefined` + // rather than computed here. const children = embeds.map((embed) => buildElkNode(embed, absoluteX, absoluteY)); - const node: ElkNode = { id, x, y, children, ports, layoutOptions: customOptions }; + const computed: NodeLayoutProperties = { x, y, layoutOptions }; + const node: ElkNode = { id, children, ports, ...(nodeOptionsFn(element, computed) ?? computed) }; edgeContainersById.set(id, node.edges = []); return node; } const { width, height } = getSizeFn(element); - const elkNode: ElkNode = { - id, - width, - height, - ports, - layoutOptions: (ports) ? { - ...portConstraintsOptions, - 'portLabels.placement': 'OUTSIDE', - ...customOptions - } : customOptions - }; - - if (!element.get('new')) { - // An already laid out element - give ELK a hint of where it currently is, so - // that `interactive` (see `Options` in `layout.mts`) can try to keep it there. - elkNode.x = x; - elkNode.y = y; - // Also expose it as the `elk.position` layout option - a distinct property from - // the plain `x`/`y` above, read specifically by - // `elk.layered.crossingMinimization.semiInteractive` to derive a *soft* ordering - // constraint between pairs of already-positioned nodes in the same layer, on top - // of whatever crossing-minimizing strategy is otherwise in effect. A node with no - // `elk.position` (e.g. one still marked `new`) is left out of that constraint, so - // it's free to be placed wherever reduces crossings. - elkNode.layoutOptions = { - ...elkNode.layoutOptions, - 'elk.position': `(${x},${y})` - }; - } - - return elkNode; + const computed: NodeLayoutProperties = { x, y, width, height, layoutOptions }; + return { id, ports, ...(nodeOptionsFn(element, computed) ?? computed) }; } const children: ElkNode[] = graph.getElements() @@ -357,17 +353,11 @@ export function exportGraph( const sourcePort = link.source().port; const targetPort = link.target().port; - const edge: ElkExtendedEdge = { - id, - sources: [(sourcePort) ? `${sourceElement.id}:${sourcePort}` : `${sourceElement.id}`], - targets: [(targetPort) ? `${targetElement.id}:${targetPort}` : `${targetElement.id}`], - layoutOptions: edgeOptionsFn(link) - }; - + let labels: ElkLabel[] | undefined; if (options.edgeLabels) { - const labels = link.labels(); - if (labels.length > 0) { - edge.labels = labels.map((label): ElkLabel => { + const linkLabels = link.labels(); + if (linkLabels.length > 0) { + labels = linkLabels.map((label): ElkLabel => { const { width, height } = label.size || DEFAULT_LABEL_SIZE; return { // Some text is required, otherwise ELK ignores the label. @@ -381,6 +371,14 @@ export function exportGraph( } } + const computed: EdgeLayoutProperties = { layoutOptions: undefined, labels }; + const edge: ElkExtendedEdge = { + id, + sources: [(sourcePort) ? `${sourceElement.id}:${sourcePort}` : `${sourceElement.id}`], + targets: [(targetPort) ? `${targetElement.id}:${targetPort}` : `${targetElement.id}`], + ...(edgeOptionsFn(link, computed) ?? computed) + }; + 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 diff --git a/packages/joint-layout-elk/src/import.mts b/packages/joint-layout-elk/src/import.mts index a7252b11d6..37b4136022 100644 --- a/packages/joint-layout-elk/src/import.mts +++ b/packages/joint-layout-elk/src/import.mts @@ -288,8 +288,6 @@ export function importLayout( // A container - ELK computed its size to fit its (recursively laid out) content. element.resize(node.width || 0, node.height || 0); } - - element.unset('new'); // remove the "new" flag, if any, so it doesn't stay styled differently } if ((setPortPositionFn || setPortLabelPositionFn) && node.ports) { diff --git a/packages/joint-layout-elk/test/index.js b/packages/joint-layout-elk/test/index.js index a4315698d0..51ea24fc46 100644 --- a/packages/joint-layout-elk/test/index.js +++ b/packages/joint-layout-elk/test/index.js @@ -56,7 +56,7 @@ QUnit.module('layout()', () => { const { graph } = createGraph(); await joint.layout.ELK.layout(graph, { - layoutOptions: { + elkLayoutOptions: { 'elk.algorithm': 'layered', 'elk.direction': 'RIGHT', 'elk.edgeRouting': 'ORTHOGONAL' From 7e4d09012acc1c2c487280b73bf5598c2173df58 Mon Sep 17 00:00:00 2001 From: Geliogabalus Date: Wed, 16 Sep 2026 14:50:19 +0200 Subject: [PATCH 16/75] wip refactor --- .../src/example.ts | 2 +- .../src/index.ts | 16 +- .../src/shapes.ts | 6 +- .../tsconfig.json | 8 +- .../layout-elk-interactive-ts/src/index.ts | 22 +- .../layout-elk-interactive-ts/tsconfig.json | 8 +- examples/layout-elk-ts/src/index.ts | 8 +- examples/layout-elk-ts/tsconfig.json | 8 +- packages/joint-layout-elk/src/export.mts | 494 +++++++------ packages/joint-layout-elk/src/import.mts | 421 ++++++----- packages/joint-layout-elk/src/index.mts | 2 +- packages/joint-layout-elk/src/layout.mts | 13 +- .../src/types/elkEdgeOptions.mts | 87 +++ .../joint-layout-elk/src/types/elkEnums.mts | 47 ++ .../joint-layout-elk/src/types/elkGraph.mts | 35 + .../src/types/elkLabelOptions.mts | 64 ++ .../elkLayoutOptions.mts} | 654 ++---------------- .../src/types/elkNodeOptions.mts | 324 +++++++++ .../src/types/elkPortOptions.mts | 74 ++ packages/joint-layout-elk/src/types/index.mts | 7 + 20 files changed, 1170 insertions(+), 1130 deletions(-) create mode 100644 packages/joint-layout-elk/src/types/elkEdgeOptions.mts create mode 100644 packages/joint-layout-elk/src/types/elkEnums.mts create mode 100644 packages/joint-layout-elk/src/types/elkGraph.mts create mode 100644 packages/joint-layout-elk/src/types/elkLabelOptions.mts rename packages/joint-layout-elk/src/{elkOptions.mts => types/elkLayoutOptions.mts} (63%) create mode 100644 packages/joint-layout-elk/src/types/elkNodeOptions.mts create mode 100644 packages/joint-layout-elk/src/types/elkPortOptions.mts create mode 100644 packages/joint-layout-elk/src/types/index.mts diff --git a/examples/layout-elk-containers-ports-ts/src/example.ts b/examples/layout-elk-containers-ports-ts/src/example.ts index 3754b9ed4d..b0356c0778 100644 --- a/examples/layout-elk-containers-ports-ts/src/example.ts +++ b/examples/layout-elk-containers-ports-ts/src/example.ts @@ -5,7 +5,7 @@ import { dia } from '@joint/core'; // boundaries. Every plain `example.Service` has exactly one 'in' and one // 'out' port; the four "hub" services (Load Balancer, API Gateway, Auth // Service, Logger) are `example.HubService` instead, with a custom number of -// ports - highlighted, and the only ones that opt into `positionPorts` so +// ports - highlighted, and the only ones that opt into `portsPosition` so // ELK orders their ports to minimize crossings (see `index.ts`). export const graphJSON: dia.Graph.JSON = { cells: [ diff --git a/examples/layout-elk-containers-ports-ts/src/index.ts b/examples/layout-elk-containers-ports-ts/src/index.ts index 17d238e311..9eb3bd7d08 100644 --- a/examples/layout-elk-containers-ports-ts/src/index.ts +++ b/examples/layout-elk-containers-ports-ts/src/index.ts @@ -1,5 +1,5 @@ import { dia, shapes } from '@joint/core'; -import { ElkLayoutOptions, layout } from '@joint/layout-elk'; +import { ElkLayoutOptions, layout, NodeProperties, NodePropertiesCallbackParameters } from '@joint/layout-elk'; import ELK from 'elkjs/lib/elk-api.js'; import { graphJSON } from './example'; import { Container, HubService, InteractionLink, Service } from './shapes'; @@ -97,15 +97,15 @@ const init = () => { elk, // Let ELK reposition (and reorder) every port in the diagram, instead // of keeping them where JointJS's own port groups first placed them. - positionPorts: 'fixed-side', + portsPosition: 'fixed-side', positionPortLabels: true, - nodeOptions: (element, computed) => { + nodeProperties: ({ element, computedProperties }: NodePropertiesCallbackParameters): NodeProperties => { // Reserve extra top padding inside containers, so children don't // overlap the container's title label. - if (element.getEmbeddedCells().length === 0) return undefined; + if (element.getEmbeddedCells().length === 0) return computedProperties; return { - ...computed, - layoutOptions: { ...computed.layoutOptions, 'elk.padding': CONTAINER_PADDING } + ...computedProperties, + layoutOptions: { ...computedProperties.layoutOptions, 'elk.padding': CONTAINER_PADDING } }; }, elkLayoutOptions @@ -155,8 +155,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!) ); }); } diff --git a/examples/layout-elk-containers-ports-ts/src/shapes.ts b/examples/layout-elk-containers-ports-ts/src/shapes.ts index c21b95488c..600293dafe 100644 --- a/examples/layout-elk-containers-ports-ts/src/shapes.ts +++ b/examples/layout-elk-containers-ports-ts/src/shapes.ts @@ -19,7 +19,11 @@ const PORT_ATTRS = { const PORT_LABEL = { position: { name: 'outside' - } + }, + // Generous enough for every port label text in this example ('in', 'in1', 'in2', + // 'out', 'out1', 'out2') - `@joint/layout-elk` reads this size directly (via + // `positionPortLabels`) rather than measuring the rendered text itself. + size: { width: 34, height: 18 } }; // Square ports (rather than `PORT_ATTRS`' circles) set `HubService` apart as diff --git a/examples/layout-elk-containers-ports-ts/tsconfig.json b/examples/layout-elk-containers-ports-ts/tsconfig.json index 7ecb0809ec..04a61c7d89 100644 --- a/examples/layout-elk-containers-ports-ts/tsconfig.json +++ b/examples/layout-elk-containers-ports-ts/tsconfig.json @@ -1,11 +1,17 @@ { "compilerOptions": { "module": "ES6", - "moduleResolution": "node", + "moduleResolution": "bundler", "target": "es6", + "lib": [ + "es2022", + "dom" + ], "noImplicitAny": false, "sourceMap": false, + "rootDir": "./src", "outDir": "./build", + "noUncheckedSideEffectImports": false, "resolveJsonModule": true, "esModuleInterop": true } diff --git a/examples/layout-elk-interactive-ts/src/index.ts b/examples/layout-elk-interactive-ts/src/index.ts index 71af90aa11..771ddcb267 100644 --- a/examples/layout-elk-interactive-ts/src/index.ts +++ b/examples/layout-elk-interactive-ts/src/index.ts @@ -1,5 +1,5 @@ import { dia, shapes, g } from '@joint/core'; -import { ElkLayoutOptions, layout, NodeLayoutProperties } from '@joint/layout-elk'; +import { ElkLayoutOptions, layout, NodeProperties, NodePropertiesCallbackParameters } from '@joint/layout-elk'; import './styles.scss'; const ELK_DIRECTION = 'RIGHT'; @@ -26,11 +26,11 @@ let zoomLevel = 1; * layering/placement is free to place it based on topology, instead of anchoring it near the * (0, 0) `element.position()` defaults to. */ -function nodeOptions(element: dia.Element, computed: NodeLayoutProperties): NodeLayoutProperties | undefined { - if (!element.get('new')) return undefined; +function nodeProperties({ element, computedProperties }: NodePropertiesCallbackParameters): NodeProperties { + if (!element.get('new')) return computedProperties; // Actually omit `x`/`y` (not just set them to `undefined`) - elkjs chokes on a // present-but-`undefined` coordinate instead of treating it as absent. - const { x: _x, y: _y, width, height, layoutOptions: computedLayoutOptions } = computed; + const { width, height, layoutOptions: computedLayoutOptions } = computedProperties; const { 'elk.position': _elkPosition, ...layoutOptions } = computedLayoutOptions ?? {}; return { width, height, layoutOptions }; } @@ -80,7 +80,7 @@ const init = () => { // The very first layout always computes the whole graph from scratch - there is // nothing to be "interactive" about yet, since no element has a position at all. - layout(graph, { elkLayoutOptions, nodeOptions }).then(() => { + layout(graph, { elkLayoutOptions, nodeProperties }).then(() => { paper.unfreeze(); zoom(paper, zoomLevel); }).catch((error) => { @@ -101,7 +101,7 @@ const init = () => { const elements = graph.getElements(); const parent = elements[g.random(0, elements.length - 1)]; const element = createElement(`n${nextId++}`); - // Marks it for `nodeOptions` (above) to strip position hints from, so ELK is free + // Marks it for `nodeProperties` (above) to strip position hints from, so ELK is free // to place it based on topology instead of anchoring it near (0, 0). element.set('new', true); @@ -113,7 +113,11 @@ const init = () => { // itself. Uncheck "Interactive layout" to see the whole graph get reshuffled by // a from-scratch layout instead. Either way, re-fit the viewport afterwards so // the (possibly larger) diagram stays fully visible. - layout(graph, { elkLayoutOptions, interactive: interactiveToggle.checked, nodeOptions }).then(() => { + layout(graph, { + elkLayoutOptions, + interactive: interactiveToggle.checked, + nodeProperties + }).then(() => { // Layout succeeded - `element` now has a real position, so it's no longer "new". element.unset('new'); paper.unfreeze(); @@ -162,8 +166,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!) ); }); } diff --git a/examples/layout-elk-interactive-ts/tsconfig.json b/examples/layout-elk-interactive-ts/tsconfig.json index 7ecb0809ec..8c23544e9e 100644 --- a/examples/layout-elk-interactive-ts/tsconfig.json +++ b/examples/layout-elk-interactive-ts/tsconfig.json @@ -1,10 +1,16 @@ { "compilerOptions": { "module": "ES6", - "moduleResolution": "node", + "moduleResolution": "bundler", "target": "es6", + "lib": [ + "es2022", + "dom" + ], "noImplicitAny": false, "sourceMap": false, + "rootDir": "./src", + "noUncheckedSideEffectImports": false, "outDir": "./build", "resolveJsonModule": true, "esModuleInterop": true diff --git a/examples/layout-elk-ts/src/index.ts b/examples/layout-elk-ts/src/index.ts index 4ae09f05a9..5a359aeb54 100644 --- a/examples/layout-elk-ts/src/index.ts +++ b/examples/layout-elk-ts/src/index.ts @@ -46,7 +46,7 @@ const init = () => { layout(graph, { elk, - layoutOptions: { + elkLayoutOptions: { /** * Overall direction of the layout. * 'UP' | 'DOWN' | 'LEFT' | 'RIGHT' @@ -143,8 +143,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!) ); }); } @@ -218,7 +218,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}`; diff --git a/examples/layout-elk-ts/tsconfig.json b/examples/layout-elk-ts/tsconfig.json index 7ecb0809ec..04a61c7d89 100644 --- a/examples/layout-elk-ts/tsconfig.json +++ b/examples/layout-elk-ts/tsconfig.json @@ -1,11 +1,17 @@ { "compilerOptions": { "module": "ES6", - "moduleResolution": "node", + "moduleResolution": "bundler", "target": "es6", + "lib": [ + "es2022", + "dom" + ], "noImplicitAny": false, "sourceMap": false, + "rootDir": "./src", "outDir": "./build", + "noUncheckedSideEffectImports": false, "resolveJsonModule": true, "esModuleInterop": true } diff --git a/packages/joint-layout-elk/src/export.mts b/packages/joint-layout-elk/src/export.mts index 61535817f8..b12a7c8320 100644 --- a/packages/joint-layout-elk/src/export.mts +++ b/packages/joint-layout-elk/src/export.mts @@ -1,5 +1,5 @@ import type { dia } from '@joint/core'; -import { getPortPositionsMode, type PortPositionsMode, type PortPositionsOptions } from './import.mjs'; +import type { PortsPositionMode } from './import.mjs'; import type { ElkNode, @@ -10,53 +10,56 @@ import type { NodeElkLayoutOptions, PortElkLayoutOptions, LabelElkLayoutOptions -} from './elkOptions.mjs'; - -export const DEFAULT_LABEL_SIZE: dia.Size = { - width: 50, - height: 20 -}; +} from './types/index.mjs'; // ELK ignores labels with no text. const ELK_LABEL_TEXT = '-'; -// Used to estimate a port label's size from its text (see `getPortLabelSize`) when -// no explicit size is given - a rough, DOM-free approximation, not a real measurement. -const DEFAULT_FONT_SIZE = 16; -const AVERAGE_CHAR_WIDTH_RATIO = 0.6; -const LINE_HEIGHT_RATIO = 1.2; const ELK_INLINE_LABEL_OPTIONS: LabelElkLayoutOptions = { 'edgeLabels.inline': 'true' }; -// Maps a `PortPositionsMode` onto the corresponding `elk.portConstraints` value - -// see `PortPositionsMode` for what each mode means. -const ELK_PORT_CONSTRAINTS_BY_MODE: Record = { + +// Maps a `PortsPositionMode` onto the corresponding `elk.portConstraints` value - +// see `PortsPositionMode` for what each mode means. +const ELK_PORT_CONSTRAINTS_BY_MODE: Record = { 'fixed': { 'elk.portConstraints': 'FIXED_POS' }, 'fixed-side': { 'elk.portConstraints': 'FIXED_SIDE' }, 'free': { 'elk.portConstraints': 'FREE' }, }; -type GetSizeCallback = (element: dia.Element) => dia.Size; -type GetPortLabelSizeCallback = (port: dia.Element.Port, element: dia.Element) => dia.Size; - /** * The ELK node properties `nodeOptions` can inspect and adjust - everything about a node * this package itself computes, except `id` (structural) and `ports`/`children` (built * separately, from the element's JointJS ports/embeds). */ -export type NodeLayoutProperties = Pick; +export type NodeProperties = Omit; /** * The ELK port properties `portOptions` can inspect and adjust - everything about a port * this package itself computes, except `id` (structural). */ -export type PortLayoutProperties = Pick; +export type PortProperties = Omit; /** * The ELK edge properties `edgeOptions` can inspect and adjust - everything about an edge * this package itself computes, except `id`/`sources`/`targets` (structural). */ -export type EdgeLayoutProperties = Pick; +export type EdgeProperties = Omit; + +export type NodePropertiesCallback = (params: NodePropertiesCallbackParameters) => NodeProperties; +export type NodePropertiesCallbackParameters = { + element: dia.Element; + computedProperties: NodeProperties; +} + +export type PortPropertiesCallback = (params: PortPropertiesCallbackParameters) => PortProperties; +export type PortPropertiesCallbackParameters = { + port: dia.Element.Port; + element: dia.Element; + computedProperties: PortProperties; +}; -type NodeOptionsCallback = (element: dia.Element, computed: NodeLayoutProperties) => NodeLayoutProperties | undefined; -type PortOptionsCallback = (port: dia.Element.Port, element: dia.Element, computed: PortLayoutProperties) => PortLayoutProperties | undefined; -type EdgeOptionsCallback = (link: dia.Link, computed: EdgeLayoutProperties) => EdgeLayoutProperties | undefined; +export type EdgePropertiesCallback = (params: EdgePropertiesCallbackParameters) => EdgeProperties; +export type EdgePropertiesCallbackParameters = { + link: dia.Link; + computedProperties: EdgeProperties; +}; export interface ElkGraphPort { element: dia.Element; @@ -71,36 +74,9 @@ export interface ElkGraphData { } export interface ExportGraphOptions { - /** - * Specify custom logic to determine the element's size used during layout - * instead of the default `element.size()`. Not called for elements that - * have embedded elements - their size is computed by ELK to fit their content. - */ - getSize?: GetSizeCallback; - /** - * Specify custom logic to determine a port's label size, used when `positionPortLabels` - * is enabled, instead of the default - the port's own `label.size`, falling back to its - * group's `label.size`, falling back to an estimate from the label's text and font size - * (`attrs.text.text`/`attrs.text.fontSize`, the latter defaulting to 16 if not set), and - * finally to `DEFAULT_LABEL_SIZE` if there is no text either. - */ - getPortLabelSize?: GetPortLabelSizeCallback; - /** - * Per-element ELK layout options, merged into the generated ELK node. - * @example - * nodeOptions: (element) => ({ 'partitioning.partition': element.get('layer') }) - */ - nodeOptions?: NodeOptionsCallback; - /** - * Per-port ELK layout options, merged into the generated ELK port. - * @example - * portOptions: (port) => ({ 'port.side': port.group === 'in' ? 'WEST' : 'EAST' }) - */ - portOptions?: PortOptionsCallback; - /** - * Per-link ELK layout options, merged into the generated ELK edge. - */ - edgeOptions?: EdgeOptionsCallback; + nodeProperties?: NodePropertiesCallback; + portProperties?: PortPropertiesCallback; + edgeProperties?: EdgePropertiesCallback; /** * Whether to account for link labels during layout and position them * along the routed link afterwards. @@ -110,11 +86,11 @@ export interface ExportGraphOptions { /** * How freely ELK may reposition (and reorder) ports along their element, * instead of keeping them at the position JointJS itself already computed - * for them - see `PortPositionsMode`. Any new positions are written back - * onto the graph - see the `positionPorts` option in `ImportLayoutOptions`. + * for them - see `PortsPositionMode`. Any new positions are written back + * onto the graph - see the `portsPosition` option in `ImportLayoutOptions`. * @defaultValue 'fixed' */ - positionPorts?: PortPositionsMode | PortPositionsOptions; + portsPosition?: PortsPositionMode; /** * Whether to let ELK reposition port labels along their port, instead of keeping * them at the position JointJS itself already computed for them. The new @@ -125,59 +101,32 @@ export interface ExportGraphOptions { positionPortLabels?: boolean; } -const getSize: GetSizeCallback = (element) => { - return element.size(); +export const DEFAULT_LABEL_SIZE: dia.Size = { + width: 50, + height: 20 }; -/** - * A rough, DOM-free approximation of a text's rendered size - not a real measurement - * (that would need a live SVG document, see `util.breakText` in `@joint/core`), just - * enough to give ELK a sane amount of space to reserve for a port label. - */ -function estimateTextSize(text: string, fontSize: number): dia.Size { - return { - width: Math.ceil(text.length * fontSize * AVERAGE_CHAR_WIDTH_RATIO), - height: Math.ceil(fontSize * LINE_HEIGHT_RATIO) - }; -} - -const getPortLabelSize: GetPortLabelSizeCallback = (port, element) => { - // `label.size` isn't part of the officially typed `dia.Element.Port`/`PortGroup.label` - // shape, but JointJS reads it off both at render time if present - a port's own size - // takes precedence over its group's, same as JointJS resolves every other port/group - // property (`attrs`, `markup`, ...). - const portLabelSize = (port.label as { size?: dia.Size } | undefined)?.size; - if (portLabelSize) { - return portLabelSize; - } - const groupDef = port.group && element.prop(`ports/groups/${port.group}`); - if (groupDef?.label?.size) { - return groupDef.label.size; - } +let exportGraphOptions: ExportGraphOptions; - // No explicit size anywhere - estimate one from the label's actual text (again, - // the port's own `attrs` take precedence over its group's) instead of resorting - // straight away to `DEFAULT_LABEL_SIZE`. - const text = port.attrs?.text?.text ?? groupDef?.attrs?.text?.text; - if (text) { - const fontSize = parseFloat(port.attrs?.text?.fontSize ?? groupDef?.attrs?.text?.fontSize); - return estimateTextSize(text, isNaN(fontSize) ? DEFAULT_FONT_SIZE : fontSize); - } +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; - return DEFAULT_LABEL_SIZE; -}; - -const nodeOptions: NodeOptionsCallback = (_element, _computed) => { - return undefined; -}; - -const portOptions: PortOptionsCallback = (_port, _element, _computed) => { - return undefined; -}; +/** + * (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): void { + exportGraphOptions = options; -const edgeOptions: EdgeOptionsCallback = (_link, _computed) => { - return undefined; -}; + elementsById = new Map(); + linksById = new Map(); + portsById = new Map(); + edgeContainersById = new Map(); +} /** * Builds the ELK ports for an element's JointJS ports, starting out at the @@ -186,13 +135,7 @@ const edgeOptions: EdgeOptionsCallback = (_link, _computed) => { * that position as final, is controlled by the node's own `elk.portConstraints` * (see `ELK_PORT_CONSTRAINTS_BY_MODE` in `buildElkNode`). */ -function buildPorts( - element: dia.Element, - portOptionsFn: PortOptionsCallback, - getPortLabelSizeFn: GetPortLabelSizeCallback, - portsById: Map, - positionPortLabels: boolean -): ElkPort[] | undefined { +function buildPorts(element: dia.Element): ElkPort[] | undefined { if (!element.hasPorts()) return undefined; return element.getPorts().map((port): ElkPort => { @@ -200,31 +143,18 @@ function buildPorts( const elkPortId = `${element.id}:${portId}`; portsById.set(elkPortId, { element, portId }); - const groupDef = port.group && element.prop(`ports/groups/${port.group}`); const layoutOptions: PortElkLayoutOptions = {}; - // A port's side is always determined by its group - JointJS has no way for an - // individual port to sit on a different side than the rest of its group - so a - // grouped port takes its group's `position`; an ungrouped one falls back to - // JointJS's own default side ('left'), the same side it actually renders on. - const positionName = (port.group) ? groupDef?.position : 'left'; - // ELK's `port.side` is a string, not a number, so we have to map JointJS's - // named port positions to the corresponding string values. - const side = (positionName === 'left') ? 'WEST' - : (positionName === 'right') ? 'EAST' - : (positionName === 'top') ? 'NORTH' - : (positionName === 'bottom') ? 'SOUTH' - : undefined; - if (side) { - layoutOptions['port.side'] = side; - } - - // A port's own `label` (if it has one) always takes precedence over its group's - - // same as JointJS itself resolves it (see `getPortLabelSizeFn`) - so either one is - // enough to warrant reserving/positioning a label for this port. + // `port` (from `element.getPorts()`) already carries the fully resolved label - + // a port's own `label` (if it has one) merged over its group's, same as JointJS + // itself resolves it. `element.portProp`/`getPort`, by contrast, only ever see the + // port's own raw, unmerged JSON, so they can't be used here. let labels: ElkLabel[] | undefined; - if (positionPortLabels && (port.label || groupDef?.label)) { - const { width: labelWidth, height: labelHeight } = getPortLabelSizeFn(port, element); + if (exportGraphOptions.positionPortLabels) { + // `label` isn't part of the officially typed `dia.Element.Port` shape, but + // JointJS reads it off (both the port's own and, merged in, its group's) at + // render time if present. + const { width: labelWidth, height: labelHeight } = (port.label as { size?: dia.Size } | undefined)?.size ?? DEFAULT_LABEL_SIZE; labels = [{ // Some text is required, otherwise ELK ignores the label. text: ELK_LABEL_TEXT, @@ -237,11 +167,178 @@ function buildPorts( const { x, y, width, height } = element.getPortRelativeRect(portId); layoutOptions['port.borderOffset'] = (-width / 2).toString(); - const computed: PortLayoutProperties = { x, y, width, height, layoutOptions, labels }; - return { id: elkPortId, ...(portOptionsFn(port, element, computed) ?? computed) }; + let portProperties: PortProperties = { + x, + y, + width, + height, + labels, + ...layoutOptions + }; + if (exportGraphOptions.portProperties) { + portProperties = exportGraphOptions.portProperties({ + port, + element, + computedProperties: portProperties + }); + } + + return { + id: elkPortId, + ...portProperties + }; }); } +/** + * ELK positions a node's children (and routes a node's own edges) relative to that + * node's own origin (see `toAbsolute` in `importLayout`) - `containerX`/`containerY` + * convert an element's own graph-absolute `position()` into that frame, so that the + * `x`/`y` handed to `nodeOptions` is always a usable hint of where the element + * currently is. + */ +function buildElkNode(element: dia.Element, containerPosition: dia.Point = { x: 0, y: 0 }): ElkNode { + const id = `${element.id}`; + elementsById.set(id, element); + + const ports = buildPorts(element); + const { + x: absoluteX, + y: absoluteY + } = element.position(); + const x = absoluteX - containerPosition.x; + const y = absoluteY - containerPosition.y; + + + const layoutOptions: NodeElkLayoutOptions = (ports) ? { + ...ELK_PORT_CONSTRAINTS_BY_MODE[exportGraphOptions.portsPosition ?? 'fixed'], + 'portLabels.placement': 'OUTSIDE' + } : {}; + // A hint of the element's current position - read directly (as the plain `x`/`y` + // below) by ELK's `interactive` strategies (see `layout.mts`), and via this distinct + // option by `elk.layered.crossingMinimization.semiInteractive`. A brand new element + // has no meaningful position yet - strip this (and `x`/`y`) via `nodeOptions` (e.g. + // based on your own "is this new" convention) to let ELK place it freely instead of + // anchoring it here. + layoutOptions['elk.position'] = `(${x},${y})`; + + const embeds = element.getEmbeddedCells() + .filter((cell): cell is dia.Element => cell.isElement()); + + + let children: ElkNode[] | undefined; + let edges: ElkExtendedEdge[] | undefined; + // A container's real size is computed by ELK to fit its (recursively laid out) + // content - `0` is only a placeholder starting point here (elkjs errors out on a + // hierarchical node with no numeric width/height at all), not the final size + // `nodeProperties` sees. + let width = 0; + let height = 0; + if (embeds.length > 0) { + children = embeds.map((embed) => buildElkNode(embed, { x, y })); + // 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 { + ({ width, height } = element.size()); + } + + let nodeProperties: NodeProperties = { x, y, width, height, layoutOptions }; + if (exportGraphOptions.nodeProperties) { + nodeProperties = exportGraphOptions.nodeProperties({ + element, + computedProperties: nodeProperties + }); + } + const node: ElkNode = { + id, + children, + ports, + edges, + ...nodeProperties + }; + return node; +} + +// The lowest common ancestor of an element and itself/an ancestor is the element's parent chain - +// this returns that chain, ordered from the outermost ancestor to the immediate parent. +function getAncestorPath(element: dia.Element): string[] { + return element.getAncestors().reverse().map((cell) => `${cell.id}`); +} + +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; +} + +function buildEdge(link: dia.Link): void { + const sourceElement = link.getSourceElement(); + const targetElement = link.getTargetElement(); + // Links not connected to two elements (e.g. connected to a point or + // to another link) are not part of the layout. + if (!sourceElement || !targetElement) return; + if (!elementsById.has(`${sourceElement.id}`) || !elementsById.has(`${targetElement.id}`)) return; + + const id = `${link.id}`; + linksById.set(id, link); + + const sourcePort = link.source().port; + const targetPort = link.target().port; + + let labels: ElkLabel[] | undefined; + if (exportGraphOptions.edgeLabels) { + const linkLabels = link.labels(); + if (linkLabels.length > 0) { + labels = linkLabels.map((label): ElkLabel => { + const { width, height } = label.size || DEFAULT_LABEL_SIZE; + return { + // Some text is required, otherwise ELK ignores the label. + text: ELK_LABEL_TEXT, + width, + height, + // Place the label directly on the edge (and allocate space for it). + layoutOptions: ELK_INLINE_LABEL_OPTIONS + }; + }); + } + } + + const sources = (sourcePort) ? [`${sourceElement.id}:${sourcePort}`] : [`${sourceElement.id}`]; + + const targets = (targetPort) ? [`${targetElement.id}:${targetPort}`] : [`${targetElement.id}`]; + + let edgeProperties: EdgeProperties = { + layoutOptions: {}, + labels + }; + if (exportGraphOptions.edgeProperties) { + edgeProperties = exportGraphOptions.edgeProperties({ + link, + computedProperties: edgeProperties + }); + } + + const edge: ElkExtendedEdge = { + id, + sources, + targets, + ...edgeProperties + }; + + 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 is a container + // that has already been registered in `edgeContainersById` by the time links are processed. + (edges as ElkExtendedEdge[]).push(edge); +} + /** * Converts a JointJS graph (elements, their embedded elements, ports and the * links between them) to an ELK graph structure. @@ -252,64 +349,7 @@ export function exportGraph( elkLayoutOptions: ElkLayoutOptions ): ElkGraphData { - const getSizeFn = options.getSize ?? getSize; - const getPortLabelSizeFn = options.getPortLabelSize ?? getPortLabelSize; - const nodeOptionsFn = options.nodeOptions ?? nodeOptions; - const portOptionsFn = options.portOptions ?? portOptions; - const edgeOptionsFn = options.edgeOptions ?? edgeOptions; - const portConstraintsOptions = ELK_PORT_CONSTRAINTS_BY_MODE[getPortPositionsMode(options.positionPorts)]; - - const elementsById = new Map(); - const linksById = new Map(); - const portsById = new 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. - const edgeContainersById = new Map(); - - // ELK positions a node's children (and routes a node's own edges) relative to that - // node's own origin (see `toAbsolute` in `importLayout`) - `containerX`/`containerY` - // convert an element's own graph-absolute `position()` into that frame, so that the - // `x`/`y` handed to `nodeOptions` is always a usable hint of where the element - // currently is. - function buildElkNode(element: dia.Element, containerX = 0, containerY = 0): ElkNode { - const id = `${element.id}`; - elementsById.set(id, element); - - const ports = buildPorts(element, portOptionsFn, getPortLabelSizeFn, portsById, !!options.positionPortLabels); - const { x: absoluteX, y: absoluteY } = element.position(); - const x = absoluteX - containerX; - const y = absoluteY - containerY; - - const layoutOptions: NodeElkLayoutOptions = (ports) ? { - ...portConstraintsOptions, - 'portLabels.placement': 'OUTSIDE' - } : {}; - // A hint of the element's current position - read directly (as the plain `x`/`y` - // below) by ELK's `interactive` strategies (see `layout.mts`), and via this distinct - // option by `elk.layered.crossingMinimization.semiInteractive`. A brand new element - // has no meaningful position yet - strip this (and `x`/`y`) via `nodeOptions` (e.g. - // based on your own "is this new" convention) to let ELK place it freely instead of - // anchoring it here. - layoutOptions['elk.position'] = `(${x},${y})`; - - const embeds = element.getEmbeddedCells() - .filter((cell): cell is dia.Element => cell.isElement()); - - if (embeds.length > 0) { - // A container - its size is computed by ELK to fit its (recursively laid out) - // content, so `width`/`height` are left for `nodeOptions` to see as `undefined` - // rather than computed here. - const children = embeds.map((embed) => buildElkNode(embed, absoluteX, absoluteY)); - const computed: NodeLayoutProperties = { x, y, layoutOptions }; - const node: ElkNode = { id, children, ports, ...(nodeOptionsFn(element, computed) ?? computed) }; - edgeContainersById.set(id, node.edges = []); - return node; - } - - const { width, height } = getSizeFn(element); - const computed: NodeLayoutProperties = { x, y, width, height, layoutOptions }; - return { id, ports, ...(nodeOptionsFn(element, computed) ?? computed) }; - } + init(options); const children: ElkNode[] = graph.getElements() .filter((element) => !element.parent()) @@ -321,71 +361,11 @@ export function exportGraph( 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[]); - // The lowest common ancestor of an element and itself/an ancestor is the element's parent chain - - // this returns that chain, ordered from the outermost ancestor to the immediate parent. - function getAncestorPath(element: dia.Element): string[] { - return element.getAncestors().reverse().map((cell) => `${cell.id}`); - } - - 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; - } - - graph.getLinks().forEach((link) => { - const sourceElement = link.getSourceElement(); - const targetElement = link.getTargetElement(); - // Links not connected to two elements (e.g. connected to a point or - // to another link) are not part of the layout. - if (!sourceElement || !targetElement) return; - if (!elementsById.has(`${sourceElement.id}`) || !elementsById.has(`${targetElement.id}`)) return; - - const id = `${link.id}`; - linksById.set(id, link); - - const sourcePort = link.source().port; - const targetPort = link.target().port; - - let labels: ElkLabel[] | undefined; - if (options.edgeLabels) { - const linkLabels = link.labels(); - if (linkLabels.length > 0) { - labels = linkLabels.map((label): ElkLabel => { - const { width, height } = label.size || DEFAULT_LABEL_SIZE; - return { - // Some text is required, otherwise ELK ignores the label. - text: ELK_LABEL_TEXT, - width, - height, - // Place the label directly on the edge (and allocate space for it). - layoutOptions: ELK_INLINE_LABEL_OPTIONS - }; - }); - } - } - - const computed: EdgeLayoutProperties = { layoutOptions: undefined, labels }; - const edge: ElkExtendedEdge = { - id, - sources: [(sourcePort) ? `${sourceElement.id}:${sourcePort}` : `${sourceElement.id}`], - targets: [(targetPort) ? `${targetElement.id}:${targetPort}` : `${targetElement.id}`], - ...(edgeOptionsFn(link, computed) ?? computed) - }; - - 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 is a container - // that has already been registered in `edgeContainersById` by the time links are processed. - (edges as ElkExtendedEdge[]).push(edge); - }); + graph.getLinks().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 index 37b4136022..ac85579f59 100644 --- a/packages/joint-layout-elk/src/import.mts +++ b/packages/joint-layout-elk/src/import.mts @@ -1,26 +1,49 @@ import { type dia, g } from '@joint/core'; import type { ElkPoint } from 'elkjs'; -import type { ElkNode, ElkExtendedEdge } from './elkOptions.mjs'; +import type { ElkNode, ElkExtendedEdge } from './types/index.mjs'; import type { ElkGraphPort } from './export.mjs'; -type SetPositionCallback = (element: dia.Element, position: dia.Point) => void; -type SetVerticesCallback = (link: dia.Link, vertices: dia.Point[]) => void; -type SetAnchorCallback = (link: dia.Link, element: dia.Element, point: dia.Point, endType: 'source' | 'target') => void; -type SetLabelsCallback = (link: dia.Link, labelBBox: dia.BBox, points: dia.Point[], labelIndex: number) => void; -type SetPortPositionCallback = (element: dia.Element, portId: string, position: dia.Point) => void; -type SetPortLabelPositionCallback = (element: dia.Element, portId: string, position: dia.Point) => void; +export type SetElementAttributesCallback = (params: SetElementAttributesCallbackParameters) => void; +export type SetElementAttributesCallbackParameters = { + 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; + }; +}; -export interface EdgeLabelsOptions { - /** - * Sets a link label's position, based on the ELK edge label. - * Only takes effect when `edgeLabels` is enabled. - * @example - * setLabels: (link, labelBBox, points, labelIndex) => link.label(labelIndex, { - * position: { distance: 0, offset: 0 } - * }); - */ - setLabels?: SetLabelsCallback; -} +export type SetPortAttributesCallback = (params: SetPortAttributesCallbackParameters) => void; +export type SetPortAttributesCallbackParameters = { + element: dia.Element; + portId: string; + attributes: { + position: dia.Point; + // Present only when `positionPortLabels` is enabled and the port has a label. + labelPosition?: dia.Point; + }; +}; + +export type SetLinkAttributesCallback = (params: SetLinkAttributesCallbackParameters) => void; +export type SetLinkAttributesCallbackParameters = { + link: dia.Link; + attributes: { + vertices: dia.Point[]; + // Present only for an end not already connected to a port - a port-connected + // end already has the anchor JointJS itself computed for that port (the same + // position ELK was told to route to), so it does not need overriding. + sourceAnchor?: dia.Point; + targetAnchor?: dia.Point; + // Present only when `edgeLabels` is enabled and the link has labels. + labels?: SetLinkAttributesLabelParameters[]; + }; +}; +export type SetLinkAttributesLabelParameters = { + index: number; + bbox: dia.BBox; + points: dia.Point[]; +}; /** * Controls how freely ELK may reposition a port along its element - maps directly @@ -33,92 +56,28 @@ export interface EdgeLabelsOptions { * - `'free'` - ELK may reposition the port anywhere around its element, including * onto a different side than its group's (`FREE`). */ -export type PortPositionsMode = 'fixed' | 'fixed-side' | 'free'; - -export interface PortPositionsOptions { - /** - * How freely ELK may reposition the port. - * @defaultValue 'fixed' - */ - mode?: PortPositionsMode; - /** - * Sets a port's position, based on the ELK port's layout result. - * Only takes effect when `mode` is not `'fixed'`. - * @example - * setPortPosition: (element, portId, position) => element.portProp(portId, ['position', 'args'], position) - */ - setPortPosition?: SetPortPositionCallback; -} - -/** - * Resolves the effective `PortPositionsMode` for a `positionPorts` option - a plain - * mode string, an options object with an optional `mode` (defaulting to `'fixed'`), - * or `undefined` (also `'fixed'`). - */ -export function getPortPositionsMode(positionPorts: PortPositionsMode | PortPositionsOptions | undefined): PortPositionsMode { - if (typeof positionPorts === 'string') return positionPorts; - return positionPorts?.mode ?? 'fixed'; -} - -export interface PortLabelPositionsOptions { - /** - * Sets a port label's position, based on the ELK port label's layout result. - * Only takes effect when `positionPortLabels` is enabled. - * @example - * setPortLabelPosition: (element, portId, position) => element.portProp(portId, ['label', 'position', 'args'], position) - */ - setPortLabelPosition?: SetPortLabelPositionCallback; -} +export type PortsPositionMode = 'fixed' | 'fixed-side' | 'free'; export interface ImportLayoutOptions { - /** - * Specify a function to use when setting a new position to an element after layout - * instead of the default `element.position(x, y)`. - * @example - * setPosition: (el, pos) => el.position(pos.x, pos.y) - */ - setPosition?: SetPositionCallback; - /** - * Specify a function to use when setting new vertices to a link after layout - * instead of the default `link.vertices(vertices)`. - * @example - * setVertices: (link, vertices) => link.vertices(vertices) - */ - setVertices?: SetVerticesCallback; - /** - * Specify a function to use when setting a link's anchor at either source or target, based on the start/end - * point of the ELK edge section instead of the default `topLeft` anchor. Not called for a link end that is - * connected to a port - ports keep the anchor JointJS already gives them. - * @example - * setAnchor: (link, element, point, endType) => { - * const delta = element.getRelativePointFromAbsolute(point); - * link.prop(`${endType}/anchor`, { - * name: 'topLeft', - * args: { - * dx: delta.x, - * dy: delta.y, - * useModelGeometry: true - * } - * }); - * } - */ - setAnchor?: SetAnchorCallback; + setElementAttributes?: SetElementAttributesCallback; + setLinkAttributes?: SetLinkAttributesCallback; + setPortAttributes?: SetPortAttributesCallback; /** * Whether to account for link labels during layout and position them * along the routed link afterwards. * @defaultValue true */ - edgeLabels?: boolean | EdgeLabelsOptions; + edgeLabels?: boolean; /** * How freely ELK may reposition (and reorder) ports along their element, * instead of keeping them at the position JointJS itself already computed - * for them - see `PortPositionsMode`. When set to anything other than + * for them - see `PortsPositionMode`. When set to anything other than * `'fixed'`, every port's owning group is switched to an `'absolute'` * position (preserving its `attrs`/`markup`/`label`) so the position ELK * computed for it can be applied. * @defaultValue 'fixed' */ - positionPorts?: PortPositionsMode | PortPositionsOptions; + portsPosition?: PortsPositionMode; /** * Whether to let ELK reposition port labels along their port, instead of keeping * them at the position JointJS itself already computed for them (via the port @@ -127,18 +86,10 @@ export interface ImportLayoutOptions { * computed for it can be applied. * @defaultValue false */ - positionPortLabels?: boolean | PortLabelPositionsOptions; + positionPortLabels?: boolean; } -const defaultSetPosition = (element: dia.Element, position: dia.Point) => { - element.position(position.x, position.y); -}; - -const defaultSetVertices = (link: dia.Link, vertices: dia.Point[]) => { - link.vertices(vertices); -}; - -const defaultSetAnchor = (link: dia.Link, element: dia.Element, point: dia.Point, endType: 'source' | 'target') => { +function setLinkAnchor(link: dia.Link, element: dia.Element, point: dia.Point, endType: 'source' | 'target'): void { const delta = element.getRelativePointFromAbsolute(point); link.prop(`${endType}/anchor`, { name: 'topLeft', @@ -148,9 +99,16 @@ const defaultSetAnchor = (link: dia.Link, element: dia.Element, point: dia.Point useModelGeometry: true } }); +} + +const defaultSetElementAttributes: SetElementAttributesCallback = ({ element, attributes }) => { + element.position(attributes.position.x, attributes.position.y); + if (attributes.size) { + element.resize(attributes.size.width, attributes.size.height); + } }; -const defaultSetPortPosition = (element: dia.Element, portId: string, position: dia.Point) => { +const defaultSetPortAttributes: SetPortAttributesCallback = ({ element, portId, attributes }) => { const { group } = element.getPort(portId); if (group !== undefined) { // Every port ends up with a computed position (all of an element's ports are @@ -158,166 +116,175 @@ const defaultSetPortPosition = (element: dia.Element, portId: string, position: // only replaces the group's `position`, leaving its `attrs`/`markup`/`label` intact. element.prop(['ports', 'groups', group, 'position'], { name: 'absolute' }); } - element.portProp(portId, ['position', 'args'], position); -}; - -const defaultSetPortLabelPosition = (element: dia.Element, portId: string, position: dia.Point) => { - const { group } = element.getPort(portId); - if (group !== undefined) { - // Every positioned port label ends up with a computed position (only ports whose - // group defines a `label` are exported, but all of those are), so switching the - // whole group's label to `'manual'` is safe here - it only replaces the group - // label's `position`, leaving its `attrs`/`markup` intact. - element.prop(['ports', 'groups', group, 'label', 'position'], { name: 'manual' }); + element.portProp(portId, ['position', 'args'], attributes.position); + + if (attributes.labelPosition) { + if (group !== undefined) { + // Every positioned port label ends up with a computed position (only ports whose + // group defines a `label` are exported, but all of those are), so switching the + // whole group's label to `'manual'` is safe here - it only replaces the group + // label's `position`, leaving its `attrs`/`markup` intact. + element.prop(['ports', 'groups', group, 'label', 'position'], { name: 'manual' }); + } + element.portProp(portId, ['label', 'position', 'args'], attributes.labelPosition); } - element.portProp(portId, ['label', 'position', 'args'], position); }; -const defaultSetLabels = (link: dia.Link, labelBBox: dia.BBox, points: dia.Point[], labelIndex: number) => { +const defaultSetLinkAttributes: SetLinkAttributesCallback = ({ link, attributes }) => { + link.vertices(attributes.vertices); + + if (attributes.sourceAnchor) { + setLinkAnchor(link, link.getSourceElement() as dia.Element, attributes.sourceAnchor, 'source'); + } + if (attributes.targetAnchor) { + setLinkAnchor(link, link.getTargetElement() as dia.Element, attributes.targetAnchor, 'target'); + } - const polyline = new g.Polyline(points); + attributes.labels?.forEach(({ index, bbox, points }) => { + const polyline = new g.Polyline(points); - const { x, y, width, height } = labelBBox; - const center = new g.Point(x + width / 2, y + height / 2); + const { x, y, width, height } = bbox; + const center = new g.Point(x + width / 2, y + height / 2); - const distance = polyline.closestPointLength(center); - // Get the tangent at the closest point to calculate the offset - const tangent = polyline.tangentAtLength(distance); + const distance = polyline.closestPointLength(center); + // Get the tangent at the closest point to calculate the offset + const tangent = polyline.tangentAtLength(distance); - link.label(labelIndex, { - position: { - distance, - offset: tangent ? tangent.pointOffset(center) : 0 - } + link.label(index, { + position: { + distance, + offset: tangent ? tangent.pointOffset(center) : 0 + } + }); }); }; +let importLayoutOptions: ImportLayoutOptions; + +let elementsById: Map; +let linksById: Map; +let portsById: Map; + /** - * Applies an ELK layout result back onto the JointJS graph. + * (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. */ -export function importLayout( - elkGraph: ElkNode, - elementsById: Map, - linksById: Map, - portsById: Map, +function init( + elements: Map, + links: Map, + ports: Map, options: ImportLayoutOptions ): void { + importLayoutOptions = options; - const setPositionFn = options.setPosition ?? defaultSetPosition; - const setVerticesFn = options.setVertices ?? defaultSetVertices; - const setAnchorFn = options.setAnchor ?? defaultSetAnchor; - - let setPortPositionFn: SetPortPositionCallback | undefined; - if (getPortPositionsMode(options.positionPorts) !== 'fixed') { - setPortPositionFn = defaultSetPortPosition; - - if (typeof options.positionPorts === 'object') { - setPortPositionFn = options.positionPorts.setPortPosition ?? defaultSetPortPosition; - } - } - - let setPortLabelPositionFn: SetPortLabelPositionCallback | undefined; - if (options.positionPortLabels) { - setPortLabelPositionFn = defaultSetPortLabelPosition; - - if (typeof options.positionPortLabels === 'object') { - setPortLabelPositionFn = options.positionPortLabels.setPortLabelPosition ?? defaultSetPortLabelPosition; - } - } + elementsById = elements; + linksById = links; + portsById = ports; +} - let setLabelsFn: SetLabelsCallback | undefined; - if (options.edgeLabels) { - setLabelsFn = defaultSetLabels; +// ELK positions a node's children (and routes a node's own edges) 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. +function toAbsolute(point: ElkPoint, containerPosition: dia.Point): dia.Point { + return { + x: containerPosition.x + point.x, + y: containerPosition.y + point.y + }; +} - if (typeof options.edgeLabels === 'object') { - setLabelsFn = options.edgeLabels.setLabels ?? defaultSetLabels; - } - } +function importEdges(edges: ElkExtendedEdge[] | undefined, containerPosition: dia.Point = { x: 0, y: 0 }): void { + const setLinkAttributes = importLayoutOptions.setLinkAttributes ?? defaultSetLinkAttributes; - // ELK positions a node's children (and routes a node's own edges) relative to that - // node's own origin - `containerX`/`containerY` accumulate the offset needed to turn - // those relative coordinates into graph-absolute ones as we walk down the hierarchy. - const toAbsolute = (point: ElkPoint, containerX: number, containerY: number): dia.Point => ({ - x: containerX + point.x, - y: containerY + point.y - }); + (edges || []).forEach((edge) => { + const link = linksById.get(edge.id); + if (!link) return; - function importEdges(edges: ElkExtendedEdge[] | undefined, containerX: number, containerY: number): void { - (edges || []).forEach((edge) => { - const link = linksById.get(edge.id); - if (!link) return; + const [section] = edge.sections || []; + if (!section) return; - const [section] = edge.sections || []; - if (!section) return; + const { startPoint, endPoint, bendPoints = [] } = section; - const { startPoint, endPoint, bendPoints = [] } = section; + const vertices = bendPoints.map((point) => toAbsolute(point, containerPosition)); - setVerticesFn(link, bendPoints.map((point) => toAbsolute(point, containerX, containerY))); + // A port-connected end already has the anchor JointJS itself computed for that + // port (the same position ELK was told to route to) - it does not need overriding. + const sourceAnchor = (link.source().port) ? undefined : toAbsolute(startPoint, containerPosition); + const targetAnchor = (link.target().port) ? undefined : toAbsolute(endPoint, containerPosition); - const sourceElement = link.getSourceElement() as dia.Element; - const targetElement = link.getTargetElement() as dia.Element; + let labels: SetLinkAttributesLabelParameters[] | undefined; + if (importLayoutOptions.edgeLabels && edge.labels && edge.labels.length > 0) { + const points = [startPoint, ...bendPoints, endPoint] + .map((point) => toAbsolute(point, containerPosition)); + labels = edge.labels.map((label, index) => { + const { x = 0, y = 0, width = 0, height = 0 } = label; + return { + index, + bbox: { x: containerPosition.x + x, y: containerPosition.y + y, width, height }, + points + }; + }); + } - // A port-connected end already has the anchor JointJS itself computed for that - // port (the same position ELK was told to route to) - it does not need overriding. - if (!link.source().port) { - setAnchorFn(link, sourceElement, toAbsolute(startPoint, containerX, containerY), 'source'); - } - if (!link.target().port) { - setAnchorFn(link, targetElement, toAbsolute(endPoint, containerX, containerY), 'target'); - } + setLinkAttributes({ link, attributes: { vertices, sourceAnchor, targetAnchor, labels }}); + }); +} - if (setLabelsFn && edge.labels && edge.labels.length > 0) { - const points = [startPoint, ...bendPoints, endPoint] - .map((point) => toAbsolute(point, containerX, containerY)); - edge.labels.forEach((label, labelIndex) => { - const { x = 0, y = 0, width = 0, height = 0 } = label; - setLabelsFn?.(link, { x: containerX + x, y: containerY + y, width, height }, points, labelIndex); - }); +function importNode(node: ElkNode, containerPosition: dia.Point = { x: 0, y: 0 }): void { + const position = toAbsolute({ x: node.x || 0, y: node.y || 0 }, containerPosition); + + const element = elementsById.get(node.id); + if (element) { + const isContainer = !!node.children && node.children.length > 0; + const setElementAttributes = importLayoutOptions.setElementAttributes ?? defaultSetElementAttributes; + setElementAttributes({ + element, + attributes: { + position, + // A container's size is computed by ELK to fit its (recursively laid out) content. + size: (isContainer) ? { width: node.width || 0, height: node.height || 0 } : undefined } }); } - function importNode(node: ElkNode, containerX: number, containerY: number): void { - const x = containerX + (node.x || 0); - const y = containerY + (node.y || 0); - - const element = elementsById.get(node.id); - if (element) { - setPositionFn(element, { x, y }); - if (node.children && node.children.length > 0) { - // A container - ELK computed its size to fit its (recursively laid out) content. - element.resize(node.width || 0, node.height || 0); + if (node.ports) { + const setPortAttributes = importLayoutOptions.setPortAttributes ?? defaultSetPortAttributes; + node.ports.forEach((port) => { + const found = portsById.get(port.id); + if (!found) return; + + let labelPosition: dia.Point | undefined; + if (importLayoutOptions.positionPortLabels) { + const [label] = port.labels || []; + if (label) { + labelPosition = { x: label.x || 0, y: label.y || 0 }; + } } - } - if ((setPortPositionFn || setPortLabelPositionFn) && node.ports) { - node.ports.forEach((port) => { - const found = portsById.get(port.id); - if (!found) return; + setPortAttributes({ + element: found.element, + portId: found.portId, + attributes: { position: { x: port.x || 0, y: port.y || 0 }, labelPosition } + }); + }); + } - if (setPortPositionFn) { - setPortPositionFn(found.element, found.portId, { - x: port.x || 0, - y: port.y || 0 - }); - } + (node.children || []).forEach((child) => importNode(child, position)); + importEdges(node.edges, position); +} - if (setPortLabelPositionFn) { - const [label] = port.labels || []; - if (label) { - setPortLabelPositionFn(found.element, found.portId, { - x: label.x || 0, - y: label.y || 0 - }); - } - } - }); - } +/** + * Applies an ELK layout result back onto the JointJS graph. + */ +export function importLayout( + elkGraph: ElkNode, + elementsById: Map, + linksById: Map, + portsById: Map, + options: ImportLayoutOptions +): void { - (node.children || []).forEach((child) => importNode(child, x, y)); - importEdges(node.edges, x, y); - } + init(elementsById, linksById, portsById, options); - (elkGraph.children || []).forEach((node) => importNode(node, 0, 0)); - importEdges(elkGraph.edges, 0, 0); + (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 index 567d250afe..72d07213c8 100644 --- a/packages/joint-layout-elk/src/index.mts +++ b/packages/joint-layout-elk/src/index.mts @@ -2,4 +2,4 @@ export * from './layout.mjs'; export * from './import.mjs'; export * from './export.mjs'; -export type * from './elkOptions.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 index b8c74d97a5..9ae41fd1a6 100644 --- a/packages/joint-layout-elk/src/layout.mts +++ b/packages/joint-layout-elk/src/layout.mts @@ -4,8 +4,8 @@ import { importLayout } from './import.mjs'; import { exportGraph } from './export.mjs'; import type { ExportGraphOptions } from './export.mjs'; -import type { EdgeLabelsOptions, ImportLayoutOptions, PortPositionsMode, PortPositionsOptions, PortLabelPositionsOptions } from './import.mjs'; -import type { ElkLayoutOptions, ElkNode } from './elkOptions.mjs'; +import type { ImportLayoutOptions, PortsPositionMode } from './import.mjs'; +import type { ElkLayoutOptions, ElkNode } from './types/index.mjs'; import type { dia } from '@joint/core'; import type { ELK, ElkNode as RawElkNode } from 'elkjs'; @@ -33,8 +33,7 @@ const INTERACTIVE_LAYOUT_OPTIONS: ElkLayoutOptions = { // `'layered'`'s own interactivity is per-phase - each of these reads the corresponding // aspect (edge direction, x/y) straight off an element's current position instead of // computing it from scratch, so the four are meant to be used together. - // 'elk.layered.cycleBreaking.strategy': 'INTERACTIVE', - 'elk.layered.layering.strategy': 'INTERACTIVE', + // 'elk.layered.layering.strategy': 'INTERACTIVE', // NOT `crossingMinimization.strategy: 'INTERACTIVE'` - that variant doesn't minimize // crossings at all, it just sorts each layer by previous y and calls it done (see // `InteractiveCrossingMinimizer` upstream). `semiInteractive` instead keeps the real @@ -85,7 +84,7 @@ export interface Options extends * along the routed link afterwards. * @defaultValue true */ - edgeLabels?: boolean | EdgeLabelsOptions; + edgeLabels?: boolean; /** * How freely ELK may reposition (and reorder) ports along their element, * instead of keeping them at the position JointJS itself already computed @@ -95,7 +94,7 @@ export interface Options extends * computed for it can be applied. * @defaultValue 'fixed' */ - positionPorts?: PortPositionsMode | PortPositionsOptions; + portsPosition?: PortsPositionMode; /** * Whether to let ELK reposition port labels along their port, instead of keeping * them at the position JointJS itself already computed for them (via the port @@ -104,7 +103,7 @@ export interface Options extends * computed for it can be applied. * @defaultValue false */ - positionPortLabels?: boolean | PortLabelPositionsOptions; + positionPortLabels?: boolean; /** * A name for the layout batch, which can be used to group multiple layout operations together. * @defaultValue 'layout' 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..c88f069794 --- /dev/null +++ b/packages/joint-layout-elk/src/types/elkEdgeOptions.mts @@ -0,0 +1,87 @@ +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; + /** + * A fixed list of bend points for the edge. This is used by the 'Fixed Layout' algorithm to + * specify a pre-defined routing for an edge. The vector chain must include the source point, any + * bend points, and the target point, so it must have at least two points. + */ + 'elk.bendPoints'?: string; + /** + * Allows to specify individual spacing values for graph elements that shall be different from the + * value specified for the element's parent. + */ + 'elk.spacing.individual'?: string; + /** + * Defines the priority of an object; its meaning depends on the specific layout algorithm and the + * context where it is used. + */ + 'elk.priority'?: `${number}`; + /** + * This option is not used as option, but as output of the layout algorithms. It is attached to + * edges and determines the points where junction symbols should be drawn in order to represent + * hyperedges with orthogonal routing. Whether such points are computed depends on the chosen + * layout algorithm and edge routing style. The points are put into the vector chain with no + * specific order. + * @defaultValue `new KVectorChain()` + */ + 'elk.junctionPoints'?: string; + /** + * No layout is done for the associated element. This is used to mark parts of a diagram to avoid + * their inclusion in the layout graph, or to mark parts of the layout graph to prevent layout + * engines from processing them. If you wish to exclude the contents of a compound node from + * automatic layout, while the node itself is still considered on its own layer, use the 'Fixed + * Layout' algorithm for that node. + * @defaultValue 'false' + */ + 'elk.noLayout'?: 'true' | 'false'; + /** + * Whether a self loop should be routed inside a node instead of around that node. + * @defaultValue 'false' + */ + 'elk.insideSelfLoops.yo'?: 'true' | 'false'; + /** + * The thickness of an edge. This is a hint on the line width used to draw an edge, possibly + * requiring more space to be reserved for it. + * @defaultValue '1' + */ + 'elk.edge.thickness'?: `${number}`; + /** + * The type of an edge. This is usually used for UML class diagrams, where associations must be + * handled differently from generalizations. + * @defaultValue 'NONE' + */ + 'elk.edge.type'?: EdgeType; + /** + * Defines how important it is to have a certain edge point into the direction of the overall + * layout. This option is evaluated during the cycle breaking phase. + * @defaultValue '0' + */ + 'elk.layered.priority.direction'?: `${number}`; + /** + * Defines how important it is to keep an edge as short as possible. This option is evaluated + * during the layering phase. + * @defaultValue '0' + */ + 'elk.layered.priority.shortness'?: `${number}`; + /** + * Defines how important it is to keep an edge straight, i.e. aligned with one of the two axes. + * This option is evaluated during node placement. + * @defaultValue '0' + */ + 'elk.layered.priority.straightness'?: `${number}`; + /** + * Used to define partial ordering groups during crossing minimization. A lower group id means that + * the group is sorted before other groups. A group model order of 0 is the default group. + * @defaultValue '0' + */ + 'elk.layered.considerModelOrder.groupModelOrder.crossingMinimizationId'?: `${number}`; + /** + * Used to define partial ordering groups during component packing. A lower group id means that the + * group is sorted before other groups. A group model order of 0 is the default group. + * @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..048f4d8738 --- /dev/null +++ b/packages/joint-layout-elk/src/types/elkLabelOptions.mts @@ -0,0 +1,64 @@ +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; + /** + * Allows to specify individual spacing values for graph elements that shall be different from the + * value specified for the element's parent. + */ + 'elk.spacing.individual'?: string; + /** + * Hints for where node labels are to be placed; if empty, the node label's position is not + * modified. + * @defaultValue `NodeLabelPlacement.fixed` + */ + 'elk.nodeLabels.placement'?: string; + /** + * The position of a node, port, or label. This is used by the 'Fixed Layout' algorithm to specify + * a pre-defined position. + */ + 'elk.position'?: string; + /** + * Gives a hint on where to put edge labels. + * @defaultValue 'CENTER' + */ + 'elk.edgeLabels.placement'?: EdgeLabelPlacement; + /** + * If true, an edge label is placed directly on its edge. May only apply to center edge labels. + * This kind of label placement is only advisable if the label's rendering is such that it is not + * crossed by its edge and thus stays legible. + * @defaultValue 'false' + */ + 'elk.edgeLabels.inline'?: 'true' | 'false'; + /** + * Font name used for a label. + */ + 'elk.font.name'?: string; + /** + * Font size used for a label. + */ + 'elk.font.size'?: `${number}`; + /** + * Determines the amount of fuzziness to be used when performing softwrapping on labels. The value + * expresses the percent of overhang that is permitted for each line. If the next line would take + * up less space than this threshold, it is appended to the current line instead of being placed in + * a new line. + * @defaultValue '0.0' + */ + 'elk.softwrappingFuzziness'?: `${number}`; + /** + * No layout is done for the associated element. This is used to mark parts of a diagram to avoid + * their inclusion in the layout graph, or to mark parts of the layout graph to prevent layout + * engines from processing them. If you wish to exclude the contents of a compound node from + * automatic layout, while the node itself is still considered on its own layer, use the 'Fixed + * Layout' algorithm for that node. + * @defaultValue 'false' + */ + 'elk.noLayout'?: 'true' | 'false'; + /** + * Determines in which layer center labels of long edges should be placed. + * @defaultValue 'MEDIAN_LAYER' + */ + 'elk.layered.edgeLabels.centerLabelPlacementStrategy'?: CenterEdgeLabelPlacementStrategy; +} diff --git a/packages/joint-layout-elk/src/elkOptions.mts b/packages/joint-layout-elk/src/types/elkLayoutOptions.mts similarity index 63% rename from packages/joint-layout-elk/src/elkOptions.mts rename to packages/joint-layout-elk/src/types/elkLayoutOptions.mts index 0fcd8de724..5f1ae8be2f 100644 --- a/packages/joint-layout-elk/src/elkOptions.mts +++ b/packages/joint-layout-elk/src/types/elkLayoutOptions.mts @@ -1,544 +1,46 @@ import type { - ElkNode as RawElkNode, - ElkPort as RawElkPort, - ElkExtendedEdge as RawElkExtendedEdge, - ElkLabel as RawElkLabel -} from 'elkjs'; - -export interface NodeElkLayoutOptions { - // Falls back to a plain string for any ELK option beyond this package's Core/Layered coverage. - [key: string]: string | undefined; - /** - * Alignment of the selected node relative to other nodes; the exact meaning depends on the used - * algorithm. - * @defaultValue 'AUTOMATIC' - */ - 'elk.alignment'?: Alignment; - /** - * Determines whether separate layout runs are triggered for different compound nodes in a - * hierarchical graph. Setting a node's hierarchy handling to `INCLUDE_CHILDREN` will lay out that - * node and all of its descendants in a single layout run, until a descendant is encountered which - * has its hierarchy handling set to `SEPARATE_CHILDREN`. In general, `SEPARATE_CHILDREN` will - * ensure that a new layout run is triggered for a node with that setting. Including multiple - * levels of hierarchy in a single layout run may allow cross-hierarchical edges to be laid out - * properly. If the root node is set to `INHERIT` (or not set at all), the default behavior is - * `SEPARATE_CHILDREN`. - * @defaultValue 'INHERIT' - */ - 'elk.hierarchyHandling'?: HierarchyHandling; - /** - * The padding to be left to a parent element's border when placing child elements. This can also - * serve as an output option of a layout algorithm if node size calculation is setup appropriately. - * @defaultValue `new ElkPadding(12)` - */ - 'elk.padding'?: string; - /** - * Spacing between pairs of ports of the same node. - * @defaultValue '10' - */ - 'elk.spacing.portPort'?: `${number}`; - /** - * Allows to specify individual spacing values for graph elements that shall be different from the - * value specified for the element's parent. - */ - 'elk.spacing.individual'?: string; - /** - * Partition to which the node belongs. This requires Layout Partitioning to be active. Nodes with - * lower partition IDs will appear to the left of nodes with higher partition IDs (assuming a - * left-to-right layout direction). - */ - 'elk.partitioning.partition'?: `${number}`; - /** - * Hints for where node labels are to be placed; if empty, the node label's position is not - * modified. - * @defaultValue `NodeLabelPlacement.fixed` - */ - 'elk.nodeLabels.placement'?: string; - /** - * Defines the default port distribution for a node. May be overridden for each side individually. - * @defaultValue 'DISTRIBUTED' - */ - 'elk.portAlignment.default'?: PortAlignment; - /** - * Defines how ports on the northern side are placed, overriding the node's general port alignment. - */ - 'elk.portAlignment.north'?: PortAlignment; - /** - * Defines how ports on the southern side are placed, overriding the node's general port alignment. - */ - 'elk.portAlignment.south'?: PortAlignment; - /** - * Defines how ports on the western side are placed, overriding the node's general port alignment. - */ - 'elk.portAlignment.west'?: PortAlignment; - /** - * Defines how ports on the eastern side are placed, overriding the node's general port alignment. - */ - 'elk.portAlignment.east'?: PortAlignment; - /** - * Defines constraints of the position of the ports of a node. - * @defaultValue 'UNDEFINED' - */ - 'elk.portConstraints'?: PortConstraints; - /** - * The position of a node, port, or label. This is used by the 'Fixed Layout' algorithm to specify - * a pre-defined position. - */ - 'elk.position'?: string; - /** - * Defines the priority of an object; its meaning depends on the specific layout algorithm and the - * context where it is used. - */ - 'elk.priority'?: `${number}`; - /** - * What should be taken into account when calculating a node's size. Empty size constraints specify - * that a node's size is already fixed and should not be changed. - * @defaultValue `EnumSet.noneOf(SizeConstraint)` - */ - 'elk.nodeSize.constraints'?: string; - /** - * Options modifying the behavior of the size constraints set on a node. Each member of the set - * specifies something that should be taken into account when calculating node sizes. The empty set - * corresponds to no further modifications. - * @defaultValue `EnumSet.of(SizeOptions.DEFAULT_MINIMUM_SIZE)` - */ - 'elk.nodeSize.options'?: string; - /** - * The minimal size to which a node can be reduced. - * @defaultValue `new KVector(0, 0)` - */ - 'elk.nodeSize.minimum'?: string; - /** - * Whether the node should be regarded as a comment box instead of a regular node. In that case its - * placement should be similar to how labels are handled. Any edges incident to a comment box - * specify to which graph elements the comment is related. - * @defaultValue 'false' - */ - 'elk.commentBox'?: 'true' | 'false'; - /** - * Whether the node should be handled as a hypernode. - * @defaultValue 'false' - */ - 'elk.hypernode'?: 'true' | 'false'; - /** - * Margins define additional space around the actual bounds of a graph element. For instance, ports - * or labels being placed on the outside of a node's border might introduce such a margin. The - * margin is used to guarantee non-overlap of other graph elements with those ports or labels. - * @defaultValue `new ElkMargin()` - */ - 'elk.margins'?: string; - /** - * No layout is done for the associated element. This is used to mark parts of a diagram to avoid - * their inclusion in the layout graph, or to mark parts of the layout graph to prevent layout - * engines from processing them. If you wish to exclude the contents of a compound node from - * automatic layout, while the node itself is still considered on its own layer, use the 'Fixed - * Layout' algorithm for that node. - * @defaultValue 'false' - */ - 'elk.noLayout'?: 'true' | 'false'; - /** - * Decides on a placement method for port labels; if empty, the node label's position is not - * modified. - * @defaultValue `PortLabelPlacement.outside` - */ - 'elk.portLabels.placement'?: string; - /** - * Use 'portLabels.placement': NEXT_TO_PORT_OF_POSSIBLE. - * @defaultValue 'false' - */ - 'elk.portLabels.nextToPortIfPossible'?: 'true' | 'false'; - /** - * If this option is true (default), the labels of a port will be treated as a group when it comes - * to centering them next to their port. If this option is false, only the first label will be - * centered next to the port, with the others being placed below. This only applies to labels of - * eastern and western ports and will have no effect if labels are not placed next to their port. - * @defaultValue 'true' - */ - 'elk.portLabels.treatAsGroup'?: 'true' | 'false'; - /** - * The scaling factor to be applied to the corresponding node in recursive layout. It causes the - * corresponding node's size to be adjusted, and its ports and labels to be sized and placed - * accordingly after the layout of that node has been determined (and before the node itself and - * its siblings are arranged). The scaling is not reverted afterwards, so the resulting layout - * graph contains the adjusted size and position data. This option is currently not supported if - * 'Layout Hierarchy' is set. - * @defaultValue '1' - */ - 'elk.scaleFactor'?: `${number}`; - /** - * The size approximator to be used to set sizes of hierarchical nodes during topdown layout. The - * default value is null, which results in nodes keeping whatever size is defined for them e.g. - * through parent parallel node or by manually setting the size. - * @defaultValue `null` - */ - 'elk.topdown.sizeApproximator'?: string; - /** - * The fixed size of a hierarchical node when using topdown layout. If this value is set on a - * parallel node it applies to its children, when set on a hierarchical node it applies to the node - * itself. - * @defaultValue '150' - */ - 'elk.topdown.hierarchicalNodeWidth'?: `${number}`; - /** - * The fixed aspect ratio of a hierarchical node when using topdown layout. Default is 1/sqrt(2). - * If this value is set on a parallel node it applies to its children, when set on a hierarchical - * node it applies to the node itself. - * @defaultValue '1.414' - */ - 'elk.topdown.hierarchicalNodeAspectRatio'?: `${number}`; - /** - * The different node types used for topdown layout. If the node type is set to - * `TopdownNodeTypes.PARALLEL_NODE` the algorithm must be set to a `TopdownLayoutProvider` such as - * `TopdownPacking`. The `nodeSize.fixedGraphSize` option is technically only required for - * hierarchical nodes. - * @defaultValue `null` - */ - 'elk.topdown.nodeType'?: TopdownNodeTypes; - /** - * Whether this node allows to route self loops inside of it instead of around it. If set to true, - * this will make the node a compound node if it isn't already, and will require the layout - * algorithm to support compound nodes with hierarchical ports. - * @defaultValue 'false' - */ - 'elk.insideSelfLoops.activate'?: 'true' | 'false'; - /** - * Determines a constraint on the placement of the node regarding the layering. - * @defaultValue 'NONE' - */ - 'elk.layered.layering.layerConstraint'?: LayerConstraint; - /** - * Allows to set a constraint regarding the layer placement of a node. Let i be the value of teh - * constraint. Assumed the drawing has n layers and i < n. If set to i, it expresses that the node - * should be placed in i-th layer. Should i>=n be true then the node is placed in the last layer of - * the drawing. Note that this option is not part of any of ELK Layered's default configurations - * but is only evaluated as part of the `InteractiveLayeredGraphVisitor`, which must be applied - * manually or used via the `DiagramLayoutEngine. - * @defaultValue `null` - */ - 'elk.layered.layering.layerChoiceConstraint'?: `${number}`; - /** - * Layer identifier that was calculated by ELK Layered for a node. This is only generated if - * interactiveLayot or generatePositionAndLayerIds is set. - * @defaultValue '-1' - */ - 'elk.layered.layering.layerId'?: `${number}`; - /** - * Allows to set a constraint which specifies of which node the current node is the predecessor. If - * set to 's' then the node is the predecessor of 's' and is in the same layer - * @defaultValue `null` - */ - 'elk.layered.crossingMinimization.inLayerPredOf'?: string; - /** - * Allows to set a constraint which specifies of which node the current node is the successor. If - * set to 's' then the node is the successor of 's' and is in the same layer - * @defaultValue `null` - */ - 'elk.layered.crossingMinimization.inLayerSuccOf'?: string; - /** - * Allows to set a constraint regarding the position placement of a node in a layer. Assumed the - * layer in which the node placed includes n other nodes and i < n. If set to i, it expresses that - * the node should be placed at the i-th position. Should i>=n be true then the node is placed at - * the last position in the layer. Note that this option is not part of any of ELK Layered's - * default configurations but is only evaluated as part of the `InteractiveLayeredGraphVisitor`, - * which must be applied manually or used via the `DiagramLayoutEngine. - * @defaultValue `null` - */ - 'elk.layered.crossingMinimization.positionChoiceConstraint'?: `${number}`; - /** - * Position within a layer that was determined by ELK Layered for a node. This is only generated if - * interactiveLayot or generatePositionAndLayerIds is set. - * @defaultValue '-1' - */ - 'elk.layered.crossingMinimization.positionId'?: `${number}`; - /** - * Aims at shorter and straighter edges. Two configurations are possible: (a) allow ports to move - * freely on the side they are assigned to (the order is always defined beforehand), (b) - * additionally allow to enlarge a node wherever it helps. If this option is not configured for a - * node, the 'nodeFlexibility.default' value is used, which is specified for the node's parent. - */ - 'elk.layered.nodePlacement.networkSimplex.nodeFlexibility'?: NodeFlexibility; - /** - * Alter the distribution of the loops around the node. It only takes effect for - * PortConstraints.FREE. - * @defaultValue 'NORTH' - */ - 'elk.layered.edgeRouting.selfLoopDistribution'?: SelfLoopDistributionStrategy; - /** - * Alter the ordering of the loops they can either be stacked or sequenced. It only takes effect - * for PortConstraints.FREE. - * @defaultValue 'STACKED' - */ - 'elk.layered.edgeRouting.selfLoopOrdering'?: SelfLoopOrderingStrategy; - /** - * Use a heuristic to decide whether or not to actually perform the layer split with the goal of - * minimizing the total edge length. This option only works when layerSplit is set to 2. The - * property can be set to the nodes in a layer, which then applies the property for the layer. If - * any node sets the value to true, then the value is set to true for the entire layer. - * @defaultValue 'false' - */ - 'elk.layered.layerUnzipping.minimizeEdgeLength'?: 'true' | 'false'; - /** - * Defines the number of sublayers to split a layer into. The property can be set to the nodes in a - * layer, which then applies the property for the layer. If multiple nodes set the value to - * different values, then the lowest value is chosen. - * @defaultValue '2' - */ - 'elk.layered.layerUnzipping.layerSplit'?: `${number}`; - /** - * If set to true, nodes will always be placed in the first sublayer after a long edge when using - * the ALTERNATING strategy. Otherwise long edge dummies are treated the same as regular nodes. The - * default value is true. The property can be set to the nodes in a layer, which then applies the - * property for the layer. If any node sets the value to false, then the value is set to false for - * the entire layer. - * @defaultValue 'true' - */ - 'elk.layered.layerUnzipping.resetOnLongEdges'?: 'true' | 'false'; - /** - * Set on a node to not set a model order for this node even though it is a real node. - * @defaultValue 'false' - */ - 'elk.layered.considerModelOrder.noModelOrder'?: 'true' | 'false'; - /** - * Used to define partial ordering groups during cycle breaking. A lower group id means that the - * group is sorted before other groups. A group model order of 0 is the default group. - * @defaultValue '0' - */ - 'elk.layered.considerModelOrder.groupModelOrder.cycleBreakingId'?: `${number}`; - /** - * Used to define partial ordering groups during crossing minimization. A lower group id means that - * the group is sorted before other groups. A group model order of 0 is the default group. - * @defaultValue '0' - */ - 'elk.layered.considerModelOrder.groupModelOrder.crossingMinimizationId'?: `${number}`; - /** - * Used to define partial ordering groups during component packing. A lower group id means that the - * group is sorted before other groups. A group model order of 0 is the default group. - * @defaultValue '0' - */ - 'elk.layered.considerModelOrder.groupModelOrder.componentGroupId'?: `${number}`; -} - -export interface EdgeElkLayoutOptions { - // Falls back to a plain string for any ELK option beyond this package's Core/Layered coverage. - [key: string]: string | undefined; - /** - * A fixed list of bend points for the edge. This is used by the 'Fixed Layout' algorithm to - * specify a pre-defined routing for an edge. The vector chain must include the source point, any - * bend points, and the target point, so it must have at least two points. - */ - 'elk.bendPoints'?: string; - /** - * Allows to specify individual spacing values for graph elements that shall be different from the - * value specified for the element's parent. - */ - 'elk.spacing.individual'?: string; - /** - * Defines the priority of an object; its meaning depends on the specific layout algorithm and the - * context where it is used. - */ - 'elk.priority'?: `${number}`; - /** - * This option is not used as option, but as output of the layout algorithms. It is attached to - * edges and determines the points where junction symbols should be drawn in order to represent - * hyperedges with orthogonal routing. Whether such points are computed depends on the chosen - * layout algorithm and edge routing style. The points are put into the vector chain with no - * specific order. - * @defaultValue `new KVectorChain()` - */ - 'elk.junctionPoints'?: string; - /** - * No layout is done for the associated element. This is used to mark parts of a diagram to avoid - * their inclusion in the layout graph, or to mark parts of the layout graph to prevent layout - * engines from processing them. If you wish to exclude the contents of a compound node from - * automatic layout, while the node itself is still considered on its own layer, use the 'Fixed - * Layout' algorithm for that node. - * @defaultValue 'false' - */ - 'elk.noLayout'?: 'true' | 'false'; - /** - * Whether a self loop should be routed inside a node instead of around that node. - * @defaultValue 'false' - */ - 'elk.insideSelfLoops.yo'?: 'true' | 'false'; - /** - * The thickness of an edge. This is a hint on the line width used to draw an edge, possibly - * requiring more space to be reserved for it. - * @defaultValue '1' - */ - 'elk.edge.thickness'?: `${number}`; - /** - * The type of an edge. This is usually used for UML class diagrams, where associations must be - * handled differently from generalizations. - * @defaultValue 'NONE' - */ - 'elk.edge.type'?: EdgeType; - /** - * Defines how important it is to have a certain edge point into the direction of the overall - * layout. This option is evaluated during the cycle breaking phase. - * @defaultValue '0' - */ - 'elk.layered.priority.direction'?: `${number}`; - /** - * Defines how important it is to keep an edge as short as possible. This option is evaluated - * during the layering phase. - * @defaultValue '0' - */ - 'elk.layered.priority.shortness'?: `${number}`; - /** - * Defines how important it is to keep an edge straight, i.e. aligned with one of the two axes. - * This option is evaluated during node placement. - * @defaultValue '0' - */ - 'elk.layered.priority.straightness'?: `${number}`; - /** - * Used to define partial ordering groups during crossing minimization. A lower group id means that - * the group is sorted before other groups. A group model order of 0 is the default group. - * @defaultValue '0' - */ - 'elk.layered.considerModelOrder.groupModelOrder.crossingMinimizationId'?: `${number}`; - /** - * Used to define partial ordering groups during component packing. A lower group id means that the - * group is sorted before other groups. A group model order of 0 is the default group. - * @defaultValue '0' - */ - 'elk.layered.considerModelOrder.groupModelOrder.componentGroupId'?: `${number}`; -} - -export interface PortElkLayoutOptions { - // Falls back to a plain string for any ELK option beyond this package's Core/Layered coverage. - [key: string]: string | undefined; - /** - * Allows to specify individual spacing values for graph elements that shall be different from the - * value specified for the element's parent. - */ - 'elk.spacing.individual'?: string; - /** - * The position of a node, port, or label. This is used by the 'Fixed Layout' algorithm to specify - * a pre-defined position. - */ - 'elk.position'?: string; - /** - * No layout is done for the associated element. This is used to mark parts of a diagram to avoid - * their inclusion in the layout graph, or to mark parts of the layout graph to prevent layout - * engines from processing them. If you wish to exclude the contents of a compound node from - * automatic layout, while the node itself is still considered on its own layer, use the 'Fixed - * Layout' algorithm for that node. - * @defaultValue 'false' - */ - 'elk.noLayout'?: 'true' | 'false'; - /** - * The offset to the port position where connections shall be attached. - */ - 'elk.port.anchor'?: string; - /** - * The index of a port in the fixed order around a node. The order is assumed as clockwise, - * starting with the leftmost port on the top side. This option must be set if 'Port Constraints' - * is set to FIXED_ORDER and no specific positions are given for the ports. Additionally, the - * option 'Port Side' must be defined in this case. - */ - 'elk.port.index'?: `${number}`; - /** - * The side of a node on which a port is situated. This option must be set if 'Port Constraints' is - * set to FIXED_SIDE or FIXED_ORDER and no specific positions are given for the ports. - * @defaultValue 'UNDEFINED' - */ - 'elk.port.side'?: PortSide; - /** - * The offset of ports on the node border. With a positive offset the port is moved outside of the - * node, while with a negative offset the port is moved towards the inside. An offset of 0 means - * that the port is placed directly on the node border, i.e. if the port side is north, the port's - * south border touches the nodes's north border; if the port side is east, the port's west border - * touches the nodes's east border; if the port side is south, the port's north border touches the - * node's south border; if the port side is west, the port's east border touches the node's west - * border. - */ - 'elk.port.borderOffset'?: `${number}`; - /** - * Used to define partial ordering groups during crossing minimization. A lower group id means that - * the group is sorted before other groups. A group model order of 0 is the default group. - * @defaultValue '0' - */ - 'elk.layered.considerModelOrder.groupModelOrder.crossingMinimizationId'?: `${number}`; - /** - * Used to define partial ordering groups during component packing. A lower group id means that the - * group is sorted before other groups. A group model order of 0 is the default group. - * @defaultValue '0' - */ - 'elk.layered.considerModelOrder.groupModelOrder.componentGroupId'?: `${number}`; - /** - * Specifies whether non-flow ports may switch sides if their node's port constraints are either - * FIXED_SIDE or FIXED_ORDER. A non-flow port is a port on a side that is not part of the currently - * configured layout flow. For instance, given a left-to-right layout direction, north and south - * ports would be considered non-flow ports. Further note that the underlying criterium whether to - * switch sides or not solely relies on the minimization of edge crossings. Hence, edge length and - * other aesthetics criteria are not addressed. - * @defaultValue 'false' - */ - 'elk.layered.allowNonFlowPortsToSwitchSides'?: 'true' | 'false'; -} - -export interface LabelElkLayoutOptions { - // Falls back to a plain string for any ELK option beyond this package's Core/Layered coverage. - [key: string]: string | undefined; - /** - * Allows to specify individual spacing values for graph elements that shall be different from the - * value specified for the element's parent. - */ - 'elk.spacing.individual'?: string; - /** - * Hints for where node labels are to be placed; if empty, the node label's position is not - * modified. - * @defaultValue `NodeLabelPlacement.fixed` - */ - 'elk.nodeLabels.placement'?: string; - /** - * The position of a node, port, or label. This is used by the 'Fixed Layout' algorithm to specify - * a pre-defined position. - */ - 'elk.position'?: string; - /** - * Gives a hint on where to put edge labels. - * @defaultValue 'CENTER' - */ - 'elk.edgeLabels.placement'?: EdgeLabelPlacement; - /** - * If true, an edge label is placed directly on its edge. May only apply to center edge labels. - * This kind of label placement is only advisable if the label's rendering is such that it is not - * crossed by its edge and thus stays legible. - * @defaultValue 'false' - */ - 'elk.edgeLabels.inline'?: 'true' | 'false'; - /** - * Font name used for a label. - */ - 'elk.font.name'?: string; - /** - * Font size used for a label. - */ - 'elk.font.size'?: `${number}`; - /** - * Determines the amount of fuzziness to be used when performing softwrapping on labels. The value - * expresses the percent of overhang that is permitted for each line. If the next line would take - * up less space than this threshold, it is appended to the current line instead of being placed in - * a new line. - * @defaultValue '0.0' - */ - 'elk.softwrappingFuzziness'?: `${number}`; - /** - * No layout is done for the associated element. This is used to mark parts of a diagram to avoid - * their inclusion in the layout graph, or to mark parts of the layout graph to prevent layout - * engines from processing them. If you wish to exclude the contents of a compound node from - * automatic layout, while the node itself is still considered on its own layer, use the 'Fixed - * Layout' algorithm for that node. - * @defaultValue 'false' - */ - 'elk.noLayout'?: 'true' | 'false'; - /** - * Determines in which layer center labels of long edges should be placed. - * @defaultValue 'MEDIAN_LAYER' - */ - 'elk.layered.edgeLabels.centerLabelPlacementStrategy'?: CenterEdgeLabelPlacementStrategy; -} + 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. @@ -1688,75 +1190,3 @@ export interface ElkLayoutOptions { */ 'elk.layered.generatePositionAndLayerIds'?: 'true' | 'false'; } - -// 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'; - -// 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/elkNodeOptions.mts b/packages/joint-layout-elk/src/types/elkNodeOptions.mts new file mode 100644 index 0000000000..31591ace56 --- /dev/null +++ b/packages/joint-layout-elk/src/types/elkNodeOptions.mts @@ -0,0 +1,324 @@ +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; + /** + * Alignment of the selected node relative to other nodes; the exact meaning depends on the used + * algorithm. + * @defaultValue 'AUTOMATIC' + */ + 'elk.alignment'?: Alignment; + /** + * Determines whether separate layout runs are triggered for different compound nodes in a + * hierarchical graph. Setting a node's hierarchy handling to `INCLUDE_CHILDREN` will lay out that + * node and all of its descendants in a single layout run, until a descendant is encountered which + * has its hierarchy handling set to `SEPARATE_CHILDREN`. In general, `SEPARATE_CHILDREN` will + * ensure that a new layout run is triggered for a node with that setting. Including multiple + * levels of hierarchy in a single layout run may allow cross-hierarchical edges to be laid out + * properly. If the root node is set to `INHERIT` (or not set at all), the default behavior is + * `SEPARATE_CHILDREN`. + * @defaultValue 'INHERIT' + */ + 'elk.hierarchyHandling'?: HierarchyHandling; + /** + * The padding to be left to a parent element's border when placing child elements. This can also + * serve as an output option of a layout algorithm if node size calculation is setup appropriately. + * @defaultValue `new ElkPadding(12)` + */ + 'elk.padding'?: string; + /** + * Spacing between pairs of ports of the same node. + * @defaultValue '10' + */ + 'elk.spacing.portPort'?: `${number}`; + /** + * Allows to specify individual spacing values for graph elements that shall be different from the + * value specified for the element's parent. + */ + 'elk.spacing.individual'?: string; + /** + * Partition to which the node belongs. This requires Layout Partitioning to be active. Nodes with + * lower partition IDs will appear to the left of nodes with higher partition IDs (assuming a + * left-to-right layout direction). + */ + 'elk.partitioning.partition'?: `${number}`; + /** + * Hints for where node labels are to be placed; if empty, the node label's position is not + * modified. + * @defaultValue `NodeLabelPlacement.fixed` + */ + 'elk.nodeLabels.placement'?: string; + /** + * Defines the default port distribution for a node. May be overridden for each side individually. + * @defaultValue 'DISTRIBUTED' + */ + 'elk.portAlignment.default'?: PortAlignment; + /** + * Defines how ports on the northern side are placed, overriding the node's general port alignment. + */ + 'elk.portAlignment.north'?: PortAlignment; + /** + * Defines how ports on the southern side are placed, overriding the node's general port alignment. + */ + 'elk.portAlignment.south'?: PortAlignment; + /** + * Defines how ports on the western side are placed, overriding the node's general port alignment. + */ + 'elk.portAlignment.west'?: PortAlignment; + /** + * Defines how ports on the eastern side are placed, overriding the node's general port alignment. + */ + 'elk.portAlignment.east'?: PortAlignment; + /** + * Defines constraints of the position of the ports of a node. + * @defaultValue 'UNDEFINED' + */ + 'elk.portConstraints'?: PortConstraints; + /** + * The position of a node, port, or label. This is used by the 'Fixed Layout' algorithm to specify + * a pre-defined position. + */ + 'elk.position'?: string; + /** + * Defines the priority of an object; its meaning depends on the specific layout algorithm and the + * context where it is used. + */ + 'elk.priority'?: `${number}`; + /** + * What should be taken into account when calculating a node's size. Empty size constraints specify + * that a node's size is already fixed and should not be changed. + * @defaultValue `EnumSet.noneOf(SizeConstraint)` + */ + 'elk.nodeSize.constraints'?: string; + /** + * Options modifying the behavior of the size constraints set on a node. Each member of the set + * specifies something that should be taken into account when calculating node sizes. The empty set + * corresponds to no further modifications. + * @defaultValue `EnumSet.of(SizeOptions.DEFAULT_MINIMUM_SIZE)` + */ + 'elk.nodeSize.options'?: string; + /** + * The minimal size to which a node can be reduced. + * @defaultValue `new KVector(0, 0)` + */ + 'elk.nodeSize.minimum'?: string; + /** + * Whether the node should be regarded as a comment box instead of a regular node. In that case its + * placement should be similar to how labels are handled. Any edges incident to a comment box + * specify to which graph elements the comment is related. + * @defaultValue 'false' + */ + 'elk.commentBox'?: 'true' | 'false'; + /** + * Whether the node should be handled as a hypernode. + * @defaultValue 'false' + */ + 'elk.hypernode'?: 'true' | 'false'; + /** + * Margins define additional space around the actual bounds of a graph element. For instance, ports + * or labels being placed on the outside of a node's border might introduce such a margin. The + * margin is used to guarantee non-overlap of other graph elements with those ports or labels. + * @defaultValue `new ElkMargin()` + */ + 'elk.margins'?: string; + /** + * No layout is done for the associated element. This is used to mark parts of a diagram to avoid + * their inclusion in the layout graph, or to mark parts of the layout graph to prevent layout + * engines from processing them. If you wish to exclude the contents of a compound node from + * automatic layout, while the node itself is still considered on its own layer, use the 'Fixed + * Layout' algorithm for that node. + * @defaultValue 'false' + */ + 'elk.noLayout'?: 'true' | 'false'; + /** + * Decides on a placement method for port labels; if empty, the node label's position is not + * modified. + * @defaultValue `PortLabelPlacement.outside` + */ + 'elk.portLabels.placement'?: string; + /** + * Use 'portLabels.placement': NEXT_TO_PORT_OF_POSSIBLE. + * @defaultValue 'false' + */ + 'elk.portLabels.nextToPortIfPossible'?: 'true' | 'false'; + /** + * If this option is true (default), the labels of a port will be treated as a group when it comes + * to centering them next to their port. If this option is false, only the first label will be + * centered next to the port, with the others being placed below. This only applies to labels of + * eastern and western ports and will have no effect if labels are not placed next to their port. + * @defaultValue 'true' + */ + 'elk.portLabels.treatAsGroup'?: 'true' | 'false'; + /** + * The scaling factor to be applied to the corresponding node in recursive layout. It causes the + * corresponding node's size to be adjusted, and its ports and labels to be sized and placed + * accordingly after the layout of that node has been determined (and before the node itself and + * its siblings are arranged). The scaling is not reverted afterwards, so the resulting layout + * graph contains the adjusted size and position data. This option is currently not supported if + * 'Layout Hierarchy' is set. + * @defaultValue '1' + */ + 'elk.scaleFactor'?: `${number}`; + /** + * The size approximator to be used to set sizes of hierarchical nodes during topdown layout. The + * default value is null, which results in nodes keeping whatever size is defined for them e.g. + * through parent parallel node or by manually setting the size. + * @defaultValue `null` + */ + 'elk.topdown.sizeApproximator'?: string; + /** + * The fixed size of a hierarchical node when using topdown layout. If this value is set on a + * parallel node it applies to its children, when set on a hierarchical node it applies to the node + * itself. + * @defaultValue '150' + */ + 'elk.topdown.hierarchicalNodeWidth'?: `${number}`; + /** + * The fixed aspect ratio of a hierarchical node when using topdown layout. Default is 1/sqrt(2). + * If this value is set on a parallel node it applies to its children, when set on a hierarchical + * node it applies to the node itself. + * @defaultValue '1.414' + */ + 'elk.topdown.hierarchicalNodeAspectRatio'?: `${number}`; + /** + * The different node types used for topdown layout. If the node type is set to + * `TopdownNodeTypes.PARALLEL_NODE` the algorithm must be set to a `TopdownLayoutProvider` such as + * `TopdownPacking`. The `nodeSize.fixedGraphSize` option is technically only required for + * hierarchical nodes. + * @defaultValue `null` + */ + 'elk.topdown.nodeType'?: TopdownNodeTypes; + /** + * Whether this node allows to route self loops inside of it instead of around it. If set to true, + * this will make the node a compound node if it isn't already, and will require the layout + * algorithm to support compound nodes with hierarchical ports. + * @defaultValue 'false' + */ + 'elk.insideSelfLoops.activate'?: 'true' | 'false'; + /** + * Determines a constraint on the placement of the node regarding the layering. + * @defaultValue 'NONE' + */ + 'elk.layered.layering.layerConstraint'?: LayerConstraint; + /** + * Allows to set a constraint regarding the layer placement of a node. Let i be the value of teh + * constraint. Assumed the drawing has n layers and i < n. If set to i, it expresses that the node + * should be placed in i-th layer. Should i>=n be true then the node is placed in the last layer of + * the drawing. Note that this option is not part of any of ELK Layered's default configurations + * but is only evaluated as part of the `InteractiveLayeredGraphVisitor`, which must be applied + * manually or used via the `DiagramLayoutEngine. + * @defaultValue `null` + */ + 'elk.layered.layering.layerChoiceConstraint'?: `${number}`; + /** + * Layer identifier that was calculated by ELK Layered for a node. This is only generated if + * interactiveLayot or generatePositionAndLayerIds is set. + * @defaultValue '-1' + */ + 'elk.layered.layering.layerId'?: `${number}`; + /** + * Allows to set a constraint which specifies of which node the current node is the predecessor. If + * set to 's' then the node is the predecessor of 's' and is in the same layer + * @defaultValue `null` + */ + 'elk.layered.crossingMinimization.inLayerPredOf'?: string; + /** + * Allows to set a constraint which specifies of which node the current node is the successor. If + * set to 's' then the node is the successor of 's' and is in the same layer + * @defaultValue `null` + */ + 'elk.layered.crossingMinimization.inLayerSuccOf'?: string; + /** + * Allows to set a constraint regarding the position placement of a node in a layer. Assumed the + * layer in which the node placed includes n other nodes and i < n. If set to i, it expresses that + * the node should be placed at the i-th position. Should i>=n be true then the node is placed at + * the last position in the layer. Note that this option is not part of any of ELK Layered's + * default configurations but is only evaluated as part of the `InteractiveLayeredGraphVisitor`, + * which must be applied manually or used via the `DiagramLayoutEngine. + * @defaultValue `null` + */ + 'elk.layered.crossingMinimization.positionChoiceConstraint'?: `${number}`; + /** + * Position within a layer that was determined by ELK Layered for a node. This is only generated if + * interactiveLayot or generatePositionAndLayerIds is set. + * @defaultValue '-1' + */ + 'elk.layered.crossingMinimization.positionId'?: `${number}`; + /** + * Aims at shorter and straighter edges. Two configurations are possible: (a) allow ports to move + * freely on the side they are assigned to (the order is always defined beforehand), (b) + * additionally allow to enlarge a node wherever it helps. If this option is not configured for a + * node, the 'nodeFlexibility.default' value is used, which is specified for the node's parent. + */ + 'elk.layered.nodePlacement.networkSimplex.nodeFlexibility'?: NodeFlexibility; + /** + * Alter the distribution of the loops around the node. It only takes effect for + * PortConstraints.FREE. + * @defaultValue 'NORTH' + */ + 'elk.layered.edgeRouting.selfLoopDistribution'?: SelfLoopDistributionStrategy; + /** + * Alter the ordering of the loops they can either be stacked or sequenced. It only takes effect + * for PortConstraints.FREE. + * @defaultValue 'STACKED' + */ + 'elk.layered.edgeRouting.selfLoopOrdering'?: SelfLoopOrderingStrategy; + /** + * Use a heuristic to decide whether or not to actually perform the layer split with the goal of + * minimizing the total edge length. This option only works when layerSplit is set to 2. The + * property can be set to the nodes in a layer, which then applies the property for the layer. If + * any node sets the value to true, then the value is set to true for the entire layer. + * @defaultValue 'false' + */ + 'elk.layered.layerUnzipping.minimizeEdgeLength'?: 'true' | 'false'; + /** + * Defines the number of sublayers to split a layer into. The property can be set to the nodes in a + * layer, which then applies the property for the layer. If multiple nodes set the value to + * different values, then the lowest value is chosen. + * @defaultValue '2' + */ + 'elk.layered.layerUnzipping.layerSplit'?: `${number}`; + /** + * If set to true, nodes will always be placed in the first sublayer after a long edge when using + * the ALTERNATING strategy. Otherwise long edge dummies are treated the same as regular nodes. The + * default value is true. The property can be set to the nodes in a layer, which then applies the + * property for the layer. If any node sets the value to false, then the value is set to false for + * the entire layer. + * @defaultValue 'true' + */ + 'elk.layered.layerUnzipping.resetOnLongEdges'?: 'true' | 'false'; + /** + * Set on a node to not set a model order for this node even though it is a real node. + * @defaultValue 'false' + */ + 'elk.layered.considerModelOrder.noModelOrder'?: 'true' | 'false'; + /** + * Used to define partial ordering groups during cycle breaking. A lower group id means that the + * group is sorted before other groups. A group model order of 0 is the default group. + * @defaultValue '0' + */ + 'elk.layered.considerModelOrder.groupModelOrder.cycleBreakingId'?: `${number}`; + /** + * Used to define partial ordering groups during crossing minimization. A lower group id means that + * the group is sorted before other groups. A group model order of 0 is the default group. + * @defaultValue '0' + */ + 'elk.layered.considerModelOrder.groupModelOrder.crossingMinimizationId'?: `${number}`; + /** + * Used to define partial ordering groups during component packing. A lower group id means that the + * group is sorted before other groups. A group model order of 0 is the default group. + * @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..65da992035 --- /dev/null +++ b/packages/joint-layout-elk/src/types/elkPortOptions.mts @@ -0,0 +1,74 @@ +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; + /** + * Allows to specify individual spacing values for graph elements that shall be different from the + * value specified for the element's parent. + */ + 'elk.spacing.individual'?: string; + /** + * The position of a node, port, or label. This is used by the 'Fixed Layout' algorithm to specify + * a pre-defined position. + */ + 'elk.position'?: string; + /** + * No layout is done for the associated element. This is used to mark parts of a diagram to avoid + * their inclusion in the layout graph, or to mark parts of the layout graph to prevent layout + * engines from processing them. If you wish to exclude the contents of a compound node from + * automatic layout, while the node itself is still considered on its own layer, use the 'Fixed + * Layout' algorithm for that node. + * @defaultValue 'false' + */ + 'elk.noLayout'?: 'true' | 'false'; + /** + * The offset to the port position where connections shall be attached. + */ + 'elk.port.anchor'?: string; + /** + * The index of a port in the fixed order around a node. The order is assumed as clockwise, + * starting with the leftmost port on the top side. This option must be set if 'Port Constraints' + * is set to FIXED_ORDER and no specific positions are given for the ports. Additionally, the + * option 'Port Side' must be defined in this case. + */ + 'elk.port.index'?: `${number}`; + /** + * The side of a node on which a port is situated. This option must be set if 'Port Constraints' is + * set to FIXED_SIDE or FIXED_ORDER and no specific positions are given for the ports. + * @defaultValue 'UNDEFINED' + */ + 'elk.port.side'?: PortSide; + /** + * The offset of ports on the node border. With a positive offset the port is moved outside of the + * node, while with a negative offset the port is moved towards the inside. An offset of 0 means + * that the port is placed directly on the node border, i.e. if the port side is north, the port's + * south border touches the nodes's north border; if the port side is east, the port's west border + * touches the nodes's east border; if the port side is south, the port's north border touches the + * node's south border; if the port side is west, the port's east border touches the node's west + * border. + */ + 'elk.port.borderOffset'?: `${number}`; + /** + * Used to define partial ordering groups during crossing minimization. A lower group id means that + * the group is sorted before other groups. A group model order of 0 is the default group. + * @defaultValue '0' + */ + 'elk.layered.considerModelOrder.groupModelOrder.crossingMinimizationId'?: `${number}`; + /** + * Used to define partial ordering groups during component packing. A lower group id means that the + * group is sorted before other groups. A group model order of 0 is the default group. + * @defaultValue '0' + */ + 'elk.layered.considerModelOrder.groupModelOrder.componentGroupId'?: `${number}`; + /** + * Specifies whether non-flow ports may switch sides if their node's port constraints are either + * FIXED_SIDE or FIXED_ORDER. A non-flow port is a port on a side that is not part of the currently + * configured layout flow. For instance, given a left-to-right layout direction, north and south + * ports would be considered non-flow ports. Further note that the underlying criterium whether to + * switch sides or not solely relies on the minimization of edge crossings. Hence, edge length and + * other aesthetics criteria are not addressed. + * @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'; From 3b7f1254ada1d71fbc626355f7c05a265e163fb0 Mon Sep 17 00:00:00 2001 From: Geliogabalus Date: Wed, 16 Sep 2026 17:34:45 +0200 Subject: [PATCH 17/75] fix --- examples/layout-elk-containers-ports-ts/src/index.ts | 10 ++++++++-- packages/joint-layout-elk/src/export.mts | 8 ++++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/examples/layout-elk-containers-ports-ts/src/index.ts b/examples/layout-elk-containers-ports-ts/src/index.ts index 9eb3bd7d08..d62922adaa 100644 --- a/examples/layout-elk-containers-ports-ts/src/index.ts +++ b/examples/layout-elk-containers-ports-ts/src/index.ts @@ -1,5 +1,5 @@ import { dia, shapes } from '@joint/core'; -import { ElkLayoutOptions, layout, NodeProperties, NodePropertiesCallbackParameters } from '@joint/layout-elk'; +import { ElkLayoutOptions, layout, NodeProperties, NodePropertiesCallbackParameters, type PortProperties, type PortPropertiesCallbackParameters } from '@joint/layout-elk'; import ELK from 'elkjs/lib/elk-api.js'; import { graphJSON } from './example'; import { Container, HubService, InteractionLink, Service } from './shapes'; @@ -105,9 +105,15 @@ const init = () => { if (element.getEmbeddedCells().length === 0) return computedProperties; return { ...computedProperties, - layoutOptions: { ...computedProperties.layoutOptions, 'elk.padding': CONTAINER_PADDING } + layoutOptions: { + ...computedProperties.layoutOptions, + 'elk.padding': CONTAINER_PADDING + } }; }, + portProperties: ({ port, element, computedProperties }: PortPropertiesCallbackParameters): PortProperties => { + return computedProperties; + }, elkLayoutOptions }).then(() => { paper.unfreeze(); diff --git a/packages/joint-layout-elk/src/export.mts b/packages/joint-layout-elk/src/export.mts index b12a7c8320..05389ea65f 100644 --- a/packages/joint-layout-elk/src/export.mts +++ b/packages/joint-layout-elk/src/export.mts @@ -151,10 +151,10 @@ function buildPorts(element: dia.Element): ElkPort[] | undefined { // port's own raw, unmerged JSON, so they can't be used here. let labels: ElkLabel[] | undefined; if (exportGraphOptions.positionPortLabels) { - // `label` isn't part of the officially typed `dia.Element.Port` shape, but - // JointJS reads it off (both the port's own and, merged in, its group's) at - // render time if present. - const { width: labelWidth, height: labelHeight } = (port.label as { size?: dia.Size } | undefined)?.size ?? DEFAULT_LABEL_SIZE; + // @ts-expect-error `getPortMetrics` isn't officially typed + const portMetrics = element.getPortMetrics(portId); + + const { width: labelWidth, height: labelHeight } = portMetrics.labelSize ?? DEFAULT_LABEL_SIZE; labels = [{ // Some text is required, otherwise ELK ignores the label. text: ELK_LABEL_TEXT, From 9b2df31ccf65bd9dd6536a07e652f9557cbd4576 Mon Sep 17 00:00:00 2001 From: Geliogabalus Date: Wed, 16 Sep 2026 18:11:01 +0200 Subject: [PATCH 18/75] update --- .../src/example.ts | 61 +++++++++++++++---- .../src/shapes.ts | 42 +++++++++++-- packages/joint-layout-elk/src/export.mts | 2 + 3 files changed, 86 insertions(+), 19 deletions(-) diff --git a/examples/layout-elk-containers-ports-ts/src/example.ts b/examples/layout-elk-containers-ports-ts/src/example.ts index b0356c0778..74a9ec610c 100644 --- a/examples/layout-elk-containers-ports-ts/src/example.ts +++ b/examples/layout-elk-containers-ports-ts/src/example.ts @@ -1,12 +1,15 @@ import { dia } from '@joint/core'; -// A fixed (non-random) system diagram: three containers grouping eight -// services that communicate over ports, including links that cross container -// boundaries. Every plain `example.Service` has exactly one 'in' and one -// 'out' port; the four "hub" services (Load Balancer, API Gateway, Auth -// Service, Logger) are `example.HubService` instead, with a custom number of -// ports - highlighted, and the only ones that opt into `portsPosition` so -// ELK orders their ports to minimize crossings (see `index.ts`). +// 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. Every plain `example.Service` has exactly +// one 'in' and one 'out' port; the four "hub" services (Load Balancer, API +// Gateway, Auth Service, Logger) are `example.HubService` instead, with a +// custom number of ports - highlighted, and the only ones that opt into +// `portsPosition` so ELK orders their ports to minimize crossings (see +// `index.ts`). export const graphJSON: dia.Graph.JSON = { cells: [ // Containers @@ -14,13 +17,27 @@ export const graphJSON: dia.Graph.JSON = { id: 'frontend', type: 'example.Container', attrs: { label: { text: 'Frontend' } }, - embeds: ['webui', 'mobileui', 'lb', 'gateway'] + embeds: ['webui', 'mobileui', 'edge'] + }, + { + id: 'edge', + type: 'example.Container', + parent: 'frontend', + attrs: { label: { text: 'Edge' } }, + embeds: ['lb', 'gateway'] }, { id: 'backend', type: 'example.Container', attrs: { label: { text: 'Backend' } }, - embeds: ['auth', 'cache', 'db'] + embeds: ['auth', 'storage'] + }, + { + id: 'storage', + type: 'example.Container', + parent: 'backend', + attrs: { label: { text: 'Storage' } }, + embeds: ['cache', 'db'] }, { id: 'observability', @@ -45,7 +62,7 @@ export const graphJSON: dia.Graph.JSON = { { id: 'lb', type: 'example.HubService', - parent: 'frontend', + parent: 'edge', size: { width: 130, height: 80 }, attrs: { body: { fill: '#E3E9C2' }, label: { text: 'Load Balancer' } }, ports: { @@ -59,7 +76,7 @@ export const graphJSON: dia.Graph.JSON = { { id: 'gateway', type: 'example.HubService', - parent: 'frontend', + parent: 'edge', size: { width: 130, height: 80 }, attrs: { body: { fill: '#E3E9C2' }, label: { text: 'API Gateway' } }, ports: { @@ -89,13 +106,13 @@ export const graphJSON: dia.Graph.JSON = { { id: 'cache', type: 'example.Service', - parent: 'backend', + parent: 'storage', attrs: { body: { fill: '#F9FBB2' }, label: { text: 'Cache' } } }, { id: 'db', type: 'example.Service', - parent: 'backend', + parent: 'storage', attrs: { body: { fill: '#C89F9C' }, label: { text: 'Database' } } }, @@ -170,6 +187,24 @@ export const graphJSON: dia.Graph.JSON = { 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-ports-ts/src/shapes.ts b/examples/layout-elk-containers-ports-ts/src/shapes.ts index 600293dafe..49d25d2181 100644 --- a/examples/layout-elk-containers-ports-ts/src/shapes.ts +++ b/examples/layout-elk-containers-ports-ts/src/shapes.ts @@ -1,4 +1,4 @@ -import { shapes, util } from '@joint/core'; +import { dia, shapes, util } from '@joint/core'; const PORT_SIZE = { width: 12, height: 12 }; const PORT_ATTRS = { @@ -19,13 +19,24 @@ const PORT_ATTRS = { const PORT_LABEL = { position: { name: 'outside' - }, - // Generous enough for every port label text in this example ('in', 'in1', 'in2', - // 'out', 'out1', 'out2') - `@joint/layout-elk` reads this size directly (via - // `positionPortLabels`) rather than measuring the rendered text itself. - size: { width: 34, height: 18 } + } }; +// `@joint/layout-elk` reads a port label's `size` directly (via `positionPortLabels`) +// 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. +const PORT_LABEL_AVERAGE_CHAR_WIDTH = PORT_ATTRS.text.fontSize * 0.4; +const PORT_LABEL_HORIZONTAL_PADDING = 6; +const PORT_LABEL_HEIGHT = PORT_ATTRS.text.fontSize + 4; + +function estimatePortLabelSize(text: string): dia.Size { + return { + width: Math.ceil(text.length * PORT_LABEL_AVERAGE_CHAR_WIDTH) + PORT_LABEL_HORIZONTAL_PADDING, + 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: 14 }; @@ -126,6 +137,25 @@ export class Service extends shapes.standard.Rectangle { } }, 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)); + }); + } } /** diff --git a/packages/joint-layout-elk/src/export.mts b/packages/joint-layout-elk/src/export.mts index 05389ea65f..f9ac5a3a67 100644 --- a/packages/joint-layout-elk/src/export.mts +++ b/packages/joint-layout-elk/src/export.mts @@ -367,5 +367,7 @@ export function exportGraph( graph.getLinks().forEach(buildEdge); + console.log(elkGraph); + return { elkGraph, elementsById, linksById, portsById }; } From e2140cf84e4ce69a503a490f03411921ff2e7603 Mon Sep 17 00:00:00 2001 From: Geliogabalus Date: Thu, 17 Sep 2026 11:55:23 +0200 Subject: [PATCH 19/75] up --- examples/layout-elk-containers-ports-ts/src/index.ts | 8 +++----- packages/joint-layout-elk/src/export.mts | 2 -- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/examples/layout-elk-containers-ports-ts/src/index.ts b/examples/layout-elk-containers-ports-ts/src/index.ts index d62922adaa..f09a60665f 100644 --- a/examples/layout-elk-containers-ports-ts/src/index.ts +++ b/examples/layout-elk-containers-ports-ts/src/index.ts @@ -1,4 +1,4 @@ -import { dia, shapes } from '@joint/core'; +import { dia, shapes, util } from '@joint/core'; import { ElkLayoutOptions, layout, NodeProperties, NodePropertiesCallbackParameters, type PortProperties, type PortPropertiesCallbackParameters } from '@joint/layout-elk'; import ELK from 'elkjs/lib/elk-api.js'; import { graphJSON } from './example'; @@ -103,13 +103,11 @@ const init = () => { // Reserve extra top padding inside containers, so children don't // overlap the container's title label. if (element.getEmbeddedCells().length === 0) return computedProperties; - return { - ...computedProperties, + return util.defaultsDeep({}, computedProperties, { layoutOptions: { - ...computedProperties.layoutOptions, 'elk.padding': CONTAINER_PADDING } - }; + }); }, portProperties: ({ port, element, computedProperties }: PortPropertiesCallbackParameters): PortProperties => { return computedProperties; diff --git a/packages/joint-layout-elk/src/export.mts b/packages/joint-layout-elk/src/export.mts index f9ac5a3a67..05389ea65f 100644 --- a/packages/joint-layout-elk/src/export.mts +++ b/packages/joint-layout-elk/src/export.mts @@ -367,7 +367,5 @@ export function exportGraph( graph.getLinks().forEach(buildEdge); - console.log(elkGraph); - return { elkGraph, elementsById, linksById, portsById }; } From f7ad46cb9d51a7c7c955b82eac7480730525691c Mon Sep 17 00:00:00 2001 From: Geliogabalus Date: Thu, 17 Sep 2026 12:29:02 +0200 Subject: [PATCH 20/75] wip --- .../layout-elk-containers-ports-ts/index.html | 1 + .../src/index.ts | 88 +++++++++++++------ packages/joint-layout-elk/src/layout.mts | 2 +- 3 files changed, 62 insertions(+), 29 deletions(-) diff --git a/examples/layout-elk-containers-ports-ts/index.html b/examples/layout-elk-containers-ports-ts/index.html index 1b4b313ef3..609100fca8 100644 --- a/examples/layout-elk-containers-ports-ts/index.html +++ b/examples/layout-elk-containers-ports-ts/index.html @@ -13,6 +13,7 @@
Zoom Out Zoom In + Layout
diff --git a/examples/layout-elk-containers-ports-ts/src/index.ts b/examples/layout-elk-containers-ports-ts/src/index.ts index f09a60665f..41e4c51fb8 100644 --- a/examples/layout-elk-containers-ports-ts/src/index.ts +++ b/examples/layout-elk-containers-ports-ts/src/index.ts @@ -1,5 +1,5 @@ import { dia, shapes, util } from '@joint/core'; -import { ElkLayoutOptions, layout, NodeProperties, NodePropertiesCallbackParameters, type PortProperties, type PortPropertiesCallbackParameters } from '@joint/layout-elk'; +import { ElkLayoutOptions, layout, NodeProperties, NodePropertiesCallbackParameters, type Options as LayoutOptions, type PortProperties, type PortPropertiesCallbackParameters } from '@joint/layout-elk'; import ELK from 'elkjs/lib/elk-api.js'; import { graphJSON } from './example'; import { Container, HubService, InteractionLink, Service } from './shapes'; @@ -28,7 +28,10 @@ const init = () => { width: 1200, height: 700, gridSize: 1, - interactive: false, + // Elements can be dragged around; every other interaction (links, + // vertices, resizing, ...) stays disabled, matching this example's + // otherwise read-only diagram. + interactive: { elementMove: true }, async: true, frozen: true, defaultConnector: { @@ -93,32 +96,61 @@ const init = () => { workerUrl: '../node_modules/elkjs/lib/elk-worker.js' }); - layout(graph, { - elk, - // Let ELK reposition (and reorder) every port in the diagram, instead - // of keeping them where JointJS's own port groups first placed them. - portsPosition: 'fixed-side', - positionPortLabels: true, - nodeProperties: ({ element, computedProperties }: NodePropertiesCallbackParameters): NodeProperties => { - // Reserve extra top padding inside containers, so children don't - // overlap the container's title label. - if (element.getEmbeddedCells().length === 0) return computedProperties; - return util.defaultsDeep({}, computedProperties, { - layoutOptions: { - 'elk.padding': CONTAINER_PADDING - } - }); - }, - portProperties: ({ port, element, computedProperties }: PortPropertiesCallbackParameters): PortProperties => { - return computedProperties; - }, - elkLayoutOptions - }).then(() => { - paper.unfreeze(); - zoom(paper, 1); - - }).catch((error) => { - console.error('ELK layout error:', error.message); + // Wraps every `layout()` call the example makes - freezing the paper for its + // (async) duration, so nothing renders mid-layout, and reporting any error the + // same way regardless of which caller triggered the layout. + const runLayout = (options?: Partial): Promise => { + paper.freeze(); + return layout(graph, { + elk, + // Let ELK reposition (and reorder) every port in the diagram, instead + // of keeping them where JointJS's own port groups first placed them. + portsPosition: 'fixed-side', + positionPortLabels: true, + nodeProperties: ({ element, computedProperties }: NodePropertiesCallbackParameters): NodeProperties => { + // Reserve extra top padding inside containers, so children don't + // overlap the container's title label. + if (element.getEmbeddedCells().length === 0) return computedProperties; + return util.defaultsDeep({}, computedProperties, { + layoutOptions: { + 'elk.padding': CONTAINER_PADDING + } + }); + }, + portProperties: ({ port, element, computedProperties }: PortPropertiesCallbackParameters): PortProperties => { + return computedProperties; + }, + elkLayoutOptions, + ...options + }).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)); + + // Re-run the layout, with `interactive: true`, whenever the user finishes + // dragging an element. Note what this does and doesn't do: ELK's `layered` + // algorithm assigns each node's layer (i.e. column, since `elk.direction: + // 'RIGHT'`) from the graph's topology alone, every time, from scratch - so a + // drag that only reorders nodes within their existing layer sticks, but one + // that tries to move a node to a different layer typically doesn't, and the + // node snaps back close to where it was. `interactive: true` is really aimed + // at a different scenario - keeping the rest of an already laid out diagram + // stable when the *graph itself* changes (e.g. a node/edge is added), rather + // than at freely relocating an existing node by hand. + paper.on('element:pointerup', () => { + runLayout({ interactive: true }).then(() => zoom(paper, 1)); + }); + + // "Layout" toolbar button - lays out the whole graph from scratch, ignoring + // elements' current positions, same as the initial layout above. + document.getElementById('layout')!.addEventListener('click', () => { + runLayout().then(() => zoom(paper, 1)); }); }; diff --git a/packages/joint-layout-elk/src/layout.mts b/packages/joint-layout-elk/src/layout.mts index 9ae41fd1a6..dbed0d08ff 100644 --- a/packages/joint-layout-elk/src/layout.mts +++ b/packages/joint-layout-elk/src/layout.mts @@ -33,7 +33,7 @@ const INTERACTIVE_LAYOUT_OPTIONS: ElkLayoutOptions = { // `'layered'`'s own interactivity is per-phase - each of these reads the corresponding // aspect (edge direction, x/y) straight off an element's current position instead of // computing it from scratch, so the four are meant to be used together. - // 'elk.layered.layering.strategy': 'INTERACTIVE', + 'elk.layered.layering.strategy': 'INTERACTIVE', // NOT `crossingMinimization.strategy: 'INTERACTIVE'` - that variant doesn't minimize // crossings at all, it just sorts each layer by previous y and calls it done (see // `InteractiveCrossingMinimizer` upstream). `semiInteractive` instead keeps the real From b1935a72dff9a22e9e28d99a19ef0e13d9ee3c35 Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Mon, 21 Sep 2026 12:02:34 +0200 Subject: [PATCH 21/75] fixes --- .../src/index.ts | 35 +++--------- packages/joint-layout-elk/src/import.mts | 32 ++++++++--- packages/joint-layout-elk/test/index.js | 54 +++++++++++++++++-- 3 files changed, 82 insertions(+), 39 deletions(-) diff --git a/examples/layout-elk-containers-ports-ts/src/index.ts b/examples/layout-elk-containers-ports-ts/src/index.ts index 41e4c51fb8..b0447b1c7a 100644 --- a/examples/layout-elk-containers-ports-ts/src/index.ts +++ b/examples/layout-elk-containers-ports-ts/src/index.ts @@ -1,5 +1,5 @@ import { dia, shapes, util } from '@joint/core'; -import { ElkLayoutOptions, layout, NodeProperties, NodePropertiesCallbackParameters, type Options as LayoutOptions, type PortProperties, type PortPropertiesCallbackParameters } from '@joint/layout-elk'; +import { ElkLayoutOptions, layout, NodeProperties, NodePropertiesCallbackParameters, type PortProperties, type PortPropertiesCallbackParameters } from '@joint/layout-elk'; import ELK from 'elkjs/lib/elk-api.js'; import { graphJSON } from './example'; import { Container, HubService, InteractionLink, Service } from './shapes'; @@ -28,10 +28,7 @@ const init = () => { width: 1200, height: 700, gridSize: 1, - // Elements can be dragged around; every other interaction (links, - // vertices, resizing, ...) stays disabled, matching this example's - // otherwise read-only diagram. - interactive: { elementMove: true }, + interactive: false, async: true, frozen: true, defaultConnector: { @@ -99,7 +96,7 @@ const init = () => { // Wraps every `layout()` call the example makes - freezing the paper for its // (async) duration, so nothing renders mid-layout, and reporting any error the // same way regardless of which caller triggered the layout. - const runLayout = (options?: Partial): Promise => { + const runLayout = (): Promise => { paper.freeze(); return layout(graph, { elk, @@ -117,11 +114,7 @@ const init = () => { } }); }, - portProperties: ({ port, element, computedProperties }: PortPropertiesCallbackParameters): PortProperties => { - return computedProperties; - }, - elkLayoutOptions, - ...options + elkLayoutOptions }).then(() => { paper.unfreeze(); }).catch((error) => { @@ -133,22 +126,10 @@ const init = () => { // Initial layout of the fixed example data, fit to the paper's viewport. runLayout().then(() => zoom(paper, 1)); - // Re-run the layout, with `interactive: true`, whenever the user finishes - // dragging an element. Note what this does and doesn't do: ELK's `layered` - // algorithm assigns each node's layer (i.e. column, since `elk.direction: - // 'RIGHT'`) from the graph's topology alone, every time, from scratch - so a - // drag that only reorders nodes within their existing layer sticks, but one - // that tries to move a node to a different layer typically doesn't, and the - // node snaps back close to where it was. `interactive: true` is really aimed - // at a different scenario - keeping the rest of an already laid out diagram - // stable when the *graph itself* changes (e.g. a node/edge is added), rather - // than at freely relocating an existing node by hand. - paper.on('element:pointerup', () => { - runLayout({ interactive: true }).then(() => zoom(paper, 1)); - }); - - // "Layout" toolbar button - lays out the whole graph from scratch, ignoring - // elements' current positions, same as the initial layout above. + // "Layout" toolbar button - re-runs the same from-scratch layout on the + // unchanged graph. Every run is independent (no `interactive: true`), so + // clicking it repeatedly is a quick way to check that `layout()` is + // idempotent - each run should settle on the same result as the last. document.getElementById('layout')!.addEventListener('click', () => { runLayout().then(() => zoom(paper, 1)); }); diff --git a/packages/joint-layout-elk/src/import.mts b/packages/joint-layout-elk/src/import.mts index ac85579f59..f04b1ec17b 100644 --- a/packages/joint-layout-elk/src/import.mts +++ b/packages/joint-layout-elk/src/import.mts @@ -110,13 +110,20 @@ const defaultSetElementAttributes: SetElementAttributesCallback = ({ element, at const defaultSetPortAttributes: SetPortAttributesCallback = ({ element, portId, attributes }) => { const { group } = element.getPort(portId); - if (group !== undefined) { - // Every port ends up with a computed position (all of an element's ports are - // exported), so switching the whole group to `'absolute'` is safe here - it - // only replaces the group's `position`, leaving its `attrs`/`markup`/`label` intact. - element.prop(['ports', 'groups', group, 'position'], { name: 'absolute' }); + + // With `portsPosition` 'fixed' (the default), a port already stays exactly where + // JointJS's own port groups place it - ELK was only told where that is, not asked + // to move it - so there is nothing to apply back, and the group keeps its own + // position type (e.g. 'left') instead of being replaced with a fixed 'absolute' one. + if ((importLayoutOptions.portsPosition ?? 'fixed') !== 'fixed') { + if (group !== undefined) { + // Every port ends up with a computed position (all of an element's ports are + // exported), so switching the whole group to `'absolute'` is safe here - it + // only replaces the group's `position`, leaving its `attrs`/`markup`/`label` intact. + element.prop(['ports', 'groups', group, 'position'], { name: 'absolute' }); + } + element.portProp(portId, ['position', 'args'], attributes.position); } - element.portProp(portId, ['position', 'args'], attributes.position); if (attributes.labelPosition) { if (group !== undefined) { @@ -260,10 +267,21 @@ function importNode(node: ElkNode, containerPosition: dia.Point = { x: 0, y: 0 } } } + // Like a node's, ELK's own `x`/`y` for a port is the top-left corner of its + // bounding box - but the 'absolute' port position (which `layout()` switches + // every port-bearing group to, see `defaultSetPortAttributes` below) takes its + // `args.x`/`args.y` to be the port's *center* (`dia.Element#getPortRelativeRect` + // derives the port's rect by subtracting half its size from that same position). + // Without this, every `layout()` call would shift each port half its own size + // off from where the previous call left it - compounding on every further call. + const width = port.width || 0; + const height = port.height || 0; + const position: dia.Point = { x: (port.x || 0) + width / 2, y: (port.y || 0) + height / 2 }; + setPortAttributes({ element: found.element, portId: found.portId, - attributes: { position: { x: port.x || 0, y: port.y || 0 }, labelPosition } + attributes: { position, labelPosition } }); }); } diff --git a/packages/joint-layout-elk/test/index.js b/packages/joint-layout-elk/test/index.js index 51ea24fc46..1d135ecb5d 100644 --- a/packages/joint-layout-elk/test/index.js +++ b/packages/joint-layout-elk/test/index.js @@ -168,7 +168,7 @@ QUnit.module('layout()', () => { assert.ok(Array.isArray(link.vertices())); }); - QUnit.test('should call portOptions for each port', async(assert) => { + QUnit.test('should call portProperties for each port', async(assert) => { const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); const el1 = new joint.shapes.standard.Rectangle({ @@ -188,9 +188,9 @@ QUnit.module('layout()', () => { const seen = []; await joint.layout.ELK.layout(graph, { - portOptions: (port, element) => { + portProperties: ({ port, element, computedProperties }) => { seen.push([port.id, element.id]); - return undefined; + return computedProperties; } }); @@ -219,7 +219,7 @@ QUnit.module('layout()', () => { assert.equal(el1.prop(['ports', 'groups', 'out', 'position']), 'right'); }); - QUnit.test('should let ELK position ports when `positionPorts` is enabled', async(assert) => { + QUnit.test('should let ELK position ports when `portsPosition` is enabled', async(assert) => { const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); const el1 = new joint.shapes.standard.Rectangle({ @@ -249,7 +249,7 @@ QUnit.module('layout()', () => { graph.resetCells([el1, el2, link]); - await joint.layout.ELK.layout(graph, { positionPorts: 'fixed-side' }); + await joint.layout.ELK.layout(graph, { portsPosition: 'fixed-side' }); // The group's position is switched to 'absolute' so the ELK-computed position applies. assert.equal(el1.prop(['ports', 'groups', 'out', 'position', 'name']), 'absolute'); @@ -264,4 +264,48 @@ QUnit.module('layout()', () => { assert.equal(relativePosition.x, position.x); assert.equal(relativePosition.y, position.y); }); + + 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); + }); }); From 86f521edc0366ae8ce9152d59b6dbffdb9b224e6 Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Mon, 21 Sep 2026 14:28:58 +0200 Subject: [PATCH 22/75] update --- .../src/shapes.ts | 18 ++---- packages/joint-core/src/dia/ports.mjs | 40 +++++++++++- .../joint-core/test/jointjs/elementPorts.js | 32 ++++++++++ packages/joint-layout-elk/rollup.config.mjs | 64 +++++-------------- packages/joint-layout-elk/src/export.mts | 48 +++++++++++--- packages/joint-layout-elk/src/import.mts | 13 +--- 6 files changed, 132 insertions(+), 83 deletions(-) diff --git a/examples/layout-elk-containers-ports-ts/src/shapes.ts b/examples/layout-elk-containers-ports-ts/src/shapes.ts index 49d25d2181..804c94a3e2 100644 --- a/examples/layout-elk-containers-ports-ts/src/shapes.ts +++ b/examples/layout-elk-containers-ports-ts/src/shapes.ts @@ -16,12 +16,6 @@ const PORT_ATTRS = { } }; -const PORT_LABEL = { - position: { - name: 'outside' - } -}; - // `@joint/layout-elk` reads a port label's `size` directly (via `positionPortLabels`) // 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 @@ -118,16 +112,18 @@ export class Service extends shapes.standard.Rectangle { ports: { groups: { in: { - position: 'left', size: PORT_SIZE, attrs: PORT_ATTRS, - label: PORT_LABEL + elkLayout: { + side: 'WEST' + } }, out: { - position: 'right', size: PORT_SIZE, attrs: PORT_ATTRS, - label: PORT_LABEL + elkLayout: { + side: 'EAST' + } } }, items: [ @@ -153,7 +149,7 @@ export class Service extends shapes.standard.Rectangle { ports.forEach((port) => { const text = port.attrs?.text?.text; if (!port.id || typeof text !== 'string') return; - this.portProp(port.id, 'label/size', estimatePortLabelSize(text)); + this.portProp(port.id, 'elkLayout/labelSize', estimatePortLabelSize(text)); }); } } diff --git a/packages/joint-core/src/dia/ports.mjs b/packages/joint-core/src/dia/ports.mjs index 56b85214d0..00bdb494af 100644 --- a/packages/joint-core/src/dia/ports.mjs +++ b/packages/joint-core/src/dia/ports.mjs @@ -348,6 +348,25 @@ PortData.prototype = { } }; +// Group properties this module already merges into a port itself, at render time +// (see `PortData.prototype._evaluatePort`) - excluded from `portProp`'s own group +// fallback below so that behavior (raw, port-only data) stays exactly as it was. +const PORT_OWN_GROUP_PROPERTIES = ['position', 'label', 'markup', 'attrs', 'size', 'z']; + +// A port's own `value` for some `portProp` path, merged with its group's `value` for +// that same path - `value` wins on conflicts, same as `attrs`/`label`/... already do +// (see `PortData.prototype._evaluatePort`): a plain object merges recursively (so a +// port only needs to override the parts of a group-level object it cares about), while +// anything else (including a completely absent `value`) is just replaced outright. +function mergeWithPortGroupValue(value, groupValue) { + if (groupValue === undefined) return value; + if (value === undefined) return util.cloneDeep(groupValue); + if (util.isPlainObject(value) && util.isPlainObject(groupValue)) { + return util.merge({}, groupValue, value); + } + return value; +} + export const elementPortPrototype = { _initializePorts: function(options) { @@ -617,12 +636,23 @@ export const elementPortPrototype = { } var args = Array.prototype.slice.call(arguments, 1); + // A get (no `value` to set) whose `path` targets a property this module does + // not already merge into the port itself (see `PORT_OWN_GROUP_PROPERTIES`) is + // merged here instead with the port's group's own value at that same path - so + // e.g. custom metadata set once on a group (a consumer's own `elkLayout`, say) + // is still visible on every one of that group's ports, without repeating it on + // each one - see `mergeWithPortGroupValue`. + var isGet = (value === undefined) && !util.isPlainObject(path); + var pathArray = null; + if (Array.isArray(path)) { + pathArray = path; args[0] = ['ports', 'items', index].concat(path); } else if (util.isString(path)) { // Get/set an attribute by a special path syntax that delimits // nested objects by the colon character. + pathArray = path.split('/'); args[0] = ['ports/items/', index, '/', path].join(''); } else { @@ -634,7 +664,15 @@ export const elementPortPrototype = { } } - return this.prop.apply(this, args); + var result = this.prop.apply(this, args); + + if (isGet && pathArray && PORT_OWN_GROUP_PROPERTIES.indexOf(pathArray[0]) === -1) { + var group = util.toArray(this.prop('ports/items'))[index].group; + var groupValue = (group === undefined) ? undefined : util.getByPath(this.prop(['ports', 'groups', group]), pathArray); + result = mergeWithPortGroupValue(result, groupValue); + } + + return result; }, _validatePorts: function() { diff --git a/packages/joint-core/test/jointjs/elementPorts.js b/packages/joint-core/test/jointjs/elementPorts.js index 39191f99f5..12929a876f 100644 --- a/packages/joint-core/test/jointjs/elementPorts.js +++ b/packages/joint-core/test/jointjs/elementPorts.js @@ -2877,6 +2877,38 @@ QUnit.module('element ports', function() { assert.ok(_.isPlainObject(shape.portProp('one', 'object'))); assert.equal(shape.portProp('one', 'object/20'), 'object property'); }); + + QUnit.test('should merge a group\'s own custom properties into its ports', function(assert) { + + var shape = create({ + groups: { + in: { + attrs: { '.body': { fill: 'red' }}, + elkLayout: { side: 'WEST', spacing: 5 } + } + }, + items: [ + { id: 'one', group: 'in' }, + { id: 'two', group: 'in', elkLayout: { spacing: 10 }}, + { id: 'three' } + ] + }); + + // A port with no `elkLayout` of its own inherits the group's. + assert.deepEqual(shape.portProp('one', 'elkLayout'), { side: 'WEST', spacing: 5 }); + assert.equal(shape.portProp('one', 'elkLayout/side'), 'WEST'); + + // A port's own (partial) value is deep-merged on top of the group's. + assert.deepEqual(shape.portProp('two', 'elkLayout'), { side: 'WEST', spacing: 10 }); + + // A port with no group at all is unaffected. + assert.equal(shape.portProp('three', 'elkLayout'), undefined); + + // Properties this module already merges elsewhere (e.g. `attrs` - see + // `PortData#_evaluatePort`) are untouched by this - `portProp` still only + // ever returns the port's own, unmerged JSON for them. + assert.equal(shape.portProp('one', 'attrs'), undefined); + }); }); QUnit.module('event ports:add and ports:remove', function(hooks) { diff --git a/packages/joint-layout-elk/rollup.config.mjs b/packages/joint-layout-elk/rollup.config.mjs index 0a3cfe365f..5d94ff13c8 100644 --- a/packages/joint-layout-elk/rollup.config.mjs +++ b/packages/joint-layout-elk/rollup.config.mjs @@ -2,7 +2,6 @@ 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` @@ -12,29 +11,25 @@ const bannerText = `/*! ${packageJson.title} v${packageJson.version} (${formatte const input = ['./dist/esm/index.mjs']; -const external = [ - '@joint/core', - 'elkjs/lib/elk.bundled.js', - 'elkjs/lib/elk-api.js' -]; - -const globals = { - '@joint/core': 'joint', - 'elkjs/lib/elk.bundled.js': 'ELK', - 'elkjs/lib/elk-api.js': 'ELK' -}; - export default [ { input, - external, + external: [ + '@joint/core', + 'elkjs/lib/elk.bundled.js', + 'elkjs/lib/elk-api.js' + ], output: [ { file: 'dist/umd/index.js', format: 'umd', name: 'joint.layout.ELK', extend: true, - globals, + globals: { + '@joint/core': 'joint', + 'elkjs/lib/elk.bundled.js': 'ELK', + 'elkjs/lib/elk-api.js': 'ELK' + }, plugins: [ banner(() => bannerText) ] @@ -44,7 +39,11 @@ export default [ format: 'umd', name: 'joint.layout.ELK', extend: true, - globals, + globals: { + '@joint/core': 'joint', + 'elkjs/lib/elk.bundled.js': 'ELK', + 'elkjs/lib/elk-api.js': 'ELK' + }, plugins: [ terser({ format: { ascii_only: true }}), banner(() => bannerText) @@ -56,38 +55,5 @@ export default [ 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, - output: [ - { - file: 'build/test/index.js', - format: 'umd', - name: 'joint.layout.ELK', - extend: true, - globals, - sourcemap: true - } - ], - plugins: [ - 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/export.mts b/packages/joint-layout-elk/src/export.mts index 05389ea65f..8bee3aca13 100644 --- a/packages/joint-layout-elk/src/export.mts +++ b/packages/joint-layout-elk/src/export.mts @@ -143,7 +143,8 @@ function buildPorts(element: dia.Element): ElkPort[] | undefined { const elkPortId = `${element.id}:${portId}`; portsById.set(elkPortId, { element, portId }); - const layoutOptions: PortElkLayoutOptions = {}; + const properties = element.portProp(portId, 'elkLayout'); + // `port` (from `element.getPorts()`) already carries the fully resolved label - // a port's own `label` (if it has one) merged over its group's, same as JointJS @@ -151,10 +152,8 @@ function buildPorts(element: dia.Element): ElkPort[] | undefined { // port's own raw, unmerged JSON, so they can't be used here. let labels: ElkLabel[] | undefined; if (exportGraphOptions.positionPortLabels) { - // @ts-expect-error `getPortMetrics` isn't officially typed - const portMetrics = element.getPortMetrics(portId); - const { width: labelWidth, height: labelHeight } = portMetrics.labelSize ?? DEFAULT_LABEL_SIZE; + const { width: labelWidth, height: labelHeight } = properties?.labelSize ?? DEFAULT_LABEL_SIZE; labels = [{ // Some text is required, otherwise ELK ignores the label. text: ELK_LABEL_TEXT, @@ -164,17 +163,46 @@ function buildPorts(element: dia.Element): ElkPort[] | undefined { }]; } - const { x, y, width, height } = element.getPortRelativeRect(portId); - layoutOptions['port.borderOffset'] = (-width / 2).toString(); + let portProperties: PortProperties = {}; + const layoutOptions: PortElkLayoutOptions = {}; + const { x, y } = element.getPortRelativePosition(portId); + const { width, height } = element.getPortRelativeRect(portId); + + if (!exportGraphOptions.portsPosition || exportGraphOptions.portsPosition === 'fixed') { + portProperties.x = x; + portProperties.y = y; + } - let portProperties: PortProperties = { - x, - y, + if (exportGraphOptions.portsPosition === 'fixed-side') { + switch (properties?.side) { + case 'WEST': + layoutOptions['elk.port.side'] = 'WEST'; + break; + case 'EAST': + layoutOptions['elk.port.side'] = 'EAST'; + break; + case 'SOUTH': + layoutOptions['elk.port.side'] = 'SOUTH'; + break; + case 'NORTH': + layoutOptions['elk.port.side'] = 'NORTH'; + break; + default: + } + } + + if (exportGraphOptions.portsPosition === 'fixed-side' || exportGraphOptions.portsPosition === 'free') { + layoutOptions['elk.port.borderOffset'] = `${-width / 2}`; + } + + portProperties = { width, height, labels, - ...layoutOptions + layoutOptions, + ...portProperties }; + if (exportGraphOptions.portProperties) { portProperties = exportGraphOptions.portProperties({ port, diff --git a/packages/joint-layout-elk/src/import.mts b/packages/joint-layout-elk/src/import.mts index f04b1ec17b..b215d93b32 100644 --- a/packages/joint-layout-elk/src/import.mts +++ b/packages/joint-layout-elk/src/import.mts @@ -267,21 +267,10 @@ function importNode(node: ElkNode, containerPosition: dia.Point = { x: 0, y: 0 } } } - // Like a node's, ELK's own `x`/`y` for a port is the top-left corner of its - // bounding box - but the 'absolute' port position (which `layout()` switches - // every port-bearing group to, see `defaultSetPortAttributes` below) takes its - // `args.x`/`args.y` to be the port's *center* (`dia.Element#getPortRelativeRect` - // derives the port's rect by subtracting half its size from that same position). - // Without this, every `layout()` call would shift each port half its own size - // off from where the previous call left it - compounding on every further call. - const width = port.width || 0; - const height = port.height || 0; - const position: dia.Point = { x: (port.x || 0) + width / 2, y: (port.y || 0) + height / 2 }; - setPortAttributes({ element: found.element, portId: found.portId, - attributes: { position, labelPosition } + attributes: { position: { x: port.x || 0, y: port.y || 0 }, labelPosition } }); }); } From b494e69f77a082eecee8907da4ea7785226f4053 Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Mon, 21 Sep 2026 15:01:32 +0200 Subject: [PATCH 23/75] fixes --- examples/layout-elk-containers-ports-ts/src/example.ts | 2 +- examples/layout-elk-containers-ports-ts/src/shapes.ts | 4 ++-- packages/joint-layout-elk/src/export.mts | 9 --------- packages/joint-layout-elk/src/import.mts | 2 +- 4 files changed, 4 insertions(+), 13 deletions(-) diff --git a/examples/layout-elk-containers-ports-ts/src/example.ts b/examples/layout-elk-containers-ports-ts/src/example.ts index 74a9ec610c..b0eb886c87 100644 --- a/examples/layout-elk-containers-ports-ts/src/example.ts +++ b/examples/layout-elk-containers-ports-ts/src/example.ts @@ -179,7 +179,7 @@ export const graphJSON: dia.Graph.JSON = { type: 'example.InteractionLink', source: { id: 'auth', port: 'out2' }, target: { id: 'logger', port: 'in2' }, - //labels: [{ attrs: { text: { text: 'log' } } }] + labels: [{ attrs: { text: { text: 'log' } } }] }, { id: 'l8', diff --git a/examples/layout-elk-containers-ports-ts/src/shapes.ts b/examples/layout-elk-containers-ports-ts/src/shapes.ts index 804c94a3e2..ca53123074 100644 --- a/examples/layout-elk-containers-ports-ts/src/shapes.ts +++ b/examples/layout-elk-containers-ports-ts/src/shapes.ts @@ -4,8 +4,6 @@ const PORT_SIZE = { width: 12, height: 12 }; const PORT_ATTRS = { circle: { r: 6, - cx: 6, - cy: 6, fill: '#FFFFFF', stroke: '#333', strokeWidth: 2 @@ -40,6 +38,8 @@ const HUB_PORT_MARKUP = [{ }]; 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, fill: '#FFFFFF', diff --git a/packages/joint-layout-elk/src/export.mts b/packages/joint-layout-elk/src/export.mts index 8bee3aca13..3287ea1fae 100644 --- a/packages/joint-layout-elk/src/export.mts +++ b/packages/joint-layout-elk/src/export.mts @@ -145,7 +145,6 @@ function buildPorts(element: dia.Element): ElkPort[] | undefined { const properties = element.portProp(portId, 'elkLayout'); - // `port` (from `element.getPorts()`) already carries the fully resolved label - // a port's own `label` (if it has one) merged over its group's, same as JointJS // itself resolves it. `element.portProp`/`getPort`, by contrast, only ever see the @@ -242,18 +241,10 @@ function buildElkNode(element: dia.Element, containerPosition: dia.Point = { x: ...ELK_PORT_CONSTRAINTS_BY_MODE[exportGraphOptions.portsPosition ?? 'fixed'], 'portLabels.placement': 'OUTSIDE' } : {}; - // A hint of the element's current position - read directly (as the plain `x`/`y` - // below) by ELK's `interactive` strategies (see `layout.mts`), and via this distinct - // option by `elk.layered.crossingMinimization.semiInteractive`. A brand new element - // has no meaningful position yet - strip this (and `x`/`y`) via `nodeOptions` (e.g. - // based on your own "is this new" convention) to let ELK place it freely instead of - // anchoring it here. - layoutOptions['elk.position'] = `(${x},${y})`; const embeds = element.getEmbeddedCells() .filter((cell): cell is dia.Element => cell.isElement()); - let children: ElkNode[] | undefined; let edges: ElkExtendedEdge[] | undefined; // A container's real size is computed by ELK to fit its (recursively laid out) diff --git a/packages/joint-layout-elk/src/import.mts b/packages/joint-layout-elk/src/import.mts index b215d93b32..f35743274a 100644 --- a/packages/joint-layout-elk/src/import.mts +++ b/packages/joint-layout-elk/src/import.mts @@ -270,7 +270,7 @@ function importNode(node: ElkNode, containerPosition: dia.Point = { x: 0, y: 0 } setPortAttributes({ element: found.element, portId: found.portId, - attributes: { position: { x: port.x || 0, y: port.y || 0 }, labelPosition } + attributes: { position: { x: port.x! + port.width! / 2 || 0, y: port.y! + port.height! / 2 || 0 }, labelPosition } }); }); } From 974373ce544d79636bac96ed4b108cff4572900f Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Mon, 21 Sep 2026 15:03:58 +0200 Subject: [PATCH 24/75] clean up --- examples/layout-elk-interactive-ts/README.md | 24 -- examples/layout-elk-interactive-ts/index.html | 27 --- .../layout-elk-interactive-ts/package.json | 40 ---- .../layout-elk-interactive-ts/src/index.ts | 216 ------------------ .../layout-elk-interactive-ts/src/styles.scss | 60 ----- .../layout-elk-interactive-ts/tsconfig.json | 18 -- .../webpack.config.js | 40 ---- packages/joint-layout-elk/src/layout.mts | 44 ---- 8 files changed, 469 deletions(-) delete mode 100644 examples/layout-elk-interactive-ts/README.md delete mode 100644 examples/layout-elk-interactive-ts/index.html delete mode 100644 examples/layout-elk-interactive-ts/package.json delete mode 100644 examples/layout-elk-interactive-ts/src/index.ts delete mode 100644 examples/layout-elk-interactive-ts/src/styles.scss delete mode 100644 examples/layout-elk-interactive-ts/tsconfig.json delete mode 100644 examples/layout-elk-interactive-ts/webpack.config.js diff --git a/examples/layout-elk-interactive-ts/README.md b/examples/layout-elk-interactive-ts/README.md deleted file mode 100644 index 14a1c8a185..0000000000 --- a/examples/layout-elk-interactive-ts/README.md +++ /dev/null @@ -1,24 +0,0 @@ -# JointJS ELK Interactive Layout Demo - -A small tree, laid out automatically with `@joint/layout-elk`. Click "+ Add Element" to attach a new element to a random existing one and re-run the layout - with the "Interactive layout" toggle checked, only the new element gets positioned, and the rest of the graph stays where it already is. Uncheck it to see a from-scratch layout reshuffle the whole graph instead. - -## 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 start -``` - -## 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-interactive-ts/index.html b/examples/layout-elk-interactive-ts/index.html deleted file mode 100644 index 830c4b6468..0000000000 --- a/examples/layout-elk-interactive-ts/index.html +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - ELK Interactive Layout | JointJS - - - -
- Zoom Out - Zoom In - + Add Element - -
-
- - - - - diff --git a/examples/layout-elk-interactive-ts/package.json b/examples/layout-elk-interactive-ts/package.json deleted file mode 100644 index f8274d0f47..0000000000 --- a/examples/layout-elk-interactive-ts/package.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "name": "@joint/demo-layout-elk-interactive-ts", - "version": "4.3.1", - "description": "JointJS - ELK Interactive Layout Demo", - "main": "dist/bundle.js", - "homepage": "https://jointjs.com", - "author": { - "name": "client IO", - "url": "https://client.io" - }, - "license": "MPL-2.0", - "private": true, - "installConfig": { - "hoistingLimits": "workspaces" - }, - "scripts": { - "start": "webpack-dev-server", - "build": "webpack" - }, - "dependencies": { - "@joint/core": "workspace:^", - "@joint/layout-elk": "workspace:^", - "elkjs": "0.12.0" - }, - "devDependencies": { - "css-loader": "3.5.3", - "sass-loader": "8.0.2", - "style-loader": "1.2.1", - "ts-loader": "^9.2.5", - "typescript": "5.8.2", - "webpack": "5.98.0", - "webpack-cli": "6.0.1", - "webpack-dev-server": "5.2.0" - }, - "volta": { - "node": "22.14.0", - "npm": "11.2.0", - "yarn": "4.18.0" - } -} diff --git a/examples/layout-elk-interactive-ts/src/index.ts b/examples/layout-elk-interactive-ts/src/index.ts deleted file mode 100644 index 771ddcb267..0000000000 --- a/examples/layout-elk-interactive-ts/src/index.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { dia, shapes, g } from '@joint/core'; -import { ElkLayoutOptions, layout, NodeProperties, NodePropertiesCallbackParameters } from '@joint/layout-elk'; -import './styles.scss'; - -const ELK_DIRECTION = 'RIGHT'; -const NODE_SIZE = { width: 100, height: 40 }; -const COLORS = ['#F8FCDA', '#E3E9C2', '#F9FBB2', '#C89F9C']; - -// A small seed graph - a couple of branches, so there's a choice of where a -// newly added element can be attached. -const SEED_LINKS: Array<[string, string]> = [ - ['n1', 'n2'], - ['n1', 'n3'], - ['n2', 'n4'], - ['n2', 'n5'], - ['n3', 'n6'], -]; - -const SEED_NODE_IDS = new Set(SEED_LINKS.flat()); -let nextId = SEED_NODE_IDS.size + 1; -let zoomLevel = 1; - -/** - * A brand new element (see the "Add Element" handler below) has no meaningful position yet - - * strip the position hints `@joint/layout-elk` would otherwise give it, so ELK's interactive - * layering/placement is free to place it based on topology, instead of anchoring it near the - * (0, 0) `element.position()` defaults to. - */ -function nodeProperties({ element, computedProperties }: NodePropertiesCallbackParameters): NodeProperties { - if (!element.get('new')) return computedProperties; - // Actually omit `x`/`y` (not just set them to `undefined`) - elkjs chokes on a - // present-but-`undefined` coordinate instead of treating it as absent. - const { width, height, layoutOptions: computedLayoutOptions } = computedProperties; - const { 'elk.position': _elkPosition, ...layoutOptions } = computedLayoutOptions ?? {}; - return { width, height, layoutOptions }; -} - -const init = () => { - - // Create JointJS graph and paper - const graph = new dia.Graph({}, { cellNamespace: shapes }); - const paper = new dia.Paper({ - model: graph, - cellViewNamespace: shapes, - width: 1200, - height: 700, - gridSize: 1, - interactive: false, - async: true, - frozen: true, - defaultConnectionPoint: { - name: 'anchor' - }, - defaultConnector: { - name: 'straight', - args: { - cornerType: 'cubic', - cornerRadius: 5 - } - } - }); - document.getElementById('canvas')!.appendChild(paper.el); - addZoomAndPanListeners(paper); - - const elkLayoutOptions: ElkLayoutOptions = { - 'elk.direction': ELK_DIRECTION, - 'elk.spacing.nodeNode': '30', - 'elk.layered.spacing.nodeNodeBetweenLayers': '60', - 'elk.edgeRouting': 'ORTHOGONAL', - }; - - // Seed the graph with a small, already laid out tree. - SEED_LINKS.forEach(([sourceId, targetId]) => { - if (!graph.getCell(sourceId)) graph.addCell(createElement(sourceId)); - if (!graph.getCell(targetId)) graph.addCell(createElement(targetId)); - graph.addCell(createLink(sourceId, targetId)); - }); - - const interactiveToggle = document.getElementById('interactive-toggle') as HTMLInputElement; - - // The very first layout always computes the whole graph from scratch - there is - // nothing to be "interactive" about yet, since no element has a position at all. - layout(graph, { elkLayoutOptions, nodeProperties }).then(() => { - paper.unfreeze(); - zoom(paper, zoomLevel); - }).catch((error) => { - console.error('ELK layout error:', error.message); - }); - - let isLayingOut = false; - const addElementButton = document.getElementById('add-element') as HTMLElement; - addElementButton.addEventListener('click', () => { - // A `layout()` call reads/writes the graph as it stands at that moment - guard - // against a second click firing (and exporting a half-updated graph) while one - // is still in flight. - if (isLayingOut) return; - isLayingOut = true; - addElementButton.classList.add('toolbar-button-disabled'); - - // Attach the new element to a random one already on the graph. - const elements = graph.getElements(); - const parent = elements[g.random(0, elements.length - 1)]; - const element = createElement(`n${nextId++}`); - // Marks it for `nodeProperties` (above) to strip position hints from, so ELK is free - // to place it based on topology instead of anchoring it near (0, 0). - element.set('new', true); - - paper.freeze(); - graph.addCells([element, createLink(`${parent.id}`, `${element.id}`)]); - - // With `interactive: true`, every already laid out element (`parent` included) - // keeps roughly its current position - ELK only has to find a spot for `element` - // itself. Uncheck "Interactive layout" to see the whole graph get reshuffled by - // a from-scratch layout instead. Either way, re-fit the viewport afterwards so - // the (possibly larger) diagram stays fully visible. - layout(graph, { - elkLayoutOptions, - interactive: interactiveToggle.checked, - nodeProperties - }).then(() => { - // Layout succeeded - `element` now has a real position, so it's no longer "new". - element.unset('new'); - paper.unfreeze(); - zoom(paper, zoomLevel); - }).catch((error) => { - console.error('ELK layout error:', error.message); - }).finally(() => { - isLayingOut = false; - addElementButton.classList.remove('toolbar-button-disabled'); - }); - }); -}; - -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 { - - 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!) - ); - }); -} - -/** - * Create a rectangle element with the given id. - */ -function createElement(id: dia.Cell.ID): dia.Element { - return new shapes.standard.Rectangle({ - id, - size: NODE_SIZE, - attrs: { - body: { - fill: COLORS[g.random(0, COLORS.length - 1)], - stroke: '#333', - strokeWidth: 2, - rx: 5, - ry: 5 - }, - label: { - text: `${id}`, - fill: '#333', - fontSize: 14, - fontFamily: 'Arial, helvetica, sans-serif' - } - } - }); -} - -/** - * Create a link between sourceId and targetId. - */ -function createLink(sourceId: dia.Cell.ID, targetId: dia.Cell.ID): dia.Link { - return new shapes.standard.Link({ - source: { id: sourceId }, - target: { id: targetId }, - attrs: { - line: { - stroke: '#333', - strokeWidth: 1.5 - } - } - }); -} - -init(); diff --git a/examples/layout-elk-interactive-ts/src/styles.scss b/examples/layout-elk-interactive-ts/src/styles.scss deleted file mode 100644 index f248d2e7b8..0000000000 --- a/examples/layout-elk-interactive-ts/src/styles.scss +++ /dev/null @@ -1,60 +0,0 @@ - -html, body { - margin: 0; - padding: 0; -} - -#canvas { - position: absolute; - margin-top: 50px; - margin-left: 20px; - border: 1px solid #E2E2E2; - background-color: #F3F7F6; - overflow: hidden; -} - -.toolbar { - display: flex; - position: fixed; - width: 100%; - top: 10px; - margin-left: 30px; - text-align: center; - justify-content: left; - align-items: center; - z-index: 1; -} - -.toolbar-button { - outline: none; - background: #FFFFFF; - border: 1px solid #E0E0E0; - border-radius: 16px; - text-align: center; - font-family: sans-serif; - font-size: 12px; - padding: 6px 12px; - letter-spacing: 0.25px; - color: #222222; - cursor: pointer; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - margin: 0 2px; - - &:hover { - background: #F7F8F9; - } -} - -.toolbar-toggle { - display: flex; - align-items: center; - gap: 6px; -} - -.toolbar-button-disabled { - pointer-events: none; - opacity: 0.5; -} diff --git a/examples/layout-elk-interactive-ts/tsconfig.json b/examples/layout-elk-interactive-ts/tsconfig.json deleted file mode 100644 index 8c23544e9e..0000000000 --- a/examples/layout-elk-interactive-ts/tsconfig.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "compilerOptions": { - "module": "ES6", - "moduleResolution": "bundler", - "target": "es6", - "lib": [ - "es2022", - "dom" - ], - "noImplicitAny": false, - "sourceMap": false, - "rootDir": "./src", - "noUncheckedSideEffectImports": false, - "outDir": "./build", - "resolveJsonModule": true, - "esModuleInterop": true - } -} diff --git a/examples/layout-elk-interactive-ts/webpack.config.js b/examples/layout-elk-interactive-ts/webpack.config.js deleted file mode 100644 index 410b983af5..0000000000 --- a/examples/layout-elk-interactive-ts/webpack.config.js +++ /dev/null @@ -1,40 +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', - devtool: 'source-map', - module: { - rules: [ - { - test: /\.m?js/, - resolve: { - fullySpecified: false, - }, - }, - { 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/packages/joint-layout-elk/src/layout.mts b/packages/joint-layout-elk/src/layout.mts index dbed0d08ff..7d958c5d40 100644 --- a/packages/joint-layout-elk/src/layout.mts +++ b/packages/joint-layout-elk/src/layout.mts @@ -22,32 +22,6 @@ const DEFAULT_LAYOUT_OPTIONS: ElkLayoutOptions = { 'elk.layered.considerModelOrder.portModelOrder': 'true' }; -// Applied on top of `DEFAULT_LAYOUT_OPTIONS` (but under the caller's own `elkLayoutOptions`) -// when `interactive` is enabled - see its doc on `Options`. -const INTERACTIVE_LAYOUT_OPTIONS: ElkLayoutOptions = { - // Generic hint, respected by every algorithm - see e.g. ELK Force/Stress, which use it to - // skip generating a fresh initial layout and relax from each element's current position - // instead. Not `'layered'`'s primary lever (below), but harmless to set alongside it. - 'elk.interactive': 'true', - 'elk.interactiveLayout': 'true', - // `'layered'`'s own interactivity is per-phase - each of these reads the corresponding - // aspect (edge direction, x/y) straight off an element's current position instead of - // computing it from scratch, so the four are meant to be used together. - 'elk.layered.layering.strategy': 'INTERACTIVE', - // NOT `crossingMinimization.strategy: 'INTERACTIVE'` - that variant doesn't minimize - // crossings at all, it just sorts each layer by previous y and calls it done (see - // `InteractiveCrossingMinimizer` upstream). `semiInteractive` instead keeps the real - // crossing-minimizing strategy (`LAYER_SWEEP`, the default) running, and only adds - // *soft* ordering constraints between pairs of already-positioned nodes (read from the - // `elk.position` layout option - see `buildElkNode` in `export.mts`) - so genuinely new - // nodes still get placed to reduce crossings, while nodes that already had a position - // keep their relative order unless the topology actually requires otherwise. - 'elk.layered.crossingMinimization.semiInteractive': 'true', - 'elk.layered.nodePlacement.strategy': 'INTERACTIVE', - 'elk.layered.interactiveReferencePoint': 'TOP_LEFT', - 'elk.layered.mergeEdges': 'true', -}; - const DEFAULT_OPTIONS: Options = { edgeLabels: true, batchName: LAYOUT_BATCH_NAME, @@ -109,23 +83,6 @@ export interface Options extends * @defaultValue 'layout' */ batchName?: string; - /** - * Whether to let ELK treat elements' current positions (as already reflected on the - * graph, e.g. from a previous `layout()` call) as a starting point, and try to change - * the layout as little as possible from there - instead of computing a fresh layout - * from scratch every time. Useful for laying out a graph incrementally, e.g. so that - * adding one element and calling `layout()` again only affects that new element, - * leaving the rest roughly where they already are. - * - * This approximates, rather than guarantees, the previous layout - e.g. a layer's own - * position can still shift to fit its (possibly changed) content - so already laid out - * elements may still move slightly. Applies `elk.interactive` plus, for the default - * `'layered'` algorithm, its own per-phase interactive strategies; give an - * `elkLayoutOptions` of your own to override/turn off any of them individually. - * @defaultValue false - * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-interactive.html - */ - interactive?: boolean; } export interface LayoutResult { @@ -156,7 +113,6 @@ export async function layout(graph: dia.Graph, opt?: Options): Promise Date: Mon, 21 Sep 2026 15:09:18 +0200 Subject: [PATCH 25/75] port label fix --- packages/joint-layout-elk/src/import.mts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/joint-layout-elk/src/import.mts b/packages/joint-layout-elk/src/import.mts index f35743274a..766d14a2bd 100644 --- a/packages/joint-layout-elk/src/import.mts +++ b/packages/joint-layout-elk/src/import.mts @@ -263,14 +263,23 @@ function importNode(node: ElkNode, containerPosition: dia.Point = { x: 0, y: 0 } if (importLayoutOptions.positionPortLabels) { const [label] = port.labels || []; if (label) { - labelPosition = { x: label.x || 0, y: label.y || 0 }; + labelPosition = { + x: (label.x || 0) - (port.width || 0) / 2, + y: (label.y || 0) - (port.height || 0) / 2 + }; } } setPortAttributes({ element: found.element, portId: found.portId, - attributes: { position: { x: port.x! + port.width! / 2 || 0, y: port.y! + port.height! / 2 || 0 }, labelPosition } + attributes: { + position: { + x: (port.x || 0) + (port.width || 0) / 2, + y: (port.y || 0) + (port.height || 0) / 2 + }, + labelPosition + } }); }); } From 3ecd28b5d2f49bffb8fa1a8909bc7e2be9220d57 Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Mon, 21 Sep 2026 15:57:16 +0200 Subject: [PATCH 26/75] update --- packages/joint-layout-elk/src/import.mts | 204 +++++++++++++---------- packages/joint-layout-elk/test/index.js | 4 +- 2 files changed, 116 insertions(+), 92 deletions(-) diff --git a/packages/joint-layout-elk/src/import.mts b/packages/joint-layout-elk/src/import.mts index 766d14a2bd..0c70e7d179 100644 --- a/packages/joint-layout-elk/src/import.mts +++ b/packages/joint-layout-elk/src/import.mts @@ -18,32 +18,40 @@ export type SetPortAttributesCallback = (params: SetPortAttributesCallbackParame export type SetPortAttributesCallbackParameters = { element: dia.Element; portId: string; + // Shaped to be handed straight to `element.portProp(portId, attributes)` - a plain, + // deep merge onto the port's own (raw) JSON, same as its `attrs`/`markup`/`size` - + // see `dia.Element.Port`. Only carries the port's own `position.args`/`label.position.args` + // (a group's `position`/`label.position` decides which layout *function* actually reads + // them - switching that is handled separately, once per group, see `importNode`). attributes: { - position: dia.Point; + // Present only when `portsPosition` is not 'fixed' - a 'fixed' port already stays + // exactly where JointJS's own port groups place it, so there's nothing to apply. + position?: { args: dia.Point }; // Present only when `positionPortLabels` is enabled and the port has a label. - labelPosition?: dia.Point; + label?: { position: { args: dia.Point } }; }; }; export type SetLinkAttributesCallback = (params: SetLinkAttributesCallbackParameters) => void; export type SetLinkAttributesCallbackParameters = { link: dia.Link; + // Shaped to be handed straight to `link.set(attributes)`. attributes: { vertices: dia.Point[]; // Present only for an end not already connected to a port - a port-connected // end already has the anchor JointJS itself computed for that port (the same - // position ELK was told to route to), so it does not need overriding. - sourceAnchor?: dia.Point; - targetAnchor?: dia.Point; - // Present only when `edgeLabels` is enabled and the link has labels. - labels?: SetLinkAttributesLabelParameters[]; + // position ELK was told to route to), so it does not need overriding. Carries + // the end's own existing `id`/`port`/... (see `dia.Link.EndJSON`) alongside the + // new `anchor`, since `link.set('source', ...)` replaces the whole `source` outright. + source?: dia.Link.EndJSON; + target?: dia.Link.EndJSON; + // Present only when `edgeLabels` is enabled and the link has labels - the link's + // whole current `labels` array, each routed label's own `position` replaced (its + // `attrs`/`markup`/`size` untouched), since `link.set('labels', ...)` replaces the + // whole array outright. + labels?: dia.Link.Label[]; }; }; -export type SetLinkAttributesLabelParameters = { - index: number; - bbox: dia.BBox; - points: dia.Point[]; -}; /** * Controls how freely ELK may reposition a port along its element - maps directly @@ -89,81 +97,31 @@ export interface ImportLayoutOptions { positionPortLabels?: boolean; } -function setLinkAnchor(link: dia.Link, element: dia.Element, point: dia.Point, endType: 'source' | 'target'): void { +// The anchor for a link end not connected to a port - relative to `element`, same as +// JointJS itself already computes for a port-connected end (see `importEdges`), so +// both keep behaving the same way if `element` later moves or is resized. +function getPortlessEndAnchor(element: dia.Element, point: dia.Point): NonNullable { const delta = element.getRelativePointFromAbsolute(point); - link.prop(`${endType}/anchor`, { + return { name: 'topLeft', args: { dx: delta.x, dy: delta.y, useModelGeometry: true } - }); + }; } const defaultSetElementAttributes: SetElementAttributesCallback = ({ element, attributes }) => { - element.position(attributes.position.x, attributes.position.y); - if (attributes.size) { - element.resize(attributes.size.width, attributes.size.height); - } + element.set(attributes); }; const defaultSetPortAttributes: SetPortAttributesCallback = ({ element, portId, attributes }) => { - const { group } = element.getPort(portId); - - // With `portsPosition` 'fixed' (the default), a port already stays exactly where - // JointJS's own port groups place it - ELK was only told where that is, not asked - // to move it - so there is nothing to apply back, and the group keeps its own - // position type (e.g. 'left') instead of being replaced with a fixed 'absolute' one. - if ((importLayoutOptions.portsPosition ?? 'fixed') !== 'fixed') { - if (group !== undefined) { - // Every port ends up with a computed position (all of an element's ports are - // exported), so switching the whole group to `'absolute'` is safe here - it - // only replaces the group's `position`, leaving its `attrs`/`markup`/`label` intact. - element.prop(['ports', 'groups', group, 'position'], { name: 'absolute' }); - } - element.portProp(portId, ['position', 'args'], attributes.position); - } - - if (attributes.labelPosition) { - if (group !== undefined) { - // Every positioned port label ends up with a computed position (only ports whose - // group defines a `label` are exported, but all of those are), so switching the - // whole group's label to `'manual'` is safe here - it only replaces the group - // label's `position`, leaving its `attrs`/`markup` intact. - element.prop(['ports', 'groups', group, 'label', 'position'], { name: 'manual' }); - } - element.portProp(portId, ['label', 'position', 'args'], attributes.labelPosition); - } + element.portProp(portId, attributes); }; const defaultSetLinkAttributes: SetLinkAttributesCallback = ({ link, attributes }) => { - link.vertices(attributes.vertices); - - if (attributes.sourceAnchor) { - setLinkAnchor(link, link.getSourceElement() as dia.Element, attributes.sourceAnchor, 'source'); - } - if (attributes.targetAnchor) { - setLinkAnchor(link, link.getTargetElement() as dia.Element, attributes.targetAnchor, 'target'); - } - - attributes.labels?.forEach(({ index, bbox, points }) => { - const polyline = new g.Polyline(points); - - const { x, y, width, height } = bbox; - const center = new g.Point(x + width / 2, y + height / 2); - - const distance = polyline.closestPointLength(center); - // Get the tangent at the closest point to calculate the offset - const tangent = polyline.tangentAtLength(distance); - - link.label(index, { - position: { - distance, - offset: tangent ? tangent.pointOffset(center) : 0 - } - }); - }); + link.set(attributes); }; let importLayoutOptions: ImportLayoutOptions; @@ -215,24 +173,51 @@ function importEdges(edges: ElkExtendedEdge[] | undefined, containerPosition: di // A port-connected end already has the anchor JointJS itself computed for that // port (the same position ELK was told to route to) - it does not need overriding. - const sourceAnchor = (link.source().port) ? undefined : toAbsolute(startPoint, containerPosition); - const targetAnchor = (link.target().port) ? undefined : toAbsolute(endPoint, containerPosition); - - let labels: SetLinkAttributesLabelParameters[] | undefined; + // The end's own existing `id`/`port`/... is carried over alongside the new + // `anchor`, since `attributes.source`/`target` replace the whole end outright. + const currentSource = link.source(); + const source = (currentSource.port) ? undefined : { + ...currentSource, + anchor: getPortlessEndAnchor(link.getSourceElement() as dia.Element, toAbsolute(startPoint, containerPosition)) + }; + const currentTarget = link.target(); + const target = (currentTarget.port) ? undefined : { + ...currentTarget, + anchor: getPortlessEndAnchor(link.getTargetElement() as dia.Element, toAbsolute(endPoint, containerPosition)) + }; + + let labels: dia.Link.Label[] | undefined; if (importLayoutOptions.edgeLabels && edge.labels && edge.labels.length > 0) { const points = [startPoint, ...bendPoints, endPoint] .map((point) => toAbsolute(point, containerPosition)); - labels = edge.labels.map((label, index) => { + const polyline = new g.Polyline(points); + const currentLabels = link.labels(); + labels = currentLabels.slice(); + edge.labels.forEach((label, index) => { const { x = 0, y = 0, width = 0, height = 0 } = label; - return { - index, - bbox: { x: containerPosition.x + x, y: containerPosition.y + y, width, height }, - points + const center = new g.Point(containerPosition.x + x + width / 2, containerPosition.y + y + height / 2); + const distance = polyline.closestPointLength(center); + // Get the tangent at the closest point to calculate the offset + const tangent = polyline.tangentAtLength(distance); + labels![index] = { + ...currentLabels[index], + position: { + distance, + offset: tangent ? tangent.pointOffset(center) : 0 + } }; }); } - setLinkAttributes({ link, attributes: { vertices, sourceAnchor, targetAnchor, labels }}); + setLinkAttributes({ + link, + attributes: { + vertices, + ...(source ? { source } : {}), + ...(target ? { target } : {}), + ...(labels ? { labels } : {}) + } + }); }); } @@ -247,17 +232,26 @@ function importNode(node: ElkNode, containerPosition: dia.Point = { x: 0, y: 0 } element, attributes: { position, - // A container's size is computed by ELK to fit its (recursively laid out) content. - size: (isContainer) ? { width: node.width || 0, height: node.height || 0 } : undefined + // A container's size is computed by ELK to fit its (recursively laid out) + // content - omitted entirely for a leaf element (not just left `undefined`), + // since `attributes` is handed straight to `element.set(...)`, which would + // otherwise overwrite its existing size with `undefined`. + ...(isContainer ? { size: { width: node.width || 0, height: node.height || 0 }} : {}) } }); } if (node.ports) { const setPortAttributes = importLayoutOptions.setPortAttributes ?? defaultSetPortAttributes; + // A 'fixed' port (the default) already stays exactly where JointJS's own port + // groups place it - ELK was only told where that is, not asked to move it - so + // there is nothing to apply back. + const positionPorts = (importLayoutOptions.portsPosition ?? 'fixed') !== 'fixed'; + node.ports.forEach((port) => { const found = portsById.get(port.id); if (!found) return; + const { element, portId } = found; let labelPosition: dia.Point | undefined; if (importLayoutOptions.positionPortLabels) { @@ -270,15 +264,45 @@ function importNode(node: ElkNode, containerPosition: dia.Point = { x: 0, y: 0 } } } + if (!positionPorts && !labelPosition) return; + + // A computed position/label position only has visible effect once the port's + // group is switched to the layout type that reads it ('absolute' for position, + // 'manual' for labels) - which layout function actually renders a port/label is + // entirely a group-level setting; a port's own `position`/`label.position` only + // ever supplies the *args* to whichever function the group is already using + // (see `PortData#_evaluatePortPositionProperty` in `@joint/core`). + const { group } = element.getPort(portId); + if (group !== undefined) { + if (positionPorts) { + // Every port ends up with a computed position (all of an element's ports + // are exported), so switching the whole group to `'absolute'` is safe here + // - it only replaces the group's `position`, leaving its `attrs`/`markup`/ + // `label` intact. + element.prop(['ports', 'groups', group, 'position'], { name: 'absolute' }); + } + if (labelPosition) { + // Every positioned port label ends up with a computed position (only ports + // whose group defines a `label` are exported, but all of those are), so + // switching the whole group's label to `'manual'` is safe here - it only + // replaces the group label's `position`, leaving its `attrs`/`markup` intact. + element.prop(['ports', 'groups', group, 'label', 'position'], { name: 'manual' }); + } + } + setPortAttributes({ - element: found.element, - portId: found.portId, + element, + portId, attributes: { - position: { - x: (port.x || 0) + (port.width || 0) / 2, - y: (port.y || 0) + (port.height || 0) / 2 - }, - labelPosition + ...(positionPorts ? { + position: { + args: { + x: (port.x || 0) + (port.width || 0) / 2, + y: (port.y || 0) + (port.height || 0) / 2 + } + } + } : {}), + ...(labelPosition ? { label: { position: { args: labelPosition }}} : {}) } }); }); diff --git a/packages/joint-layout-elk/test/index.js b/packages/joint-layout-elk/test/index.js index 1d135ecb5d..676200c936 100644 --- a/packages/joint-layout-elk/test/index.js +++ b/packages/joint-layout-elk/test/index.js @@ -275,7 +275,7 @@ QUnit.module('layout()', () => { size: { width: 100, height: 100 }, ports: { groups: { - out: { position: 'right', size: { width: 12, height: 12 } } + out: { position: 'right', size: { width: 12, height: 12 }} }, items: [{ id: 'out1', group: 'out' }] } @@ -285,7 +285,7 @@ QUnit.module('layout()', () => { size: { width: 100, height: 100 }, ports: { groups: { - in: { position: 'left', size: { width: 12, height: 12 } } + in: { position: 'left', size: { width: 12, height: 12 }} }, items: [{ id: 'in1', group: 'in' }] } From d904418342d9d64dc2865634e739e456f5291470 Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Tue, 22 Sep 2026 13:10:46 +0200 Subject: [PATCH 27/75] updates --- .../src/index.ts | 18 ++-- packages/joint-layout-elk/src/export.mts | 92 ++++++++-------- packages/joint-layout-elk/src/import.mts | 102 ++++++------------ packages/joint-layout-elk/src/layout.mts | 20 ++-- packages/joint-layout-elk/test/index.js | 43 +++++++- 5 files changed, 135 insertions(+), 140 deletions(-) diff --git a/examples/layout-elk-containers-ports-ts/src/index.ts b/examples/layout-elk-containers-ports-ts/src/index.ts index b0447b1c7a..2d999aefe2 100644 --- a/examples/layout-elk-containers-ports-ts/src/index.ts +++ b/examples/layout-elk-containers-ports-ts/src/index.ts @@ -1,5 +1,5 @@ -import { dia, shapes, util } from '@joint/core'; -import { ElkLayoutOptions, layout, NodeProperties, NodePropertiesCallbackParameters, type PortProperties, type PortPropertiesCallbackParameters } from '@joint/layout-elk'; +import { dia, shapes } from '@joint/core'; +import { ElkLayoutOptions, layout, type NodeProperties, type NodePropertiesCallbackParameters } from '@joint/layout-elk'; import ELK from 'elkjs/lib/elk-api.js'; import { graphJSON } from './example'; import { Container, HubService, InteractionLink, Service } from './shapes'; @@ -104,15 +104,13 @@ const init = () => { // of keeping them where JointJS's own port groups first placed them. portsPosition: 'fixed-side', positionPortLabels: true, - nodeProperties: ({ element, computedProperties }: NodePropertiesCallbackParameters): NodeProperties => { + nodeProperties: ({ element }: NodePropertiesCallbackParameters): Partial => { // Reserve extra top padding inside containers, so children don't - // overlap the container's title label. - if (element.getEmbeddedCells().length === 0) return computedProperties; - return util.defaultsDeep({}, computedProperties, { - layoutOptions: { - 'elk.padding': CONTAINER_PADDING - } - }); + // overlap the container's title label. Everything else this package + // itself computes (position, size, port layout options, ...) is left + // as-is - the package merges this onto it, rather than replacing it. + if (element.getEmbeddedCells().length === 0) return {}; + return { layoutOptions: { 'elk.padding': CONTAINER_PADDING } }; }, elkLayoutOptions }).then(() => { diff --git a/packages/joint-layout-elk/src/export.mts b/packages/joint-layout-elk/src/export.mts index 3287ea1fae..9d2ba8c2cb 100644 --- a/packages/joint-layout-elk/src/export.mts +++ b/packages/joint-layout-elk/src/export.mts @@ -1,4 +1,4 @@ -import type { dia } from '@joint/core'; +import { util, type dia } from '@joint/core'; import type { PortsPositionMode } from './import.mjs'; import type { @@ -25,37 +25,30 @@ const ELK_PORT_CONSTRAINTS_BY_MODE: Record; -/** - * The ELK port properties `portOptions` can inspect and adjust - everything about a port - * this package itself computes, except `id` (structural). - */ +/** Everything about a port this package computes, for `portProperties` to adjust - except `id` (structural). */ export type PortProperties = Omit; -/** - * The ELK edge properties `edgeOptions` can inspect and adjust - everything about an edge - * this package itself computes, except `id`/`sources`/`targets` (structural). - */ +/** Everything about an edge this package computes, for `edgeProperties` to adjust - except `id`/`sources`/`targets` (structural). */ export type EdgeProperties = Omit; -export type NodePropertiesCallback = (params: NodePropertiesCallbackParameters) => NodeProperties; +// A callback's return value is merged onto its `computedProperties` (see `mergeProperties`) - +// only what it actually returns overrides the computed value, so e.g. returning `{}` (or +// omitting a key) keeps that part of `computedProperties` as-is. +export type NodePropertiesCallback = (params: NodePropertiesCallbackParameters) => Partial; export type NodePropertiesCallbackParameters = { element: dia.Element; computedProperties: NodeProperties; } -export type PortPropertiesCallback = (params: PortPropertiesCallbackParameters) => PortProperties; +export type PortPropertiesCallback = (params: PortPropertiesCallbackParameters) => Partial; export type PortPropertiesCallbackParameters = { port: dia.Element.Port; element: dia.Element; computedProperties: PortProperties; }; -export type EdgePropertiesCallback = (params: EdgePropertiesCallbackParameters) => EdgeProperties; +export type EdgePropertiesCallback = (params: EdgePropertiesCallbackParameters) => Partial; export type EdgePropertiesCallbackParameters = { link: dia.Link; computedProperties: EdgeProperties; @@ -78,24 +71,19 @@ export interface ExportGraphOptions { portProperties?: PortPropertiesCallback; edgeProperties?: EdgePropertiesCallback; /** - * Whether to account for link labels during layout and position them - * along the routed link afterwards. + * Whether to account for link labels during layout and position them afterwards. * @defaultValue true */ edgeLabels?: boolean; /** - * How freely ELK may reposition (and reorder) ports along their element, - * instead of keeping them at the position JointJS itself already computed - * for them - see `PortsPositionMode`. Any new positions are written back - * onto the graph - see the `portsPosition` option in `ImportLayoutOptions`. + * How freely ELK may reposition (and reorder) ports, instead of keeping them + * where JointJS's port groups place them - see `PortsPositionMode`. * @defaultValue 'fixed' */ portsPosition?: PortsPositionMode; /** - * Whether to let ELK reposition port labels along their port, instead of keeping - * them at the position JointJS itself already computed for them. The new - * positions are written back onto the graph - see the `positionPortLabels` - * option in `ImportLayoutOptions`. + * Whether to let ELK reposition port labels along their port, instead of + * keeping them where JointJS's port groups place them. * @defaultValue false */ positionPortLabels?: boolean; @@ -106,6 +94,13 @@ export const DEFAULT_LABEL_SIZE: dia.Size = { height: 20 }; +// Deep-merges a `nodeProperties`/`portProperties`/`edgeProperties` callback's return value +// onto what this package itself computed - the override wins on conflicts (including into +// nested objects like `layoutOptions`), `computed` fills in anything the override didn't set. +function mergeProperties(overrides: Partial, computed: T): T { + return util.defaultsDeep({}, overrides, computed) as T; +} + let exportGraphOptions: ExportGraphOptions; let elementsById: Map; @@ -129,11 +124,9 @@ function init(options: ExportGraphOptions): void { } /** - * Builds the ELK ports for an element's JointJS ports, starting out at the - * position JointJS itself has already computed for them (via the element's - * port groups). Whether ELK is free to move them from there, or has to treat - * that position as final, is controlled by the node's own `elk.portConstraints` - * (see `ELK_PORT_CONSTRAINTS_BY_MODE` in `buildElkNode`). + * Builds a node's ELK ports, starting from the position JointJS already computed + * for them - `elk.portConstraints` (see `ELK_PORT_CONSTRAINTS_BY_MODE`) decides + * whether ELK can move them from there. */ function buildPorts(element: dia.Element): ElkPort[] | undefined { if (!element.hasPorts()) return undefined; @@ -143,12 +136,12 @@ function buildPorts(element: dia.Element): ElkPort[] | undefined { const elkPortId = `${element.id}:${portId}`; portsById.set(elkPortId, { element, portId }); + // Optional per-port/group metadata (`side`, `labelSize`) a consumer can set under + // a group's/port's own `elkLayout` key - merged onto the port by `@joint/core`. const properties = element.portProp(portId, 'elkLayout'); - // `port` (from `element.getPorts()`) already carries the fully resolved label - - // a port's own `label` (if it has one) merged over its group's, same as JointJS - // itself resolves it. `element.portProp`/`getPort`, by contrast, only ever see the - // port's own raw, unmerged JSON, so they can't be used here. + // `element.getPorts()` gives the fully resolved label (merged with the group's) - + // `portProp`/`getPort` only ever see the port's own raw JSON. let labels: ElkLabel[] | undefined; if (exportGraphOptions.positionPortLabels) { @@ -167,11 +160,14 @@ function buildPorts(element: dia.Element): ElkPort[] | undefined { const { x, y } = element.getPortRelativePosition(portId); const { width, height } = element.getPortRelativeRect(portId); + // In 'fixed' mode ELK must use the exact current position; other modes let it + // compute a new one, so sending one would only anchor/bias it needlessly. if (!exportGraphOptions.portsPosition || exportGraphOptions.portsPosition === 'fixed') { portProperties.x = x; portProperties.y = y; } + // Only 'fixed-side' pins the side - 'free' lets ELK choose it on its own. if (exportGraphOptions.portsPosition === 'fixed-side') { switch (properties?.side) { case 'WEST': @@ -190,6 +186,7 @@ function buildPorts(element: dia.Element): ElkPort[] | undefined { } } + // Negative offset moves the port inward from the node border, centering it there. if (exportGraphOptions.portsPosition === 'fixed-side' || exportGraphOptions.portsPosition === 'free') { layoutOptions['elk.port.borderOffset'] = `${-width / 2}`; } @@ -203,11 +200,12 @@ function buildPorts(element: dia.Element): ElkPort[] | undefined { }; if (exportGraphOptions.portProperties) { - portProperties = exportGraphOptions.portProperties({ + const overrides = exportGraphOptions.portProperties({ port, element, computedProperties: portProperties }); + portProperties = mergeProperties(overrides, portProperties); } return { @@ -218,11 +216,8 @@ function buildPorts(element: dia.Element): ElkPort[] | undefined { } /** - * ELK positions a node's children (and routes a node's own edges) relative to that - * node's own origin (see `toAbsolute` in `importLayout`) - `containerX`/`containerY` - * convert an element's own graph-absolute `position()` into that frame, so that the - * `x`/`y` handed to `nodeOptions` is always a usable hint of where the element - * currently is. + * 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. */ function buildElkNode(element: dia.Element, containerPosition: dia.Point = { x: 0, y: 0 }): ElkNode { const id = `${element.id}`; @@ -237,6 +232,7 @@ function buildElkNode(element: dia.Element, containerPosition: dia.Point = { x: const y = absoluteY - containerPosition.y; + // Only relevant for a node that has ports - see `ELK_PORT_CONSTRAINTS_BY_MODE`. const layoutOptions: NodeElkLayoutOptions = (ports) ? { ...ELK_PORT_CONSTRAINTS_BY_MODE[exportGraphOptions.portsPosition ?? 'fixed'], 'portLabels.placement': 'OUTSIDE' @@ -247,10 +243,8 @@ function buildElkNode(element: dia.Element, containerPosition: dia.Point = { x: let children: ElkNode[] | undefined; let edges: ElkExtendedEdge[] | undefined; - // A container's real size is computed by ELK to fit its (recursively laid out) - // content - `0` is only a placeholder starting point here (elkjs errors out on a - // hierarchical node with no numeric width/height at all), not the final size - // `nodeProperties` sees. + // A container's real size is computed by ELK to fit its content - `0` is just a + // placeholder (elkjs needs a numeric size upfront for a hierarchical node). let width = 0; let height = 0; if (embeds.length > 0) { @@ -265,10 +259,11 @@ function buildElkNode(element: dia.Element, containerPosition: dia.Point = { x: let nodeProperties: NodeProperties = { x, y, width, height, layoutOptions }; if (exportGraphOptions.nodeProperties) { - nodeProperties = exportGraphOptions.nodeProperties({ + const overrides = exportGraphOptions.nodeProperties({ element, computedProperties: nodeProperties }); + nodeProperties = mergeProperties(overrides, nodeProperties); } const node: ElkNode = { id, @@ -337,10 +332,11 @@ function buildEdge(link: dia.Link): void { labels }; if (exportGraphOptions.edgeProperties) { - edgeProperties = exportGraphOptions.edgeProperties({ + const overrides = exportGraphOptions.edgeProperties({ link, computedProperties: edgeProperties }); + edgeProperties = mergeProperties(overrides, edgeProperties); } const edge: ElkExtendedEdge = { diff --git a/packages/joint-layout-elk/src/import.mts b/packages/joint-layout-elk/src/import.mts index 0c70e7d179..f5545c5304 100644 --- a/packages/joint-layout-elk/src/import.mts +++ b/packages/joint-layout-elk/src/import.mts @@ -18,14 +18,11 @@ export type SetPortAttributesCallback = (params: SetPortAttributesCallbackParame export type SetPortAttributesCallbackParameters = { element: dia.Element; portId: string; - // Shaped to be handed straight to `element.portProp(portId, attributes)` - a plain, - // deep merge onto the port's own (raw) JSON, same as its `attrs`/`markup`/`size` - - // see `dia.Element.Port`. Only carries the port's own `position.args`/`label.position.args` - // (a group's `position`/`label.position` decides which layout *function* actually reads - // them - switching that is handled separately, once per group, see `importNode`). + // 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: { - // Present only when `portsPosition` is not 'fixed' - a 'fixed' port already stays - // exactly where JointJS's own port groups place it, so there's nothing to apply. + // Present only when `portsPosition` is not 'fixed' - nothing to apply otherwise. position?: { args: dia.Point }; // Present only when `positionPortLabels` is enabled and the port has a label. label?: { position: { args: dia.Point } }; @@ -35,34 +32,25 @@ export type SetPortAttributesCallbackParameters = { export type SetLinkAttributesCallback = (params: SetLinkAttributesCallbackParameters) => void; export type SetLinkAttributesCallbackParameters = { link: dia.Link; - // Shaped to be handed straight to `link.set(attributes)`. + // Shaped for `link.set(attributes)`. attributes: { vertices: dia.Point[]; - // Present only for an end not already connected to a port - a port-connected - // end already has the anchor JointJS itself computed for that port (the same - // position ELK was told to route to), so it does not need overriding. Carries - // the end's own existing `id`/`port`/... (see `dia.Link.EndJSON`) alongside the - // new `anchor`, since `link.set('source', ...)` replaces the whole `source` outright. + // Present only for an end not already connected to 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 `edgeLabels` is enabled and the link has labels - the link's - // whole current `labels` array, each routed label's own `position` replaced (its - // `attrs`/`markup`/`size` untouched), since `link.set('labels', ...)` replaces the - // whole array outright. + // Present only when `edgeLabels` is enabled and the link has labels - the whole + // current `labels` array, with each routed label's `position` replaced. labels?: dia.Link.Label[]; }; }; /** - * Controls how freely ELK may reposition a port along its element - maps directly - * onto ELK's own `elk.portConstraints` (see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portConstraints.html): - * - `'fixed'` (default) - the port stays exactly where JointJS's own port groups - * already place it; ELK only uses that position to route edges to/from it - * (`FIXED_POS`). - * - `'fixed-side'` - ELK may reposition (and reorder) the port along the side its - * group already assigns it to, e.g. to minimize edge crossings (`FIXED_SIDE`). - * - `'free'` - ELK may reposition the port anywhere around its element, including - * onto a different side than its group's (`FREE`). + * How freely ELK may reposition a port - maps to `elk.portConstraints`: + * - `'fixed'` (default): stays at JointJS's computed position (`FIXED_POS`). + * - `'fixed-side'`: may move/reorder along its group's side (`FIXED_SIDE`). + * - `'free'`: may move anywhere, including onto a different side (`FREE`). */ export type PortsPositionMode = 'fixed' | 'fixed-side' | 'free'; @@ -71,35 +59,26 @@ export interface ImportLayoutOptions { setLinkAttributes?: SetLinkAttributesCallback; setPortAttributes?: SetPortAttributesCallback; /** - * Whether to account for link labels during layout and position them - * along the routed link afterwards. + * Whether to account for link labels during layout and position them afterwards. * @defaultValue true */ edgeLabels?: boolean; /** - * How freely ELK may reposition (and reorder) ports along their element, - * instead of keeping them at the position JointJS itself already computed - * for them - see `PortsPositionMode`. When set to anything other than - * `'fixed'`, every port's owning group is switched to an `'absolute'` - * position (preserving its `attrs`/`markup`/`label`) so the position ELK - * computed for it can be applied. + * How freely ELK may reposition (and reorder) ports, instead of keeping them + * where JointJS's port groups place them - see `PortsPositionMode`. * @defaultValue 'fixed' */ portsPosition?: PortsPositionMode; /** - * Whether to let ELK reposition port labels along their port, instead of keeping - * them at the position JointJS itself already computed for them (via the port - * group's `label`). When enabled, every port's owning group's label is switched - * to a `'manual'` position (preserving its `attrs`/`markup`) so the position ELK - * computed for it can be applied. + * Whether to let ELK reposition port labels along their port, instead of + * keeping them where JointJS's port groups place them. * @defaultValue false */ positionPortLabels?: boolean; } -// The anchor for a link end not connected to a port - relative to `element`, same as -// JointJS itself already computes for a port-connected end (see `importEdges`), so -// both keep behaving the same way if `element` later moves or is resized. +// The anchor for a link end not connected to a port - computed the same way JointJS +// computes one for a port-connected end, so both react the same way to future moves. function getPortlessEndAnchor(element: dia.Element, point: dia.Point): NonNullable { const delta = element.getRelativePointFromAbsolute(point); return { @@ -171,10 +150,8 @@ function importEdges(edges: ElkExtendedEdge[] | undefined, containerPosition: di const vertices = bendPoints.map((point) => toAbsolute(point, containerPosition)); - // A port-connected end already has the anchor JointJS itself computed for that - // port (the same position ELK was told to route to) - it does not need overriding. - // The end's own existing `id`/`port`/... is carried over alongside the new - // `anchor`, since `attributes.source`/`target` replace the whole end outright. + // A port-connected end already has its anchor computed by JointJS - no override + // needed. The end's existing `id`/`port` is kept, since `.set()` replaces it outright. const currentSource = link.source(); const source = (currentSource.port) ? undefined : { ...currentSource, @@ -232,10 +209,8 @@ function importNode(node: ElkNode, containerPosition: dia.Point = { x: 0, y: 0 } element, attributes: { position, - // A container's size is computed by ELK to fit its (recursively laid out) - // content - omitted entirely for a leaf element (not just left `undefined`), - // since `attributes` is handed straight to `element.set(...)`, which would - // otherwise overwrite its existing size with `undefined`. + // Omitted entirely for a leaf (not just `undefined`) - `attributes` goes + // straight to `element.set(...)`, which would otherwise wipe its size. ...(isContainer ? { size: { width: node.width || 0, height: node.height || 0 }} : {}) } }); @@ -243,9 +218,7 @@ function importNode(node: ElkNode, containerPosition: dia.Point = { x: 0, y: 0 } if (node.ports) { const setPortAttributes = importLayoutOptions.setPortAttributes ?? defaultSetPortAttributes; - // A 'fixed' port (the default) already stays exactly where JointJS's own port - // groups place it - ELK was only told where that is, not asked to move it - so - // there is nothing to apply back. + // A 'fixed' port (the default) already stays put - nothing to apply back. const positionPorts = (importLayoutOptions.portsPosition ?? 'fixed') !== 'fixed'; node.ports.forEach((port) => { @@ -257,6 +230,8 @@ function importNode(node: ElkNode, containerPosition: dia.Point = { x: 0, y: 0 } if (importLayoutOptions.positionPortLabels) { 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*. labelPosition = { x: (label.x || 0) - (port.width || 0) / 2, y: (label.y || 0) - (port.height || 0) / 2 @@ -266,26 +241,18 @@ function importNode(node: ElkNode, containerPosition: dia.Point = { x: 0, y: 0 } if (!positionPorts && !labelPosition) return; - // A computed position/label position only has visible effect once the port's - // group is switched to the layout type that reads it ('absolute' for position, - // 'manual' for labels) - which layout function actually renders a port/label is - // entirely a group-level setting; a port's own `position`/`label.position` only - // ever supplies the *args* to whichever function the group is already using - // (see `PortData#_evaluatePortPositionProperty` in `@joint/core`). + // A port's own `position`/`label.position` only ever supplies *args* - which + // layout function reads them ('absolute'/'manual' vs. e.g. 'left') is a + // group-level setting, so that has to be switched too (see `@joint/core`'s + // `PortData#_evaluatePortPositionProperty`). const { group } = element.getPort(portId); if (group !== undefined) { + // Safe to replace outright - only `position`/`label.position` change, + // `attrs`/`markup`/`label` stay intact. if (positionPorts) { - // Every port ends up with a computed position (all of an element's ports - // are exported), so switching the whole group to `'absolute'` is safe here - // - it only replaces the group's `position`, leaving its `attrs`/`markup`/ - // `label` intact. element.prop(['ports', 'groups', group, 'position'], { name: 'absolute' }); } if (labelPosition) { - // Every positioned port label ends up with a computed position (only ports - // whose group defines a `label` are exported, but all of those are), so - // switching the whole group's label to `'manual'` is safe here - it only - // replaces the group label's `position`, leaving its `attrs`/`markup` intact. element.prop(['ports', 'groups', group, 'label', 'position'], { name: 'manual' }); } } @@ -294,6 +261,7 @@ function importNode(node: ElkNode, containerPosition: dia.Point = { x: 0, y: 0 } element, portId, attributes: { + // Same top-left-to-center conversion as the label above, for 'absolute' position. ...(positionPorts ? { position: { args: { diff --git a/packages/joint-layout-elk/src/layout.mts b/packages/joint-layout-elk/src/layout.mts index 7d958c5d40..f74e42dc24 100644 --- a/packages/joint-layout-elk/src/layout.mts +++ b/packages/joint-layout-elk/src/layout.mts @@ -54,27 +54,19 @@ export interface Options extends */ elkLayoutOptions?: ElkLayoutOptions; /** - * Whether to account for link labels during layout and position them - * along the routed link afterwards. + * Whether to account for link labels during layout and position them afterwards. * @defaultValue true */ edgeLabels?: boolean; /** - * How freely ELK may reposition (and reorder) ports along their element, - * instead of keeping them at the position JointJS itself already computed - * for them - see `PortPositionsMode`. When set to anything other than - * `'fixed'`, every port's owning group is switched to an `'absolute'` - * position (preserving its `attrs`/`markup`/`label`) so the position ELK - * computed for it can be applied. + * How freely ELK may reposition (and reorder) ports, instead of keeping them + * where JointJS's port groups place them - see `PortsPositionMode`. * @defaultValue 'fixed' */ portsPosition?: PortsPositionMode; /** - * Whether to let ELK reposition port labels along their port, instead of keeping - * them at the position JointJS itself already computed for them (via the port - * group's `label`). When enabled, every port's owning group's label is switched - * to a `'manual'` position (preserving its `attrs`/`markup`) so the position ELK - * computed for it can be applied. + * Whether to let ELK reposition port labels along their port, instead of + * keeping them where JointJS's port groups place them. * @defaultValue false */ positionPortLabels?: boolean; @@ -122,6 +114,8 @@ export async function layout(graph: dia.Graph, opt?: Options): Promise { const seen = []; await joint.layout.ELK.layout(graph, { - portProperties: ({ port, element, computedProperties }) => { + portProperties: ({ port, element }) => { seen.push([port.id, element.id]); - return computedProperties; + return {}; } }); assert.deepEqual(seen, [['out1', 'a']]); }); + QUnit.test('should merge a nodeProperties/portProperties/edgeProperties return value onto what was computed', 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 { elkGraph } = await joint.layout.ELK.layout(graph, { + portsPosition: 'fixed-side', + // Each returns only a new `layoutOptions` key - what this package itself + // computed (e.g. `elk.portConstraints`, `elk.hierarchyHandling`) must survive. + nodeProperties: () => ({ layoutOptions: { 'elk.custom': 'node' }}), + portProperties: () => ({ layoutOptions: { 'elk.custom': 'port' }}), + edgeProperties: () => ({ 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'); + + 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 }); From 32d8dedd297ca4f647cad00393e7d34b7c32ee44 Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Tue, 22 Sep 2026 14:45:39 +0200 Subject: [PATCH 28/75] link label attributes refactor --- packages/joint-layout-elk/src/import.mts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/joint-layout-elk/src/import.mts b/packages/joint-layout-elk/src/import.mts index f5545c5304..f41d0514d1 100644 --- a/packages/joint-layout-elk/src/import.mts +++ b/packages/joint-layout-elk/src/import.mts @@ -168,7 +168,11 @@ function importEdges(edges: ElkExtendedEdge[] | undefined, containerPosition: di const points = [startPoint, ...bendPoints, endPoint] .map((point) => toAbsolute(point, containerPosition)); const polyline = new g.Polyline(points); - const currentLabels = link.labels(); + // `link.labels()` returns each label resolved against `defaultLabel`/the built-in + // default (`@joint/core`) - reading `labels` (the raw model attribute) directly + // instead, so writing `labels[index]` back below doesn't bake that resolved + // `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, index) => { const { x = 0, y = 0, width = 0, height = 0 } = label; From 52f4a605238572149a725c8dffbbaac4cffb457c Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Tue, 22 Sep 2026 16:43:53 +0200 Subject: [PATCH 29/75] update --- .../src/index.ts | 11 +---- .../src/shapes.ts | 8 ++++ packages/joint-layout-elk/src/export.mts | 22 ++++++++-- packages/joint-layout-elk/test/index.js | 41 +++++++++++++++++++ 4 files changed, 68 insertions(+), 14 deletions(-) diff --git a/examples/layout-elk-containers-ports-ts/src/index.ts b/examples/layout-elk-containers-ports-ts/src/index.ts index 2d999aefe2..ad9a284bea 100644 --- a/examples/layout-elk-containers-ports-ts/src/index.ts +++ b/examples/layout-elk-containers-ports-ts/src/index.ts @@ -1,12 +1,11 @@ import { dia, shapes } from '@joint/core'; -import { ElkLayoutOptions, layout, type NodeProperties, type NodePropertiesCallbackParameters } from '@joint/layout-elk'; +import { ElkLayoutOptions, layout } from '@joint/layout-elk'; import ELK from 'elkjs/lib/elk-api.js'; import { graphJSON } from './example'; import { Container, HubService, InteractionLink, Service } from './shapes'; import './styles.scss'; const ELK_DIRECTION = 'RIGHT'; -const CONTAINER_PADDING = '[top=40,left=20,bottom=20,right=20]'; const cellNamespace = { ...shapes, @@ -104,14 +103,6 @@ const init = () => { // of keeping them where JointJS's own port groups first placed them. portsPosition: 'fixed-side', positionPortLabels: true, - nodeProperties: ({ element }: NodePropertiesCallbackParameters): Partial => { - // Reserve extra top padding inside containers, so children don't - // overlap the container's title label. Everything else this package - // itself computes (position, size, port layout options, ...) is left - // as-is - the package merges this onto it, rather than replacing it. - if (element.getEmbeddedCells().length === 0) return {}; - return { layoutOptions: { 'elk.padding': CONTAINER_PADDING } }; - }, elkLayoutOptions }).then(() => { paper.unfreeze(); diff --git a/examples/layout-elk-containers-ports-ts/src/shapes.ts b/examples/layout-elk-containers-ports-ts/src/shapes.ts index ca53123074..f51f75a7aa 100644 --- a/examples/layout-elk-containers-ports-ts/src/shapes.ts +++ b/examples/layout-elk-containers-ports-ts/src/shapes.ts @@ -52,6 +52,8 @@ const HUB_PORT_ATTRS = { } }; +const CONTAINER_PADDING = '[top=40,left=20,bottom=20,right=20]'; + /** * A dashed, semi-transparent container - its final size and position are * computed by ELK to fit whatever gets embedded into it. Its label sits in @@ -62,6 +64,12 @@ export class Container extends shapes.standard.Rectangle { return util.defaultsDeep({ type: 'example.Container', size: { width: 100, height: 100 }, + // Extra top padding, so embedded children don't overlap this container's own + // title label - read directly by `@joint/layout-elk` (`elkLayout` merges onto + // whatever it itself computes for a node), no `nodeProperties` callback needed. + elkLayout: { + 'elk.padding': CONTAINER_PADDING + }, attrs: { body: { fill: '#EEF3F1', diff --git a/packages/joint-layout-elk/src/export.mts b/packages/joint-layout-elk/src/export.mts index 9d2ba8c2cb..ce756f9492 100644 --- a/packages/joint-layout-elk/src/export.mts +++ b/packages/joint-layout-elk/src/export.mts @@ -233,11 +233,18 @@ function buildElkNode(element: dia.Element, containerPosition: dia.Point = { x: // Only relevant for a node that has ports - see `ELK_PORT_CONSTRAINTS_BY_MODE`. - const layoutOptions: NodeElkLayoutOptions = (ports) ? { + const computedLayoutOptions: NodeElkLayoutOptions = (ports) ? { ...ELK_PORT_CONSTRAINTS_BY_MODE[exportGraphOptions.portsPosition ?? 'fixed'], 'portLabels.placement': 'OUTSIDE' } : {}; + // Optional `elk.*` layoutOptions a consumer can set directly on the element's own + // `elkLayout` property (see the same-named property `buildPorts` reads per port/group + // below), merged onto what this package itself computes - so e.g. a container's + // `elk.padding` can be declared once, on the shape, without a `nodeProperties` callback. + const elkLayout = element.prop('elkLayout') as NodeElkLayoutOptions | undefined; + const layoutOptions = elkLayout ? mergeProperties(elkLayout, computedLayoutOptions) : computedLayoutOptions; + const embeds = element.getEmbeddedCells() .filter((cell): cell is dia.Element => cell.isElement()); @@ -307,17 +314,24 @@ function buildEdge(link: dia.Link): void { let labels: ElkLabel[] | undefined; if (exportGraphOptions.edgeLabels) { - const linkLabels = link.labels(); + // Raw (not `link.labels()`) - a custom `elkLayout` property (read below) isn't + // part of the resolved label `@joint/core` returns (see `Link#_getResolvedLabel`). + const linkLabels = (link.get('labels') as dia.Link.Label[] | undefined) || []; if (linkLabels.length > 0) { labels = linkLabels.map((label): ElkLabel => { const { width, height } = label.size || DEFAULT_LABEL_SIZE; + // Optional `elk.*` layoutOptions a consumer can set directly on the label's + // own `elkLayout` property (see `buildElkNode`'s node-level equivalent above). + const elkLayout = (label as { elkLayout?: LabelElkLayoutOptions }).elkLayout; + const layoutOptions = elkLayout ? mergeProperties(elkLayout, ELK_INLINE_LABEL_OPTIONS) : ELK_INLINE_LABEL_OPTIONS; return { // Some text is required, otherwise ELK ignores the label. text: ELK_LABEL_TEXT, width, height, - // Place the label directly on the edge (and allocate space for it). - layoutOptions: ELK_INLINE_LABEL_OPTIONS + // Place the label directly on the edge (and allocate space for it), + // unless `elkLayout` overrides that. + layoutOptions }; }); } diff --git a/packages/joint-layout-elk/test/index.js b/packages/joint-layout-elk/test/index.js index ab26ff9f76..c9aac90c99 100644 --- a/packages/joint-layout-elk/test/index.js +++ b/packages/joint-layout-elk/test/index.js @@ -347,4 +347,45 @@ QUnit.module('layout()', () => { assert.equal(secondPosition.x, firstPosition.x); assert.equal(secondPosition.y, firstPosition.y); }); + + QUnit.test('should merge a node\'s own `elkLayout` into its computed layoutOptions, without a nodeProperties callback', 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 }, + elkLayout: { '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); + + 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 merge a link label\'s own `elkLayout` into its computed layoutOptions, without an edgeProperties callback', 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 }, elkLayout: { 'edgeLabels.inline': 'false' }}] + }); + + graph.resetCells([el1, el2, link]); + + const { elkGraph } = await joint.layout.ELK.layout(graph); + + const [elkEdge] = elkGraph.edges; + assert.equal(elkEdge.labels[0].layoutOptions['edgeLabels.inline'], 'false'); + + // The label's own raw JSON is unaffected - `elkLayout` isn't baked into it. + assert.deepEqual(link.get('labels')[0].elkLayout, { 'edgeLabels.inline': 'false' }); + }); }); From 278e7b2c00563f420f47abbb235d27bd68857634 Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Wed, 23 Sep 2026 13:08:22 +0200 Subject: [PATCH 30/75] update --- .../src/shapes.ts | 21 ++- packages/joint-layout-elk/src/export.mts | 98 ++++++----- packages/joint-layout-elk/test/index.js | 160 +++++++++++++++++- 3 files changed, 221 insertions(+), 58 deletions(-) diff --git a/examples/layout-elk-containers-ports-ts/src/shapes.ts b/examples/layout-elk-containers-ports-ts/src/shapes.ts index f51f75a7aa..62ebabf726 100644 --- a/examples/layout-elk-containers-ports-ts/src/shapes.ts +++ b/examples/layout-elk-containers-ports-ts/src/shapes.ts @@ -65,9 +65,9 @@ export class Container extends shapes.standard.Rectangle { type: 'example.Container', size: { width: 100, height: 100 }, // Extra top padding, so embedded children don't overlap this container's own - // title label - read directly by `@joint/layout-elk` (`elkLayout` merges onto - // whatever it itself computes for a node), no `nodeProperties` callback needed. - elkLayout: { + // title label - read directly by `@joint/layout-elk` (`elkLayoutOptions` merges + // onto whatever it itself computes for a node), no `nodeProperties` callback needed. + elkLayoutOptions: { 'elk.padding': CONTAINER_PADDING }, attrs: { @@ -122,15 +122,15 @@ export class Service extends shapes.standard.Rectangle { in: { size: PORT_SIZE, attrs: PORT_ATTRS, - elkLayout: { - side: 'WEST' + elkLayoutOptions: { + 'elk.port.side': 'WEST' } }, out: { size: PORT_SIZE, attrs: PORT_ATTRS, - elkLayout: { - side: 'EAST' + elkLayoutOptions: { + 'elk.port.side': 'EAST' } } }, @@ -157,7 +157,7 @@ export class Service extends shapes.standard.Rectangle { ports.forEach((port) => { const text = port.attrs?.text?.text; if (!port.id || typeof text !== 'string') return; - this.portProp(port.id, 'elkLayout/labelSize', estimatePortLabelSize(text)); + this.portProp(port.id, 'label/size', estimatePortLabelSize(text)); }); } } @@ -209,6 +209,11 @@ export class InteractionLink extends shapes.standard.Link { type: 'example.InteractionLink', defaultLabel: { size: { width: 80, height: 20 }, + // `@joint/layout-elk` doesn't place link labels inline by default - opt every + // label using this `defaultLabel` back into it explicitly. Read from + // `defaultLabel` (not repeated per label) since `Link#labels`/`label` passes + // it through to any label that doesn't set its own `elkLayoutOptions`. + elkLayoutOptions: { 'elk.edgeLabels.inline': 'true' }, attrs: { text: { fontSize: 11, diff --git a/packages/joint-layout-elk/src/export.mts b/packages/joint-layout-elk/src/export.mts index ce756f9492..2fc04550e3 100644 --- a/packages/joint-layout-elk/src/export.mts +++ b/packages/joint-layout-elk/src/export.mts @@ -15,7 +15,10 @@ import type { // ELK ignores labels with no text. const ELK_LABEL_TEXT = '-'; -const ELK_INLINE_LABEL_OPTIONS: LabelElkLayoutOptions = { 'edgeLabels.inline': 'true' }; +// Default for `ExportGraphOptions.elkLayoutOptionsProperty` - the name of the property an +// element/port/group/link label can set to declare custom `elk.*` layoutOptions (see +// `getElkLayoutOptionsProperty` below). +const DEFAULT_ELK_LAYOUT_OPTIONS_PROPERTY = 'elkLayoutOptions'; // Maps a `PortsPositionMode` onto the corresponding `elk.portConstraints` value - // see `PortsPositionMode` for what each mode means. @@ -87,6 +90,15 @@ export interface ExportGraphOptions { * @defaultValue false */ positionPortLabels?: boolean; + /** + * The name of the property an element, port/port group, or link label can set to + * declare custom `elk.*` layoutOptions for it, merged onto what this package itself + * computes (see "Declaring custom ELK layout options" in the README) - without a + * `nodeProperties`/`portProperties`/`edgeProperties` callback. Not to be confused with + * `elkLayoutOptions` (`layout()`'s own option, the ELK options for the whole graph). + * @defaultValue 'elkLayoutOptions' + */ + elkLayoutOptionsProperty?: string; } export const DEFAULT_LABEL_SIZE: dia.Size = { @@ -101,6 +113,12 @@ function mergeProperties(overrides: Partial, computed: T): return util.defaultsDeep({}, overrides, computed) as T; } +// The name of the property to read custom `elk.*` layoutOptions from (see +// `ExportGraphOptions.elkLayoutOptionsProperty`). +function getElkLayoutOptionsProperty(): string { + return exportGraphOptions.elkLayoutOptionsProperty || DEFAULT_ELK_LAYOUT_OPTIONS_PROPERTY; +} + let exportGraphOptions: ExportGraphOptions; let elementsById: Map; @@ -136,16 +154,17 @@ function buildPorts(element: dia.Element): ElkPort[] | undefined { const elkPortId = `${element.id}:${portId}`; portsById.set(elkPortId, { element, portId }); - // Optional per-port/group metadata (`side`, `labelSize`) a consumer can set under - // a group's/port's own `elkLayout` key - merged onto the port by `@joint/core`. - const properties = element.portProp(portId, 'elkLayout'); + // Optional `elk.*` layoutOptions a consumer can set directly under a port's/group's + // own `elkLayoutOptionsProperty` key (see `buildElkNode`'s node-level equivalent), + // merged onto what this package itself computes below - `@joint/core` merges a + // group's own value onto each of its ports for `portProp` reads. + const elkLayout = element.portProp(portId, getElkLayoutOptionsProperty()) as PortElkLayoutOptions | undefined; - // `element.getPorts()` gives the fully resolved label (merged with the group's) - - // `portProp`/`getPort` only ever see the port's own raw JSON. + // `getPortMetrics` resolves a port's `label.size` against its group's, unlike + // `element.getPorts()`/`portProp`, which only ever see the port's own raw JSON. let labels: ElkLabel[] | undefined; if (exportGraphOptions.positionPortLabels) { - - const { width: labelWidth, height: labelHeight } = properties?.labelSize ?? DEFAULT_LABEL_SIZE; + const { width: labelWidth, height: labelHeight } = element.getPortMetrics(portId).labelSize ?? DEFAULT_LABEL_SIZE; labels = [{ // Some text is required, otherwise ELK ignores the label. text: ELK_LABEL_TEXT, @@ -156,7 +175,7 @@ function buildPorts(element: dia.Element): ElkPort[] | undefined { } let portProperties: PortProperties = {}; - const layoutOptions: PortElkLayoutOptions = {}; + const computedLayoutOptions: PortElkLayoutOptions = {}; const { x, y } = element.getPortRelativePosition(portId); const { width, height } = element.getPortRelativeRect(portId); @@ -167,30 +186,15 @@ function buildPorts(element: dia.Element): ElkPort[] | undefined { portProperties.y = y; } - // Only 'fixed-side' pins the side - 'free' lets ELK choose it on its own. - if (exportGraphOptions.portsPosition === 'fixed-side') { - switch (properties?.side) { - case 'WEST': - layoutOptions['elk.port.side'] = 'WEST'; - break; - case 'EAST': - layoutOptions['elk.port.side'] = 'EAST'; - break; - case 'SOUTH': - layoutOptions['elk.port.side'] = 'SOUTH'; - break; - case 'NORTH': - layoutOptions['elk.port.side'] = 'NORTH'; - break; - default: - } - } - // Negative offset moves the port inward from the node border, centering it there. if (exportGraphOptions.portsPosition === 'fixed-side' || exportGraphOptions.portsPosition === 'free') { - layoutOptions['elk.port.borderOffset'] = `${-width / 2}`; + computedLayoutOptions['elk.port.borderOffset'] = `${-width / 2}`; } + // `elkLayout` (e.g. `{ 'elk.port.side': 'WEST' }`) maps directly onto ELK's own + // layoutOptions - merged onto what this package computed above (see `mergeProperties`). + const layoutOptions = elkLayout ? mergeProperties(elkLayout, computedLayoutOptions) : computedLayoutOptions; + portProperties = { width, height, @@ -239,10 +243,11 @@ function buildElkNode(element: dia.Element, containerPosition: dia.Point = { x: } : {}; // Optional `elk.*` layoutOptions a consumer can set directly on the element's own - // `elkLayout` property (see the same-named property `buildPorts` reads per port/group - // below), merged onto what this package itself computes - so e.g. a container's - // `elk.padding` can be declared once, on the shape, without a `nodeProperties` callback. - const elkLayout = element.prop('elkLayout') as NodeElkLayoutOptions | undefined; + // `elkLayoutOptionsProperty` property (see the same one `buildPorts` reads per + // port/group below), merged onto what this package itself computes - so e.g. a + // container's `elk.padding` can be declared once, on the shape, without a + // `nodeProperties` callback. + const elkLayout = element.prop(getElkLayoutOptionsProperty()) as NodeElkLayoutOptions | undefined; const layoutOptions = elkLayout ? mergeProperties(elkLayout, computedLayoutOptions) : computedLayoutOptions; const embeds = element.getEmbeddedCells() @@ -314,23 +319,30 @@ function buildEdge(link: dia.Link): void { let labels: ElkLabel[] | undefined; if (exportGraphOptions.edgeLabels) { - // Raw (not `link.labels()`) - a custom `elkLayout` property (read below) isn't - // part of the resolved label `@joint/core` returns (see `Link#_getResolvedLabel`). - const linkLabels = (link.get('labels') as dia.Link.Label[] | undefined) || []; - if (linkLabels.length > 0) { - labels = linkLabels.map((label): ElkLabel => { + // Resolved (`link.labels()`) - `size` falls back through `defaultLabel`/the built-in + // default the same way `@joint/core` itself resolves it for rendering, and a custom + // `elkLayoutOptionsProperty` property passes through too (whether set on the label + // itself or on `defaultLabel` - see `Link#_getResolvedLabel`), so it can be read + // directly here instead of from the label's raw JSON. + const resolvedLabels = link.labels(); + if (resolvedLabels.length > 0) { + const elkLayoutOptionsProperty = getElkLayoutOptionsProperty(); + labels = resolvedLabels.map((label): ElkLabel => { const { width, height } = label.size || DEFAULT_LABEL_SIZE; // Optional `elk.*` layoutOptions a consumer can set directly on the label's - // own `elkLayout` property (see `buildElkNode`'s node-level equivalent above). - const elkLayout = (label as { elkLayout?: LabelElkLayoutOptions }).elkLayout; - const layoutOptions = elkLayout ? mergeProperties(elkLayout, ELK_INLINE_LABEL_OPTIONS) : ELK_INLINE_LABEL_OPTIONS; + // own (or `defaultLabel`'s) `elkLayoutOptionsProperty` property (see + // `buildElkNode`'s node-level equivalent above). Not inline by default - a + // consumer wanting the old default back sets e.g. + // `elkLayoutOptions: { 'elk.edgeLabels.inline': 'true' }` explicitly. + const elkLayout = (label as Record)[elkLayoutOptionsProperty]; + // Cloned (not a direct reference into the label's own storage), same as + // `buildElkNode`/`buildPorts` - `elk.layout()` shouldn't mutate a consumer's data. + const layoutOptions = elkLayout ? mergeProperties(elkLayout, {}) : {}; return { // Some text is required, otherwise ELK ignores the label. text: ELK_LABEL_TEXT, width, height, - // Place the label directly on the edge (and allocate space for it), - // unless `elkLayout` overrides that. layoutOptions }; }); diff --git a/packages/joint-layout-elk/test/index.js b/packages/joint-layout-elk/test/index.js index c9aac90c99..f2e1b6edf7 100644 --- a/packages/joint-layout-elk/test/index.js +++ b/packages/joint-layout-elk/test/index.js @@ -89,6 +89,28 @@ QUnit.module('layout()', () => { 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 }); @@ -348,13 +370,13 @@ QUnit.module('layout()', () => { assert.equal(secondPosition.y, firstPosition.y); }); - QUnit.test('should merge a node\'s own `elkLayout` into its computed layoutOptions, without a nodeProperties callback', async(assert) => { + QUnit.test('should merge a node\'s own `elkLayoutOptions` into its computed layoutOptions, without a nodeProperties callback', 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 }, - elkLayout: { 'elk.padding': '[top=40,left=20,bottom=20,right=20]' } + 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); @@ -367,7 +389,7 @@ QUnit.module('layout()', () => { assert.equal(parentNode.layoutOptions['elk.padding'], '[top=40,left=20,bottom=20,right=20]'); }); - QUnit.test('should merge a link label\'s own `elkLayout` into its computed layoutOptions, without an edgeProperties callback', async(assert) => { + QUnit.test('should merge a link label\'s own `elkLayoutOptions` into its computed layoutOptions, without an edgeProperties callback', 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 }}); @@ -375,7 +397,7 @@ QUnit.module('layout()', () => { const link = new joint.shapes.standard.Link({ source: { id: 'a' }, target: { id: 'b' }, - labels: [{ size: { width: 40, height: 20 }, elkLayout: { 'edgeLabels.inline': 'false' }}] + labels: [{ size: { width: 40, height: 20 }, elkLayoutOptions: { 'elk.edgeLabels.inline': 'true' }}] }); graph.resetCells([el1, el2, link]); @@ -383,9 +405,133 @@ QUnit.module('layout()', () => { const { elkGraph } = await joint.layout.ELK.layout(graph); const [elkEdge] = elkGraph.edges; - assert.equal(elkEdge.labels[0].layoutOptions['edgeLabels.inline'], 'false'); + assert.equal(elkEdge.labels[0].layoutOptions['elk.edgeLabels.inline'], 'true'); + + // The label's own raw JSON is unaffected - `elkLayoutOptions` isn't baked into it. + assert.deepEqual(link.get('labels')[0].elkLayoutOptions, { 'elk.edgeLabels.inline': 'true' }); + }); + + QUnit.test('should not place a link label inline by default - it is opt-in via `elkLayoutOptions`', 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); + + const [elkEdge] = elkGraph.edges; + assert.notOk('elk.edgeLabels.inline' in elkEdge.labels[0].layoutOptions); + }); + + QUnit.test('should merge a link\'s `defaultLabel.elkLayoutOptions` into every label\'s 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' }, + defaultLabel: { + size: { width: 40, height: 20 }, + elkLayoutOptions: { 'elk.edgeLabels.inline': 'true' } + }, + // Neither label sets its own `elkLayoutOptions` - both fall back to `defaultLabel`'s. + labels: [{}, {}] + }); + + graph.resetCells([el1, el2, link]); + + const { elkGraph } = await joint.layout.ELK.layout(graph); + + const [elkEdge] = elkGraph.edges; + assert.equal(elkEdge.labels[0].layoutOptions['elk.edgeLabels.inline'], 'true'); + assert.equal(elkEdge.labels[1].layoutOptions['elk.edgeLabels.inline'], 'true'); + }); - // The label's own raw JSON is unaffected - `elkLayout` isn't baked into it. - assert.deepEqual(link.get('labels')[0].elkLayout, { 'edgeLabels.inline': 'false' }); + QUnit.test('should read the custom `elk.*` layoutOptions property under a different name when `elkLayoutOptionsProperty` is set', 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 }, + // Under the default name too - should be ignored, since a custom name is configured. + elkLayoutOptions: { 'elk.padding': 'should be ignored' }, + myElkOptions: { '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, { elkLayoutOptionsProperty: 'myElkOptions' }); + + 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 merge a port group\'s own `elkLayoutOptions` into its computed layoutOptions, without a portProperties callback', 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, { portsPosition: 'fixed-side' }); + + 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'); + // What this package itself computes (e.g. `elk.port.borderOffset`) still survives. + assert.equal(typeof elkPort.layoutOptions['elk.port.borderOffset'], 'string'); + }); + + QUnit.test('should size a port label from the port\'s (or its group\'s) `label.size`, not from `elkLayoutOptions`', 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, { positionPortLabels: true }); + + 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); }); }); From 4a456e1271a5e391795dcda041a432849502547f Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Wed, 23 Sep 2026 13:46:09 +0200 Subject: [PATCH 31/75] update --- packages/joint-layout-elk/src/export.mts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/joint-layout-elk/src/export.mts b/packages/joint-layout-elk/src/export.mts index 2fc04550e3..96b2ca2aee 100644 --- a/packages/joint-layout-elk/src/export.mts +++ b/packages/joint-layout-elk/src/export.mts @@ -160,11 +160,11 @@ function buildPorts(element: dia.Element): ElkPort[] | undefined { // group's own value onto each of its ports for `portProp` reads. const elkLayout = element.portProp(portId, getElkLayoutOptionsProperty()) as PortElkLayoutOptions | undefined; - // `getPortMetrics` resolves a port's `label.size` against its group's, unlike - // `element.getPorts()`/`portProp`, which only ever see the port's own raw JSON. let labels: ElkLabel[] | undefined; if (exportGraphOptions.positionPortLabels) { - const { width: labelWidth, height: labelHeight } = element.getPortMetrics(portId).labelSize ?? DEFAULT_LABEL_SIZE; + const labelSize = element.portProp(portId, 'label/size'); + + const { width: labelWidth, height: labelHeight } = labelSize ?? DEFAULT_LABEL_SIZE; labels = [{ // Some text is required, otherwise ELK ignores the label. text: ELK_LABEL_TEXT, From dd937564919a08f394d9c05163b1108818509691 Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Wed, 23 Sep 2026 15:15:00 +0200 Subject: [PATCH 32/75] up --- examples/layout-elk-containers-ports-ts/src/example.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/examples/layout-elk-containers-ports-ts/src/example.ts b/examples/layout-elk-containers-ports-ts/src/example.ts index b0eb886c87..c646f42226 100644 --- a/examples/layout-elk-containers-ports-ts/src/example.ts +++ b/examples/layout-elk-containers-ports-ts/src/example.ts @@ -197,14 +197,17 @@ export const graphJSON: dia.Graph.JSON = { type: 'example.InteractionLink', source: { id: 'backend' }, target: { id: 'observability' }, - labels: [{ attrs: { text: { text: 'metrics' } } }] + // Overrides `InteractionLink.defaultLabel`'s `elkLayoutOptions` (own value wins - + // see `Link#labels`) - floated beside the edge instead of centered directly on it, + // so it doesn't obscure a long aggregate link's whole path. + labels: [{ attrs: { text: { text: 'metrics' } }, elkLayoutOptions: { 'elk.edgeLabels.inline': 'false' } }] }, { id: 'l10', type: 'example.InteractionLink', source: { id: 'frontend' }, target: { id: 'observability' }, - labels: [{ attrs: { text: { text: 'analytics' } } }] + labels: [{ attrs: { text: { text: 'analytics' } }, elkLayoutOptions: { 'elk.edgeLabels.inline': 'false' } }] } ] }; From 11afedc0045ba32475315e09b673795839794748 Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Thu, 24 Sep 2026 15:08:02 +0200 Subject: [PATCH 33/75] wip --- .../src/index.ts | 52 ++- .../src/shapes.ts | 24 +- packages/joint-layout-elk/src/export.mts | 433 +++++++++--------- packages/joint-layout-elk/src/import.mts | 84 +--- packages/joint-layout-elk/src/layout.mts | 24 +- 5 files changed, 303 insertions(+), 314 deletions(-) diff --git a/examples/layout-elk-containers-ports-ts/src/index.ts b/examples/layout-elk-containers-ports-ts/src/index.ts index ad9a284bea..8e60b51114 100644 --- a/examples/layout-elk-containers-ports-ts/src/index.ts +++ b/examples/layout-elk-containers-ports-ts/src/index.ts @@ -1,5 +1,13 @@ import { dia, shapes } from '@joint/core'; -import { ElkLayoutOptions, layout } from '@joint/layout-elk'; +import { + ElkLayoutOptions, + ExportElementCallback, + ExportPortCallback, + ExportEdgeCallback, + NodeElkLayoutOptions, + PortElkLayoutOptions, + layout +} from '@joint/layout-elk'; import ELK from 'elkjs/lib/elk-api.js'; import { graphJSON } from './example'; import { Container, HubService, InteractionLink, Service } from './shapes'; @@ -7,6 +15,41 @@ import './styles.scss'; const ELK_DIRECTION = 'RIGHT'; +// `@joint/layout-elk` no longer reads a cell's `elkLayoutOptions` (or a port's, or a +// link label's) automatically - these three callbacks apply the ones this example's +// shapes still declare declaratively (see `shapes.ts`), merging them onto whatever +// this package itself computed for that node/port/edge label. + +// Every node with ports gets ELK's `FIXED_SIDE` port constraint, so ELK may reorder +// (but not move to a different side) ports already placed on their JointJS-assigned +// side - e.g. to reduce crossings on `HubService`'s several same-side ports. +const exportElement: ExportElementCallback = ({ element, elkNode }) => { + if (element.hasPorts()) { + elkNode.layoutOptions['elk.portConstraints'] = 'FIXED_SIDE'; + } + const elkLayoutOptions = element.prop('elkLayoutOptions') as NodeElkLayoutOptions | undefined; + if (elkLayoutOptions) Object.assign(elkNode.layoutOptions, elkLayoutOptions); +}; + +const exportPort: ExportPortCallback = ({ port, element, elkPort }) => { + const portId = `${port.id}`; + const elkLayoutOptions = element.portProp(portId, 'elkLayoutOptions') as PortElkLayoutOptions | undefined; + if (elkLayoutOptions) Object.assign(elkPort.layoutOptions, elkLayoutOptions); +}; + +// A label's `elkLayoutOptions` (own, or `defaultLabel`'s - `link.labels()` already +// resolves that, see `Link#labels`) applies to *that label's* own `layoutOptions` +// (e.g. `elk.edgeLabels.inline`), not the edge's. +const exportEdge: ExportEdgeCallback = ({ link, elkEdge }) => { + link.labels().forEach((label, index) => { + const elkLayoutOptions = label.elkLayoutOptions as Record | undefined; + const elkLabel = elkEdge.labels?.[index]; + if (elkLayoutOptions && elkLabel) { + elkLabel.layoutOptions = { ...elkLabel.layoutOptions, ...elkLayoutOptions }; + } + }); +}; + const cellNamespace = { ...shapes, example: { @@ -99,10 +142,9 @@ const init = () => { paper.freeze(); return layout(graph, { elk, - // Let ELK reposition (and reorder) every port in the diagram, instead - // of keeping them where JointJS's own port groups first placed them. - portsPosition: 'fixed-side', - positionPortLabels: true, + exportElement, + exportPort, + exportEdge, elkLayoutOptions }).then(() => { paper.unfreeze(); diff --git a/examples/layout-elk-containers-ports-ts/src/shapes.ts b/examples/layout-elk-containers-ports-ts/src/shapes.ts index 62ebabf726..179852178b 100644 --- a/examples/layout-elk-containers-ports-ts/src/shapes.ts +++ b/examples/layout-elk-containers-ports-ts/src/shapes.ts @@ -14,10 +14,10 @@ const PORT_ATTRS = { } }; -// `@joint/layout-elk` reads a port label's `size` directly (via `positionPortLabels`) -// 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. +// `@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. const PORT_LABEL_AVERAGE_CHAR_WIDTH = PORT_ATTRS.text.fontSize * 0.4; const PORT_LABEL_HORIZONTAL_PADDING = 6; const PORT_LABEL_HEIGHT = PORT_ATTRS.text.fontSize + 4; @@ -65,8 +65,9 @@ export class Container extends shapes.standard.Rectangle { type: 'example.Container', size: { width: 100, height: 100 }, // Extra top padding, so embedded children don't overlap this container's own - // title label - read directly by `@joint/layout-elk` (`elkLayoutOptions` merges - // onto whatever it itself computes for a node), no `nodeProperties` callback needed. + // title label - a plain custom property, applied by this example's own + // `exportElement` callback in `index.ts` (`@joint/layout-elk` no longer reads + // a cell's `elkLayoutOptions` automatically). elkLayoutOptions: { 'elk.padding': CONTAINER_PADDING }, @@ -118,6 +119,8 @@ export class Service extends shapes.standard.Rectangle { } }, ports: { + // `elkLayoutOptions` here is a plain custom property too, applied by this + // example's `exportPort` callback in `index.ts` (see `Container` above). groups: { in: { size: PORT_SIZE, @@ -209,10 +212,11 @@ export class InteractionLink extends shapes.standard.Link { type: 'example.InteractionLink', defaultLabel: { size: { width: 80, height: 20 }, - // `@joint/layout-elk` doesn't place link labels inline by default - opt every - // label using this `defaultLabel` back into it explicitly. Read from - // `defaultLabel` (not repeated per label) since `Link#labels`/`label` passes - // it through to any label that doesn't set its own `elkLayoutOptions`. + // ELK doesn't place edge labels inline by default - opt every label using + // this `defaultLabel` back into it explicitly. Read from `defaultLabel` (not + // repeated per label) since `Link#labels`/`label` passes it through to any + // label that doesn't set its own `elkLayoutOptions` (see `Link#_getResolvedLabel`), + // and applied by this example's `exportEdge` callback in `index.ts`. elkLayoutOptions: { 'elk.edgeLabels.inline': 'true' }, attrs: { text: { diff --git a/packages/joint-layout-elk/src/export.mts b/packages/joint-layout-elk/src/export.mts index 96b2ca2aee..440dacb32e 100644 --- a/packages/joint-layout-elk/src/export.mts +++ b/packages/joint-layout-elk/src/export.mts @@ -1,5 +1,4 @@ -import { util, type dia } from '@joint/core'; -import type { PortsPositionMode } from './import.mjs'; +import { type dia } from '@joint/core'; import type { ElkNode, @@ -9,52 +8,108 @@ import type { ElkLayoutOptions, NodeElkLayoutOptions, PortElkLayoutOptions, + EdgeElkLayoutOptions, LabelElkLayoutOptions } from './types/index.mjs'; // ELK ignores labels with no text. const ELK_LABEL_TEXT = '-'; -// Default for `ExportGraphOptions.elkLayoutOptionsProperty` - the name of the property an -// element/port/group/link label can set to declare custom `elk.*` layoutOptions (see -// `getElkLayoutOptionsProperty` below). -const DEFAULT_ELK_LAYOUT_OPTIONS_PROPERTY = 'elkLayoutOptions'; - -// Maps a `PortsPositionMode` onto the corresponding `elk.portConstraints` value - -// see `PortsPositionMode` for what each mode means. -const ELK_PORT_CONSTRAINTS_BY_MODE: Record = { - 'fixed': { 'elk.portConstraints': 'FIXED_POS' }, - 'fixed-side': { 'elk.portConstraints': 'FIXED_SIDE' }, - 'free': { 'elk.portConstraints': 'FREE' }, -}; +/** + * A node's label, as far as layout is concerned: a box and its options. ELK sizes + * labels from the box and never reads their text, so there is none to set. + */ +export interface ElkNodeLabelDraft { + x: number; + y: number; + width: number; + height: number; + layoutOptions?: LabelElkLayoutOptions; +} + +/** + * A port's label - no `x`/`y` (unlike `ElkNodeLabelDraft`): the node's own + * `portLabels.placement: 'OUTSIDE'` is what places it, not the label itself. + */ +export interface ElkPortLabelDraft { + width: number; + height: number; + layoutOptions?: LabelElkLayoutOptions; +} -/** Everything about a node this package computes, for `nodeProperties` to adjust - except `id`/`ports`/`children` (structural). */ -export type NodeProperties = Omit; -/** Everything about a port this package computes, for `portProperties` to adjust - except `id` (structural). */ -export type PortProperties = Omit; -/** Everything about an edge this package computes, for `edgeProperties` to adjust - except `id`/`sources`/`targets` (structural). */ -export type EdgeProperties = Omit; - -// A callback's return value is merged onto its `computedProperties` (see `mergeProperties`) - -// only what it actually returns overrides the computed value, so e.g. returning `{}` (or -// omitting a key) keeps that part of `computedProperties` as-is. -export type NodePropertiesCallback = (params: NodePropertiesCallbackParameters) => Partial; -export type NodePropertiesCallbackParameters = { +/** + * An edge's label - no `x`/`y` (unlike `ElkNodeLabelDraft`): ELK places it along + * the routed edge itself, per `layoutOptions` (e.g. `elk.edgeLabels.inline`). + */ +export interface ElkEdgeLabelDraft { + width: number; + height: number; + layoutOptions?: LabelElkLayoutOptions; +} + +export interface ElkNodeDraft { + readonly id: string; + /** Relative to the parent node. */ + x?: number; + y?: number; + /** `0` for a container: ELK sizes it to fit its content. */ + width: number; + height: number; + layoutOptions: NodeElkLayoutOptions; + /** + * Empty. JointJS elements carry no labels, so add them only if ELK should + * size around them, e.g. under `elk.nodeSize.constraints: 'NODE_LABELS'`. + */ + labels?: ElkNodeLabelDraft[]; +} + +/** + * 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: ExportElementCallbackParameters) => void | false; +export type ExportElementCallbackParameters = { element: dia.Element; - computedProperties: NodeProperties; + elkNode: ElkNodeDraft; +}; + +export interface ElkPortDraft { + readonly id: string; + x: number; + y: number; + width: number; + height: number; + layoutOptions: PortElkLayoutOptions; + labels?: ElkPortLabelDraft[]; } -export type PortPropertiesCallback = (params: PortPropertiesCallbackParameters) => Partial; -export type PortPropertiesCallbackParameters = { +/** + * Mutate `elkPort` to customize what this package computed for a port, or return + * `false` to drop the port from the ELK graph - any edge connected to it falls + * back to anchoring on the element itself, the same as a naturally portless one. + */ +export type ExportPortCallback = (params: ExportPortCallbackParameters) => void | false; +export type ExportPortCallbackParameters = { port: dia.Element.Port; element: dia.Element; - computedProperties: PortProperties; + elkPort: ElkPortDraft; }; -export type EdgePropertiesCallback = (params: EdgePropertiesCallbackParameters) => Partial; -export type EdgePropertiesCallbackParameters = { +export interface ElkEdgeDraft { + readonly id: string; + layoutOptions: EdgeElkLayoutOptions; + labels?: ElkEdgeLabelDraft[]; +} + +/** + * 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 ExportEdgeCallback = (params: ExportEdgeCallbackParameters) => void | false; +export type ExportEdgeCallbackParameters = { link: dia.Link; - computedProperties: EdgeProperties; + elkEdge: ElkEdgeDraft; }; export interface ElkGraphPort { @@ -70,35 +125,9 @@ export interface ElkGraphData { } export interface ExportGraphOptions { - nodeProperties?: NodePropertiesCallback; - portProperties?: PortPropertiesCallback; - edgeProperties?: EdgePropertiesCallback; - /** - * Whether to account for link labels during layout and position them afterwards. - * @defaultValue true - */ - edgeLabels?: boolean; - /** - * How freely ELK may reposition (and reorder) ports, instead of keeping them - * where JointJS's port groups place them - see `PortsPositionMode`. - * @defaultValue 'fixed' - */ - portsPosition?: PortsPositionMode; - /** - * Whether to let ELK reposition port labels along their port, instead of - * keeping them where JointJS's port groups place them. - * @defaultValue false - */ - positionPortLabels?: boolean; - /** - * The name of the property an element, port/port group, or link label can set to - * declare custom `elk.*` layoutOptions for it, merged onto what this package itself - * computes (see "Declaring custom ELK layout options" in the README) - without a - * `nodeProperties`/`portProperties`/`edgeProperties` callback. Not to be confused with - * `elkLayoutOptions` (`layout()`'s own option, the ELK options for the whole graph). - * @defaultValue 'elkLayoutOptions' - */ - elkLayoutOptionsProperty?: string; + exportElement?: ExportElementCallback; + exportPort?: ExportPortCallback; + exportEdge?: ExportEdgeCallback; } export const DEFAULT_LABEL_SIZE: dia.Size = { @@ -106,24 +135,15 @@ export const DEFAULT_LABEL_SIZE: dia.Size = { height: 20 }; -// Deep-merges a `nodeProperties`/`portProperties`/`edgeProperties` callback's return value -// onto what this package itself computed - the override wins on conflicts (including into -// nested objects like `layoutOptions`), `computed` fills in anything the override didn't set. -function mergeProperties(overrides: Partial, computed: T): T { - return util.defaultsDeep({}, overrides, computed) as T; -} - -// The name of the property to read custom `elk.*` layoutOptions from (see -// `ExportGraphOptions.elkLayoutOptionsProperty`). -function getElkLayoutOptionsProperty(): string { - return exportGraphOptions.elkLayoutOptionsProperty || DEFAULT_ELK_LAYOUT_OPTIONS_PROPERTY; -} - let exportGraphOptions: ExportGraphOptions; let elementsById: Map; let linksById: Map; let portsById: Map; +// A port `exportPort` dropped, keyed by its element's id then its own port id - so +// `buildEdge` can fall an edge connected to it back to the element itself, instead +// of referencing a port id that was never actually added to the ELK graph. +let excludedPortIdsByElement: 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; @@ -138,96 +158,90 @@ function init(options: ExportGraphOptions): void { elementsById = new Map(); linksById = new Map(); portsById = new Map(); + excludedPortIdsByElement = new Map(); edgeContainersById = new Map(); } +function getExcludedPortIds(element: dia.Element): Set { + const id = `${element.id}`; + let excluded = excludedPortIdsByElement.get(id); + if (!excluded) { + excluded = new Set(); + excludedPortIdsByElement.set(id, excluded); + } + return excluded; +} + +function isPortExcluded(element: dia.Element, portId: string): boolean { + return !!excludedPortIdsByElement.get(`${element.id}`)?.has(portId); +} + /** - * Builds a node's ELK ports, starting from the position JointJS already computed - * for them - `elk.portConstraints` (see `ELK_PORT_CONSTRAINTS_BY_MODE`) decides - * whether ELK can move them from there. + * 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; - return element.getPorts().map((port): ElkPort => { + const ports: ElkPort[] = []; + + element.getPorts().forEach((port) => { const portId = `${port.id}`; const elkPortId = `${element.id}:${portId}`; - portsById.set(elkPortId, { element, portId }); - - // Optional `elk.*` layoutOptions a consumer can set directly under a port's/group's - // own `elkLayoutOptionsProperty` key (see `buildElkNode`'s node-level equivalent), - // merged onto what this package itself computes below - `@joint/core` merges a - // group's own value onto each of its ports for `portProp` reads. - const elkLayout = element.portProp(portId, getElkLayoutOptionsProperty()) as PortElkLayoutOptions | undefined; - - let labels: ElkLabel[] | undefined; - if (exportGraphOptions.positionPortLabels) { - const labelSize = element.portProp(portId, 'label/size'); - const { width: labelWidth, height: labelHeight } = labelSize ?? DEFAULT_LABEL_SIZE; - labels = [{ - // Some text is required, otherwise ELK ignores the label. - text: ELK_LABEL_TEXT, - width: labelWidth, - height: labelHeight, - layoutOptions: {} - }]; - } + const labelSize = element.portProp(portId, 'label/size'); + const { width: labelWidth, height: labelHeight } = labelSize ?? DEFAULT_LABEL_SIZE; - let portProperties: PortProperties = {}; - const computedLayoutOptions: PortElkLayoutOptions = {}; const { x, y } = element.getPortRelativePosition(portId); const { width, height } = element.getPortRelativeRect(portId); - // In 'fixed' mode ELK must use the exact current position; other modes let it - // compute a new one, so sending one would only anchor/bias it needlessly. - if (!exportGraphOptions.portsPosition || exportGraphOptions.portsPosition === 'fixed') { - portProperties.x = x; - portProperties.y = y; - } - - // Negative offset moves the port inward from the node border, centering it there. - if (exportGraphOptions.portsPosition === 'fixed-side' || exportGraphOptions.portsPosition === 'free') { - computedLayoutOptions['elk.port.borderOffset'] = `${-width / 2}`; - } - - // `elkLayout` (e.g. `{ 'elk.port.side': 'WEST' }`) maps directly onto ELK's own - // layoutOptions - merged onto what this package computed above (see `mergeProperties`). - const layoutOptions = elkLayout ? mergeProperties(elkLayout, computedLayoutOptions) : computedLayoutOptions; - - portProperties = { + const elkPort: ElkPortDraft = { + id: elkPortId, + x, + y, width, height, - labels, - layoutOptions, - ...portProperties + layoutOptions: { + // Negative offset moves the port inward from the node border, centering it there. + 'elk.port.borderOffset': `${-width / 2}` + }, + labels: [{ + width: labelWidth, + height: labelHeight, + layoutOptions: {} + }] }; - if (exportGraphOptions.portProperties) { - const overrides = exportGraphOptions.portProperties({ - port, - element, - computedProperties: portProperties - }); - portProperties = mergeProperties(overrides, portProperties); + if (exportGraphOptions.exportPort?.({ port, element, elkPort }) === false) { + getExcludedPortIds(element).add(portId); + return; } - return { - id: elkPortId, - ...portProperties - }; + portsById.set(elkPort.id, { element, portId }); + ports.push({ + ...elkPort, + labels: (elkPort.labels || []).map((label): ElkLabel => ({ + // Some text is required, otherwise ELK ignores the label. + text: ELK_LABEL_TEXT, + ...label + })) + }); }); + + 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, containerPosition: dia.Point = { x: 0, y: 0 }): ElkNode { +function buildElkNode(element: dia.Element, containerPosition: dia.Point = { x: 0, y: 0 }): ElkNode | null { const id = `${element.id}`; - elementsById.set(id, element); - const ports = buildPorts(element); const { x: absoluteX, y: absoluteY @@ -235,56 +249,53 @@ function buildElkNode(element: dia.Element, containerPosition: dia.Point = { x: const x = absoluteX - containerPosition.x; const y = absoluteY - containerPosition.y; - - // Only relevant for a node that has ports - see `ELK_PORT_CONSTRAINTS_BY_MODE`. - const computedLayoutOptions: NodeElkLayoutOptions = (ports) ? { - ...ELK_PORT_CONSTRAINTS_BY_MODE[exportGraphOptions.portsPosition ?? 'fixed'], + // Only relevant for a node that has ports. + const computedLayoutOptions: NodeElkLayoutOptions = element.hasPorts() ? { 'portLabels.placement': 'OUTSIDE' } : {}; - // Optional `elk.*` layoutOptions a consumer can set directly on the element's own - // `elkLayoutOptionsProperty` property (see the same one `buildPorts` reads per - // port/group below), merged onto what this package itself computes - so e.g. a - // container's `elk.padding` can be declared once, on the shape, without a - // `nodeProperties` callback. - const elkLayout = element.prop(getElkLayoutOptionsProperty()) as NodeElkLayoutOptions | undefined; - const layoutOptions = elkLayout ? mergeProperties(elkLayout, computedLayoutOptions) : computedLayoutOptions; - const embeds = element.getEmbeddedCells() .filter((cell): cell is dia.Element => cell.isElement()); - let children: ElkNode[] | undefined; - let edges: ElkExtendedEdge[] | undefined; // A container's real size is computed by ELK to fit its content - `0` is just a // placeholder (elkjs needs a numeric size upfront for a hierarchical node). let width = 0; let height = 0; + if (embeds.length === 0) { + ({ width, height } = element.size()); + } + + const elkNode: ElkNodeDraft = { + id, + width, + height, + layoutOptions: computedLayoutOptions + }; + + if (exportGraphOptions.exportElement?.({ element, elkNode }) === false) return null; + + elementsById.set(id, element); + + const ports = buildPorts(element); + + let children: ElkNode[] | undefined; + let edges: ElkExtendedEdge[] | undefined; if (embeds.length > 0) { - children = embeds.map((embed) => buildElkNode(embed, { x, y })); + children = embeds + .map((embed) => buildElkNode(embed, { x, y })) + .filter((node): node is ElkNode => node !== null); // 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 { - ({ width, height } = element.size()); } - let nodeProperties: NodeProperties = { x, y, width, height, layoutOptions }; - if (exportGraphOptions.nodeProperties) { - const overrides = exportGraphOptions.nodeProperties({ - element, - computedProperties: nodeProperties - }); - nodeProperties = mergeProperties(overrides, nodeProperties); - } - const node: ElkNode = { - id, + return { + ...elkNode, children, ports, - edges, - ...nodeProperties + edges }; - return node; } // The lowest common ancestor of an element and itself/an ancestor is the element's parent chain - @@ -303,80 +314,75 @@ function getLowestCommonAncestorId(sourcePath: string[], targetPath: string[]): return commonId; } +/** + * Builds a link's ELK edge. `exportEdge` (if given) may mutate the edge's draft, or + * return `false` to drop it from the ELK graph (see `ExportEdgeCallback`). + */ function buildEdge(link: dia.Link): void { const sourceElement = link.getSourceElement(); const targetElement = link.getTargetElement(); // Links not connected to two elements (e.g. connected to a point or // to another link) are not part of the layout. if (!sourceElement || !targetElement) return; + // 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(`${sourceElement.id}`) || !elementsById.has(`${targetElement.id}`)) return; const id = `${link.id}`; - linksById.set(id, link); const sourcePort = link.source().port; const targetPort = link.target().port; - let labels: ElkLabel[] | undefined; - if (exportGraphOptions.edgeLabels) { - // Resolved (`link.labels()`) - `size` falls back through `defaultLabel`/the built-in - // default the same way `@joint/core` itself resolves it for rendering, and a custom - // `elkLayoutOptionsProperty` property passes through too (whether set on the label - // itself or on `defaultLabel` - see `Link#_getResolvedLabel`), so it can be read - // directly here instead of from the label's raw JSON. - const resolvedLabels = link.labels(); - if (resolvedLabels.length > 0) { - const elkLayoutOptionsProperty = getElkLayoutOptionsProperty(); - labels = resolvedLabels.map((label): ElkLabel => { - const { width, height } = label.size || DEFAULT_LABEL_SIZE; - // Optional `elk.*` layoutOptions a consumer can set directly on the label's - // own (or `defaultLabel`'s) `elkLayoutOptionsProperty` property (see - // `buildElkNode`'s node-level equivalent above). Not inline by default - a - // consumer wanting the old default back sets e.g. - // `elkLayoutOptions: { 'elk.edgeLabels.inline': 'true' }` explicitly. - const elkLayout = (label as Record)[elkLayoutOptionsProperty]; - // Cloned (not a direct reference into the label's own storage), same as - // `buildElkNode`/`buildPorts` - `elk.layout()` shouldn't mutate a consumer's data. - const layoutOptions = elkLayout ? mergeProperties(elkLayout, {}) : {}; - return { - // Some text is required, otherwise ELK ignores the label. - text: ELK_LABEL_TEXT, - width, - height, - layoutOptions - }; - }); - } + // A port `exportPort` dropped falls back to anchoring the edge on the element + // itself, same as a naturally portless connection. + const sources = (sourcePort && !isPortExcluded(sourceElement, sourcePort)) + ? [`${sourceElement.id}:${sourcePort}`] + : [`${sourceElement.id}`]; + const targets = (targetPort && !isPortExcluded(targetElement, targetPort)) + ? [`${targetElement.id}:${targetPort}`] + : [`${targetElement.id}`]; + + // Resolved (`link.labels()`) - `size` falls back through `defaultLabel`/the built-in + // default the same way `@joint/core` itself resolves it for rendering, and a custom + // `elkLayoutOptionsProperty` property passes through too (whether set on the label + // itself or on `defaultLabel` - see `Link#_getResolvedLabel`), so it can be read + // directly here instead of from the label's raw JSON. + const resolvedLabels = link.labels(); + let labels: ElkEdgeLabelDraft[] | undefined; + if (resolvedLabels.length > 0) { + labels = resolvedLabels.map((label): ElkEdgeLabelDraft => { + const { width, height } = label.size || DEFAULT_LABEL_SIZE; + return { width, height, layoutOptions: {}}; + }); } - const sources = (sourcePort) ? [`${sourceElement.id}:${sourcePort}`] : [`${sourceElement.id}`]; - - const targets = (targetPort) ? [`${targetElement.id}:${targetPort}`] : [`${targetElement.id}`]; - - let edgeProperties: EdgeProperties = { + const elkEdge: ElkEdgeDraft = { + id, layoutOptions: {}, labels }; - if (exportGraphOptions.edgeProperties) { - const overrides = exportGraphOptions.edgeProperties({ - link, - computedProperties: edgeProperties - }); - edgeProperties = mergeProperties(overrides, edgeProperties); - } + + if (exportGraphOptions.exportEdge?.({ link, elkEdge }) === false) return; + + linksById.set(id, link); const edge: ElkExtendedEdge = { - id, + ...elkEdge, sources, targets, - ...edgeProperties + labels: elkEdge.labels && elkEdge.labels.map((label): ElkLabel => ({ + // Some text is required, otherwise ELK ignores the label. + text: ELK_LABEL_TEXT, + ...label + })) }; 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 is a container - // that has already been registered in `edgeContainersById` by the time links are processed. + // 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); } @@ -394,7 +400,8 @@ export function exportGraph( const children: ElkNode[] = graph.getElements() .filter((element) => !element.parent()) - .map((element) => buildElkNode(element)); + .map((element) => buildElkNode(element)) + .filter((node): node is ElkNode => node !== null); const elkGraph: ElkNode = { id: 'root', diff --git a/packages/joint-layout-elk/src/import.mts b/packages/joint-layout-elk/src/import.mts index f41d0514d1..210891a3b2 100644 --- a/packages/joint-layout-elk/src/import.mts +++ b/packages/joint-layout-elk/src/import.mts @@ -46,35 +46,10 @@ export type SetLinkAttributesCallbackParameters = { }; }; -/** - * How freely ELK may reposition a port - maps to `elk.portConstraints`: - * - `'fixed'` (default): stays at JointJS's computed position (`FIXED_POS`). - * - `'fixed-side'`: may move/reorder along its group's side (`FIXED_SIDE`). - * - `'free'`: may move anywhere, including onto a different side (`FREE`). - */ -export type PortsPositionMode = 'fixed' | 'fixed-side' | 'free'; - export interface ImportLayoutOptions { setElementAttributes?: SetElementAttributesCallback; setLinkAttributes?: SetLinkAttributesCallback; setPortAttributes?: SetPortAttributesCallback; - /** - * Whether to account for link labels during layout and position them afterwards. - * @defaultValue true - */ - edgeLabels?: boolean; - /** - * How freely ELK may reposition (and reorder) ports, instead of keeping them - * where JointJS's port groups place them - see `PortsPositionMode`. - * @defaultValue 'fixed' - */ - portsPosition?: PortsPositionMode; - /** - * Whether to let ELK reposition port labels along their port, instead of - * keeping them where JointJS's port groups place them. - * @defaultValue false - */ - positionPortLabels?: boolean; } // The anchor for a link end not connected to a port - computed the same way JointJS @@ -164,7 +139,7 @@ function importEdges(edges: ElkExtendedEdge[] | undefined, containerPosition: di }; let labels: dia.Link.Label[] | undefined; - if (importLayoutOptions.edgeLabels && edge.labels && edge.labels.length > 0) { + if (edge.labels && edge.labels.length > 0) { const points = [startPoint, ...bendPoints, endPoint] .map((point) => toAbsolute(point, containerPosition)); const polyline = new g.Polyline(points); @@ -222,8 +197,6 @@ function importNode(node: ElkNode, containerPosition: dia.Point = { x: 0, y: 0 } if (node.ports) { const setPortAttributes = importLayoutOptions.setPortAttributes ?? defaultSetPortAttributes; - // A 'fixed' port (the default) already stays put - nothing to apply back. - const positionPorts = (importLayoutOptions.portsPosition ?? 'fixed') !== 'fixed'; node.ports.forEach((port) => { const found = portsById.get(port.id); @@ -231,50 +204,33 @@ function importNode(node: ElkNode, containerPosition: dia.Point = { x: 0, y: 0 } const { element, portId } = found; let labelPosition: dia.Point | undefined; - if (importLayoutOptions.positionPortLabels) { - 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*. - labelPosition = { - x: (label.x || 0) - (port.width || 0) / 2, - y: (label.y || 0) - (port.height || 0) / 2 - }; - } - } - - if (!positionPorts && !labelPosition) return; - - // A port's own `position`/`label.position` only ever supplies *args* - which - // layout function reads them ('absolute'/'manual' vs. e.g. 'left') is a - // group-level setting, so that has to be switched too (see `@joint/core`'s - // `PortData#_evaluatePortPositionProperty`). - const { group } = element.getPort(portId); - if (group !== undefined) { - // Safe to replace outright - only `position`/`label.position` change, - // `attrs`/`markup`/`label` stay intact. - if (positionPorts) { - element.prop(['ports', 'groups', group, 'position'], { name: 'absolute' }); - } - if (labelPosition) { - element.prop(['ports', 'groups', group, 'label', 'position'], { name: 'manual' }); - } + 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*. + labelPosition = { + x: (label.x || 0) - (port.width || 0) / 2, + y: (label.y || 0) - (port.height || 0) / 2 + }; } setPortAttributes({ element, portId, attributes: { - // Same top-left-to-center conversion as the label above, for 'absolute' position. - ...(positionPorts ? { - position: { - args: { - x: (port.x || 0) + (port.width || 0) / 2, - y: (port.y || 0) + (port.height || 0) / 2 + position: { + args: { + x: (port.x || 0) + (port.width || 0) / 2, + y: (port.y || 0) + (port.height || 0) / 2 + } + }, + ...(labelPosition ? { + label: { + position: { + args: labelPosition } } - } : {}), - ...(labelPosition ? { label: { position: { args: labelPosition }}} : {}) + } : {}) } }); }); diff --git a/packages/joint-layout-elk/src/layout.mts b/packages/joint-layout-elk/src/layout.mts index f74e42dc24..1ca1572422 100644 --- a/packages/joint-layout-elk/src/layout.mts +++ b/packages/joint-layout-elk/src/layout.mts @@ -4,7 +4,7 @@ import { importLayout } from './import.mjs'; import { exportGraph } from './export.mjs'; import type { ExportGraphOptions } from './export.mjs'; -import type { ImportLayoutOptions, PortsPositionMode } from './import.mjs'; +import type { ImportLayoutOptions } from './import.mjs'; import type { ElkLayoutOptions, ElkNode } from './types/index.mjs'; import type { dia } from '@joint/core'; import type { ELK, ElkNode as RawElkNode } from 'elkjs'; @@ -23,7 +23,6 @@ const DEFAULT_LAYOUT_OPTIONS: ElkLayoutOptions = { }; const DEFAULT_OPTIONS: Options = { - edgeLabels: true, batchName: LAYOUT_BATCH_NAME, }; @@ -32,9 +31,7 @@ let defaultElk: ELK | undefined; /** * Layout configuration options. */ -export interface Options extends - Omit, - Omit { +export interface Options extends ImportLayoutOptions, ExportGraphOptions { /** * A custom ELK instance, e.g. one configured to run inside a Web Worker. @@ -53,23 +50,6 @@ export interface Options extends * @defaultValue `{ 'elk.algorithm': 'layered', 'elk.hierarchyHandling': 'INCLUDE_CHILDREN' }` */ elkLayoutOptions?: ElkLayoutOptions; - /** - * Whether to account for link labels during layout and position them afterwards. - * @defaultValue true - */ - edgeLabels?: boolean; - /** - * How freely ELK may reposition (and reorder) ports, instead of keeping them - * where JointJS's port groups place them - see `PortsPositionMode`. - * @defaultValue 'fixed' - */ - portsPosition?: PortsPositionMode; - /** - * Whether to let ELK reposition port labels along their port, instead of - * keeping them where JointJS's port groups place them. - * @defaultValue false - */ - positionPortLabels?: boolean; /** * A name for the layout batch, which can be used to group multiple layout operations together. * @defaultValue 'layout' From 4be9d85fe53d65c54889dadab9f702b7cf34b441 Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Fri, 25 Sep 2026 12:30:27 +0200 Subject: [PATCH 34/75] update API --- .../src/example.ts | 4 +- .../src/index.ts | 76 +++++---- .../src/shapes.ts | 37 ++-- packages/joint-layout-elk/src/export.mts | 160 ++++++++---------- packages/joint-layout-elk/src/import.mts | 14 +- 5 files changed, 144 insertions(+), 147 deletions(-) diff --git a/examples/layout-elk-containers-ports-ts/src/example.ts b/examples/layout-elk-containers-ports-ts/src/example.ts index c646f42226..ec7de3f37b 100644 --- a/examples/layout-elk-containers-ports-ts/src/example.ts +++ b/examples/layout-elk-containers-ports-ts/src/example.ts @@ -200,14 +200,14 @@ export const graphJSON: dia.Graph.JSON = { // Overrides `InteractionLink.defaultLabel`'s `elkLayoutOptions` (own value wins - // see `Link#labels`) - floated beside the edge instead of centered directly on it, // so it doesn't obscure a long aggregate link's whole path. - labels: [{ attrs: { text: { text: 'metrics' } }, elkLayoutOptions: { 'elk.edgeLabels.inline': 'false' } }] + labels: [{ attrs: { text: { text: 'metrics' } }, inline: false }] }, { id: 'l10', type: 'example.InteractionLink', source: { id: 'frontend' }, target: { id: 'observability' }, - labels: [{ attrs: { text: { text: 'analytics' } }, elkLayoutOptions: { 'elk.edgeLabels.inline': 'false' } }] + labels: [{ attrs: { text: { text: 'analytics' } }, inline: false }] } ] }; diff --git a/examples/layout-elk-containers-ports-ts/src/index.ts b/examples/layout-elk-containers-ports-ts/src/index.ts index 8e60b51114..2bc57020b5 100644 --- a/examples/layout-elk-containers-ports-ts/src/index.ts +++ b/examples/layout-elk-containers-ports-ts/src/index.ts @@ -6,7 +6,9 @@ import { ExportEdgeCallback, NodeElkLayoutOptions, PortElkLayoutOptions, - layout + layout, + ExportEdgeLabelCallback, + ExportPortLabelCallback } from '@joint/layout-elk'; import ELK from 'elkjs/lib/elk-api.js'; import { graphJSON } from './example'; @@ -15,41 +17,6 @@ import './styles.scss'; const ELK_DIRECTION = 'RIGHT'; -// `@joint/layout-elk` no longer reads a cell's `elkLayoutOptions` (or a port's, or a -// link label's) automatically - these three callbacks apply the ones this example's -// shapes still declare declaratively (see `shapes.ts`), merging them onto whatever -// this package itself computed for that node/port/edge label. - -// Every node with ports gets ELK's `FIXED_SIDE` port constraint, so ELK may reorder -// (but not move to a different side) ports already placed on their JointJS-assigned -// side - e.g. to reduce crossings on `HubService`'s several same-side ports. -const exportElement: ExportElementCallback = ({ element, elkNode }) => { - if (element.hasPorts()) { - elkNode.layoutOptions['elk.portConstraints'] = 'FIXED_SIDE'; - } - const elkLayoutOptions = element.prop('elkLayoutOptions') as NodeElkLayoutOptions | undefined; - if (elkLayoutOptions) Object.assign(elkNode.layoutOptions, elkLayoutOptions); -}; - -const exportPort: ExportPortCallback = ({ port, element, elkPort }) => { - const portId = `${port.id}`; - const elkLayoutOptions = element.portProp(portId, 'elkLayoutOptions') as PortElkLayoutOptions | undefined; - if (elkLayoutOptions) Object.assign(elkPort.layoutOptions, elkLayoutOptions); -}; - -// A label's `elkLayoutOptions` (own, or `defaultLabel`'s - `link.labels()` already -// resolves that, see `Link#labels`) applies to *that label's* own `layoutOptions` -// (e.g. `elk.edgeLabels.inline`), not the edge's. -const exportEdge: ExportEdgeCallback = ({ link, elkEdge }) => { - link.labels().forEach((label, index) => { - const elkLayoutOptions = label.elkLayoutOptions as Record | undefined; - const elkLabel = elkEdge.labels?.[index]; - if (elkLayoutOptions && elkLabel) { - elkLabel.layoutOptions = { ...elkLabel.layoutOptions, ...elkLayoutOptions }; - } - }); -}; - const cellNamespace = { ...shapes, example: { @@ -135,6 +102,40 @@ const init = () => { workerUrl: '../node_modules/elkjs/lib/elk-worker.js' }); + 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 = ({ port, elkPort }) => { + switch (port.group) { + case 'in': + elkPort.layoutOptions['elk.port.side'] = 'WEST'; + break; + case 'out': + elkPort.layoutOptions['elk.port.side'] = 'EAST'; + break; + } + }; + + const exportPortLabel: ExportPortLabelCallback = ({ port, element, elkPortLabel }) => { + const portId = `${port.id}`; + const { width, height} = element.portProp(portId, 'label/size'); + elkPortLabel.width = width; + elkPortLabel.height = height; + }; + + const exportEdgeLabel: ExportEdgeLabelCallback = ({ label, elkEdgeLabel }) => { + const inline = label['inline']; + elkEdgeLabel.layoutOptions['elk.edgeLabels.inline'] = inline ? 'true' : 'false'; + }; + // Wraps every `layout()` call the example makes - freezing the paper for its // (async) duration, so nothing renders mid-layout, and reporting any error the // same way regardless of which caller triggered the layout. @@ -144,7 +145,8 @@ const init = () => { elk, exportElement, exportPort, - exportEdge, + exportPortLabel, + exportEdgeLabel, elkLayoutOptions }).then(() => { paper.unfreeze(); diff --git a/examples/layout-elk-containers-ports-ts/src/shapes.ts b/examples/layout-elk-containers-ports-ts/src/shapes.ts index 179852178b..e6f1b7b6e8 100644 --- a/examples/layout-elk-containers-ports-ts/src/shapes.ts +++ b/examples/layout-elk-containers-ports-ts/src/shapes.ts @@ -64,13 +64,7 @@ export class Container extends shapes.standard.Rectangle { return util.defaultsDeep({ type: 'example.Container', size: { width: 100, height: 100 }, - // Extra top padding, so embedded children don't overlap this container's own - // title label - a plain custom property, applied by this example's own - // `exportElement` callback in `index.ts` (`@joint/layout-elk` no longer reads - // a cell's `elkLayoutOptions` automatically). - elkLayoutOptions: { - 'elk.padding': CONTAINER_PADDING - }, + padding: CONTAINER_PADDING, attrs: { body: { fill: '#EEF3F1', @@ -123,18 +117,28 @@ export class Service extends shapes.standard.Rectangle { // example's `exportPort` callback in `index.ts` (see `Container` above). groups: { in: { + position: { + name: 'absolute' + }, + label: { + position: { + name: 'manual' + } + }, size: PORT_SIZE, attrs: PORT_ATTRS, - elkLayoutOptions: { - 'elk.port.side': 'WEST' - } }, out: { + position: { + name: 'absolute' + }, + label: { + position: { + name: 'manual' + } + }, size: PORT_SIZE, attrs: PORT_ATTRS, - elkLayoutOptions: { - 'elk.port.side': 'EAST' - } } }, items: [ @@ -212,12 +216,7 @@ export class InteractionLink extends shapes.standard.Link { type: 'example.InteractionLink', defaultLabel: { size: { width: 80, height: 20 }, - // ELK doesn't place edge labels inline by default - opt every label using - // this `defaultLabel` back into it explicitly. Read from `defaultLabel` (not - // repeated per label) since `Link#labels`/`label` passes it through to any - // label that doesn't set its own `elkLayoutOptions` (see `Link#_getResolvedLabel`), - // and applied by this example's `exportEdge` callback in `index.ts`. - elkLayoutOptions: { 'elk.edgeLabels.inline': 'true' }, + inline: true, attrs: { text: { fontSize: 11, diff --git a/packages/joint-layout-elk/src/export.mts b/packages/joint-layout-elk/src/export.mts index 440dacb32e..38b88d88a7 100644 --- a/packages/joint-layout-elk/src/export.mts +++ b/packages/joint-layout-elk/src/export.mts @@ -16,35 +16,14 @@ import type { const ELK_LABEL_TEXT = '-'; /** - * A node's label, as far as layout is concerned: a box and its options. ELK sizes - * labels from the box and never reads their text, so there is none to set. + * An ELK label draft */ -export interface ElkNodeLabelDraft { - x: number; - y: number; - width: number; - height: number; - layoutOptions?: LabelElkLayoutOptions; -} - -/** - * A port's label - no `x`/`y` (unlike `ElkNodeLabelDraft`): the node's own - * `portLabels.placement: 'OUTSIDE'` is what places it, not the label itself. - */ -export interface ElkPortLabelDraft { - width: number; - height: number; - layoutOptions?: LabelElkLayoutOptions; -} - -/** - * An edge's label - no `x`/`y` (unlike `ElkNodeLabelDraft`): ELK places it along - * the routed edge itself, per `layoutOptions` (e.g. `elk.edgeLabels.inline`). - */ -export interface ElkEdgeLabelDraft { +export interface ElkLabelDraft { + x?: number; + y?: number; width: number; height: number; - layoutOptions?: LabelElkLayoutOptions; + layoutOptions: LabelElkLayoutOptions; } export interface ElkNodeDraft { @@ -60,7 +39,7 @@ export interface ElkNodeDraft { * Empty. JointJS elements carry no labels, so add them only if ELK should * size around them, e.g. under `elk.nodeSize.constraints: 'NODE_LABELS'`. */ - labels?: ElkNodeLabelDraft[]; + labels?: ElkLabelDraft[]; } /** @@ -81,7 +60,6 @@ export interface ElkPortDraft { width: number; height: number; layoutOptions: PortElkLayoutOptions; - labels?: ElkPortLabelDraft[]; } /** @@ -99,7 +77,6 @@ export type ExportPortCallbackParameters = { export interface ElkEdgeDraft { readonly id: string; layoutOptions: EdgeElkLayoutOptions; - labels?: ElkEdgeLabelDraft[]; } /** @@ -112,6 +89,20 @@ export type ExportEdgeCallbackParameters = { elkEdge: ElkEdgeDraft; }; +export type ExportPortLabelCallback = (params: ExportPortLabelCallbackParameters) => void | false; +export type ExportPortLabelCallbackParameters = { + port: dia.Element.Port; + element: dia.Element; + elkPortLabel: ElkLabelDraft; +}; + +export type ExportEdgeLabelCallback = (params: ExportEdgeLabelCallbackParameters) => void | false; +export type ExportEdgeLabelCallbackParameters = { + link: dia.Link; + label: dia.Link.Label; + elkEdgeLabel: ElkLabelDraft; +}; + export interface ElkGraphPort { element: dia.Element; portId: string; @@ -127,14 +118,11 @@ export interface ElkGraphData { export interface ExportGraphOptions { exportElement?: ExportElementCallback; exportPort?: ExportPortCallback; + exportPortLabel?: ExportPortLabelCallback; exportEdge?: ExportEdgeCallback; + exportEdgeLabel?: ExportEdgeLabelCallback; } -export const DEFAULT_LABEL_SIZE: dia.Size = { - width: 50, - height: 20 -}; - let exportGraphOptions: ExportGraphOptions; let elementsById: Map; @@ -190,9 +178,6 @@ function buildPorts(element: dia.Element): ElkPort[] | undefined { const portId = `${port.id}`; const elkPortId = `${element.id}:${portId}`; - const labelSize = element.portProp(portId, 'label/size'); - const { width: labelWidth, height: labelHeight } = labelSize ?? DEFAULT_LABEL_SIZE; - const { x, y } = element.getPortRelativePosition(portId); const { width, height } = element.getPortRelativeRect(portId); @@ -205,12 +190,7 @@ function buildPorts(element: dia.Element): ElkPort[] | undefined { layoutOptions: { // Negative offset moves the port inward from the node border, centering it there. 'elk.port.borderOffset': `${-width / 2}` - }, - labels: [{ - width: labelWidth, - height: labelHeight, - layoutOptions: {} - }] + } }; if (exportGraphOptions.exportPort?.({ port, element, elkPort }) === false) { @@ -218,14 +198,26 @@ function buildPorts(element: dia.Element): ElkPort[] | undefined { return; } + const portLabel: ElkLabelDraft = { + width: 0, + height: 0, + layoutOptions: {} + }; + + exportGraphOptions.exportPortLabel?.({ port, element, elkPortLabel: portLabel }); + + let labels: ElkLabel[] = []; + if (portLabel.width && portLabel.height) { + labels = [{ + ...portLabel, + text: ELK_LABEL_TEXT + }]; + } + portsById.set(elkPort.id, { element, portId }); ports.push({ ...elkPort, - labels: (elkPort.labels || []).map((label): ElkLabel => ({ - // Some text is required, otherwise ELK ignores the label. - text: ELK_LABEL_TEXT, - ...label - })) + labels }); }); @@ -239,23 +231,10 @@ function buildPorts(element: dia.Element): ElkPort[] | undefined { * so nothing is registered and nothing downstream (a child, a port, a connected edge) * can end up referencing it. */ -function buildElkNode(element: dia.Element, containerPosition: dia.Point = { x: 0, y: 0 }): ElkNode | null { +function buildElkNode(element: dia.Element): ElkNode | null { const id = `${element.id}`; - const { - x: absoluteX, - y: absoluteY - } = element.position(); - const x = absoluteX - containerPosition.x; - const y = absoluteY - containerPosition.y; - - // Only relevant for a node that has ports. - const computedLayoutOptions: NodeElkLayoutOptions = element.hasPorts() ? { - 'portLabels.placement': 'OUTSIDE' - } : {}; - - const embeds = element.getEmbeddedCells() - .filter((cell): cell is dia.Element => cell.isElement()); + const embeds = element.getEmbeddedCells().filter(cell => cell.isElement()); // A container's real size is computed by ELK to fit its content - `0` is just a // placeholder (elkjs needs a numeric size upfront for a hierarchical node). @@ -269,10 +248,12 @@ function buildElkNode(element: dia.Element, containerPosition: dia.Point = { x: id, width, height, - layoutOptions: computedLayoutOptions + layoutOptions: {} }; - if (exportGraphOptions.exportElement?.({ element, elkNode }) === false) return null; + // If exportElement() returns false omit the element + if (exportGraphOptions.exportElement?.({ element, elkNode }) === false) + return null; elementsById.set(id, element); @@ -282,7 +263,7 @@ function buildElkNode(element: dia.Element, containerPosition: dia.Point = { x: let edges: ElkExtendedEdge[] | undefined; if (embeds.length > 0) { children = embeds - .map((embed) => buildElkNode(embed, { x, y })) + .map((embed) => buildElkNode(embed)) .filter((node): node is ElkNode => node !== null); // Shared with `edgeContainersById` (see there) - edges filed under this container // by `buildEdge` need to end up on the node itself. @@ -342,39 +323,48 @@ function buildEdge(link: dia.Link): void { ? [`${targetElement.id}:${targetPort}`] : [`${targetElement.id}`]; + const elkEdge: ElkEdgeDraft = { + id, + layoutOptions: {} + }; + + if (exportGraphOptions.exportEdge?.({ link, elkEdge }) === false) + return; + + linksById.set(id, link); + // Resolved (`link.labels()`) - `size` falls back through `defaultLabel`/the built-in // default the same way `@joint/core` itself resolves it for rendering, and a custom // `elkLayoutOptionsProperty` property passes through too (whether set on the label // itself or on `defaultLabel` - see `Link#_getResolvedLabel`), so it can be read // directly here instead of from the label's raw JSON. const resolvedLabels = link.labels(); - let labels: ElkEdgeLabelDraft[] | undefined; + let labels: ElkLabel[] = []; if (resolvedLabels.length > 0) { - labels = resolvedLabels.map((label): ElkEdgeLabelDraft => { - const { width, height } = label.size || DEFAULT_LABEL_SIZE; - return { width, height, layoutOptions: {}}; - }); - } - - const elkEdge: ElkEdgeDraft = { - id, - layoutOptions: {}, - labels - }; + labels = resolvedLabels.reduce((result: ElkLabel[], label) => { + const { width, height } = label.size!; + const labelDraft: ElkLabelDraft = { + width, + height, + layoutOptions: {} + }; - if (exportGraphOptions.exportEdge?.({ link, elkEdge }) === false) return; + if (exportGraphOptions.exportEdgeLabel?.({ link, label, elkEdgeLabel: labelDraft }) === false) + return result; - linksById.set(id, link); + result.push({ + ...labelDraft, + text: ELK_LABEL_TEXT + }); + return result; + }, []); + } const edge: ElkExtendedEdge = { ...elkEdge, sources, targets, - labels: elkEdge.labels && elkEdge.labels.map((label): ElkLabel => ({ - // Some text is required, otherwise ELK ignores the label. - text: ELK_LABEL_TEXT, - ...label - })) + labels }; const lcaId = getLowestCommonAncestorId(getAncestorPath(sourceElement), getAncestorPath(targetElement)); diff --git a/packages/joint-layout-elk/src/import.mts b/packages/joint-layout-elk/src/import.mts index 210891a3b2..b4be393cbc 100644 --- a/packages/joint-layout-elk/src/import.mts +++ b/packages/joint-layout-elk/src/import.mts @@ -1,6 +1,6 @@ import { type dia, g } from '@joint/core'; import type { ElkPoint } from 'elkjs'; -import type { ElkNode, ElkExtendedEdge } from './types/index.mjs'; +import type { ElkNode, ElkExtendedEdge, ElkPort } from './types/index.mjs'; import type { ElkGraphPort } from './export.mjs'; export type SetElementAttributesCallback = (params: SetElementAttributesCallbackParameters) => void; @@ -12,6 +12,7 @@ export type SetElementAttributesCallbackParameters = { // (recursively laid out) content; a leaf element keeps its existing size. size?: dia.Size; }; + elkNode: ElkNode }; export type SetPortAttributesCallback = (params: SetPortAttributesCallbackParameters) => void; @@ -27,6 +28,7 @@ export type SetPortAttributesCallbackParameters = { // Present only when `positionPortLabels` is enabled and the port has a label. label?: { position: { args: dia.Point } }; }; + elkPort: ElkPort }; export type SetLinkAttributesCallback = (params: SetLinkAttributesCallbackParameters) => void; @@ -44,6 +46,7 @@ export type SetLinkAttributesCallbackParameters = { // current `labels` array, with each routed label's `position` replaced. labels?: dia.Link.Label[]; }; + elkEdge: ElkExtendedEdge }; export interface ImportLayoutOptions { @@ -172,7 +175,8 @@ function importEdges(edges: ElkExtendedEdge[] | undefined, containerPosition: di ...(source ? { source } : {}), ...(target ? { target } : {}), ...(labels ? { labels } : {}) - } + }, + elkEdge: edge }); }); } @@ -191,7 +195,8 @@ function importNode(node: ElkNode, containerPosition: dia.Point = { x: 0, y: 0 } // Omitted entirely for a leaf (not just `undefined`) - `attributes` goes // straight to `element.set(...)`, which would otherwise wipe its size. ...(isContainer ? { size: { width: node.width || 0, height: node.height || 0 }} : {}) - } + }, + elkNode: node }); } @@ -231,7 +236,8 @@ function importNode(node: ElkNode, containerPosition: dia.Point = { x: 0, y: 0 } } } } : {}) - } + }, + elkPort: port }); }); } From 32fa66103182f133b025b6ee0e051190236485d8 Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Fri, 25 Sep 2026 12:52:06 +0200 Subject: [PATCH 35/75] ports core revert --- packages/joint-core/src/dia/ports.mjs | 40 +------------------ .../joint-core/test/jointjs/elementPorts.js | 32 --------------- 2 files changed, 1 insertion(+), 71 deletions(-) diff --git a/packages/joint-core/src/dia/ports.mjs b/packages/joint-core/src/dia/ports.mjs index 00bdb494af..56b85214d0 100644 --- a/packages/joint-core/src/dia/ports.mjs +++ b/packages/joint-core/src/dia/ports.mjs @@ -348,25 +348,6 @@ PortData.prototype = { } }; -// Group properties this module already merges into a port itself, at render time -// (see `PortData.prototype._evaluatePort`) - excluded from `portProp`'s own group -// fallback below so that behavior (raw, port-only data) stays exactly as it was. -const PORT_OWN_GROUP_PROPERTIES = ['position', 'label', 'markup', 'attrs', 'size', 'z']; - -// A port's own `value` for some `portProp` path, merged with its group's `value` for -// that same path - `value` wins on conflicts, same as `attrs`/`label`/... already do -// (see `PortData.prototype._evaluatePort`): a plain object merges recursively (so a -// port only needs to override the parts of a group-level object it cares about), while -// anything else (including a completely absent `value`) is just replaced outright. -function mergeWithPortGroupValue(value, groupValue) { - if (groupValue === undefined) return value; - if (value === undefined) return util.cloneDeep(groupValue); - if (util.isPlainObject(value) && util.isPlainObject(groupValue)) { - return util.merge({}, groupValue, value); - } - return value; -} - export const elementPortPrototype = { _initializePorts: function(options) { @@ -636,23 +617,12 @@ export const elementPortPrototype = { } var args = Array.prototype.slice.call(arguments, 1); - // A get (no `value` to set) whose `path` targets a property this module does - // not already merge into the port itself (see `PORT_OWN_GROUP_PROPERTIES`) is - // merged here instead with the port's group's own value at that same path - so - // e.g. custom metadata set once on a group (a consumer's own `elkLayout`, say) - // is still visible on every one of that group's ports, without repeating it on - // each one - see `mergeWithPortGroupValue`. - var isGet = (value === undefined) && !util.isPlainObject(path); - var pathArray = null; - if (Array.isArray(path)) { - pathArray = path; args[0] = ['ports', 'items', index].concat(path); } else if (util.isString(path)) { // Get/set an attribute by a special path syntax that delimits // nested objects by the colon character. - pathArray = path.split('/'); args[0] = ['ports/items/', index, '/', path].join(''); } else { @@ -664,15 +634,7 @@ export const elementPortPrototype = { } } - var result = this.prop.apply(this, args); - - if (isGet && pathArray && PORT_OWN_GROUP_PROPERTIES.indexOf(pathArray[0]) === -1) { - var group = util.toArray(this.prop('ports/items'))[index].group; - var groupValue = (group === undefined) ? undefined : util.getByPath(this.prop(['ports', 'groups', group]), pathArray); - result = mergeWithPortGroupValue(result, groupValue); - } - - return result; + return this.prop.apply(this, args); }, _validatePorts: function() { diff --git a/packages/joint-core/test/jointjs/elementPorts.js b/packages/joint-core/test/jointjs/elementPorts.js index 12929a876f..39191f99f5 100644 --- a/packages/joint-core/test/jointjs/elementPorts.js +++ b/packages/joint-core/test/jointjs/elementPorts.js @@ -2877,38 +2877,6 @@ QUnit.module('element ports', function() { assert.ok(_.isPlainObject(shape.portProp('one', 'object'))); assert.equal(shape.portProp('one', 'object/20'), 'object property'); }); - - QUnit.test('should merge a group\'s own custom properties into its ports', function(assert) { - - var shape = create({ - groups: { - in: { - attrs: { '.body': { fill: 'red' }}, - elkLayout: { side: 'WEST', spacing: 5 } - } - }, - items: [ - { id: 'one', group: 'in' }, - { id: 'two', group: 'in', elkLayout: { spacing: 10 }}, - { id: 'three' } - ] - }); - - // A port with no `elkLayout` of its own inherits the group's. - assert.deepEqual(shape.portProp('one', 'elkLayout'), { side: 'WEST', spacing: 5 }); - assert.equal(shape.portProp('one', 'elkLayout/side'), 'WEST'); - - // A port's own (partial) value is deep-merged on top of the group's. - assert.deepEqual(shape.portProp('two', 'elkLayout'), { side: 'WEST', spacing: 10 }); - - // A port with no group at all is unaffected. - assert.equal(shape.portProp('three', 'elkLayout'), undefined); - - // Properties this module already merges elsewhere (e.g. `attrs` - see - // `PortData#_evaluatePort`) are untouched by this - `portProp` still only - // ever returns the port's own, unmerged JSON for them. - assert.equal(shape.portProp('one', 'attrs'), undefined); - }); }); QUnit.module('event ports:add and ports:remove', function(hooks) { From db5137ff94f5911d3aee84d7b2ec0d461cb415b8 Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Mon, 28 Sep 2026 13:43:52 +0200 Subject: [PATCH 36/75] restyle --- .../layout-elk-containers-ports-ts/README.md | 2 +- .../layout-elk-containers-ports-ts/index.html | 4 +- .../src/example.ts | 63 ++++---- .../src/index.ts | 14 +- .../src/shapes.ts | 88 +++++------ .../src/styles.scss | 137 ++++++++++++++++-- 6 files changed, 205 insertions(+), 103 deletions(-) diff --git a/examples/layout-elk-containers-ports-ts/README.md b/examples/layout-elk-containers-ports-ts/README.md index 2e24d4a4cb..787818cf89 100644 --- a/examples/layout-elk-containers-ports-ts/README.md +++ b/examples/layout-elk-containers-ports-ts/README.md @@ -1,6 +1,6 @@ # JointJS ELK Containers & Ports Demo -A fixed (non-random), small system diagram laid out automatically with `@joint/layout-elk`: two containers ("Frontend", "Backend"), each grouping a couple of services that connect through ports - including a link that crosses from one container into the other. +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 diff --git a/examples/layout-elk-containers-ports-ts/index.html b/examples/layout-elk-containers-ports-ts/index.html index 609100fca8..4fece7d404 100644 --- a/examples/layout-elk-containers-ports-ts/index.html +++ b/examples/layout-elk-containers-ports-ts/index.html @@ -7,13 +7,15 @@ ELK Containers & Ports Layout | JointJS + + +
Zoom Out Zoom In - Layout
diff --git a/examples/layout-elk-containers-ports-ts/src/example.ts b/examples/layout-elk-containers-ports-ts/src/example.ts index ec7de3f37b..fe3bd62c87 100644 --- a/examples/layout-elk-containers-ports-ts/src/example.ts +++ b/examples/layout-elk-containers-ports-ts/src/example.ts @@ -4,19 +4,22 @@ import { dia } from '@joint/core'; // 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. Every plain `example.Service` has exactly -// one 'in' and one 'out' port; the four "hub" services (Load Balancer, API -// Gateway, Auth Service, Logger) are `example.HubService` instead, with a -// custom number of ports - highlighted, and the only ones that opt into -// `portsPosition` so ELK orders their ports to minimize crossings (see -// `index.ts`). +// 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: 'Frontend' } }, + attrs: { label: { text: 'Client Layer' } }, embeds: ['webui', 'mobileui', 'edge'] }, { @@ -29,42 +32,42 @@ export const graphJSON: dia.Graph.JSON = { { id: 'backend', type: 'example.Container', - attrs: { label: { text: 'Backend' } }, + attrs: { label: { text: 'Core Services' } }, embeds: ['auth', 'storage'] }, { id: 'storage', type: 'example.Container', parent: 'backend', - attrs: { label: { text: 'Storage' } }, + attrs: { label: { text: 'Data Layer' } }, embeds: ['cache', 'db'] }, { id: 'observability', type: 'example.Container', attrs: { label: { text: 'Observability' } }, - embeds: ['logger'] + embeds: ['monitoring'] }, - // Frontend + // Client Layer { id: 'webui', type: 'example.Service', parent: 'frontend', - attrs: { body: { fill: '#F8FCDA' }, label: { text: 'Web UI' } } + attrs: { label: { text: 'Web App' } } }, { id: 'mobileui', type: 'example.Service', parent: 'frontend', - attrs: { body: { fill: '#F8FCDA' }, label: { text: 'Mobile UI' } } + attrs: { label: { text: 'Mobile App' } } }, { id: 'lb', type: 'example.HubService', parent: 'edge', size: { width: 130, height: 80 }, - attrs: { body: { fill: '#E3E9C2' }, label: { text: 'Load Balancer' } }, + attrs: { label: { text: 'Load Balancer' } }, ports: { items: [ { id: 'in1', group: 'in', attrs: { text: { text: 'in1' } } }, @@ -78,7 +81,7 @@ export const graphJSON: dia.Graph.JSON = { type: 'example.HubService', parent: 'edge', size: { width: 130, height: 80 }, - attrs: { body: { fill: '#E3E9C2' }, label: { text: 'API Gateway' } }, + attrs: { label: { text: 'API Gateway' } }, ports: { items: [ { id: 'in', group: 'in', attrs: { text: { text: 'in' } } }, @@ -88,13 +91,13 @@ export const graphJSON: dia.Graph.JSON = { } }, - // Backend + // Core Services { id: 'auth', type: 'example.HubService', parent: 'backend', size: { width: 130, height: 80 }, - attrs: { body: { fill: '#F9FBB2' }, label: { text: 'Auth Service' } }, + attrs: { label: { text: 'Auth Service' } }, ports: { items: [ { id: 'in', group: 'in', attrs: { text: { text: 'in' } } }, @@ -107,22 +110,22 @@ export const graphJSON: dia.Graph.JSON = { id: 'cache', type: 'example.Service', parent: 'storage', - attrs: { body: { fill: '#F9FBB2' }, label: { text: 'Cache' } } + attrs: { label: { text: 'Redis Cache' } } }, { id: 'db', type: 'example.Service', parent: 'storage', - attrs: { body: { fill: '#C89F9C' }, label: { text: 'Database' } } + attrs: { label: { text: 'PostgreSQL' } } }, // Observability { - id: 'logger', + id: 'monitoring', type: 'example.HubService', parent: 'observability', size: { width: 130, height: 80 }, - attrs: { body: { fill: '#D9D2E9' }, label: { text: 'Logger' } }, + attrs: { label: { text: 'Monitoring' } }, ports: { items: [ { id: 'in1', group: 'in', attrs: { text: { text: 'in1' } } }, @@ -164,8 +167,8 @@ export const graphJSON: dia.Graph.JSON = { id: 'l5', type: 'example.InteractionLink', source: { id: 'gateway', port: 'out2' }, - target: { id: 'logger', port: 'in1' }, - labels: [{ attrs: { text: { text: 'log' } } }] + target: { id: 'monitoring', port: 'in1' }, + labels: [{ attrs: { text: { text: 'metrics' } } }] }, { id: 'l6', @@ -178,8 +181,8 @@ export const graphJSON: dia.Graph.JSON = { id: 'l7', type: 'example.InteractionLink', source: { id: 'auth', port: 'out2' }, - target: { id: 'logger', port: 'in2' }, - labels: [{ attrs: { text: { text: 'log' } } }] + target: { id: 'monitoring', port: 'in2' }, + labels: [{ attrs: { text: { text: 'logs' } } }] }, { id: 'l8', @@ -197,17 +200,17 @@ export const graphJSON: dia.Graph.JSON = { type: 'example.InteractionLink', source: { id: 'backend' }, target: { id: 'observability' }, - // Overrides `InteractionLink.defaultLabel`'s `elkLayoutOptions` (own value wins - - // see `Link#labels`) - floated beside the edge instead of centered directly on it, - // so it doesn't obscure a long aggregate link's whole path. - labels: [{ attrs: { text: { text: 'metrics' } }, inline: false }] + // Overrides `InteractionLink.defaultLabel`'s `inline` (own value wins - + // see `Link#labels`) - floated beside the edge instead of centered directly on + // it, so it doesn't obscure a long aggregate link's whole path. + labels: [{ attrs: { text: { text: 'metrics' } } }] }, { id: 'l10', type: 'example.InteractionLink', source: { id: 'frontend' }, target: { id: 'observability' }, - labels: [{ attrs: { text: { text: 'analytics' } }, inline: false }] + labels: [{ attrs: { text: { text: 'analytics' } } }] } ] }; diff --git a/examples/layout-elk-containers-ports-ts/src/index.ts b/examples/layout-elk-containers-ports-ts/src/index.ts index 2bc57020b5..f3d034a8e0 100644 --- a/examples/layout-elk-containers-ports-ts/src/index.ts +++ b/examples/layout-elk-containers-ports-ts/src/index.ts @@ -1,4 +1,4 @@ -import { dia, shapes } from '@joint/core'; +import { dia, shapes, setTheme } from '@joint/core'; import { ElkLayoutOptions, ExportElementCallback, @@ -29,6 +29,10 @@ const cellNamespace = { 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({ @@ -158,14 +162,6 @@ const init = () => { // Initial layout of the fixed example data, fit to the paper's viewport. runLayout().then(() => zoom(paper, 1)); - - // "Layout" toolbar button - re-runs the same from-scratch layout on the - // unchanged graph. Every run is independent (no `interactive: true`), so - // clicking it repeatedly is a quick way to check that `layout()` is - // idempotent - each run should settle on the same result as the last. - document.getElementById('layout')!.addEventListener('click', () => { - runLayout().then(() => zoom(paper, 1)); - }); }; function zoom(paper: dia.Paper, zoomLevel: number): void { diff --git a/examples/layout-elk-containers-ports-ts/src/shapes.ts b/examples/layout-elk-containers-ports-ts/src/shapes.ts index e6f1b7b6e8..ee8647b915 100644 --- a/examples/layout-elk-containers-ports-ts/src/shapes.ts +++ b/examples/layout-elk-containers-ports-ts/src/shapes.ts @@ -4,23 +4,22 @@ const PORT_SIZE = { width: 12, height: 12 }; const PORT_ATTRS = { circle: { r: 6, - fill: '#FFFFFF', - stroke: '#333', - strokeWidth: 2 + class: 'md-port' }, text: { - fontSize: 14, - fill: '#555' + 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. -const PORT_LABEL_AVERAGE_CHAR_WIDTH = PORT_ATTRS.text.fontSize * 0.4; +// 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_HORIZONTAL_PADDING = 6; -const PORT_LABEL_HEIGHT = PORT_ATTRS.text.fontSize + 4; +const PORT_LABEL_HEIGHT = PORT_LABEL_FONT_SIZE + 4; function estimatePortLabelSize(text: string): dia.Size { return { @@ -42,22 +41,19 @@ const HUB_PORT_ATTRS = { y: -HUB_PORT_SIZE.height / 2, width: HUB_PORT_SIZE.width, height: HUB_PORT_SIZE.height, - fill: '#FFFFFF', - stroke: '#B85C38', - strokeWidth: 2 + class: 'md-port' }, text: { - fontSize: 14, - fill: '#555' + class: 'md-port-label' } }; const CONTAINER_PADDING = '[top=40,left=20,bottom=20,right=20]'; /** - * A dashed, semi-transparent container - 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. + * 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() { @@ -67,22 +63,14 @@ export class Container extends shapes.standard.Rectangle { padding: CONTAINER_PADDING, attrs: { body: { - fill: '#EEF3F1', - stroke: '#7C9C92', - strokeWidth: 2, - strokeDasharray: '6,3', - rx: 8, - ry: 8 + class: 'md-container' }, label: { x: 12, y: 10, textAnchor: 'start', textVerticalAnchor: 'top', - fontWeight: 'bold', - fontSize: 13, - fill: '#3E5C53', - fontFamily: 'Arial, helvetica, sans-serif' + class: 'md-container-label' } } }, super.defaults); @@ -91,8 +79,8 @@ export class Container extends shapes.standard.Rectangle { /** * A service node with exactly one 'in' (left) and one 'out' (right) port, - * always - only `fill` and label `text` are left for each instance to fill - * in. See `HubService` for a service with a custom number of ports. + * 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() { @@ -101,20 +89,13 @@ export class Service extends shapes.standard.Rectangle { size: { width: 130, height: 50 }, attrs: { body: { - stroke: '#333', - strokeWidth: 2, - rx: 5, - ry: 5 + class: 'md-card' }, label: { - fill: '#333', - fontSize: 13, - fontFamily: 'Arial, helvetica, sans-serif' + class: 'md-card-label' } }, ports: { - // `elkLayoutOptions` here is a plain custom property too, applied by this - // example's `exportPort` callback in `index.ts` (see `Container` above). groups: { in: { position: { @@ -171,9 +152,9 @@ export class Service extends shapes.standard.Rectangle { /** * A service with a custom (per-instance) number of ports - `ports.items` - * always comes from the instance, replacing `Service`'s fixed pair. Also - * highlighted with a thicker, colored stroke, to stand out as a hub with - * several ports fanning in/out on the same side. + * 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() { @@ -184,8 +165,7 @@ export class HubService extends Service { type: 'example.HubService', attrs: { body: { - stroke: '#B85C38', - strokeWidth: 3 + class: 'md-card md-card--hub' } }, ports: { @@ -207,21 +187,29 @@ export class HubService extends Service { } /** - * A link with a labelled, pill-shaped background - only the label `text` - * is left for each instance to fill in. + * 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 }, inline: true, attrs: { text: { - fontSize: 11, - fontFamily: 'Arial, helvetica, sans-serif', - fill: '#333' + class: 'md-chip-text' }, rect: { ref: null, @@ -229,9 +217,9 @@ export class InteractionLink extends shapes.standard.Link { y: 'calc(y - calc(h / 2))', width: 'calc(w)', height: 'calc(h)', - fill: '#FFB7C3', - strokeWidth: 1, - stroke: '#333' + rx: 'calc(h / 2)', + ry: 'calc(h / 2)', + class: 'md-chip-bg' } }, position: 0.5 diff --git a/examples/layout-elk-containers-ports-ts/src/styles.scss b/examples/layout-elk-containers-ports-ts/src/styles.scss index e712102c7b..431995d02f 100644 --- a/examples/layout-elk-containers-ports-ts/src/styles.scss +++ b/examples/layout-elk-containers-ports-ts/src/styles.scss @@ -1,15 +1,35 @@ +// 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 #E2E2E2; - background-color: #F3F7F6; + border: 1px solid var(--md-outline); + background-color: #FAFAFA; overflow: hidden; } @@ -26,23 +46,116 @@ html, body { .toolbar-button { outline: none; - background: #FFFFFF; - border: 1px solid #E0E0E0; - border-radius: 16px; + 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: sans-serif; - font-size: 12px; - padding: 6px 12px; - letter-spacing: 0.25px; - color: #222222; + 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 2px; + margin: 0 4px; + transition: box-shadow 0.15s ease, background 0.15s ease; &:hover { - background: #F7F8F9; + 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); } } From 71df9829a752eea39955f7040b7fdcff96632316 Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Tue, 29 Sep 2026 12:34:29 +0200 Subject: [PATCH 37/75] reordering example wip --- examples/layout-elk-flowchart-ts/.gitignore | 3 + examples/layout-elk-flowchart-ts/README.md | 24 + examples/layout-elk-flowchart-ts/index.html | 29 ++ examples/layout-elk-flowchart-ts/package.json | 40 ++ .../layout-elk-flowchart-ts/src/example.ts | 121 +++++ examples/layout-elk-flowchart-ts/src/index.ts | 458 ++++++++++++++++++ .../layout-elk-flowchart-ts/src/shapes.ts | 187 +++++++ .../layout-elk-flowchart-ts/src/styles.scss | 144 ++++++ .../layout-elk-flowchart-ts/tsconfig.json | 18 + .../layout-elk-flowchart-ts/webpack.config.js | 39 ++ 10 files changed, 1063 insertions(+) create mode 100644 examples/layout-elk-flowchart-ts/.gitignore create mode 100644 examples/layout-elk-flowchart-ts/README.md create mode 100644 examples/layout-elk-flowchart-ts/index.html create mode 100644 examples/layout-elk-flowchart-ts/package.json create mode 100644 examples/layout-elk-flowchart-ts/src/example.ts create mode 100644 examples/layout-elk-flowchart-ts/src/index.ts create mode 100644 examples/layout-elk-flowchart-ts/src/shapes.ts create mode 100644 examples/layout-elk-flowchart-ts/src/styles.scss create mode 100644 examples/layout-elk-flowchart-ts/tsconfig.json create mode 100644 examples/layout-elk-flowchart-ts/webpack.config.js diff --git a/examples/layout-elk-flowchart-ts/.gitignore b/examples/layout-elk-flowchart-ts/.gitignore new file mode 100644 index 0000000000..69c575d17f --- /dev/null +++ b/examples/layout-elk-flowchart-ts/.gitignore @@ -0,0 +1,3 @@ +build/ +dist/ +node_modules/ diff --git a/examples/layout-elk-flowchart-ts/README.md b/examples/layout-elk-flowchart-ts/README.md new file mode 100644 index 0000000000..ce9499f8df --- /dev/null +++ b/examples/layout-elk-flowchart-ts/README.md @@ -0,0 +1,24 @@ +# JointJS ELK Interactive Flowchart Demo + +A top-to-bottom login flowchart - with two genuine cycles (retry loops) - laid out automatically with `@joint/layout-elk`. Click a step's top "+" to give it another input, or its bottom "+" to give it another output; click any of its unconnected output ports to grow a new, connected step from it, or drag a link between two unconnected ports to wire two existing steps together directly. Drag one step onto another step in the same layer (e.g. a decision's outcomes) to reorder them - a semitransparent floating copy follows the pointer while you drag, with nothing in the graph itself moving until you drop, at which point the graph re-lays out for real (ELK reordering ports as needed to keep crossings down, and respecting the new order). + +## 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 start +``` + +## 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-flowchart-ts/index.html b/examples/layout-elk-flowchart-ts/index.html new file mode 100644 index 0000000000..28c3e84c4c --- /dev/null +++ b/examples/layout-elk-flowchart-ts/index.html @@ -0,0 +1,29 @@ + + + + + + + + ELK Interactive Flowchart | JointJS + + + +
+ Zoom Out + Zoom In +
+
+ Click a step's top "+" to add an input, or its bottom "+" to add an + output. Click any (unconnected) output port to grow a new step from + it, or drag between two unconnected ports to connect two existing + steps. Drag a step onto another in the same row to reorder it - ELK + re-lays out to match. +
+
+ + + + + diff --git a/examples/layout-elk-flowchart-ts/package.json b/examples/layout-elk-flowchart-ts/package.json new file mode 100644 index 0000000000..3dadf40c41 --- /dev/null +++ b/examples/layout-elk-flowchart-ts/package.json @@ -0,0 +1,40 @@ +{ + "name": "@joint/demo-layout-elk-flowchart-ts", + "version": "4.3.1", + "description": "JointJS - ELK Layout Interactive Flowchart Demo", + "main": "dist/bundle.js", + "homepage": "https://jointjs.com", + "author": { + "name": "client IO", + "url": "https://client.io" + }, + "license": "MPL-2.0", + "private": true, + "installConfig": { + "hoistingLimits": "workspaces" + }, + "scripts": { + "start": "webpack-dev-server", + "build": "webpack" + }, + "dependencies": { + "@joint/core": "workspace:^", + "@joint/layout-elk": "workspace:^", + "elkjs": "0.12.0" + }, + "devDependencies": { + "css-loader": "3.5.3", + "sass-loader": "8.0.2", + "style-loader": "1.2.1", + "ts-loader": "^9.2.5", + "typescript": "5.8.2", + "webpack": "5.98.0", + "webpack-cli": "6.0.1", + "webpack-dev-server": "5.2.0" + }, + "volta": { + "node": "22.14.0", + "npm": "11.2.0", + "yarn": "4.18.0" + } +} diff --git a/examples/layout-elk-flowchart-ts/src/example.ts b/examples/layout-elk-flowchart-ts/src/example.ts new file mode 100644 index 0000000000..849d33a91b --- /dev/null +++ b/examples/layout-elk-flowchart-ts/src/example.ts @@ -0,0 +1,121 @@ +import { dia } from '@joint/core'; + +// A fixed (non-random) login flowchart - two genuine cycles (a failed-validation +// retry back to "Enter Credentials", and a failed-session retry back to "Check +// Account Status"), "Generate Session" starting out with two 'out' ports of its +// own (success/error) - a plain step can have more than one outgoing path too, +// not just a `Decision` - and "Active?" starting out with three siblings +// ("Account Locked"/"Generate Session"/"Account Suspended") sharing its layer, +// to demonstrate reordering more than a plain pair. Try either "+" button (any +// node) or clicking a port (any 'out' one) to grow the flowchart further, or +// drag a link between two still-unconnected ports to wire two existing steps +// together - see `index.ts`. +export const graphJSON: dia.Graph.JSON = { + cells: [ + { + id: 'start', + type: 'flowchart.Terminal', + attrs: { label: { text: 'Start' } }, + ports: { items: [{ id: 'out', group: 'out' }] } + }, + { + id: 'enterCredentials', + type: 'flowchart.Process', + attrs: { label: { text: 'Enter Credentials' } }, + ports: { items: [{ id: 'in', group: 'in' }, { id: 'out', group: 'out' }] } + }, + { + id: 'validateCredentials', + type: 'flowchart.Decision', + attrs: { label: { text: 'Valid?' } }, + ports: { + items: [ + { id: 'in', group: 'in' }, + { id: 'valid', group: 'out' }, + { id: 'invalid', group: 'out' } + ] + } + }, + { + id: 'showError', + type: 'flowchart.Process', + attrs: { label: { text: 'Show Error' } }, + ports: { items: [{ id: 'in', group: 'in' }, { id: 'out', group: 'out' }] } + }, + { + id: 'checkAccountStatus', + type: 'flowchart.Decision', + attrs: { label: { text: 'Active?' } }, + ports: { + items: [ + { id: 'in', group: 'in' }, + { id: 'active', group: 'out' }, + { id: 'locked', group: 'out' }, + { id: 'suspended', group: 'out' } + ] + } + }, + { + id: 'showLockedMessage', + type: 'flowchart.Process', + attrs: { label: { text: 'Account Locked' } }, + ports: { items: [{ id: 'in', group: 'in' }, { id: 'out', group: 'out' }] } + }, + { + id: 'showSuspendedMessage', + type: 'flowchart.Process', + attrs: { label: { text: 'Account Suspended' } }, + ports: { items: [{ id: 'in', group: 'in' }, { id: 'out', group: 'out' }] } + }, + { + id: 'generateSession', + type: 'flowchart.Process', + attrs: { label: { text: 'Generate Session' } }, + ports: { + items: [ + { id: 'in', group: 'in' }, + { id: 'success', group: 'out' }, + { id: 'error', group: 'out' } + ] + } + }, + { + id: 'sessionError', + type: 'flowchart.Process', + attrs: { label: { text: 'Session Error' } }, + ports: { items: [{ id: 'in', group: 'in' }, { id: 'out', group: 'out' }] } + }, + { + id: 'grantAccess', + type: 'flowchart.Process', + attrs: { label: { text: 'Grant Access' } }, + ports: { items: [{ id: 'in', group: 'in' }, { id: 'out', group: 'out' }] } + }, + { + id: 'end', + type: 'flowchart.Terminal', + attrs: { label: { text: 'End' } }, + ports: { items: [{ id: 'in', group: 'in' }] } + }, + + // Links + { id: 'l1', type: 'flowchart.FlowLink', source: { id: 'start', port: 'out' }, target: { id: 'enterCredentials', port: 'in' } }, + { id: 'l2', type: 'flowchart.FlowLink', source: { id: 'enterCredentials', port: 'out' }, target: { id: 'validateCredentials', port: 'in' } }, + { id: 'l3', type: 'flowchart.FlowLink', source: { id: 'validateCredentials', port: 'invalid' }, target: { id: 'showError', port: 'in' }, labels: [{ attrs: { text: { text: 'Invalid' } } }] }, + { id: 'l4', type: 'flowchart.FlowLink', source: { id: 'validateCredentials', port: 'valid' }, target: { id: 'checkAccountStatus', port: 'in' }, labels: [{ attrs: { text: { text: 'Valid' } } }] }, + // Cycle 1: back up to "Enter Credentials" for another attempt. + { id: 'l5', type: 'flowchart.FlowLink', source: { id: 'showError', port: 'out' }, target: { id: 'enterCredentials', port: 'in' } }, + { id: 'l6', type: 'flowchart.FlowLink', source: { id: 'checkAccountStatus', port: 'locked' }, target: { id: 'showLockedMessage', port: 'in' }, labels: [{ attrs: { text: { text: 'Locked' } } }] }, + { id: 'l7', type: 'flowchart.FlowLink', source: { id: 'checkAccountStatus', port: 'active' }, target: { id: 'generateSession', port: 'in' }, labels: [{ attrs: { text: { text: 'Active' } } }] }, + // A third sibling alongside "Account Locked"/"Generate Session" - all three share + // "Active?" as their layer (see `index.ts`'s sibling-scoped drag-to-reorder). + { id: 'l13', type: 'flowchart.FlowLink', source: { id: 'checkAccountStatus', port: 'suspended' }, target: { id: 'showSuspendedMessage', port: 'in' }, labels: [{ attrs: { text: { text: 'Suspended' } } }] }, + { id: 'l8', type: 'flowchart.FlowLink', source: { id: 'showLockedMessage', port: 'out' }, target: { id: 'end', port: 'in' } }, + { id: 'l14', type: 'flowchart.FlowLink', source: { id: 'showSuspendedMessage', port: 'out' }, target: { id: 'end', port: 'in' } }, + { id: 'l9', type: 'flowchart.FlowLink', source: { id: 'generateSession', port: 'success' }, target: { id: 'grantAccess', port: 'in' }, labels: [{ attrs: { text: { text: 'Success' } } }] }, + { id: 'l10', type: 'flowchart.FlowLink', source: { id: 'generateSession', port: 'error' }, target: { id: 'sessionError', port: 'in' }, labels: [{ attrs: { text: { text: 'Error' } } }] }, + // Cycle 2: back up to re-check the account before retrying. + { id: 'l11', type: 'flowchart.FlowLink', source: { id: 'sessionError', port: 'out' }, target: { id: 'checkAccountStatus', port: 'in' } }, + { id: 'l12', type: 'flowchart.FlowLink', source: { id: 'grantAccess', port: 'out' }, target: { id: 'end', port: 'in' } } + ] +}; diff --git a/examples/layout-elk-flowchart-ts/src/index.ts b/examples/layout-elk-flowchart-ts/src/index.ts new file mode 100644 index 0000000000..e8db8c9e1a --- /dev/null +++ b/examples/layout-elk-flowchart-ts/src/index.ts @@ -0,0 +1,458 @@ +import { dia, elementTools, setTheme, util } from '@joint/core'; +import { + ElkLayoutOptions, + ExportElementCallback, + ExportPortCallback, + ExportEdgeLabelCallback, + SetPortAttributesCallback, + layout +} from '@joint/layout-elk'; +import ELK from 'elkjs/lib/elk-api.js'; +import { graphJSON } from './example'; +import { Decision, FlowchartNode, FlowLink, Process, Terminal } from './shapes'; +import './styles.scss'; + +const cellNamespace = { + flowchart: { Process, Decision, Terminal, FlowLink } +}; + +// A "+" button - two of them, permanently shown on any `FlowchartNode` (a +// `Process`/`Decision` - not a `Terminal`, which stays a fixed entry/exit +// point): one just under the top border (`addInPort()`), one just above the +// bottom border (`addOutPort()`) - see the `render:done` handler below for +// how each one is positioned and which port group it grows. +class AddPortButton extends elementTools.Button { + children = [ + { tagName: 'circle', selector: 'button', attributes: { r: 6, class: 'add-button' } }, + { tagName: 'path', selector: 'icon', attributes: { d: 'M -3 0 3 0 M 0 -3 0 3', class: 'add-button-icon' } } + ]; +} + +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'); + + const graph = new dia.Graph({}, { cellNamespace }); + + // A port is "free" - eligible both to start a new link from (an 'out' + // port) and to drop one onto (an 'in' port) - only while nothing already + // connects to it. Checked by exact port id, not just "does this element + // have any free port", since a node can carry several of either group + // (a `Decision`'s two branches, or any node grown via the "+" buttons). + const isPortFree = (element: dia.Element, portId: string | null): boolean => { + if (!portId) return false; + return graph.getConnectedLinks(element).every((link) => { + const source = link.source(); + const target = link.target(); + return !(source.id === element.id && source.port === portId) && + !(target.id === element.id && target.port === portId); + }); + }; + + const paper = new dia.Paper({ + model: graph, + cellViewNamespace: cellNamespace, + width: 1100, + height: 750, + gridSize: 1, + async: true, + frozen: true, + defaultConnector: { + name: 'straight', + args: { cornerType: 'cubic', cornerRadius: 8 } + }, + defaultConnectionPoint: { name: 'boundary' }, + // A plain, undecorated link isn't a `FlowLink` - but dragging a *free* + // port to connect it to another one now needs a real link to drag, so + // this is a `FlowLink` too, exactly like `element:magnet:pointerclick`'s + // own new-step-and-link. It only ever actually gets added to the graph + // on a valid drop, per `validateMagnet`/`validateConnection` below. + defaultLink: () => new FlowLink(), + // A link dragged from a magnet and dropped anywhere else (a blank + // spot, or - critically - nowhere at all, i.e. a plain click with no + // movement on a now-valid, *free* port) must not stick around + // half-connected to a bare point - every `FlowLink` always connects + // two actual ports, or doesn't exist. Without this, clicking a free + // 'out' port (to spawn a new step, see `element:magnet:pointerclick`) + // would *also* leave behind a second, dangling, nowhere-connected link + // from that same click - `linkPinning`'s default (`true`) is what + // otherwise keeps it "pinned" to that unconnected point instead of + // discarding it. + linkPinning: false, + // A magnet only supports starting JointJS's own native drag-a-link + // gesture (as opposed to just a *click*, see + // `element:magnet:pointerclick` below) from a *free* 'out' port - the + // source side of a new connection. Every other magnet (an 'in' port, + // or any already-connected port) stays "passive" to it, same as if it + // had no `magnet` attr at all for that purpose, and falls back to + // plain element dragging instead - which doesn't take anything away + // from reordering (`element:pointerdown` below). + validateMagnet: (cellView, magnet) => { + const element = cellView.model; + if (!(element instanceof dia.Element)) return false; + if (cellView.findAttribute('port-group', magnet) !== 'out') return false; + return isPortFree(element, cellView.findAttribute('port', magnet)); + }, + // The other end of that new connection has to land on a *free* 'in' + // port, on a *different* element - never back onto the same node + // (a flowchart step never loops directly into itself). + validateConnection: (cellViewS, _magnetS, cellViewT, magnetT, end) => { + if (end !== 'target' || !magnetT || cellViewS === cellViewT) return false; + const targetElement = cellViewT.model; + if (!(targetElement instanceof dia.Element)) return false; + if (cellViewT.findAttribute('port-group', magnetT) !== 'in') return false; + return isPortFree(targetElement, cellViewT.findAttribute('port', magnetT)); + } + // `interactive` stays at its default (`true`) - dragging an element is how + // this example lets a user reorder it (see `element:pointerup` below); a + // newly clicked-from port's new step starts out wherever, since ELK + // repositions everything on the next layout pass regardless. + }); + document.getElementById('canvas')!.appendChild(paper.el); + addZoomAndPanListeners(paper); + + // A successful manual connection between two previously free ports - + // re-lay out so ELK routes the new edge properly instead of leaving it + // wherever the native drag happened to draw it. + paper.on('link:connect', () => { + runLayout(); + }); + + // `order` is the model order ELK respects (see `elk.layered.considerModelOrder.strategy` + // below) - `reorderAfter` reassigns it among siblings on drop, and `graph.getElements()` + // needs to come back in that same sequence for it to have any effect, which means the + // graph's cells (sorted by `z` - see `dia.CellCollection`'s `comparator`) need their `z` + // kept in lockstep with it. Doing that here, once, reactively, means nothing that sets + // `order` (below, and `reorderAfter`) ever also has to remember to update `z` itself. + const ORDER_Z_OFFSET = 2; + graph.on('change:order', (element: dia.Element, order: number) => { + element.set('z', ORDER_Z_OFFSET + order); + }); + + graph.fromJSON(graphJSON); + // Initial order: insertion order, i.e. `example.ts`'s own array order. + graph.getElements().forEach((element, index) => element.set('order', index)); + + const elkLayoutOptions: ElkLayoutOptions = { + // Top-to-bottom flowchart. + 'elk.direction': 'DOWN', + 'elk.spacing.nodeNode': '48', + 'elk.layered.spacing.nodeNodeBetweenLayers': '68', + 'elk.edgeRouting': 'ORTHOGONAL', + // Cycles are genuine here (see `example.ts`) - `MODEL_ORDER` always breaks + // a cycle at the edge whose target was added before its source (a "back" + // reference, by construction order), the same, deterministic way every + // run - unlike a plain greedy search, which can just as easily reverse a + // *forward* edge instead, leaving the graph's overall rank order confusing. + 'elk.layered.cycleBreaking.strategy': 'MODEL_ORDER', + // Keep new elements/links (added interactively, appended to the graph) from + // being freely reshuffled among the existing ones wherever ELK's crossing + // minimizer would otherwise put them - it still may reorder *ports* (see + // `exportPort` below) to reduce crossings, just not the elements themselves. + 'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES', + // `considerModelOrder.strategy` above is only a preference crossing + // minimization can still override wherever it believes another arrangement + // has fewer crossings - which, for two siblings whose crossing count is the + // same either way (a common case for two plain leaf branches), can silently + // ignore the model order entirely. This makes it absolute instead, so the + // reorder feature's `order` (via `z`) always actually has a visible effect. + 'elk.layered.crossingMinimization.forceNodeModelOrder': 'true', + 'elk.layered.layering.strategy': 'INTERACTIVE' + }; + + const elk = new ELK({ + workerUrl: '../node_modules/elkjs/lib/elk-worker.js' + }); + + // `FIXED_SIDE` (not the default `FREE`) is what lets ELK reorder a node's ports + // along their side to reduce crossings, instead of only routing edges to + // wherever a port happens to already be. + const exportElement: ExportElementCallback = ({ elkNode, element }) => { + elkNode.layoutOptions['elk.portConstraints'] = 'FIXED_SIDE'; + const position = element.position(); + elkNode.x = position.x; + elkNode.y = position.y; + }; + + // Every 'in' port sits on the node's top, every 'out' port on its bottom - + // matching the top-to-bottom flow and `FlowchartNode`'s own port groups. + // `elk.port.index` gives crossing minimization (which, under `FIXED_SIDE`, + // still uses it as its initial/tie-break order rather than requiring it + // outright) an explicit left-to-right order to start from, rather than + // leaving a brand new, still-unconnected port (added via the "+" buttons) + // to whatever an edgeless port happens to fall back to. ELK's own + // documented convention for that index is clockwise starting at the + // top-left, which makes the *south* side's index count right-to-left, the + // opposite of north's left-to-right - so it has to be reversed for 'out' + // ports specifically, or a newly-added one (last in `getGroupPorts()`, + // meant to land on the *right*, matching a new 'in' port) would instead + // end up leftmost. + const exportPort: ExportPortCallback = ({ port, elkPort }) => { + elkPort.layoutOptions['elk.port.side'] = (port.group === 'in') ? 'NORTH' : 'SOUTH'; + }; + + // Every branch condition ("Valid"/"Invalid", ...) sits directly on its edge, + // rather than floating beside it. + const exportEdgeLabel: ExportEdgeLabelCallback = ({ elkEdgeLabel }) => { + elkEdgeLabel.layoutOptions['elk.edgeLabels.inline'] = 'true'; + }; + + // ELK (and the built-in 'top'/'bottom' port position functions) place a port + // on the node's *bounding box* border - correct for `Process`/`Terminal`'s + // rectangles, but not for `Decision`'s diamond, whose actual edge sits at + // that same y for only one x (the tip). This projects the port back onto + // the diamond's real slanted edge - only its y moves; x (which side, and + // where along it) stays exactly what ELK computed. + const setPortAttributes: SetPortAttributesCallback = ({ element, portId, attributes }) => { + if (element instanceof Decision && attributes.position) { + const { width, height } = element.size(); + const { x, y } = attributes.position.args; + const distanceFromCenter = Math.abs(x - width / 2); + const edgeY = distanceFromCenter / (width / 2) * (height / 2); + attributes.position.args.y = (y < height / 2) ? edgeY : height - edgeY; + } + element.portProp(portId, attributes); + }; + + const runLayout = (): Promise => { + paper.freeze(); + return layout(graph, { + elk, + exportElement, + exportPort, + exportEdgeLabel, + setPortAttributes, + elkLayoutOptions + }).then(() => { + paper.unfreeze(); + zoom(paper, 1); + }).catch((error) => { + paper.unfreeze(); + console.error('ELK layout error:', error.message); + }); + }; + + runLayout(); + + // "+" buttons - two per `FlowchartNode`, always shown (not just on hover). + // `render:done` fires after every render pass - initial load, and every + // re-layout/new-element pass alike - so this both covers the initial set + // of elements and keeps picking up any added afterwards; `hasTools()` + // makes it idempotent, since the same view's `render:done` fires again + // on every later pass too. `x`/`y` position each button relative to the + // element's own (current) bbox top-left corner - a fixed inset down from + // the top border for the 'in' button, and, since node height varies + // (`Decision` vs `Process`/`Terminal`), a percentage-of-height position + // (`'100%'`, the bottom border) plus a negative pixel `offset` for the + // 'out' button, so it always ends up the same fixed inset *up* from + // whatever the bottom border actually is. + const ADD_BUTTON_INSET = 20; + paper.on('render:done', () => { + graph.getElements().forEach((element) => { + if (!(element instanceof FlowchartNode)) return; + const elementView = paper.findViewByModel(element); + if (!elementView || elementView.hasTools()) return; + elementView.addTools(new dia.ToolsView({ + tools: [ + new AddPortButton({ + x: '50%', + y: ADD_BUTTON_INSET, + action: () => { + element.addInPort(); + runLayout(); + } + }), + new AddPortButton({ + x: '50%', + y: '100%', + offset: { y: -ADD_BUTTON_INSET }, + action: () => { + element.addOutPort(); + runLayout(); + } + }) + ] + })); + }); + }); + + // Click a port to grow the flowchart from it: a new step, connected from that + // port - only 'out' ports make sense as a starting point for a new downstream + // step. Prompts for the new step's label; leaving it blank falls back to an + // auto-numbered one. + let newStepCount = 0; + paper.on('element:magnet:pointerclick', (elementView: dia.ElementView, evt: dia.Event, magnet: SVGElement) => { + const element = elementView.model; + const portId = elementView.findAttribute('port', magnet); + const portGroup = elementView.findAttribute('port-group', magnet); + if (!portId || portGroup !== 'out') return; + + newStepCount++; + const label = window.prompt('New step name:', `Step ${newStepCount}`) || `Step ${newStepCount}`; + + // Appended at the end of the model order - set directly (not via `.set()` + // afterwards) since a constructor's initial attributes don't trigger + // `change:order`, so `z` needs setting alongside it here, just this once. + const order = graph.getElements().length; + const newStep = new Process({ + position: element.position(), + attrs: { label: { text: label } }, + ports: { items: [{ group: 'in' }, { group: 'out' }] }, + order, + z: ORDER_Z_OFFSET + order + }); + const newLink = new FlowLink({ + source: { id: element.id, port: portId }, + target: { id: newStep.id } + }); + graph.addCells([newStep, newLink]); + + runLayout(); + }); + + // Drag an element onto another one to reorder it - only among actual + // *siblings*, i.e. the other elements ELK placed in the same *layer* of + // this top-to-bottom layout - approximated here by comparing each + // element's current *y*, since a layered layout always aligns every + // element of one layer to the same y regardless of its own height (see + // `LAYER_Y_EPSILON`). This is deliberately about the *layout*, not the + // graph's parent/child structure: e.g. "Active?" (`checkAccountStatus`) + // and "Show Error" don't share a single parent - a cycle also feeds + // "Active?" from "Session Error", so it has two - but they DO sit in the + // same layer, and reordering them relative to each other is exactly what + // dragging one onto the other should do. An element alone in its own + // layer (nothing else at a matching y - true of `Start`/`End`, always + // alone at the very first/last rank) has nothing to reorder against, so + // `element:pointerdown` leaves it to drag natively, with no preview and + // no reorder on drop (same as a plain click). + // + // Nothing in the graph itself moves during the drag - not the dragged + // element (`preventDefaultInteraction` stops its own native move), not + // any sibling either. A cloned, semitransparent copy of the dragged + // element is what actually follows the pointer - horizontally only, + // reordering being a left-right rearrangement among siblings that all + // sit at the same rank - appended directly to the paper's front layer, + // outside the graph entirely. Only the actual drop (`element:pointerup`) + // touches the model at all: it compares the drop position against every + // sibling's own (real, never moved) position to work out the new order, + // then a real, full ELK `layout()` runs. + const LAYER_Y_EPSILON = 1; + let draggedElement: dia.Element | null = null; + let draggedSiblings: dia.Element[] | null = null; + let draggedBBox: dia.BBox | null = null; + let previewNode: SVGElement | null = null; + + const clearPreview = (): void => { + previewNode?.remove(); + previewNode = null; + }; + + paper.on('element:pointerdown', (elementView: dia.ElementView, evt: dia.Event) => { + const element = elementView.model; + // Always prevent JointJS's own native move, reorderable or not - an + // element with no siblings (e.g. `Start`/`End`) would otherwise still + // be freely draggable around the canvas by default, just with no + // preview and no effect on drop (`runLayout()` snaps it right back). + // Blocking the native move outright means it's simply not possible to + // move it around in the first place. + elementView.preventDefaultInteraction(evt); + + const y = element.position().y; + const siblings = graph.getElements().filter((el) => ( + el !== element && Math.abs(el.position().y - y) < LAYER_Y_EPSILON + )); + if (siblings.length === 0) return; + + draggedElement = element; + draggedSiblings = siblings; + draggedBBox = element.getBBox().toJSON(); + + previewNode = elementView.el.cloneNode(true) as SVGElement; + previewNode.setAttribute('class', `${previewNode.getAttribute('class') || ''} drag-preview`); + previewNode.setAttribute('transform', `translate(${draggedBBox.x}, ${draggedBBox.y})`); + paper.getLayerView(dia.Paper.Layers.FRONT).el.appendChild(previewNode); + }); + + paper.on('element:pointermove', (elementView: dia.ElementView, _evt: dia.Event, x: number) => { + if (elementView.model !== draggedElement || !previewNode || !draggedBBox) return; + previewNode.setAttribute('transform', `translate(${x - draggedBBox.width / 2}, ${draggedBBox.y})`); + }); + + paper.on('element:pointerup', (elementView: dia.ElementView, _evt: dia.Event, x: number) => { + if (elementView.model === draggedElement && draggedSiblings) { + reorderAmongSiblings(elementView.model, draggedSiblings, x); + } + clearPreview(); + + draggedElement = null; + draggedSiblings = null; + draggedBBox = null; + + runLayout(); + }); +}; + +// Reassigns `element`'s `order` attribute among `siblings` (the other +// elements in its current layer), based on where it was dropped (`dropX`, +// its would-be center) - every sibling sorts by its own real bbox center +// instead, since none of them ever actually moved during the drag. Only the +// group's own, already-assigned `order` values are reused, permuted into the +// new sequence - not reassigned from scratch - so no element outside the +// group (with its own unrelated `order` value) is ever touched by a reorder +// that's supposed to be purely local to this layer. `z` (hence +// `graph.getElements()`'s own order, hence `exportGraph`, hence +// `considerModelOrder.strategy`) follows automatically, via the +// `change:order` listener registered in `init()`. +function reorderAmongSiblings(element: dia.Element, siblings: dia.Element[], dropX: number): void { + const group = [element, ...siblings]; + const orderValues: number[] = group.map((el) => el.get('order')).sort((a, b) => a - b); + const sorted = util.sortBy(group, (el) => (el === element) ? dropX : el.getBBox().center().x); + sorted.forEach((el, i) => el.set('order', orderValues[i])); +} + +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-flowchart-ts/src/shapes.ts b/examples/layout-elk-flowchart-ts/src/shapes.ts new file mode 100644 index 0000000000..f18d11c6d3 --- /dev/null +++ b/examples/layout-elk-flowchart-ts/src/shapes.ts @@ -0,0 +1,187 @@ +import { dia, shapes, util } from '@joint/core'; + +const PORT_ATTRS = { + circle: { + r: 6, + class: 'port', + // Without this, a port is just a circle - not a magnet `index.ts`'s + // `element:magnet:pointerclick` (or JointJS's own link-dragging) can ever + // hit-test against (see `Paper#pointerdown`'s `target.closest('[magnet]')`). + magnet: true + } +}; + +/** + * Shared by `Process` and `Decision` - a top 'in' port group and a bottom 'out' + * port group (ELK positions both, see `index.ts`'s `exportPort`), plus + * `addInPort()`/`addOutPort()`, used by the two "+" buttons (`index.ts`) to + * grow a node an extra port interactively, without needing a distinct + * "decision" type - any node can end up with more than one incoming or + * outgoing path. + */ +export class FlowchartNode extends shapes.standard.Rectangle { + defaults() { + return util.defaultsDeep({ + size: { width: 160, height: 70 }, + // No fixed `z` here - `index.ts` derives it from this element's `order` + // (the model-order ELK respects, see `reorderAmongSiblings`), always + // keeping it above `FlowLink`'s own fixed `z: 1` so a port stays clickable (see + // `element:magnet:pointerclick`) even where a link already connects to + // it, which paint order would otherwise put on top of it. + ports: { + groups: { + in: { + position: { name: 'top' }, + attrs: PORT_ATTRS, + markup: [{ tagName: 'circle', selector: 'circle' }] + }, + out: { + position: { name: 'bottom' }, + attrs: PORT_ATTRS, + markup: [{ tagName: 'circle', selector: 'circle' }] + } + } + } + }, super.defaults); + } + + addInPort(): string { + const portId = `${this.generatePortId()}`; + this.addPort({ id: portId, group: 'in' }); + return portId; + } + + addOutPort(): string { + const portId = `${this.generatePortId()}`; + this.addPort({ id: portId, group: 'out' }); + return portId; + } +} + +/** + * A step - a plain rectangle. Starts with one 'in' and one 'out' port, but + * `addInPort()`/`addOutPort()` (see `FlowchartNode`) let it grow extra ports, + * the same as `Decision` - the shape is just a visual hint, not a structural + * limit. + */ +export class Process extends FlowchartNode { + defaults() { + return util.defaultsDeep({ + type: 'flowchart.Process', + attrs: { + body: { class: 'node node--process' }, + label: { class: 'node-label' } + }, + ports: { + items: [ + { group: 'in' }, + { group: 'out' } + ] + } + }, super.defaults()); + } +} + +/** + * A decision - a diamond, custom-drawn since `standard.Rectangle`'s markup has + * no such shape. Starts with two 'out' ports (its two usual branches), but + * inherits `addInPort()`/`addOutPort()` too, for extra ones. + */ +export class Decision extends FlowchartNode { + preinitialize() { + this.markup = [ + { tagName: 'path', selector: 'body' }, + { tagName: 'text', selector: 'label' } + ]; + } + + defaults() { + return util.defaultsDeep({ + type: 'flowchart.Decision', + size: { width: 172, height: 104 }, + attrs: { + body: { + class: 'node node--decision', + d: 'M calc(0.5*w) 0 L calc(w) calc(0.5*h) L calc(0.5*w) calc(h) L 0 calc(0.5*h) z' + }, + label: { class: 'node-label' } + }, + ports: { + items: [ + { group: 'in' }, + { group: 'out' }, + { group: 'out' } + ] + } + }, super.defaults()); + } +} + +/** + * Start/end - a pill (its own class sets `rx`/`ry` to a `calc(h/2)` CSS value, + * see `styles.scss`). One port only, 'in' for an end, 'out' for a start - + * `example.ts` picks which by only ever adding one `ports.items` entry. + */ +export class Terminal extends shapes.standard.Rectangle { + defaults() { + return util.defaultsDeep({ + type: 'flowchart.Terminal', + size: { width: 125, height: 46 }, + // See `FlowchartNode`'s own comment on `z` - derived from `order`, same reason. + attrs: { + body: { class: 'node node--terminal' }, + label: { class: 'node-label node-label--on-primary' } + }, + ports: { + groups: { + in: { + position: { name: 'top' }, + attrs: PORT_ATTRS, + markup: [{ tagName: 'circle', selector: 'circle' }] + }, + out: { + position: { name: 'bottom' }, + attrs: PORT_ATTRS, + markup: [{ tagName: 'circle', selector: 'circle' }] + } + } + } + }, super.defaults); + } +} + +/** + * A flow edge - a plain arrow, with an optional label for a branch's condition + * (e.g. "Yes"/"No") when its source has more than one outgoing path. + */ +export class FlowLink extends shapes.standard.Link { + defaults() { + return util.defaultsDeep({ + type: 'flowchart.FlowLink', + // Fixed, and lower than every node's - so a link never paints over (and + // steals the click from) the port it connects to. + z: 1, + attrs: { + line: { + class: 'link', + stroke: '#78909C' + } + }, + defaultLabel: { + size: { width: 60, height: 18 }, + attrs: { + text: { class: 'link-label-text' }, + rect: { + ref: null, + x: 'calc(x - calc(w / 2))', + y: 'calc(y - calc(h / 2))', + width: 'calc(w)', + height: 'calc(h)', + class: 'link-label-bg' + } + }, + position: 0.5 + } + }, super.defaults); + } +} diff --git a/examples/layout-elk-flowchart-ts/src/styles.scss b/examples/layout-elk-flowchart-ts/src/styles.scss new file mode 100644 index 0000000000..e5bd59dd5a --- /dev/null +++ b/examples/layout-elk-flowchart-ts/src/styles.scss @@ -0,0 +1,144 @@ +:root { + --primary: #3F51B5; + --primary-tint: #E8EAF6; + --on-primary-tint: #283593; + --surface: #FFFFFF; + --outline: #C7CBDD; + --on-surface: rgba(0, 0, 0, 0.87); + --on-surface-variant: rgba(0, 0, 0, 0.6); +} + +html, body { + margin: 0; + padding: 0; + font-family: 'Segoe UI', Roboto, sans-serif; +} + +#canvas { + position: absolute; + margin-top: 50px; + margin-left: 20px; + border: 1px solid var(--outline); + background-color: #FAFAFA; + overflow: hidden; +} + +.toolbar { + display: flex; + position: fixed; + width: 100%; + top: 10px; + margin-left: 30px; + z-index: 1; +} + +.toolbar-button { + outline: none; + background: var(--surface); + border: 1px solid var(--outline); + border-radius: 4px; + font-family: inherit; + font-size: 13px; + padding: 6px 12px; + color: var(--primary); + cursor: pointer; + user-select: none; + margin: 0 2px; + + &:hover { + background: var(--primary-tint); + } +} + +.help-text { + position: fixed; + top: 10px; + right: 20px; + max-width: 300px; + font-family: inherit; + font-size: 12px; + color: var(--on-surface-variant); + text-align: right; + line-height: 1.4; +} + +.joint-theme-material { + + .node { + fill: var(--surface); + stroke: var(--outline); + stroke-width: 1.5px; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.25)); + } + + .node--process { + rx: 6px; + ry: 6px; + } + + .node--decision { + stroke: var(--primary); + } + + .node--terminal { + rx: 20px; + ry: 20px; + fill: var(--primary); + stroke: none; + } + + .node-label { + font-family: inherit; + font-size: 13px; + font-weight: 500; + fill: var(--on-surface); + } + + // `node--terminal`'s fill is already the primary color - its own label needs + // the "on primary" (light) text color instead of the default dark one above. + .node-label--on-primary { + fill: #FFFFFF; + } + + .port { + fill: var(--surface); + stroke: var(--primary); + stroke-width: 2px; + } + + .link { + stroke-linecap: round; + } + + .link-label-bg { + fill: var(--primary-tint); + rx: 4px; + ry: 4px; + } + + .link-label-text { + font-family: inherit; + font-size: 11px; + font-weight: 500; + fill: var(--on-primary-tint); + } + + .add-button { + fill: var(--primary); + cursor: pointer; + } + + .add-button-icon { + stroke: #FFFFFF; + stroke-width: 2px; + pointer-events: none; + } + + // The cloned, floating preview of whichever element is currently being + // dragged (see `index.ts`'s `element:pointerdown`/`pointermove`) - not the + // real element, which never moves during the drag. + .drag-preview { + opacity: 0.5; + pointer-events: none; + } +} diff --git a/examples/layout-elk-flowchart-ts/tsconfig.json b/examples/layout-elk-flowchart-ts/tsconfig.json new file mode 100644 index 0000000000..04a61c7d89 --- /dev/null +++ b/examples/layout-elk-flowchart-ts/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "module": "ES6", + "moduleResolution": "bundler", + "target": "es6", + "lib": [ + "es2022", + "dom" + ], + "noImplicitAny": false, + "sourceMap": false, + "rootDir": "./src", + "outDir": "./build", + "noUncheckedSideEffectImports": false, + "resolveJsonModule": true, + "esModuleInterop": true + } +} diff --git a/examples/layout-elk-flowchart-ts/webpack.config.js b/examples/layout-elk-flowchart-ts/webpack.config.js new file mode 100644 index 0000000000..7b10ca335d --- /dev/null +++ b/examples/layout-elk-flowchart-ts/webpack.config.js @@ -0,0 +1,39 @@ +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: /\.m?js/, + resolve: { + fullySpecified: false, + }, + }, + { test: /\.ts$/, loader: 'ts-loader' }, + { + test: /\.s[ac]ss$/i, + use: [ + 'style-loader', + 'css-loader', + 'sass-loader', + ], + }, + ], + }, + devServer: { + static: { + directory: __dirname, + }, + compress: true, + }, +}; From 41b106da9f68ab6b58494fcba427bb61caac5577 Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Tue, 29 Sep 2026 13:11:53 +0200 Subject: [PATCH 38/75] comment fixes --- .../layout-elk-containers-ports-ts/src/example.ts | 15 +++++---------- packages/joint-layout-elk/src/export.mts | 10 +++++----- packages/joint-layout-elk/src/import.mts | 8 ++++---- 3 files changed, 14 insertions(+), 19 deletions(-) diff --git a/examples/layout-elk-containers-ports-ts/src/example.ts b/examples/layout-elk-containers-ports-ts/src/example.ts index fe3bd62c87..a70d8bb79d 100644 --- a/examples/layout-elk-containers-ports-ts/src/example.ts +++ b/examples/layout-elk-containers-ports-ts/src/example.ts @@ -19,34 +19,29 @@ export const graphJSON: dia.Graph.JSON = { { id: 'frontend', type: 'example.Container', - attrs: { label: { text: 'Client Layer' } }, - embeds: ['webui', 'mobileui', 'edge'] + attrs: { label: { text: 'Client Layer' } } }, { id: 'edge', type: 'example.Container', parent: 'frontend', - attrs: { label: { text: 'Edge' } }, - embeds: ['lb', 'gateway'] + attrs: { label: { text: 'Edge' } } }, { id: 'backend', type: 'example.Container', - attrs: { label: { text: 'Core Services' } }, - embeds: ['auth', 'storage'] + attrs: { label: { text: 'Core Services' } } }, { id: 'storage', type: 'example.Container', parent: 'backend', - attrs: { label: { text: 'Data Layer' } }, - embeds: ['cache', 'db'] + attrs: { label: { text: 'Data Layer' } } }, { id: 'observability', type: 'example.Container', - attrs: { label: { text: 'Observability' } }, - embeds: ['monitoring'] + attrs: { label: { text: 'Observability' } } }, // Client Layer diff --git a/packages/joint-layout-elk/src/export.mts b/packages/joint-layout-elk/src/export.mts index 38b88d88a7..2ec6b01f04 100644 --- a/packages/joint-layout-elk/src/export.mts +++ b/packages/joint-layout-elk/src/export.mts @@ -333,12 +333,12 @@ function buildEdge(link: dia.Link): void { linksById.set(id, link); - // Resolved (`link.labels()`) - `size` falls back through `defaultLabel`/the built-in - // default the same way `@joint/core` itself resolves it for rendering, and a custom - // `elkLayoutOptionsProperty` property passes through too (whether set on the label - // itself or on `defaultLabel` - see `Link#_getResolvedLabel`), so it can be read + // Resolved (`link.getComputedLabels()`) - `size` falls back through `defaultLabel`/the + // built-in default the same way `@joint/core` itself resolves it for rendering, and a + // custom `elkLayoutOptionsProperty` property passes through too (whether set on the + // label itself or on `defaultLabel` - see `Link#getComputedLabels`), so it can be read // directly here instead of from the label's raw JSON. - const resolvedLabels = link.labels(); + const resolvedLabels = link.getComputedLabels(); let labels: ElkLabel[] = []; if (resolvedLabels.length > 0) { labels = resolvedLabels.reduce((result: ElkLabel[], label) => { diff --git a/packages/joint-layout-elk/src/import.mts b/packages/joint-layout-elk/src/import.mts index b4be393cbc..cfd478c909 100644 --- a/packages/joint-layout-elk/src/import.mts +++ b/packages/joint-layout-elk/src/import.mts @@ -146,10 +146,10 @@ function importEdges(edges: ElkExtendedEdge[] | undefined, containerPosition: di const points = [startPoint, ...bendPoints, endPoint] .map((point) => toAbsolute(point, containerPosition)); const polyline = new g.Polyline(points); - // `link.labels()` returns each label resolved against `defaultLabel`/the built-in - // default (`@joint/core`) - reading `labels` (the raw model attribute) directly - // instead, so writing `labels[index]` back below doesn't bake that resolved - // `markup`/`attrs`/`size` permanently into the label's own stored JSON. + // `link.getComputedLabels()` (`@joint/core`) returns each label resolved against + // `defaultLabel`/the built-in default - reading `labels` (the raw model attribute) + // directly instead, so writing `labels[index]` back below doesn't bake that + // resolved `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, index) => { From 6c0543f3bb6ea6329a0724d06fd5f4f1892454f1 Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Tue, 29 Sep 2026 14:03:57 +0200 Subject: [PATCH 39/75] tests fix --- packages/joint-layout-elk/rollup.config.mjs | 42 +++++++ packages/joint-layout-elk/test/index.js | 130 ++++++++++++-------- 2 files changed, 122 insertions(+), 50 deletions(-) diff --git a/packages/joint-layout-elk/rollup.config.mjs b/packages/joint-layout-elk/rollup.config.mjs index 5d94ff13c8..da55d044fe 100644 --- a/packages/joint-layout-elk/rollup.config.mjs +++ b/packages/joint-layout-elk/rollup.config.mjs @@ -2,6 +2,7 @@ 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` @@ -55,5 +56,46 @@ export default [ 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', + 'elkjs/lib/elk-api.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', + 'elkjs/lib/elk-api.js': 'ELK' + }, + sourcemap: true + } + ], + plugins: [ + 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/test/index.js b/packages/joint-layout-elk/test/index.js index f2e1b6edf7..5f637fb866 100644 --- a/packages/joint-layout-elk/test/index.js +++ b/packages/joint-layout-elk/test/index.js @@ -190,7 +190,7 @@ QUnit.module('layout()', () => { assert.ok(Array.isArray(link.vertices())); }); - QUnit.test('should call portProperties for each port', async(assert) => { + 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({ @@ -210,16 +210,15 @@ QUnit.module('layout()', () => { const seen = []; await joint.layout.ELK.layout(graph, { - portProperties: ({ port, element }) => { + exportPort: ({ port, element }) => { seen.push([port.id, element.id]); - return {}; } }); assert.deepEqual(seen, [['out1', 'a']]); }); - QUnit.test('should merge a nodeProperties/portProperties/edgeProperties return value onto what was computed', async(assert) => { + QUnit.test('should let exportElement/exportPort/exportEdge 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({ @@ -237,21 +236,31 @@ QUnit.module('layout()', () => { 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. `elk.port.borderOffset`, or an `elkNode`/`elkPort`'s own `width`) survives + // alongside whatever the callback itself adds. const { elkGraph } = await joint.layout.ELK.layout(graph, { - portsPosition: 'fixed-side', - // Each returns only a new `layoutOptions` key - what this package itself - // computed (e.g. `elk.portConstraints`, `elk.hierarchyHandling`) must survive. - nodeProperties: () => ({ layoutOptions: { 'elk.custom': 'node' }}), - portProperties: () => ({ layoutOptions: { 'elk.custom': 'port' }}), - edgeProperties: () => ({ layoutOptions: { 'elk.custom': 'edge' }}) + exportElement: ({ elkNode }) => { + elkNode.layoutOptions['elk.portConstraints'] = 'FIXED_SIDE'; + elkNode.layoutOptions['elk.custom'] = 'node'; + }, + exportPort: ({ elkPort }) => { + elkPort.layoutOptions['elk.custom'] = 'port'; + }, + exportEdge: ({ 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.layoutOptions['elk.port.borderOffset'], 'string'); assert.equal(typeof elkPort.width, 'number'); const [elkEdge] = elkGraph.edges; @@ -280,7 +289,7 @@ QUnit.module('layout()', () => { assert.equal(el1.prop(['ports', 'groups', 'out', 'position']), 'right'); }); - QUnit.test('should let ELK position ports when `portsPosition` is enabled', async(assert) => { + 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({ @@ -310,11 +319,18 @@ QUnit.module('layout()', () => { graph.resetCells([el1, el2, link]); - await joint.layout.ELK.layout(graph, { portsPosition: 'fixed-side' }); + await joint.layout.ELK.layout(graph, { + exportElement: ({ elkNode }) => { + elkNode.layoutOptions['elk.portConstraints'] = 'FIXED_SIDE'; + } + }); - // The group's position is switched to 'absolute' so the ELK-computed position applies. - assert.equal(el1.prop(['ports', 'groups', 'out', 'position', 'name']), 'absolute'); - assert.equal(el2.prop(['ports', 'groups', 'in', 'position', 'name']), 'absolute'); + // 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'); @@ -370,12 +386,14 @@ QUnit.module('layout()', () => { assert.equal(secondPosition.y, firstPosition.y); }); - QUnit.test('should merge a node\'s own `elkLayoutOptions` into its computed layoutOptions, without a nodeProperties callback', async(assert) => { + 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 }}); @@ -383,13 +401,17 @@ QUnit.module('layout()', () => { graph.resetCells([parent, child]); - const { elkGraph } = await joint.layout.ELK.layout(graph); + 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 merge a link label\'s own `elkLayoutOptions` into its computed layoutOptions, without an edgeProperties callback', async(assert) => { + QUnit.test('should let exportEdgeLabel 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 }}); @@ -402,16 +424,21 @@ QUnit.module('layout()', () => { graph.resetCells([el1, el2, link]); - const { elkGraph } = await joint.layout.ELK.layout(graph); + const { elkGraph } = await joint.layout.ELK.layout(graph, { + exportEdgeLabel: ({ label, elkEdgeLabel }) => { + Object.assign(elkEdgeLabel.layoutOptions, label.elkLayoutOptions); + } + }); const [elkEdge] = elkGraph.edges; assert.equal(elkEdge.labels[0].layoutOptions['elk.edgeLabels.inline'], 'true'); - // The label's own raw JSON is unaffected - `elkLayoutOptions` isn't baked into it. + // The label's own raw JSON is unaffected - reading it in `exportEdgeLabel` doesn't + // write anything back. assert.deepEqual(link.get('labels')[0].elkLayoutOptions, { 'elk.edgeLabels.inline': 'true' }); }); - QUnit.test('should not place a link label inline by default - it is opt-in via `elkLayoutOptions`', async(assert) => { + QUnit.test('should not place a link label inline by default - only `exportEdgeLabel` can opt one in', 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 }}); @@ -430,7 +457,7 @@ QUnit.module('layout()', () => { assert.notOk('elk.edgeLabels.inline' in elkEdge.labels[0].layoutOptions); }); - QUnit.test('should merge a link\'s `defaultLabel.elkLayoutOptions` into every label\'s computed layoutOptions', async(assert) => { + QUnit.test('should let exportEdgeLabel 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 }}); @@ -442,41 +469,26 @@ QUnit.module('layout()', () => { size: { width: 40, height: 20 }, elkLayoutOptions: { 'elk.edgeLabels.inline': 'true' } }, - // Neither label sets its own `elkLayoutOptions` - both fall back to `defaultLabel`'s. + // Neither label sets its own `elkLayoutOptions` - both fall back to + // `defaultLabel`'s, already merged in by `Link#getComputedLabels()` (`@joint/core`) + // by the time `exportEdgeLabel` sees `label` below. labels: [{}, {}] }); graph.resetCells([el1, el2, link]); - const { elkGraph } = await joint.layout.ELK.layout(graph); + const { elkGraph } = await joint.layout.ELK.layout(graph, { + exportEdgeLabel: ({ label, elkEdgeLabel }) => { + Object.assign(elkEdgeLabel.layoutOptions, label.elkLayoutOptions); + } + }); const [elkEdge] = elkGraph.edges; assert.equal(elkEdge.labels[0].layoutOptions['elk.edgeLabels.inline'], 'true'); assert.equal(elkEdge.labels[1].layoutOptions['elk.edgeLabels.inline'], 'true'); }); - QUnit.test('should read the custom `elk.*` layoutOptions property under a different name when `elkLayoutOptionsProperty` is set', 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 }, - // Under the default name too - should be ignored, since a custom name is configured. - elkLayoutOptions: { 'elk.padding': 'should be ignored' }, - myElkOptions: { '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, { elkLayoutOptionsProperty: 'myElkOptions' }); - - 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 merge a port group\'s own `elkLayoutOptions` into its computed layoutOptions, without a portProperties callback', async(assert) => { + 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({ @@ -492,17 +504,26 @@ QUnit.module('layout()', () => { graph.resetCells([el1]); - const { elkGraph } = await joint.layout.ELK.layout(graph, { portsPosition: 'fixed-side' }); + const { elkGraph } = await joint.layout.ELK.layout(graph, { + exportElement: ({ elkNode }) => { + elkNode.layoutOptions['elk.portConstraints'] = 'FIXED_SIDE'; + }, + exportPort: ({ element, port, elkPort }) => { + const groupOptions = element.prop(['ports', 'groups', port.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'); - // What this package itself computes (e.g. `elk.port.borderOffset`) still survives. + // What this package itself computes (e.g. `elk.port.borderOffset`) still survives - + // `exportPort` adds to the same `layoutOptions` object, it doesn't replace it. assert.equal(typeof elkPort.layoutOptions['elk.port.borderOffset'], 'string'); }); - QUnit.test('should size a port label from the port\'s (or its group\'s) `label.size`, not from `elkLayoutOptions`', async(assert) => { + 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({ @@ -521,7 +542,16 @@ QUnit.module('layout()', () => { graph.resetCells([el1]); - const { elkGraph } = await joint.layout.ELK.layout(graph, { positionPortLabels: true }); + 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, port, elkPortLabel }) => { + const { width, height } = element.getPortMetrics(port.id).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; From 140f7b01383e4973e8b1f328bf7c1e9f0dce2f88 Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Tue, 29 Sep 2026 16:44:00 +0200 Subject: [PATCH 40/75] to exportLink --- .../src/index.ts | 7 +- examples/layout-elk-flowchart-ts/src/index.ts | 10 +- packages/joint-layout-elk/src/export.mts | 20 +-- packages/joint-layout-elk/test/index.js | 115 ++++++++++++++++-- 4 files changed, 124 insertions(+), 28 deletions(-) diff --git a/examples/layout-elk-containers-ports-ts/src/index.ts b/examples/layout-elk-containers-ports-ts/src/index.ts index f3d034a8e0..8cc043e850 100644 --- a/examples/layout-elk-containers-ports-ts/src/index.ts +++ b/examples/layout-elk-containers-ports-ts/src/index.ts @@ -3,11 +3,10 @@ import { ElkLayoutOptions, ExportElementCallback, ExportPortCallback, - ExportEdgeCallback, NodeElkLayoutOptions, PortElkLayoutOptions, layout, - ExportEdgeLabelCallback, + ExportLinkLabelCallback, ExportPortLabelCallback } from '@joint/layout-elk'; import ELK from 'elkjs/lib/elk-api.js'; @@ -135,7 +134,7 @@ const init = () => { elkPortLabel.height = height; }; - const exportEdgeLabel: ExportEdgeLabelCallback = ({ label, elkEdgeLabel }) => { + const exportLinkLabel: ExportLinkLabelCallback = ({ label, elkEdgeLabel }) => { const inline = label['inline']; elkEdgeLabel.layoutOptions['elk.edgeLabels.inline'] = inline ? 'true' : 'false'; }; @@ -150,7 +149,7 @@ const init = () => { exportElement, exportPort, exportPortLabel, - exportEdgeLabel, + exportLinkLabel, elkLayoutOptions }).then(() => { paper.unfreeze(); diff --git a/examples/layout-elk-flowchart-ts/src/index.ts b/examples/layout-elk-flowchart-ts/src/index.ts index e8db8c9e1a..483a377779 100644 --- a/examples/layout-elk-flowchart-ts/src/index.ts +++ b/examples/layout-elk-flowchart-ts/src/index.ts @@ -3,7 +3,7 @@ import { ElkLayoutOptions, ExportElementCallback, ExportPortCallback, - ExportEdgeLabelCallback, + ExportLinkLabelCallback, SetPortAttributesCallback, layout } from '@joint/layout-elk'; @@ -195,7 +195,7 @@ const init = () => { // Every branch condition ("Valid"/"Invalid", ...) sits directly on its edge, // rather than floating beside it. - const exportEdgeLabel: ExportEdgeLabelCallback = ({ elkEdgeLabel }) => { + const exportLinkLabel: ExportLinkLabelCallback = ({ elkEdgeLabel }) => { elkEdgeLabel.layoutOptions['elk.edgeLabels.inline'] = 'true'; }; @@ -206,13 +206,13 @@ const init = () => { // the diamond's real slanted edge - only its y moves; x (which side, and // where along it) stays exactly what ELK computed. const setPortAttributes: SetPortAttributesCallback = ({ element, portId, attributes }) => { - if (element instanceof Decision && attributes.position) { + /*if (element instanceof Decision && attributes.position) { const { width, height } = element.size(); const { x, y } = attributes.position.args; const distanceFromCenter = Math.abs(x - width / 2); const edgeY = distanceFromCenter / (width / 2) * (height / 2); attributes.position.args.y = (y < height / 2) ? edgeY : height - edgeY; - } + }*/ element.portProp(portId, attributes); }; @@ -222,7 +222,7 @@ const init = () => { elk, exportElement, exportPort, - exportEdgeLabel, + exportLinkLabel, setPortAttributes, elkLayoutOptions }).then(() => { diff --git a/packages/joint-layout-elk/src/export.mts b/packages/joint-layout-elk/src/export.mts index 2ec6b01f04..69facdfc63 100644 --- a/packages/joint-layout-elk/src/export.mts +++ b/packages/joint-layout-elk/src/export.mts @@ -83,8 +83,8 @@ export interface ElkEdgeDraft { * 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 ExportEdgeCallback = (params: ExportEdgeCallbackParameters) => void | false; -export type ExportEdgeCallbackParameters = { +export type ExportLinkCallback = (params: ExportLinkCallbackParameters) => void | false; +export type ExportLinkCallbackParameters = { link: dia.Link; elkEdge: ElkEdgeDraft; }; @@ -96,8 +96,8 @@ export type ExportPortLabelCallbackParameters = { elkPortLabel: ElkLabelDraft; }; -export type ExportEdgeLabelCallback = (params: ExportEdgeLabelCallbackParameters) => void | false; -export type ExportEdgeLabelCallbackParameters = { +export type ExportLinkLabelCallback = (params: ExportLinkLabelCallbackParameters) => void | false; +export type ExportLinkLabelCallbackParameters = { link: dia.Link; label: dia.Link.Label; elkEdgeLabel: ElkLabelDraft; @@ -119,8 +119,8 @@ export interface ExportGraphOptions { exportElement?: ExportElementCallback; exportPort?: ExportPortCallback; exportPortLabel?: ExportPortLabelCallback; - exportEdge?: ExportEdgeCallback; - exportEdgeLabel?: ExportEdgeLabelCallback; + exportLink?: ExportLinkCallback; + exportLinkLabel?: ExportLinkLabelCallback; } let exportGraphOptions: ExportGraphOptions; @@ -296,8 +296,8 @@ function getLowestCommonAncestorId(sourcePath: string[], targetPath: string[]): } /** - * Builds a link's ELK edge. `exportEdge` (if given) may mutate the edge's draft, or - * return `false` to drop it from the ELK graph (see `ExportEdgeCallback`). + * 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 { const sourceElement = link.getSourceElement(); @@ -328,7 +328,7 @@ function buildEdge(link: dia.Link): void { layoutOptions: {} }; - if (exportGraphOptions.exportEdge?.({ link, elkEdge }) === false) + if (exportGraphOptions.exportLink?.({ link, elkEdge }) === false) return; linksById.set(id, link); @@ -349,7 +349,7 @@ function buildEdge(link: dia.Link): void { layoutOptions: {} }; - if (exportGraphOptions.exportEdgeLabel?.({ link, label, elkEdgeLabel: labelDraft }) === false) + if (exportGraphOptions.exportLinkLabel?.({ link, label, elkEdgeLabel: labelDraft }) === false) return result; result.push({ diff --git a/packages/joint-layout-elk/test/index.js b/packages/joint-layout-elk/test/index.js index 5f637fb866..ab5ac982ee 100644 --- a/packages/joint-layout-elk/test/index.js +++ b/packages/joint-layout-elk/test/index.js @@ -218,7 +218,7 @@ QUnit.module('layout()', () => { assert.deepEqual(seen, [['out1', 'a']]); }); - QUnit.test('should let exportElement/exportPort/exportEdge add to the computed layoutOptions without losing it', async(assert) => { + 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({ @@ -248,7 +248,7 @@ QUnit.module('layout()', () => { exportPort: ({ elkPort }) => { elkPort.layoutOptions['elk.custom'] = 'port'; }, - exportEdge: ({ elkEdge }) => { + exportLink: ({ elkEdge }) => { elkEdge.layoutOptions['elk.custom'] = 'edge'; } }); @@ -411,7 +411,7 @@ QUnit.module('layout()', () => { assert.equal(parentNode.layoutOptions['elk.padding'], '[top=40,left=20,bottom=20,right=20]'); }); - QUnit.test('should let exportEdgeLabel read a link label\'s own custom property into its computed layoutOptions', async(assert) => { + 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 }}); @@ -425,7 +425,7 @@ QUnit.module('layout()', () => { graph.resetCells([el1, el2, link]); const { elkGraph } = await joint.layout.ELK.layout(graph, { - exportEdgeLabel: ({ label, elkEdgeLabel }) => { + exportLinkLabel: ({ label, elkEdgeLabel }) => { Object.assign(elkEdgeLabel.layoutOptions, label.elkLayoutOptions); } }); @@ -433,12 +433,12 @@ QUnit.module('layout()', () => { const [elkEdge] = elkGraph.edges; assert.equal(elkEdge.labels[0].layoutOptions['elk.edgeLabels.inline'], 'true'); - // The label's own raw JSON is unaffected - reading it in `exportEdgeLabel` doesn't + // 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': 'true' }); }); - QUnit.test('should not place a link label inline by default - only `exportEdgeLabel` can opt one in', async(assert) => { + QUnit.test('should not place a link label inline by default - only `exportLinkLabel` can opt one in', 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 }}); @@ -457,7 +457,7 @@ QUnit.module('layout()', () => { assert.notOk('elk.edgeLabels.inline' in elkEdge.labels[0].layoutOptions); }); - QUnit.test('should let exportEdgeLabel read a link\'s `defaultLabel`-inherited custom property for every label', async(assert) => { + 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 }}); @@ -471,14 +471,14 @@ QUnit.module('layout()', () => { }, // Neither label sets its own `elkLayoutOptions` - both fall back to // `defaultLabel`'s, already merged in by `Link#getComputedLabels()` (`@joint/core`) - // by the time `exportEdgeLabel` sees `label` below. + // by the time `exportLinkLabel` sees `label` below. labels: [{}, {}] }); graph.resetCells([el1, el2, link]); const { elkGraph } = await joint.layout.ELK.layout(graph, { - exportEdgeLabel: ({ label, elkEdgeLabel }) => { + exportLinkLabel: ({ label, elkEdgeLabel }) => { Object.assign(elkEdgeLabel.layoutOptions, label.elkLayoutOptions); } }); @@ -564,4 +564,101 @@ QUnit.module('layout()', () => { assert.equal(out2Label.width, 55); assert.equal(out2Label.height, 11); }); + + 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 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 drop only that port when exportPort returns false, falling the edge back to the element', 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 { elkGraph } = await joint.layout.ELK.layout(graph, { + exportPort: () => false + }); + + const elkNode = elkGraph.children.find((node) => node.id === 'a'); + assert.deepEqual(elkNode.ports, []); + + const [elkEdge] = elkGraph.edges; + assert.deepEqual(elkEdge.sources, ['a']); + }); + + 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, []); + }); }); From 95dc3925bb2df5a2e6ba185101d0a9d991db40e6 Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Wed, 30 Sep 2026 11:10:13 +0200 Subject: [PATCH 41/75] cleaning comments and README --- packages/joint-layout-elk/README.md | 182 +++++------------------ packages/joint-layout-elk/src/export.mts | 12 +- packages/joint-layout-elk/src/import.mts | 20 ++- packages/joint-layout-elk/src/index.mts | 2 + packages/joint-layout-elk/src/layout.mts | 2 +- 5 files changed, 60 insertions(+), 158 deletions(-) diff --git a/packages/joint-layout-elk/README.md b/packages/joint-layout-elk/README.md index 3861c81bff..8e0e1c6687 100644 --- a/packages/joint-layout-elk/README.md +++ b/packages/joint-layout-elk/README.md @@ -2,9 +2,9 @@ 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 fully depends on [JointJS](https://github.com/clientio/joint) (*>=4.0*), so please read its `README.md` before using this library. +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 computes a layout and writes the result (positions, link vertices, anchors and label positions) back onto the graph. It does not keep the graph laid out afterwards. +`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 @@ -46,179 +46,67 @@ const { bbox } = await layout(graph, { ### `layout(graph, options?): Promise` -- `graph`: `dia.Graph` - the graph to lay out. Elements embedded in another element are laid out as a container - the parent is resized (and positioned) by ELK to fit its content, at any nesting depth. By default (`positionPorts: 'fixed'`), ports are laid out at the position JointJS itself already computes for them (via the element's port groups) - ELK only uses that position to route edges to/from them; pass `positionPorts: 'fixed-side'` or `'free'` to let ELK reposition them instead (see below). -- `options?`: `Options` - Layout configuration (see below) +- `graph`: `dia.Graph` - the graph to lay out. +- `options?`: `Options` - layout configuration (see below). ```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, debugging) + elkGraph: ElkNode; // The raw ELK layout result (e.g. for junction points) } ``` -### Options Interface +### `Options` ```ts -type GetSizeCallback = (element: dia.Element) => dia.Size; -type SetPositionCallback = (element: dia.Element, position: dia.Point) => void; -type SetVerticesCallback = (link: dia.Link, vertices: dia.Point[]) => void; -type SetAnchorCallback = (link: dia.Link, element: dia.Element, point: dia.Point, endType: 'source' | 'target') => void; -type SetLabelsCallback = (link: dia.Link, labelBBox: dia.BBox, points: dia.Point[], labelIndex: number) => void; -type SetPortPositionCallback = (element: dia.Element, portId: string, position: dia.Point) => void; -// - 'fixed': ports stay exactly where JointJS's own port groups place them (`FIXED_POS`). -// - 'fixed-side': ELK may reposition/reorder ports along their group's assigned side (`FIXED_SIDE`). -// - 'free': ELK may reposition ports anywhere around their element (`FREE`). -type PortPositionsMode = 'fixed' | 'fixed-side' | 'free'; - -// What this package itself would compute for a node/port/edge, and what `nodeOptions`/ -// `portOptions`/`edgeOptions` (see "Adjusting computed node/port/edge properties" below) -// get to inspect and adjust - everything except structural fields (`id`, `sources`/ -// `targets`, `ports`/`children`, which are handled separately). -type NodeLayoutProperties = Pick; -type PortLayoutProperties = Pick; -type EdgeLayoutProperties = Pick; -type NodeOptionsCallback = (element: dia.Element, computed: NodeLayoutProperties) => NodeLayoutProperties | undefined; -type PortOptionsCallback = (port: dia.Element.Port, element: dia.Element, computed: PortLayoutProperties) => PortLayoutProperties | undefined; -type EdgeOptionsCallback = (link: dia.Link, computed: EdgeLayoutProperties) => EdgeLayoutProperties | undefined; - interface Options { // A custom ELK instance, e.g. one configured to run inside a Web Worker. elk?: ELK; // Default: a shared, main-thread instance (`elkjs/lib/elk.bundled.js`) // ELK layout options, passed through to ELK unmodified. - elkLayoutOptions?: ElkLayoutOptions; // Default: { 'elk.algorithm': 'layered' } - // Whether to account for link labels during layout and position them afterwards. - edgeLabels?: boolean; // Default: true - // How freely ELK may reposition (and reorder) ports itself, instead of keeping - // them at their JointJS-computed position - see "Letting ELK position ports" below. - positionPorts?: PortPositionsMode | { mode?: PortPositionsMode; setPortPosition?: SetPortPositionCallback }; // Default: 'fixed' - // Whether to treat elements' current positions as a starting point and change the - // layout as little as possible from there, instead of computing it from scratch - - // see "Incremental/interactive layout" below. - interactive?: boolean; // Default: false - // Element sizing callback - getSize?: GetSizeCallback; // Default: element.size() - // Callbacks for customizing how the layout is applied - setPosition?: SetPositionCallback; // Default: element.position(x, y) - setVertices?: boolean | SetVerticesCallback; // Default: true - setAnchor?: boolean | SetAnchorCallback; // Default: true - setLabels?: boolean | SetLabelsCallback; // Default: true - // Escape hatches into ELK's per-node/port/edge option space - see "Adjusting computed - // node/port/edge properties" below. - nodeOptions?: NodeOptionsCallback; - portOptions?: PortOptionsCallback; - edgeOptions?: EdgeOptionsCallback; + elkLayoutOptions?: ElkLayoutOptions; // Default: { 'elk.algorithm': 'layered', 'elk.hierarchyHandling': 'INCLUDE_CHILDREN' } + // A name for the layout batch, grouping everything `layout()` applies into one graph change. + batchName?: string; // Default: 'layout' + + // 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; } ``` -## 🎯 Examples - -### Running ELK in a Web Worker - -`elkjs` supports Web Workers natively. Pass your own `ELK` instance, configured with a `workerUrl` - the consumer controls bundling, since bundlers need the literal worker URL at the call site: - -```ts -import ELK from 'elkjs/lib/elk-api.js'; -import { layout } from '@joint/layout-elk'; - -const elk = new ELK({ - workerUrl: new URL('elkjs/lib/elk-worker.min.js', import.meta.url).href -}); - -// The instance can be reused across calls. -await layout(graph, { elk }); - -// Call this yourself once the instance is no longer needed. -elk.terminateWorker(); -``` - -With no `elk` option, the package creates a default instance running on the main thread (`elkjs/lib/elk.bundled.js`). The public API is identical in both modes. - -### Adjusting computed node/port/edge properties - -`nodeOptions`/`portOptions`/`edgeOptions` are called with everything this package itself would use for that node/port/edge (its `x`/`y`/`width`/`height` and `layoutOptions`, or `labels` for a port/edge) - not just the element/port/link, so you can build on what's already computed instead of writing it all from scratch. Return `undefined` to leave it as computed; return a value to use that instead (spread the given one and adjust the parts you care about, since **whatever you return replaces the computed value outright** - it isn't merged with it): - -```ts -layout(graph, { - nodeOptions: (element, computed) => ({ - ...computed, - layoutOptions: { ...computed.layoutOptions, 'partitioning.partition': `${element.get('layer')}` } - }), - elkLayoutOptions: { - 'elk.algorithm': 'layered', - 'elk.partitioning.activate': 'true' - } -}); -``` - -Because the callback sees the computed value, it can also *remove* something this package would otherwise set - e.g. every node's `x`/`y` (and the `elk.position` layout option, used by `elk.layered.crossingMinimization.semiInteractive`) reflect the element's current position, which only makes sense once it actually has one. For a freshly created element (flagged here however you like, e.g. a `new` attribute you set yourself) you'd typically want ELK to place it wherever fits, not anchor it at `(0, 0)`: - -```ts -layout(graph, { - nodeOptions: (element, computed) => { - if (!element.get('new')) return undefined; - // Actually omit `x`/`y` (not just set them to `undefined`) - elkjs treats a - // present-but-`undefined` coordinate differently from an absent one. - const { x: _x, y: _y, width, height, layoutOptions: computedLayoutOptions } = computed; - const { 'elk.position': _elkPosition, ...layoutOptions } = computedLayoutOptions ?? {}; - return { width, height, layoutOptions }; - } -}); -``` +### Export callbacks -### Letting ELK position ports - -By default (`positionPorts: 'fixed'`), ports stay exactly where JointJS's own port groups already place them - ELK only uses that position to route edges. Pass `positionPorts: 'fixed-side'` to let ELK reposition (and reorder) ports along the side their group already assigns them to, e.g. to minimize edge crossings, or `'free'` to let it place them on any side: +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. ```ts -layout(graph, { positionPorts: 'fixed-side' }); +type ExportElementCallback = (params: { element: dia.Element; elkNode: ElkNodeDraft }) => void | false; +type ExportPortCallback = (params: { port: dia.Element.Port; element: dia.Element; elkPort: ElkPortDraft }) => void | false; +type ExportPortLabelCallback = (params: { port: dia.Element.Port; element: dia.Element; elkPortLabel: ElkLabelDraft }) => void | false; +type ExportLinkCallback = (params: { link: dia.Link; elkEdge: ElkEdgeDraft }) => void | false; +type ExportLinkLabelCallback = (params: { link: dia.Link; label: dia.Link.Label; elkEdgeLabel: ElkLabelDraft }) => void | false; ``` -This only takes visible effect for a port whose group renders it at a plain `x`/`y` (the `'absolute'` position) - `layout()` switches every port-bearing group to that position for you (its `attrs`/`markup`/`label` are left untouched), so this works regardless of how the group was originally configured (`'left'`, `'right'`, a custom callback, ...). To customize how a computed position is applied instead of the default `element.portProp(portId, ['position', 'args'], position)`, pass an object (`mode` defaults to `'fixed'` here too, so it still needs to be given explicitly): +### Import callbacks -```ts -layout(graph, { - positionPorts: { - mode: 'fixed-side', - setPortPosition: (element, portId, position) => element.portProp(portId, ['position', 'args'], position) - } -}); -``` - -### Incremental/interactive layout - -By default, every `layout()` call computes the whole graph's layout from scratch. Pass `interactive: true` to instead let ELK treat elements' current positions - already reflected on the graph, e.g. from an earlier `layout()` call - as a starting point, and change the layout as little as possible from there: - -```ts -// Initial layout. -await layout(graph); - -// ... later, after adding one new element/link to the already laid out graph: -await layout(graph, { interactive: true }); -``` - -This is useful for laying out a graph incrementally - e.g. adding one element to an already laid out graph and re-running `layout({ interactive: true })` only positions that new element, instead of reshuffling the whole diagram. It's an approximation, not a guarantee, of the previous layout though - e.g. a layer's own position can still shift to fit its (possibly changed) content - so already laid out elements may still move slightly. - -### Animated transitions +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 -import { util } from '@joint/core'; - -layout(graph, { - setPosition: (element, position) => { - element.transition('position', position, { - duration: 500, - timingFunction: util.timing.cubic, - valueFunction: util.interpolate.object - }); - } -}); +type SetElementAttributesCallback = (params: { element: dia.Element; attributes: { position: dia.Point; size?: dia.Size }; elkNode: ElkNode }) => void; +type SetPortAttributesCallback = (params: { element: dia.Element; portId: string; attributes: { position: { args: dia.Point }; label?: { position: { args: dia.Point } } }; elkPort: ElkPort }) => 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; ``` ## ⚠️ Caveats & Known Limitations -- **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 (behind `edgeLabels`). -- **Ports keep their JointJS-computed position by default** (`positionPorts: 'fixed'`) - `layout()` only tells ELK where they already are (`elk.portConstraints: FIXED_POS`), so edges route to/from the exact spot the element's port groups place them at. Opt into ELK repositioning them with `positionPorts: 'fixed-side'`/`'free'` (see above). -- **`interactive` approximates the previous layout, it doesn't freeze it** - already laid out elements are not guaranteed to keep their exact position (see "Incremental/interactive layout" above). +- **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. +- **Ports keep their JointJS-computed position by default** - `layout()` only tells ELK where they already are, so edges route to/from the exact spot the element's port groups place them at. Opt into ELK repositioning/reordering them by setting `elk.portConstraints` (e.g. `'FIXED_SIDE'`/`'FREE'`) via `exportElement`/`exportPort`. - **Asynchronous** - unlike `@joint/layout-directed-graph`, `layout()` returns a `Promise`, since `elkjs` computes layouts asynchronously (and, optionally, inside a Web Worker). - **ID handling** - ELK requires string ids; element and link ids are converted with `` `${id}` `` internally, but never written back to the graph. diff --git a/packages/joint-layout-elk/src/export.mts b/packages/joint-layout-elk/src/export.mts index 69facdfc63..4c474a6ab7 100644 --- a/packages/joint-layout-elk/src/export.mts +++ b/packages/joint-layout-elk/src/export.mts @@ -26,6 +26,7 @@ export interface ElkLabelDraft { layoutOptions: LabelElkLayoutOptions; } +/** An ELK node draft, one per JointJS element. */ export interface ElkNodeDraft { readonly id: string; /** Relative to the parent node. */ @@ -53,6 +54,7 @@ export type ExportElementCallbackParameters = { elkNode: ElkNodeDraft; }; +/** An ELK port draft, one per JointJS port. */ export interface ElkPortDraft { readonly id: string; x: number; @@ -74,6 +76,7 @@ export type ExportPortCallbackParameters = { elkPort: ElkPortDraft; }; +/** An ELK edge draft, one per JointJS link. */ export interface ElkEdgeDraft { readonly id: string; layoutOptions: EdgeElkLayoutOptions; @@ -251,7 +254,6 @@ function buildElkNode(element: dia.Element): ElkNode | null { layoutOptions: {} }; - // If exportElement() returns false omit the element if (exportGraphOptions.exportElement?.({ element, elkNode }) === false) return null; @@ -285,6 +287,8 @@ function getAncestorPath(element: dia.Element): string[] { return element.getAncestors().reverse().map((cell) => `${cell.id}`); } +// 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); @@ -334,10 +338,8 @@ function buildEdge(link: dia.Link): void { linksById.set(id, link); // Resolved (`link.getComputedLabels()`) - `size` falls back through `defaultLabel`/the - // built-in default the same way `@joint/core` itself resolves it for rendering, and a - // custom `elkLayoutOptionsProperty` property passes through too (whether set on the - // label itself or on `defaultLabel` - see `Link#getComputedLabels`), so it can be read - // directly here instead of from the label's raw JSON. + // built-in default the same way `@joint/core` itself resolves it for rendering, so it + // can be read directly here instead of from the label's raw JSON. const resolvedLabels = link.getComputedLabels(); let labels: ElkLabel[] = []; if (resolvedLabels.length > 0) { diff --git a/packages/joint-layout-elk/src/import.mts b/packages/joint-layout-elk/src/import.mts index cfd478c909..f3ac93e6f2 100644 --- a/packages/joint-layout-elk/src/import.mts +++ b/packages/joint-layout-elk/src/import.mts @@ -3,6 +3,7 @@ import type { ElkPoint } from 'elkjs'; import type { ElkNode, ElkExtendedEdge, ElkPort } from './types/index.mjs'; import type { ElkGraphPort } from './export.mjs'; +/** Applies the ELK-computed position (and, for a container, size) to `element`. */ export type SetElementAttributesCallback = (params: SetElementAttributesCallbackParameters) => void; export type SetElementAttributesCallbackParameters = { element: dia.Element; @@ -15,6 +16,7 @@ export type SetElementAttributesCallbackParameters = { elkNode: ElkNode }; +/** Applies the ELK-computed position (and, for a labeled port, label position) to a port. */ export type SetPortAttributesCallback = (params: SetPortAttributesCallbackParameters) => void; export type SetPortAttributesCallbackParameters = { element: dia.Element; @@ -23,14 +25,14 @@ export type SetPortAttributesCallbackParameters = { // `position.args`/`label.position.args` (a group's `position`/`label.position` // decides which layout function reads them - handled separately, see `importNode`). attributes: { - // Present only when `portsPosition` is not 'fixed' - nothing to apply otherwise. - position?: { args: dia.Point }; - // Present only when `positionPortLabels` is enabled and the port has a label. + position: { args: dia.Point }; + // Present only when the port has a label. label?: { position: { args: dia.Point } }; }; elkPort: ElkPort }; +/** Applies the ELK-computed vertices, end anchors and label positions to a link. */ export type SetLinkAttributesCallback = (params: SetLinkAttributesCallbackParameters) => void; export type SetLinkAttributesCallbackParameters = { link: dia.Link; @@ -42,8 +44,8 @@ export type SetLinkAttributesCallbackParameters = { // replaces `source`/`target` outright rather than merging into them. source?: dia.Link.EndJSON; target?: dia.Link.EndJSON; - // Present only when `edgeLabels` is enabled and the link has labels - the whole - // current `labels` array, with each routed label's `position` replaced. + // 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 @@ -114,6 +116,10 @@ function toAbsolute(point: ElkPoint, containerPosition: dia.Point): dia.Point { }; } +/** + * Applies a container's (or the root's) own ELK edges back onto their JointJS links, + * via `setLinkAttributes` (or the default). + */ function importEdges(edges: ElkExtendedEdge[] | undefined, containerPosition: dia.Point = { x: 0, y: 0 }): void { const setLinkAttributes = importLayoutOptions.setLinkAttributes ?? defaultSetLinkAttributes; @@ -181,6 +187,10 @@ function importEdges(edges: ElkExtendedEdge[] | undefined, containerPosition: di }); } +/** + * 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); diff --git a/packages/joint-layout-elk/src/index.mts b/packages/joint-layout-elk/src/index.mts index 72d07213c8..1653d5b3d4 100644 --- a/packages/joint-layout-elk/src/index.mts +++ b/packages/joint-layout-elk/src/index.mts @@ -1,3 +1,5 @@ +// 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'; diff --git a/packages/joint-layout-elk/src/layout.mts b/packages/joint-layout-elk/src/layout.mts index 1ca1572422..3454251e57 100644 --- a/packages/joint-layout-elk/src/layout.mts +++ b/packages/joint-layout-elk/src/layout.mts @@ -88,7 +88,7 @@ export async function layout(graph: dia.Graph, opt?: Options): Promise Date: Wed, 30 Sep 2026 12:30:08 +0200 Subject: [PATCH 42/75] renaming options --- packages/joint-layout-elk/README.md | 6 +++--- packages/joint-layout-elk/src/layout.mts | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/joint-layout-elk/README.md b/packages/joint-layout-elk/README.md index 8e0e1c6687..b0a49c8207 100644 --- a/packages/joint-layout-elk/README.md +++ b/packages/joint-layout-elk/README.md @@ -47,7 +47,7 @@ const { bbox } = await layout(graph, { ### `layout(graph, options?): Promise` - `graph`: `dia.Graph` - the graph to lay out. -- `options?`: `Options` - layout configuration (see below). +- `options?`: `LayoutOptions` - layout configuration (see below). ```ts interface LayoutResult { @@ -56,10 +56,10 @@ interface LayoutResult { } ``` -### `Options` +### `LayoutOptions` ```ts -interface Options { +interface LayoutOptions { // A custom ELK instance, e.g. one configured to run inside a Web Worker. elk?: ELK; // Default: a shared, main-thread instance (`elkjs/lib/elk.bundled.js`) // ELK layout options, passed through to ELK unmodified. diff --git a/packages/joint-layout-elk/src/layout.mts b/packages/joint-layout-elk/src/layout.mts index 3454251e57..9b0a71beef 100644 --- a/packages/joint-layout-elk/src/layout.mts +++ b/packages/joint-layout-elk/src/layout.mts @@ -22,7 +22,7 @@ const DEFAULT_LAYOUT_OPTIONS: ElkLayoutOptions = { 'elk.layered.considerModelOrder.portModelOrder': 'true' }; -const DEFAULT_OPTIONS: Options = { +const DEFAULT_OPTIONS: LayoutOptions = { batchName: LAYOUT_BATCH_NAME, }; @@ -31,7 +31,7 @@ let defaultElk: ELK | undefined; /** * Layout configuration options. */ -export interface Options extends ImportLayoutOptions, ExportGraphOptions { +export interface LayoutOptions extends ImportLayoutOptions, ExportGraphOptions { /** * A custom ELK instance, e.g. one configured to run inside a Web Worker. @@ -79,9 +79,9 @@ function getBBox(elkGraph: ElkNode): g.Rect { return g.Rect.fromRectUnion(...rects) || new g.Rect(0, 0, 0, 0); } -export async function layout(graph: dia.Graph, opt?: Options): Promise { +export async function layout(graph: dia.Graph, opt?: LayoutOptions): Promise { - const options = util.defaults({}, opt || {}, DEFAULT_OPTIONS) as Options; + const options = util.defaults({}, opt || {}, DEFAULT_OPTIONS) as LayoutOptions; const elkLayoutOptions = util.defaults( {}, opt?.elkLayoutOptions || {}, From f5c8063b604c41cc3fd4085aa150b0000ddbb5a9 Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Wed, 30 Sep 2026 13:13:53 +0200 Subject: [PATCH 43/75] default behaviour example --- examples/layout-elk-default-ts/.gitignore | 3 + examples/layout-elk-default-ts/README.md | 24 +++ examples/layout-elk-default-ts/index.html | 25 +++ examples/layout-elk-default-ts/package.json | 40 +++++ examples/layout-elk-default-ts/src/example.ts | 125 +++++++++++++++ examples/layout-elk-default-ts/src/index.ts | 119 ++++++++++++++ examples/layout-elk-default-ts/src/shapes.ts | 135 ++++++++++++++++ .../layout-elk-default-ts/src/styles.scss | 149 ++++++++++++++++++ examples/layout-elk-default-ts/tsconfig.json | 18 +++ .../layout-elk-default-ts/webpack.config.js | 39 +++++ 10 files changed, 677 insertions(+) create mode 100644 examples/layout-elk-default-ts/.gitignore create mode 100644 examples/layout-elk-default-ts/README.md create mode 100644 examples/layout-elk-default-ts/index.html create mode 100644 examples/layout-elk-default-ts/package.json create mode 100644 examples/layout-elk-default-ts/src/example.ts create mode 100644 examples/layout-elk-default-ts/src/index.ts create mode 100644 examples/layout-elk-default-ts/src/shapes.ts create mode 100644 examples/layout-elk-default-ts/src/styles.scss create mode 100644 examples/layout-elk-default-ts/tsconfig.json create mode 100644 examples/layout-elk-default-ts/webpack.config.js diff --git a/examples/layout-elk-default-ts/.gitignore b/examples/layout-elk-default-ts/.gitignore new file mode 100644 index 0000000000..69c575d17f --- /dev/null +++ b/examples/layout-elk-default-ts/.gitignore @@ -0,0 +1,3 @@ +build/ +dist/ +node_modules/ diff --git a/examples/layout-elk-default-ts/README.md b/examples/layout-elk-default-ts/README.md new file mode 100644 index 0000000000..54eeac4124 --- /dev/null +++ b/examples/layout-elk-default-ts/README.md @@ -0,0 +1,24 @@ +# 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 start +``` + +## 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-default-ts/index.html b/examples/layout-elk-default-ts/index.html new file mode 100644 index 0000000000..2f21945983 --- /dev/null +++ b/examples/layout-elk-default-ts/index.html @@ -0,0 +1,25 @@ + + + + + + + + ELK Default Usage | JointJS + + + + + + +
+ Zoom Out + Zoom In +
+
+ + + + + diff --git a/examples/layout-elk-default-ts/package.json b/examples/layout-elk-default-ts/package.json new file mode 100644 index 0000000000..1f1ccb9f1b --- /dev/null +++ b/examples/layout-elk-default-ts/package.json @@ -0,0 +1,40 @@ +{ + "name": "@joint/demo-layout-elk-default-ts", + "version": "4.3.1", + "description": "JointJS - ELK Layout Default Usage Demo", + "main": "dist/bundle.js", + "homepage": "https://jointjs.com", + "author": { + "name": "client IO", + "url": "https://client.io" + }, + "license": "MPL-2.0", + "private": true, + "installConfig": { + "hoistingLimits": "workspaces" + }, + "scripts": { + "start": "webpack-dev-server", + "build": "webpack" + }, + "dependencies": { + "@joint/core": "workspace:^", + "@joint/layout-elk": "workspace:^", + "elkjs": "0.12.0" + }, + "devDependencies": { + "css-loader": "3.5.3", + "sass-loader": "8.0.2", + "style-loader": "1.2.1", + "ts-loader": "^9.2.5", + "typescript": "5.8.2", + "webpack": "5.98.0", + "webpack-cli": "6.0.1", + "webpack-dev-server": "5.2.0" + }, + "volta": { + "node": "22.14.0", + "npm": "11.2.0", + "yarn": "4.18.0" + } +} diff --git a/examples/layout-elk-default-ts/src/example.ts b/examples/layout-elk-default-ts/src/example.ts new file mode 100644 index 0000000000..18c0b74200 --- /dev/null +++ b/examples/layout-elk-default-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-default-ts/src/index.ts b/examples/layout-elk-default-ts/src/index.ts new file mode 100644 index 0000000000..2c7d438545 --- /dev/null +++ b/examples/layout-elk-default-ts/src/index.ts @@ -0,0 +1,119 @@ +import { dia, shapes, setTheme } from '@joint/core'; +import { layout } from '@joint/layout-elk'; +import ELK from 'elkjs/lib/elk-api.js'; +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); + + // Run ELK in a Web Worker, via the `@joint/layout-elk` package. + const elk = new ELK({ + workerUrl: '../node_modules/elkjs/lib/elk-worker.js' + }); + + // 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. + layout(graph, { + elk, + elkLayoutOptions: { + 'elk.algorithm': 'layered', + 'elk.direction': 'RIGHT', + 'elk.edgeRouting': 'ORTHOGONAL', + 'elk.spacing.nodeNode': '30', + 'elk.layered.spacing.nodeNodeBetweenLayers': '50', + 'elk.spacing.edgeLabel': '8' + }, + }).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-default-ts/src/shapes.ts b/examples/layout-elk-default-ts/src/shapes.ts new file mode 100644 index 0000000000..323a4f7abd --- /dev/null +++ b/examples/layout-elk-default-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-default-ts/src/styles.scss b/examples/layout-elk-default-ts/src/styles.scss new file mode 100644 index 0000000000..8df932ad06 --- /dev/null +++ b/examples/layout-elk-default-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-default-ts/tsconfig.json b/examples/layout-elk-default-ts/tsconfig.json new file mode 100644 index 0000000000..04a61c7d89 --- /dev/null +++ b/examples/layout-elk-default-ts/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "module": "ES6", + "moduleResolution": "bundler", + "target": "es6", + "lib": [ + "es2022", + "dom" + ], + "noImplicitAny": false, + "sourceMap": false, + "rootDir": "./src", + "outDir": "./build", + "noUncheckedSideEffectImports": false, + "resolveJsonModule": true, + "esModuleInterop": true + } +} diff --git a/examples/layout-elk-default-ts/webpack.config.js b/examples/layout-elk-default-ts/webpack.config.js new file mode 100644 index 0000000000..7b10ca335d --- /dev/null +++ b/examples/layout-elk-default-ts/webpack.config.js @@ -0,0 +1,39 @@ +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: /\.m?js/, + resolve: { + fullySpecified: false, + }, + }, + { test: /\.ts$/, loader: 'ts-loader' }, + { + test: /\.s[ac]ss$/i, + use: [ + 'style-loader', + 'css-loader', + 'sass-loader', + ], + }, + ], + }, + devServer: { + static: { + directory: __dirname, + }, + compress: true, + }, +}; From c785c6f82cf5592835853afaef0dfda53c773fd9 Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Thu, 1 Oct 2026 13:48:47 +0200 Subject: [PATCH 44/75] update --- .../src/index.ts | 11 ++++---- examples/layout-elk-default-ts/src/index.ts | 2 +- examples/layout-elk-flowchart-ts/src/index.ts | 8 +++--- packages/joint-layout-elk/README.md | 6 ++--- packages/joint-layout-elk/src/export.mts | 14 +++++------ packages/joint-layout-elk/test/index.js | 25 ++++++++++--------- 6 files changed, 33 insertions(+), 33 deletions(-) diff --git a/examples/layout-elk-containers-ports-ts/src/index.ts b/examples/layout-elk-containers-ports-ts/src/index.ts index 8cc043e850..705b486e11 100644 --- a/examples/layout-elk-containers-ports-ts/src/index.ts +++ b/examples/layout-elk-containers-ports-ts/src/index.ts @@ -116,8 +116,8 @@ const init = () => { } }; - const exportPort: ExportPortCallback = ({ port, elkPort }) => { - switch (port.group) { + const exportPort: ExportPortCallback = ({ portId, element, elkPort }) => { + switch (element.getPort(portId).group) { case 'in': elkPort.layoutOptions['elk.port.side'] = 'WEST'; break; @@ -127,15 +127,14 @@ const init = () => { } }; - const exportPortLabel: ExportPortLabelCallback = ({ port, element, elkPortLabel }) => { - const portId = `${port.id}`; + const exportPortLabel: ExportPortLabelCallback = ({ portId, element, elkPortLabel }) => { const { width, height} = element.portProp(portId, 'label/size'); elkPortLabel.width = width; elkPortLabel.height = height; }; - const exportLinkLabel: ExportLinkLabelCallback = ({ label, elkEdgeLabel }) => { - const inline = label['inline']; + const exportLinkLabel: ExportLinkLabelCallback = ({ link, labelIndex, elkEdgeLabel }) => { + const inline = link.label(labelIndex)['inline']; elkEdgeLabel.layoutOptions['elk.edgeLabels.inline'] = inline ? 'true' : 'false'; }; diff --git a/examples/layout-elk-default-ts/src/index.ts b/examples/layout-elk-default-ts/src/index.ts index 2c7d438545..259cb58a96 100644 --- a/examples/layout-elk-default-ts/src/index.ts +++ b/examples/layout-elk-default-ts/src/index.ts @@ -62,7 +62,7 @@ const init = () => { 'elk.edgeRouting': 'ORTHOGONAL', 'elk.spacing.nodeNode': '30', 'elk.layered.spacing.nodeNodeBetweenLayers': '50', - 'elk.spacing.edgeLabel': '8' + 'elk.spacing.edgeLabel': '2' }, }).then(() => { paper.unfreeze(); diff --git a/examples/layout-elk-flowchart-ts/src/index.ts b/examples/layout-elk-flowchart-ts/src/index.ts index 483a377779..81579d4caa 100644 --- a/examples/layout-elk-flowchart-ts/src/index.ts +++ b/examples/layout-elk-flowchart-ts/src/index.ts @@ -189,8 +189,8 @@ const init = () => { // ports specifically, or a newly-added one (last in `getGroupPorts()`, // meant to land on the *right*, matching a new 'in' port) would instead // end up leftmost. - const exportPort: ExportPortCallback = ({ port, elkPort }) => { - elkPort.layoutOptions['elk.port.side'] = (port.group === 'in') ? 'NORTH' : 'SOUTH'; + const exportPort: ExportPortCallback = ({ portId, element, elkPort }) => { + elkPort.layoutOptions['elk.port.side'] = (element.getPort(portId).group === 'in') ? 'NORTH' : 'SOUTH'; }; // Every branch condition ("Valid"/"Invalid", ...) sits directly on its edge, @@ -206,13 +206,13 @@ const init = () => { // the diamond's real slanted edge - only its y moves; x (which side, and // where along it) stays exactly what ELK computed. const setPortAttributes: SetPortAttributesCallback = ({ element, portId, attributes }) => { - /*if (element instanceof Decision && attributes.position) { + if (element instanceof Decision && attributes.position) { const { width, height } = element.size(); const { x, y } = attributes.position.args; const distanceFromCenter = Math.abs(x - width / 2); const edgeY = distanceFromCenter / (width / 2) * (height / 2); attributes.position.args.y = (y < height / 2) ? edgeY : height - edgeY; - }*/ + } element.portProp(portId, attributes); }; diff --git a/packages/joint-layout-elk/README.md b/packages/joint-layout-elk/README.md index b0a49c8207..08364fcdbe 100644 --- a/packages/joint-layout-elk/README.md +++ b/packages/joint-layout-elk/README.md @@ -87,10 +87,10 @@ Each receives an ELK draft already populated with what this package computed for ```ts type ExportElementCallback = (params: { element: dia.Element; elkNode: ElkNodeDraft }) => void | false; -type ExportPortCallback = (params: { port: dia.Element.Port; element: dia.Element; elkPort: ElkPortDraft }) => void | false; -type ExportPortLabelCallback = (params: { port: dia.Element.Port; element: dia.Element; elkPortLabel: ElkLabelDraft }) => 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; label: dia.Link.Label; elkEdgeLabel: ElkLabelDraft }) => void | false; +type ExportLinkLabelCallback = (params: { link: dia.Link; labelIndex: number; elkEdgeLabel: ElkLabelDraft }) => void | false; ``` ### Import callbacks diff --git a/packages/joint-layout-elk/src/export.mts b/packages/joint-layout-elk/src/export.mts index 4c474a6ab7..7c753ea7c3 100644 --- a/packages/joint-layout-elk/src/export.mts +++ b/packages/joint-layout-elk/src/export.mts @@ -71,7 +71,7 @@ export interface ElkPortDraft { */ export type ExportPortCallback = (params: ExportPortCallbackParameters) => void | false; export type ExportPortCallbackParameters = { - port: dia.Element.Port; + portId: string; element: dia.Element; elkPort: ElkPortDraft; }; @@ -94,7 +94,7 @@ export type ExportLinkCallbackParameters = { export type ExportPortLabelCallback = (params: ExportPortLabelCallbackParameters) => void | false; export type ExportPortLabelCallbackParameters = { - port: dia.Element.Port; + portId: string; element: dia.Element; elkPortLabel: ElkLabelDraft; }; @@ -102,7 +102,7 @@ export type ExportPortLabelCallbackParameters = { export type ExportLinkLabelCallback = (params: ExportLinkLabelCallbackParameters) => void | false; export type ExportLinkLabelCallbackParameters = { link: dia.Link; - label: dia.Link.Label; + labelIndex: number; elkEdgeLabel: ElkLabelDraft; }; @@ -196,7 +196,7 @@ function buildPorts(element: dia.Element): ElkPort[] | undefined { } }; - if (exportGraphOptions.exportPort?.({ port, element, elkPort }) === false) { + if (exportGraphOptions.exportPort?.({ portId, element, elkPort }) === false) { getExcludedPortIds(element).add(portId); return; } @@ -207,7 +207,7 @@ function buildPorts(element: dia.Element): ElkPort[] | undefined { layoutOptions: {} }; - exportGraphOptions.exportPortLabel?.({ port, element, elkPortLabel: portLabel }); + exportGraphOptions.exportPortLabel?.({ portId, element, elkPortLabel: portLabel }); let labels: ElkLabel[] = []; if (portLabel.width && portLabel.height) { @@ -343,7 +343,7 @@ function buildEdge(link: dia.Link): void { const resolvedLabels = link.getComputedLabels(); let labels: ElkLabel[] = []; if (resolvedLabels.length > 0) { - labels = resolvedLabels.reduce((result: ElkLabel[], label) => { + labels = resolvedLabels.reduce((result: ElkLabel[], label, labelIndex) => { const { width, height } = label.size!; const labelDraft: ElkLabelDraft = { width, @@ -351,7 +351,7 @@ function buildEdge(link: dia.Link): void { layoutOptions: {} }; - if (exportGraphOptions.exportLinkLabel?.({ link, label, elkEdgeLabel: labelDraft }) === false) + if (exportGraphOptions.exportLinkLabel?.({ link, labelIndex, elkEdgeLabel: labelDraft }) === false) return result; result.push({ diff --git a/packages/joint-layout-elk/test/index.js b/packages/joint-layout-elk/test/index.js index ab5ac982ee..46750d5247 100644 --- a/packages/joint-layout-elk/test/index.js +++ b/packages/joint-layout-elk/test/index.js @@ -210,8 +210,8 @@ QUnit.module('layout()', () => { const seen = []; await joint.layout.ELK.layout(graph, { - exportPort: ({ port, element }) => { - seen.push([port.id, element.id]); + exportPort: ({ portId, element }) => { + seen.push([portId, element.id]); } }); @@ -425,8 +425,8 @@ QUnit.module('layout()', () => { graph.resetCells([el1, el2, link]); const { elkGraph } = await joint.layout.ELK.layout(graph, { - exportLinkLabel: ({ label, elkEdgeLabel }) => { - Object.assign(elkEdgeLabel.layoutOptions, label.elkLayoutOptions); + exportLinkLabel: ({ link, labelIndex, elkEdgeLabel }) => { + Object.assign(elkEdgeLabel.layoutOptions, link.label(labelIndex).elkLayoutOptions); } }); @@ -470,16 +470,16 @@ QUnit.module('layout()', () => { elkLayoutOptions: { 'elk.edgeLabels.inline': 'true' } }, // Neither label sets its own `elkLayoutOptions` - both fall back to - // `defaultLabel`'s, already merged in by `Link#getComputedLabels()` (`@joint/core`) - // by the time `exportLinkLabel` sees `label` below. + // `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: ({ label, elkEdgeLabel }) => { - Object.assign(elkEdgeLabel.layoutOptions, label.elkLayoutOptions); + exportLinkLabel: ({ link, labelIndex, elkEdgeLabel }) => { + Object.assign(elkEdgeLabel.layoutOptions, link.getComputedLabels()[labelIndex].elkLayoutOptions); } }); @@ -508,8 +508,9 @@ QUnit.module('layout()', () => { exportElement: ({ elkNode }) => { elkNode.layoutOptions['elk.portConstraints'] = 'FIXED_SIDE'; }, - exportPort: ({ element, port, elkPort }) => { - const groupOptions = element.prop(['ports', 'groups', port.group, 'elkLayoutOptions']); + exportPort: ({ element, portId, elkPort }) => { + const { group } = element.getPort(portId); + const groupOptions = element.prop(['ports', 'groups', group, 'elkLayoutOptions']); Object.assign(elkPort.layoutOptions, groupOptions); } }); @@ -546,8 +547,8 @@ QUnit.module('layout()', () => { // `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, port, elkPortLabel }) => { - const { width, height } = element.getPortMetrics(port.id).labelSize; + exportPortLabel: ({ element, portId, elkPortLabel }) => { + const { width, height } = element.getPortMetrics(portId).labelSize; elkPortLabel.width = width; elkPortLabel.height = height; } From 94e60ae4c7503c7607d41dad170a6ba9813c97f4 Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Thu, 1 Oct 2026 14:21:51 +0200 Subject: [PATCH 45/75] up --- examples/layout-elk-rectpacking-ts/.gitignore | 3 + examples/layout-elk-rectpacking-ts/README.md | 31 +++ examples/layout-elk-rectpacking-ts/index.html | 52 ++++ .../layout-elk-rectpacking-ts/package.json | 40 +++ .../layout-elk-rectpacking-ts/src/example.ts | 122 +++++++++ .../layout-elk-rectpacking-ts/src/index.ts | 233 ++++++++++++++++++ .../layout-elk-rectpacking-ts/src/shapes.ts | 54 ++++ .../layout-elk-rectpacking-ts/src/styles.scss | 148 +++++++++++ .../layout-elk-rectpacking-ts/tsconfig.json | 18 ++ .../webpack.config.js | 39 +++ 10 files changed, 740 insertions(+) create mode 100644 examples/layout-elk-rectpacking-ts/.gitignore create mode 100644 examples/layout-elk-rectpacking-ts/README.md create mode 100644 examples/layout-elk-rectpacking-ts/index.html create mode 100644 examples/layout-elk-rectpacking-ts/package.json create mode 100644 examples/layout-elk-rectpacking-ts/src/example.ts create mode 100644 examples/layout-elk-rectpacking-ts/src/index.ts create mode 100644 examples/layout-elk-rectpacking-ts/src/shapes.ts create mode 100644 examples/layout-elk-rectpacking-ts/src/styles.scss create mode 100644 examples/layout-elk-rectpacking-ts/tsconfig.json create mode 100644 examples/layout-elk-rectpacking-ts/webpack.config.js diff --git a/examples/layout-elk-rectpacking-ts/.gitignore b/examples/layout-elk-rectpacking-ts/.gitignore new file mode 100644 index 0000000000..69c575d17f --- /dev/null +++ b/examples/layout-elk-rectpacking-ts/.gitignore @@ -0,0 +1,3 @@ +build/ +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..9408b7bdf0 --- /dev/null +++ b/examples/layout-elk-rectpacking-ts/README.md @@ -0,0 +1,31 @@ +# 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 ELK-computed sizes to containers only, so the example's own `setElementAttributes` callback applies them to the tiles too. Its `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 start +``` + +## 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..d1d0044062 --- /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..8e73c68a30 --- /dev/null +++ b/examples/layout-elk-rectpacking-ts/package.json @@ -0,0 +1,40 @@ +{ + "name": "@joint/demo-layout-elk-rectpacking-ts", + "version": "4.3.1", + "description": "JointJS - ELK Rectangle Packing Layout Demo", + "main": "dist/bundle.js", + "homepage": "https://jointjs.com", + "author": { + "name": "client IO", + "url": "https://client.io" + }, + "license": "MPL-2.0", + "private": true, + "installConfig": { + "hoistingLimits": "workspaces" + }, + "scripts": { + "start": "webpack-dev-server", + "build": "webpack" + }, + "dependencies": { + "@joint/core": "workspace:^", + "@joint/layout-elk": "workspace:^", + "elkjs": "0.12.0" + }, + "devDependencies": { + "css-loader": "3.5.3", + "sass-loader": "8.0.2", + "style-loader": "1.2.1", + "ts-loader": "^9.2.5", + "typescript": "5.8.2", + "webpack": "5.98.0", + "webpack-cli": "6.0.1", + "webpack-dev-server": "5.2.0" + }, + "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..7296d8d5e3 --- /dev/null +++ b/examples/layout-elk-rectpacking-ts/src/index.ts @@ -0,0 +1,233 @@ +import { dia, g, shapes, setTheme, util } from '@joint/core'; +import { layout } from '@joint/layout-elk'; +import ELK from 'elkjs/lib/elk-api.js'; +import { graphJSON, createFileJSON, type FileKind } from './example'; +import { Folder, FileTile, FOLDER_PADDING } from './shapes'; +import './styles.scss'; + +import type { + ElkLayoutOptions, + ExportElementCallback, + SetElementAttributesCallback +} from '@joint/layout-elk'; + +const cellNamespace = { + ...shapes, + example: { + Folder, + FileTile + } +}; + +const TRANSITION_DURATION = 400; + +const FILE_EXTENSIONS: Record = { + photo: 'jpg', + video: 'mp4', + document: 'pdf', + music: 'mp3', + archive: 'zip' +}; + +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 elk = new ELK({ + workerUrl: '../node_modules/elkjs/lib/elk-worker.js' + }); + + 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; + }; + + // `@joint/layout-elk` applies the ELK-computed size to containers only - a leaf + // keeps its own size by default. Here ELK resizes tiles too (white space + // elimination), so each tile takes `elkNode`'s size as well. Both are animated. + const setElementAttributes: SetElementAttributesCallback = ({ element, attributes, elkNode }) => { + const size = attributes.size ?? { + width: elkNode.width ?? element.size().width, + height: elkNode.height ?? element.size().height + }; + transition(element, 'position', attributes.position); + transition(element, 'size', size); + }; + + let contentArea = new g.Rect(0, 0, 0, 0); + let zoomLevel = 1; + + const fit = () => { + paper.scale(zoomLevel); + paper.fitToContent({ + // Fit to the layout result itself - the elements are still mid-transition. + 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; + try { + do { + pending = false; + const { bbox } = await layout(graph, { + elk, + elkLayoutOptions: getRootOptions(), + exportElement, + setElementAttributes + }); + contentArea = bbox; + paper.unfreeze(); + fit(); + } while (pending); + } catch (error) { + paper.unfreeze(); + console.error('ELK layout error:', (error as Error).message); + } finally { + 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({ + // A plain object - not the `g.Point` the getter returns, which the + // transition below would otherwise mutate in place, without a change event. + 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 transition(element: dia.Element, path: 'position' | 'size', value: dia.Point | dia.Size): void { + element.stopTransitions(path); + element.transition(path, value, { + duration: TRANSITION_DURATION, + timingFunction: util.timing.cubic, + valueFunction: util.interpolate.object + }); +} + +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..710fb2174f --- /dev/null +++ b/examples/layout-elk-rectpacking-ts/src/styles.scss @@ -0,0 +1,148 @@ +// 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. +: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); + } +} + +// 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..04a61c7d89 --- /dev/null +++ b/examples/layout-elk-rectpacking-ts/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "module": "ES6", + "moduleResolution": "bundler", + "target": "es6", + "lib": [ + "es2022", + "dom" + ], + "noImplicitAny": false, + "sourceMap": false, + "rootDir": "./src", + "outDir": "./build", + "noUncheckedSideEffectImports": false, + "resolveJsonModule": true, + "esModuleInterop": true + } +} diff --git a/examples/layout-elk-rectpacking-ts/webpack.config.js b/examples/layout-elk-rectpacking-ts/webpack.config.js new file mode 100644 index 0000000000..7b10ca335d --- /dev/null +++ b/examples/layout-elk-rectpacking-ts/webpack.config.js @@ -0,0 +1,39 @@ +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: /\.m?js/, + resolve: { + fullySpecified: false, + }, + }, + { test: /\.ts$/, loader: 'ts-loader' }, + { + test: /\.s[ac]ss$/i, + use: [ + 'style-loader', + 'css-loader', + 'sass-loader', + ], + }, + ], + }, + devServer: { + static: { + directory: __dirname, + }, + compress: true, + }, +}; From caba681c28195607491da10c516f7c64f3ea15c0 Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Fri, 2 Oct 2026 12:43:16 +0200 Subject: [PATCH 46/75] fixes --- .../src/index.ts | 14 ++-- .../src/shapes.ts | 7 +- examples/layout-elk-default-ts/src/index.ts | 3 +- packages/joint-layout-elk/src/export.mts | 15 ++-- packages/joint-layout-elk/src/import.mts | 15 ++-- packages/joint-layout-elk/test/index.js | 71 +++++++++++++++++-- 6 files changed, 92 insertions(+), 33 deletions(-) diff --git a/examples/layout-elk-containers-ports-ts/src/index.ts b/examples/layout-elk-containers-ports-ts/src/index.ts index 705b486e11..c83b421fe1 100644 --- a/examples/layout-elk-containers-ports-ts/src/index.ts +++ b/examples/layout-elk-containers-ports-ts/src/index.ts @@ -3,8 +3,6 @@ import { ElkLayoutOptions, ExportElementCallback, ExportPortCallback, - NodeElkLayoutOptions, - PortElkLayoutOptions, layout, ExportLinkLabelCallback, ExportPortLabelCallback @@ -122,7 +120,7 @@ const init = () => { elkPort.layoutOptions['elk.port.side'] = 'WEST'; break; case 'out': - elkPort.layoutOptions['elk.port.side'] = 'EAST'; + elkPort.layoutOptions['elk.port.side'] = 'SOUTH'; break; } }; @@ -133,11 +131,6 @@ const init = () => { elkPortLabel.height = height; }; - const exportLinkLabel: ExportLinkLabelCallback = ({ link, labelIndex, elkEdgeLabel }) => { - const inline = link.label(labelIndex)['inline']; - elkEdgeLabel.layoutOptions['elk.edgeLabels.inline'] = inline ? 'true' : 'false'; - }; - // Wraps every `layout()` call the example makes - freezing the paper for its // (async) duration, so nothing renders mid-layout, and reporting any error the // same way regardless of which caller triggered the layout. @@ -148,7 +141,6 @@ const init = () => { exportElement, exportPort, exportPortLabel, - exportLinkLabel, elkLayoutOptions }).then(() => { paper.unfreeze(); @@ -159,7 +151,9 @@ const init = () => { }; // Initial layout of the fixed example data, fit to the paper's viewport. - runLayout().then(() => zoom(paper, 1)); + runLayout().then(() => { + zoom(paper, 1) + }); }; function zoom(paper: dia.Paper, zoomLevel: number): void { diff --git a/examples/layout-elk-containers-ports-ts/src/shapes.ts b/examples/layout-elk-containers-ports-ts/src/shapes.ts index ee8647b915..cbb2473001 100644 --- a/examples/layout-elk-containers-ports-ts/src/shapes.ts +++ b/examples/layout-elk-containers-ports-ts/src/shapes.ts @@ -18,19 +18,18 @@ const PORT_ATTRS = { // 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_HORIZONTAL_PADDING = 6; -const PORT_LABEL_HEIGHT = PORT_LABEL_FONT_SIZE + 4; +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) + PORT_LABEL_HORIZONTAL_PADDING, + 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: 14 }; +const HUB_PORT_SIZE = { width: 14, height: 8 }; const HUB_PORT_MARKUP = [{ tagName: 'rect', selector: 'rect' diff --git a/examples/layout-elk-default-ts/src/index.ts b/examples/layout-elk-default-ts/src/index.ts index 259cb58a96..24a413a23c 100644 --- a/examples/layout-elk-default-ts/src/index.ts +++ b/examples/layout-elk-default-ts/src/index.ts @@ -61,8 +61,7 @@ const init = () => { 'elk.direction': 'RIGHT', 'elk.edgeRouting': 'ORTHOGONAL', 'elk.spacing.nodeNode': '30', - 'elk.layered.spacing.nodeNodeBetweenLayers': '50', - 'elk.spacing.edgeLabel': '2' + 'elk.layered.spacing.nodeNodeBetweenLayers': '50' }, }).then(() => { paper.unfreeze(); diff --git a/packages/joint-layout-elk/src/export.mts b/packages/joint-layout-elk/src/export.mts index 7c753ea7c3..6ee3667c44 100644 --- a/packages/joint-layout-elk/src/export.mts +++ b/packages/joint-layout-elk/src/export.mts @@ -181,8 +181,10 @@ function buildPorts(element: dia.Element): ElkPort[] | undefined { const portId = `${port.id}`; const elkPortId = `${element.id}:${portId}`; - const { x, y } = element.getPortRelativePosition(portId); - const { width, height } = element.getPortRelativeRect(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, @@ -190,10 +192,7 @@ function buildPorts(element: dia.Element): ElkPort[] | undefined { y, width, height, - layoutOptions: { - // Negative offset moves the port inward from the node border, centering it there. - 'elk.port.borderOffset': `${-width / 2}` - } + layoutOptions: {} }; if (exportGraphOptions.exportPort?.({ portId, element, elkPort }) === false) { @@ -348,7 +347,9 @@ function buildEdge(link: dia.Link): void { const labelDraft: ElkLabelDraft = { width, height, - layoutOptions: {} + layoutOptions: { + 'elk.edgeLabels.inline': 'true' + } }; if (exportGraphOptions.exportLinkLabel?.({ link, labelIndex, elkEdgeLabel: labelDraft }) === false) diff --git a/packages/joint-layout-elk/src/import.mts b/packages/joint-layout-elk/src/import.mts index f3ac93e6f2..f0a4ee283f 100644 --- a/packages/joint-layout-elk/src/import.mts +++ b/packages/joint-layout-elk/src/import.mts @@ -218,11 +218,19 @@ function importNode(node: ElkNode, containerPosition: dia.Point = { x: 0, y: 0 } if (!found) return; const { element, portId } = found; + // ELK places a port just outside the node's border - clamping its center + // into the node's bounds moves it onto the border, whichever side it is on. + const center = { + x: Math.min(Math.max((port.x || 0) + (port.width || 0) / 2, 0), node.width || 0), + y: Math.min(Math.max((port.y || 0) + (port.height || 0) / 2, 0), node.height || 0) + }; + 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*. + // '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 @@ -234,10 +242,7 @@ function importNode(node: ElkNode, containerPosition: dia.Point = { x: 0, y: 0 } portId, attributes: { position: { - args: { - x: (port.x || 0) + (port.width || 0) / 2, - y: (port.y || 0) + (port.height || 0) / 2 - } + args: center }, ...(labelPosition ? { label: { diff --git a/packages/joint-layout-elk/test/index.js b/packages/joint-layout-elk/test/index.js index 46750d5247..ebc4bd0555 100644 --- a/packages/joint-layout-elk/test/index.js +++ b/packages/joint-layout-elk/test/index.js @@ -238,7 +238,7 @@ QUnit.module('layout()', () => { // 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. `elk.port.borderOffset`, or an `elkNode`/`elkPort`'s own `width`) survives + // (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 }) => { @@ -260,7 +260,6 @@ QUnit.module('layout()', () => { const elkPort = elkNode.ports.find((port) => port.id === 'a:out1'); assert.equal(elkPort.layoutOptions['elk.custom'], 'port'); - assert.equal(typeof elkPort.layoutOptions['elk.port.borderOffset'], 'string'); assert.equal(typeof elkPort.width, 'number'); const [elkEdge] = elkGraph.edges; @@ -342,6 +341,71 @@ QUnit.module('layout()', () => { assert.equal(relativePosition.y, position.y); }); + 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 }); @@ -519,9 +583,6 @@ QUnit.module('layout()', () => { 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'); - // What this package itself computes (e.g. `elk.port.borderOffset`) still survives - - // `exportPort` adds to the same `layoutOptions` object, it doesn't replace it. - assert.equal(typeof elkPort.layoutOptions['elk.port.borderOffset'], 'string'); }); QUnit.test('should size a port label via exportPortLabel, from the port\'s (or its group\'s) `label.size`', async(assert) => { From 1b0126724457cf1ab0ae5da61f56e94f14df977e Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Fri, 2 Oct 2026 13:57:52 +0200 Subject: [PATCH 47/75] fixes and updates --- package.json | 2 +- packages/joint-layout-elk/README.md | 3 +- packages/joint-layout-elk/package.json | 2 +- packages/joint-layout-elk/src/import.mts | 26 ++++---- packages/joint-layout-elk/src/layout.mts | 8 ++- packages/joint-layout-elk/test/index.js | 84 +++++++++++++++++++++--- 6 files changed, 98 insertions(+), 27 deletions(-) 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-layout-elk/README.md b/packages/joint-layout-elk/README.md index 08364fcdbe..0020771d1e 100644 --- a/packages/joint-layout-elk/README.md +++ b/packages/joint-layout-elk/README.md @@ -63,7 +63,7 @@ interface LayoutOptions { // A custom ELK instance, e.g. one configured to run inside a Web Worker. elk?: ELK; // Default: a shared, main-thread instance (`elkjs/lib/elk.bundled.js`) // ELK layout options, passed through to ELK unmodified. - elkLayoutOptions?: ElkLayoutOptions; // Default: { 'elk.algorithm': 'layered', 'elk.hierarchyHandling': 'INCLUDE_CHILDREN' } + elkLayoutOptions?: ElkLayoutOptions; // Default: { 'elk.algorithm': 'layered', 'elk.hierarchyHandling': 'INCLUDE_CHILDREN', 'elk.json.edgeCoords': 'ROOT' } // A name for the layout batch, grouping everything `layout()` applies into one graph change. batchName?: string; // Default: 'layout' @@ -105,6 +105,7 @@ type SetLinkAttributesCallback = (params: { link: dia.Link; attributes: { vertic ## ⚠️ 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. - **Ports keep their JointJS-computed position by default** - `layout()` only tells ELK where they already are, so edges route to/from the exact spot the element's port groups place them at. Opt into ELK repositioning/reordering them by setting `elk.portConstraints` (e.g. `'FIXED_SIDE'`/`'FREE'`) via `exportElement`/`exportPort`. - **Asynchronous** - unlike `@joint/layout-directed-graph`, `layout()` returns a `Promise`, since `elkjs` computes layouts asynchronously (and, optionally, inside a Web Worker). diff --git a/packages/joint-layout-elk/package.json b/packages/joint-layout-elk/package.json index 9ee0e017dd..663a015233 100644 --- a/packages/joint-layout-elk/package.json +++ b/packages/joint-layout-elk/package.json @@ -1,7 +1,7 @@ { "name": "@joint/layout-elk", "title": "JointJS ELK Layout", - "version": "0.1.0", + "version": "4.4.0", "description": "ELK Layout module for JointJS", "sideEffects": false, "main": "./dist/esm/index.mjs", diff --git a/packages/joint-layout-elk/src/import.mts b/packages/joint-layout-elk/src/import.mts index f0a4ee283f..34d6fdbf4d 100644 --- a/packages/joint-layout-elk/src/import.mts +++ b/packages/joint-layout-elk/src/import.mts @@ -106,9 +106,10 @@ function init( portsById = ports; } -// ELK positions a node's children (and routes a node's own edges) 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. +// 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, @@ -118,9 +119,10 @@ function toAbsolute(point: ElkPoint, containerPosition: dia.Point): dia.Point { /** * Applies a container's (or the root's) own ELK edges back onto their JointJS links, - * via `setLinkAttributes` (or the default). + * via `setLinkAttributes` (or the default). Their coordinates are used as they are - see + * `toAbsolute` above. */ -function importEdges(edges: ElkExtendedEdge[] | undefined, containerPosition: dia.Point = { x: 0, y: 0 }): void { +function importEdges(edges: ElkExtendedEdge[] | undefined): void { const setLinkAttributes = importLayoutOptions.setLinkAttributes ?? defaultSetLinkAttributes; (edges || []).forEach((edge) => { @@ -132,26 +134,24 @@ function importEdges(edges: ElkExtendedEdge[] | undefined, containerPosition: di const { startPoint, endPoint, bendPoints = [] } = section; - const vertices = bendPoints.map((point) => toAbsolute(point, containerPosition)); + const vertices = bendPoints.map(({ x, y }) => ({ x, y })); // A port-connected end already has its anchor computed by JointJS - no override // needed. The end's existing `id`/`port` is kept, since `.set()` replaces it outright. const currentSource = link.source(); const source = (currentSource.port) ? undefined : { ...currentSource, - anchor: getPortlessEndAnchor(link.getSourceElement() as dia.Element, toAbsolute(startPoint, containerPosition)) + anchor: getPortlessEndAnchor(link.getSourceElement() as dia.Element, startPoint) }; const currentTarget = link.target(); const target = (currentTarget.port) ? undefined : { ...currentTarget, - anchor: getPortlessEndAnchor(link.getTargetElement() as dia.Element, toAbsolute(endPoint, containerPosition)) + anchor: getPortlessEndAnchor(link.getTargetElement() as dia.Element, endPoint) }; let labels: dia.Link.Label[] | undefined; if (edge.labels && edge.labels.length > 0) { - const points = [startPoint, ...bendPoints, endPoint] - .map((point) => toAbsolute(point, containerPosition)); - const polyline = new g.Polyline(points); + const polyline = new g.Polyline([startPoint, ...bendPoints, endPoint]); // `link.getComputedLabels()` (`@joint/core`) returns each label resolved against // `defaultLabel`/the built-in default - reading `labels` (the raw model attribute) // directly instead, so writing `labels[index]` back below doesn't bake that @@ -160,7 +160,7 @@ function importEdges(edges: ElkExtendedEdge[] | undefined, containerPosition: di labels = currentLabels.slice(); edge.labels.forEach((label, index) => { const { x = 0, y = 0, width = 0, height = 0 } = label; - const center = new g.Point(containerPosition.x + x + width / 2, containerPosition.y + y + height / 2); + const center = new g.Point(x + width / 2, y + height / 2); const distance = polyline.closestPointLength(center); // Get the tangent at the closest point to calculate the offset const tangent = polyline.tangentAtLength(distance); @@ -258,7 +258,7 @@ function importNode(node: ElkNode, containerPosition: dia.Point = { x: 0, y: 0 } } (node.children || []).forEach((child) => importNode(child, position)); - importEdges(node.edges, position); + importEdges(node.edges); } /** diff --git a/packages/joint-layout-elk/src/layout.mts b/packages/joint-layout-elk/src/layout.mts index 9b0a71beef..cd627abb86 100644 --- a/packages/joint-layout-elk/src/layout.mts +++ b/packages/joint-layout-elk/src/layout.mts @@ -19,7 +19,11 @@ const DEFAULT_LAYOUT_OPTIONS: ElkLayoutOptions = { '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' + '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' }; const DEFAULT_OPTIONS: LayoutOptions = { @@ -47,7 +51,7 @@ export interface LayoutOptions extends ImportLayoutOptions, ExportGraphOptions { /** * ELK layout options, passed through to ELK unmodified. * @see https://eclipse.dev/elk/reference/options.html - * @defaultValue `{ 'elk.algorithm': 'layered', 'elk.hierarchyHandling': 'INCLUDE_CHILDREN' }` + * @defaultValue `{ 'elk.algorithm': 'layered', 'elk.hierarchyHandling': 'INCLUDE_CHILDREN', 'elk.json.edgeCoords': 'ROOT' }` */ elkLayoutOptions?: ElkLayoutOptions; /** diff --git a/packages/joint-layout-elk/test/index.js b/packages/joint-layout-elk/test/index.js index ebc4bd0555..48ff50b435 100644 --- a/packages/joint-layout-elk/test/index.js +++ b/packages/joint-layout-elk/test/index.js @@ -153,6 +153,66 @@ QUnit.module('layout()', () => { 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 }); @@ -483,7 +543,7 @@ QUnit.module('layout()', () => { const link = new joint.shapes.standard.Link({ source: { id: 'a' }, target: { id: 'b' }, - labels: [{ size: { width: 40, height: 20 }, elkLayoutOptions: { 'elk.edgeLabels.inline': 'true' }}] + labels: [{ size: { width: 40, height: 20 }, elkLayoutOptions: { 'elk.edgeLabels.inline': 'false' }}] }); graph.resetCells([el1, el2, link]); @@ -495,14 +555,15 @@ QUnit.module('layout()', () => { }); const [elkEdge] = elkGraph.edges; - assert.equal(elkEdge.labels[0].layoutOptions['elk.edgeLabels.inline'], 'true'); + // 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': 'true' }); + assert.deepEqual(link.get('labels')[0].elkLayoutOptions, { 'elk.edgeLabels.inline': 'false' }); }); - QUnit.test('should not place a link label inline by default - only `exportLinkLabel` can opt one in', async(assert) => { + 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 }}); @@ -516,9 +577,14 @@ QUnit.module('layout()', () => { 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 [elkEdge] = elkGraph.edges; - assert.notOk('elk.edgeLabels.inline' in elkEdge.labels[0].layoutOptions); + 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) => { @@ -531,7 +597,7 @@ QUnit.module('layout()', () => { target: { id: 'b' }, defaultLabel: { size: { width: 40, height: 20 }, - elkLayoutOptions: { 'elk.edgeLabels.inline': 'true' } + elkLayoutOptions: { 'elk.edgeLabels.inline': 'false' } }, // Neither label sets its own `elkLayoutOptions` - both fall back to // `defaultLabel`'s, merged in by `Link#getComputedLabels()` (`@joint/core`) @@ -548,8 +614,8 @@ QUnit.module('layout()', () => { }); const [elkEdge] = elkGraph.edges; - assert.equal(elkEdge.labels[0].layoutOptions['elk.edgeLabels.inline'], 'true'); - assert.equal(elkEdge.labels[1].layoutOptions['elk.edgeLabels.inline'], 'true'); + 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) => { From b0494f8055125acc1e1786f7b4fe25bd0b217918 Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Mon, 5 Oct 2026 13:01:26 +0200 Subject: [PATCH 48/75] updates --- .changeset/port-label-position-type.md | 5 + .changeset/port-prop-overloads.md | 5 + .../src/index.ts | 2 +- examples/layout-elk-default-ts/src/index.ts | 2 +- examples/layout-elk-flowchart-ts/src/index.ts | 2 +- .../layout-elk-rectpacking-ts/src/index.ts | 2 +- examples/layout-elk-ts/src/index.ts | 2 +- packages/joint-core/test/ts/index.test.ts | 16 ++ packages/joint-layout-elk/README.md | 19 +- packages/joint-layout-elk/src/export.mts | 64 +++++-- packages/joint-layout-elk/src/layout.mts | 36 +++- packages/joint-layout-elk/test/index.js | 175 +++++++++++++++--- 12 files changed, 277 insertions(+), 53 deletions(-) create mode 100644 .changeset/port-label-position-type.md create mode 100644 .changeset/port-prop-overloads.md 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-containers-ports-ts/src/index.ts b/examples/layout-elk-containers-ports-ts/src/index.ts index c83b421fe1..c4a826c360 100644 --- a/examples/layout-elk-containers-ports-ts/src/index.ts +++ b/examples/layout-elk-containers-ports-ts/src/index.ts @@ -136,7 +136,7 @@ const init = () => { // same way regardless of which caller triggered the layout. const runLayout = (): Promise => { paper.freeze(); - return layout(graph, { + return layout({ graph }, { elk, exportElement, exportPort, diff --git a/examples/layout-elk-default-ts/src/index.ts b/examples/layout-elk-default-ts/src/index.ts index 24a413a23c..6137255664 100644 --- a/examples/layout-elk-default-ts/src/index.ts +++ b/examples/layout-elk-default-ts/src/index.ts @@ -54,7 +54,7 @@ const init = () => { // `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. - layout(graph, { + layout({ graph }, { elk, elkLayoutOptions: { 'elk.algorithm': 'layered', diff --git a/examples/layout-elk-flowchart-ts/src/index.ts b/examples/layout-elk-flowchart-ts/src/index.ts index 81579d4caa..fee412ca87 100644 --- a/examples/layout-elk-flowchart-ts/src/index.ts +++ b/examples/layout-elk-flowchart-ts/src/index.ts @@ -218,7 +218,7 @@ const init = () => { const runLayout = (): Promise => { paper.freeze(); - return layout(graph, { + return layout({ graph }, { elk, exportElement, exportPort, diff --git a/examples/layout-elk-rectpacking-ts/src/index.ts b/examples/layout-elk-rectpacking-ts/src/index.ts index 7296d8d5e3..b6df2b389b 100644 --- a/examples/layout-elk-rectpacking-ts/src/index.ts +++ b/examples/layout-elk-rectpacking-ts/src/index.ts @@ -131,7 +131,7 @@ const init = () => { try { do { pending = false; - const { bbox } = await layout(graph, { + const { bbox } = await layout({ graph }, { elk, elkLayoutOptions: getRootOptions(), exportElement, diff --git a/examples/layout-elk-ts/src/index.ts b/examples/layout-elk-ts/src/index.ts index 5a359aeb54..0e3516d809 100644 --- a/examples/layout-elk-ts/src/index.ts +++ b/examples/layout-elk-ts/src/index.ts @@ -44,7 +44,7 @@ const init = () => { workerUrl: '../node_modules/elkjs/lib/elk-worker.js', }); - layout(graph, { + layout({ graph }, { elk, elkLayoutOptions: { /** diff --git a/packages/joint-core/test/ts/index.test.ts b/packages/joint-core/test/ts/index.test.ts index 84e867bb8b..11c3ded3f3 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: any = 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-layout-elk/README.md b/packages/joint-layout-elk/README.md index 0020771d1e..daa0f28f68 100644 --- a/packages/joint-layout-elk/README.md +++ b/packages/joint-layout-elk/README.md @@ -33,7 +33,7 @@ const link = new shapes.standard.Link({ source: { id: 'a' }, target: { id: 'b' } graph.addCells([rect1, rect2, link]); -const { bbox } = await layout(graph, { +const { bbox } = await layout({ graph }, { elkLayoutOptions: { 'elk.algorithm': 'layered', 'elk.direction': 'RIGHT', @@ -44,11 +44,24 @@ const { bbox } = await layout(graph, { ## 📖 API Reference -### `layout(graph, options?): Promise` +### `layout({ graph, elements?, links? }, options?): Promise` -- `graph`: `dia.Graph` - the graph to lay out. +- `graph`: `dia.Graph` - the graph to lay out (also where the layout's batch runs). +- `elements?`: `dia.Element[]` - which of its elements to lay out. Default: all of the graph's elements. +- `links?`: `dia.Link[]` - which of its links to lay out. Default: all of the graph's links. - `options?`: `LayoutOptions` - layout configuration (see below). +`elements` and `links` are both the selection and the order: + +- **Selection** - only the given elements are laid out, and a given link only if both its ends are laid out too. Everything else is left untouched. An element whose parent isn't in `elements` is laid out as a top-level element. +- **Order** - the top-level elements follow the order of `elements`, and so do each container's given children (instead of `getEmbeddedCells()` order). The links follow the order of `links`, inside each container too. +- Each cell must appear only once in its list. An empty `elements` lays out nothing. + +```ts +// Lay out only the selected elements, and every link between them. +await layout({ graph, elements: selectedElements }); +``` + ```ts interface LayoutResult { bbox: g.Rect; // Tight bounding box of the laid out graph diff --git a/packages/joint-layout-elk/src/export.mts b/packages/joint-layout-elk/src/export.mts index 6ee3667c44..c2a657d2c3 100644 --- a/packages/joint-layout-elk/src/export.mts +++ b/packages/joint-layout-elk/src/export.mts @@ -138,12 +138,17 @@ let excludedPortIdsByElement: 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`. +let elementIndicesById: 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): void { +function init(options: ExportGraphOptions, elements: dia.Element[]): void { exportGraphOptions = options; elementsById = new Map(); @@ -151,6 +156,25 @@ function init(options: ExportGraphOptions): void { portsById = new Map(); excludedPortIdsByElement = new Map(); edgeContainersById = new Map(); + elkParentIdsById = new Map(); + elementIndicesById = new Map(elements.map((element, index) => [`${element.id}`, index])); +} + +// 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) 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[] { + const indices = elementIndicesById; + return element.getEmbeddedCells() + .filter((cell): cell is dia.Element => cell.isElement() && indices.has(`${cell.id}`)) + .sort((a, b) => indices.get(`${a.id}`)! - indices.get(`${b.id}`)!); } function getExcludedPortIds(element: dia.Element): Set { @@ -233,10 +257,10 @@ function buildPorts(element: dia.Element): ElkPort[] | undefined { * so nothing is registered and nothing downstream (a child, a port, a connected edge) * can end up referencing it. */ -function buildElkNode(element: dia.Element): ElkNode | null { +function buildElkNode(element: dia.Element, parentId?: string): ElkNode | null { const id = `${element.id}`; - const embeds = element.getEmbeddedCells().filter(cell => cell.isElement()); + const embeds = getEmbeddedElements(element); // A container's real size is computed by ELK to fit its content - `0` is just a // placeholder (elkjs needs a numeric size upfront for a hierarchical node). @@ -257,6 +281,7 @@ function buildElkNode(element: dia.Element): ElkNode | null { return null; elementsById.set(id, element); + elkParentIdsById.set(id, parentId); const ports = buildPorts(element); @@ -264,7 +289,7 @@ function buildElkNode(element: dia.Element): ElkNode | null { let edges: ElkExtendedEdge[] | undefined; if (embeds.length > 0) { children = embeds - .map((embed) => buildElkNode(embed)) + .map((embed) => buildElkNode(embed, id)) .filter((node): node is ElkNode => node !== null); // Shared with `edgeContainersById` (see there) - edges filed under this container // by `buildEdge` need to end up on the node itself. @@ -281,9 +306,16 @@ function buildElkNode(element: dia.Element): ElkNode | null { } // The lowest common ancestor of an element and itself/an ancestor is the element's parent chain - -// this returns that chain, ordered from the outermost ancestor to the immediate parent. +// this returns that chain in the ELK graph, ordered from the outermost ancestor to the +// immediate parent. function getAncestorPath(element: dia.Element): string[] { - return element.getAncestors().reverse().map((cell) => `${cell.id}`); + const path: string[] = []; + let parentId = elkParentIdsById.get(`${element.id}`); + 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 @@ -380,19 +412,25 @@ function buildEdge(link: dia.Link): void { } /** - * Converts a JointJS graph (elements, their embedded elements, ports and the - * links between them) to an ELK graph structure. + * 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( - graph: dia.Graph, + elements: dia.Element[], + links: dia.Link[], options: ExportGraphOptions, elkLayoutOptions: ElkLayoutOptions ): ElkGraphData { - init(options); + init(options, elements); + + const topLevelElements = elements.filter((element) => !hasLaidOutParent(element)); - const children: ElkNode[] = graph.getElements() - .filter((element) => !element.parent()) + const children: ElkNode[] = topLevelElements .map((element) => buildElkNode(element)) .filter((node): node is ElkNode => node !== null); @@ -406,7 +444,7 @@ export function exportGraph( // `buildEdge` need to end up on `elkGraph` itself. edgeContainersById.set(undefined, elkGraph.edges as ElkExtendedEdge[]); - graph.getLinks().forEach(buildEdge); + links.forEach(buildEdge); return { elkGraph, elementsById, linksById, portsById }; } diff --git a/packages/joint-layout-elk/src/layout.mts b/packages/joint-layout-elk/src/layout.mts index cd627abb86..96c5345694 100644 --- a/packages/joint-layout-elk/src/layout.mts +++ b/packages/joint-layout-elk/src/layout.mts @@ -45,7 +45,7 @@ export interface LayoutOptions extends ImportLayoutOptions, ExportGraphOptions { * @example * import ELK from 'elkjs/lib/elk-api.js'; * const elk = new ELK({ workerUrl: new URL('elkjs/lib/elk-worker.min.js', import.meta.url).href }); - * layout(graph, { elk }); + * layout({ graph }, { elk }); */ elk?: ELK; /** @@ -83,7 +83,32 @@ function getBBox(elkGraph: ElkNode): g.Rect { return g.Rect.fromRectUnion(...rects) || new g.Rect(0, 0, 0, 0); } -export async function layout(graph: dia.Graph, opt?: LayoutOptions): Promise { +/** + * What `layout()` lays out: the graph, and optionally which of its elements/links. + */ +export interface LayoutCells { + /** The graph the elements and links belong to - also where the layout's batch runs. */ + graph: dia.Graph; + /** + * The elements to lay out, in this order - the top-level ones follow it, and so do + * each container's own children (instead of `getEmbeddedCells()` order). An element + * whose parent isn't listed is laid out as a top-level one. Each element must be + * listed only once. + * @defaultValue all of the graph's elements + */ + elements?: dia.Element[]; + /** + * The links to lay out, in this order - a link is laid out only if both its ends are too. + * Each link must be listed only once. + * @defaultValue all of the graph's links + */ + links?: dia.Link[]; +} + +/** + * Lays out a JointJS graph (or only some of its elements/links, see `LayoutCells`) with ELK. + */ +export async function layout({ graph, elements, links }: LayoutCells, opt?: LayoutOptions): Promise { const options = util.defaults({}, opt || {}, DEFAULT_OPTIONS) as LayoutOptions; const elkLayoutOptions = util.defaults( @@ -94,7 +119,12 @@ export async function layout(graph: dia.Graph, opt?: LayoutOptions): Promise { assert.equal(initialBBox.x, 0); assert.equal(initialBBox.y, 0); - const { bbox } = await joint.layout.ELK.layout(graph); + const { bbox } = await joint.layout.ELK.layout({ graph }); assert.ok(bbox.width > 0); assert.ok(bbox.height > 0); @@ -55,7 +55,7 @@ QUnit.module('layout()', () => { const { graph } = createGraph(); - await joint.layout.ELK.layout(graph, { + await joint.layout.ELK.layout({ graph }, { elkLayoutOptions: { 'elk.algorithm': 'layered', 'elk.direction': 'RIGHT', @@ -83,7 +83,7 @@ QUnit.module('layout()', () => { graph.resetCells([el1, el2, link]); - await joint.layout.ELK.layout(graph); + await joint.layout.ELK.layout({ graph }); const label = link.label(0); assert.ok(label.position && typeof label.position.distance === 'number'); @@ -104,7 +104,7 @@ QUnit.module('layout()', () => { graph.resetCells([el1, el2, link]); - const { elkGraph } = await joint.layout.ELK.layout(graph); + const { elkGraph } = await joint.layout.ELK.layout({ graph }); const [elkEdge] = elkGraph.edges; assert.equal(elkEdge.labels[0].width, 80); @@ -123,7 +123,7 @@ QUnit.module('layout()', () => { graph.resetCells([parent, child1, child2, childLink]); - await joint.layout.ELK.layout(graph); + await joint.layout.ELK.layout({ graph }); const parentBBox = parent.getBBox(); const child1BBox = child1.getBBox(); @@ -147,7 +147,7 @@ QUnit.module('layout()', () => { graph.resetCells([parent, child, outside, link]); - await joint.layout.ELK.layout(graph); + await joint.layout.ELK.layout({ graph }); assert.ok(Array.isArray(link.vertices())); assert.ok(!joint.g.intersection.exists(parent.getBBox(), outside.getBBox())); @@ -171,7 +171,7 @@ QUnit.module('layout()', () => { graph.resetCells([outer, inner, a, b, link]); - const { elkGraph } = await joint.layout.ELK.layout(graph, { + const { elkGraph } = await joint.layout.ELK.layout({ graph }, { elkLayoutOptions: { 'elk.padding': '[top=40,left=20,bottom=20,right=20]' } @@ -206,7 +206,7 @@ QUnit.module('layout()', () => { graph.resetCells([el1, el2, link]); - const { elkGraph } = await joint.layout.ELK.layout(graph, { + const { elkGraph } = await joint.layout.ELK.layout({ graph }, { elkLayoutOptions: { 'elk.json.edgeCoords': 'CONTAINER' } }); @@ -243,7 +243,7 @@ QUnit.module('layout()', () => { graph.resetCells([el1, el2, link]); - await joint.layout.ELK.layout(graph); + await joint.layout.ELK.layout({ graph }); assert.notOk(link.prop('source/anchor')); assert.notOk(link.prop('target/anchor')); @@ -269,7 +269,7 @@ QUnit.module('layout()', () => { graph.resetCells([el1, el2, link]); const seen = []; - await joint.layout.ELK.layout(graph, { + await joint.layout.ELK.layout({ graph }, { exportPort: ({ portId, element }) => { seen.push([portId, element.id]); } @@ -300,7 +300,7 @@ QUnit.module('layout()', () => { // 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, { + const { elkGraph } = await joint.layout.ELK.layout({ graph }, { exportElement: ({ elkNode }) => { elkNode.layoutOptions['elk.portConstraints'] = 'FIXED_SIDE'; elkNode.layoutOptions['elk.custom'] = 'node'; @@ -342,7 +342,7 @@ QUnit.module('layout()', () => { graph.resetCells([el1]); - await joint.layout.ELK.layout(graph); + await joint.layout.ELK.layout({ graph }); // Untouched - still the original group config, not switched to 'absolute'. assert.equal(el1.prop(['ports', 'groups', 'out', 'position']), 'right'); @@ -378,7 +378,7 @@ QUnit.module('layout()', () => { graph.resetCells([el1, el2, link]); - await joint.layout.ELK.layout(graph, { + await joint.layout.ELK.layout({ graph }, { exportElement: ({ elkNode }) => { elkNode.layoutOptions['elk.portConstraints'] = 'FIXED_SIDE'; } @@ -421,7 +421,7 @@ QUnit.module('layout()', () => { const before = el1.getPortRelativePosition('right1'); - await joint.layout.ELK.layout(graph, { + await joint.layout.ELK.layout({ graph }, { exportElement: ({ elkNode }) => { elkNode.layoutOptions['elk.portConstraints'] = 'FIXED_POS'; } @@ -452,7 +452,7 @@ QUnit.module('layout()', () => { graph.resetCells([el1, el2, el3, link1, link2]); - await joint.layout.ELK.layout(graph, { + await joint.layout.ELK.layout({ graph }, { exportElement: ({ element, elkNode }) => { if (element.hasPorts()) elkNode.layoutOptions['elk.portConstraints'] = 'FIXED_SIDE'; }, @@ -498,12 +498,12 @@ QUnit.module('layout()', () => { graph.resetCells([el1, el2, link]); - await joint.layout.ELK.layout(graph); + 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); + await joint.layout.ELK.layout({ graph }); const secondPosition = el1.getPortRelativePosition('out1'); assert.equal(secondPosition.x, firstPosition.x); @@ -525,7 +525,7 @@ QUnit.module('layout()', () => { graph.resetCells([parent, child]); - const { elkGraph } = await joint.layout.ELK.layout(graph, { + const { elkGraph } = await joint.layout.ELK.layout({ graph }, { exportElement: ({ element, elkNode }) => { Object.assign(elkNode.layoutOptions, element.get('elkLayoutOptions')); } @@ -548,7 +548,7 @@ QUnit.module('layout()', () => { graph.resetCells([el1, el2, link]); - const { elkGraph } = await joint.layout.ELK.layout(graph, { + const { elkGraph } = await joint.layout.ELK.layout({ graph }, { exportLinkLabel: ({ link, labelIndex, elkEdgeLabel }) => { Object.assign(elkEdgeLabel.layoutOptions, link.label(labelIndex).elkLayoutOptions); } @@ -576,10 +576,10 @@ QUnit.module('layout()', () => { graph.resetCells([el1, el2, link]); - const { elkGraph } = await joint.layout.ELK.layout(graph); + 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, { + const { elkGraph: optedOutElkGraph } = await joint.layout.ELK.layout({ graph }, { exportLinkLabel: ({ elkEdgeLabel }) => { elkEdgeLabel.layoutOptions['elk.edgeLabels.inline'] = 'false'; } @@ -607,7 +607,7 @@ QUnit.module('layout()', () => { graph.resetCells([el1, el2, link]); - const { elkGraph } = await joint.layout.ELK.layout(graph, { + const { elkGraph } = await joint.layout.ELK.layout({ graph }, { exportLinkLabel: ({ link, labelIndex, elkEdgeLabel }) => { Object.assign(elkEdgeLabel.layoutOptions, link.getComputedLabels()[labelIndex].elkLayoutOptions); } @@ -634,7 +634,7 @@ QUnit.module('layout()', () => { graph.resetCells([el1]); - const { elkGraph } = await joint.layout.ELK.layout(graph, { + const { elkGraph } = await joint.layout.ELK.layout({ graph }, { exportElement: ({ elkNode }) => { elkNode.layoutOptions['elk.portConstraints'] = 'FIXED_SIDE'; }, @@ -670,7 +670,7 @@ QUnit.module('layout()', () => { graph.resetCells([el1]); - const { elkGraph } = await joint.layout.ELK.layout(graph, { + 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). @@ -697,13 +697,130 @@ QUnit.module('layout()', () => { const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); - const { bbox, elkGraph } = await joint.layout.ELK.layout(graph); + const { bbox, elkGraph } = await joint.layout.ELK.layout({ graph }); assert.equal(bbox.width, 0); assert.equal(bbox.height, 0); assert.deepEqual(elkGraph.children, []); }); + QUnit.module('given `elements`/`links`', () => { + + 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({ graph, elements: [a, b], links: [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 what isn\'t given from the graph', 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]); + + // Only `elements` - every graph link between them is laid out. + const { elkGraph: withElements } = await joint.layout.ELK.layout({ graph, elements: [b, a] }); + assert.deepEqual(ids(withElements.children), ['b', 'a']); + assert.deepEqual(ids(withElements.edges), ['ab']); + + // Only `links` - every graph element is laid out. + const { elkGraph: withLinks } = await joint.layout.ELK.layout({ graph, links: [bc] }); + 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({ graph, elements: [c, a, b], links: [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({ graph, elements: [parent, b, a], links: [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({ graph, elements: [a, b] }); + + 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({ graph, elements: [] }); + + 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 drop an element (and its subtree) when exportElement returns false', async(assert) => { const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); @@ -715,7 +832,7 @@ QUnit.module('layout()', () => { graph.resetCells([parent, child, other, link]); - const { elkGraph } = await joint.layout.ELK.layout(graph, { + const { elkGraph } = await joint.layout.ELK.layout({ graph }, { exportElement: ({ element }) => element.id !== 'parent' }); @@ -741,7 +858,7 @@ QUnit.module('layout()', () => { graph.resetCells([el1, el2, link]); - const { elkGraph } = await joint.layout.ELK.layout(graph, { + const { elkGraph } = await joint.layout.ELK.layout({ graph }, { exportPort: () => false }); @@ -761,7 +878,7 @@ QUnit.module('layout()', () => { graph.resetCells([el1, el2, link]); - const { elkGraph } = await joint.layout.ELK.layout(graph, { + const { elkGraph } = await joint.layout.ELK.layout({ graph }, { exportLink: () => false }); @@ -782,7 +899,7 @@ QUnit.module('layout()', () => { graph.resetCells([el1, el2, link]); - const { elkGraph } = await joint.layout.ELK.layout(graph, { + const { elkGraph } = await joint.layout.ELK.layout({ graph }, { exportLinkLabel: () => false }); From e474c71449e00193052b1452f7f2fe728a53327c Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Mon, 5 Oct 2026 14:31:32 +0200 Subject: [PATCH 49/75] worker update --- .../package.json | 3 +- .../src/index.ts | 7 - examples/layout-elk-default-ts/package.json | 3 +- examples/layout-elk-default-ts/src/index.ts | 7 - examples/layout-elk-flowchart-ts/package.json | 3 +- examples/layout-elk-flowchart-ts/src/index.ts | 6 - .../layout-elk-rectpacking-ts/package.json | 3 +- .../layout-elk-rectpacking-ts/src/index.ts | 6 - examples/layout-elk-ts/package.json | 3 +- examples/layout-elk-ts/src/index.ts | 7 - packages/joint-layout-elk/README.md | 11 +- packages/joint-layout-elk/karma.conf.js | 2 + packages/joint-layout-elk/rollup.config.mjs | 29 + packages/joint-layout-elk/src/defaultElk.mts | 76 +++ packages/joint-layout-elk/src/elk.worker.mts | 3 + packages/joint-layout-elk/src/layout.mts | 26 +- .../src/types/elkEdgeOptions.mts | 45 +- .../src/types/elkLabelOptions.mts | 32 +- .../src/types/elkLayoutOptions.mts | 628 +++++------------- .../src/types/elkNodeOptions.mts | 173 ++--- .../src/types/elkPortOptions.mts | 43 +- .../joint-layout-elk/src/workerFactory.mts | 12 + packages/joint-layout-elk/test/index.js | 68 ++ 23 files changed, 474 insertions(+), 722 deletions(-) create mode 100644 packages/joint-layout-elk/src/defaultElk.mts create mode 100644 packages/joint-layout-elk/src/elk.worker.mts create mode 100644 packages/joint-layout-elk/src/workerFactory.mts diff --git a/examples/layout-elk-containers-ports-ts/package.json b/examples/layout-elk-containers-ports-ts/package.json index bde7c5dbe8..1b466fbe2a 100644 --- a/examples/layout-elk-containers-ports-ts/package.json +++ b/examples/layout-elk-containers-ports-ts/package.json @@ -19,8 +19,7 @@ }, "dependencies": { "@joint/core": "workspace:^", - "@joint/layout-elk": "workspace:^", - "elkjs": "0.12.0" + "@joint/layout-elk": "workspace:^" }, "devDependencies": { "css-loader": "3.5.3", diff --git a/examples/layout-elk-containers-ports-ts/src/index.ts b/examples/layout-elk-containers-ports-ts/src/index.ts index c4a826c360..b02c93f457 100644 --- a/examples/layout-elk-containers-ports-ts/src/index.ts +++ b/examples/layout-elk-containers-ports-ts/src/index.ts @@ -7,7 +7,6 @@ import { ExportLinkLabelCallback, ExportPortLabelCallback } from '@joint/layout-elk'; -import ELK from 'elkjs/lib/elk-api.js'; import { graphJSON } from './example'; import { Container, HubService, InteractionLink, Service } from './shapes'; import './styles.scss'; @@ -98,11 +97,6 @@ const init = () => { 'elk.layered.priority.direction': '40' } - // Run ELK in a Web Worker, via the `@joint/layout-elk` package. - const elk = new ELK({ - workerUrl: '../node_modules/elkjs/lib/elk-worker.js' - }); - const exportElement: ExportElementCallback = ({ element, elkNode }) => { if (element.hasPorts()) { elkNode.layoutOptions['elk.portConstraints'] = 'FIXED_SIDE'; @@ -137,7 +131,6 @@ const init = () => { const runLayout = (): Promise => { paper.freeze(); return layout({ graph }, { - elk, exportElement, exportPort, exportPortLabel, diff --git a/examples/layout-elk-default-ts/package.json b/examples/layout-elk-default-ts/package.json index 1f1ccb9f1b..23a202cf34 100644 --- a/examples/layout-elk-default-ts/package.json +++ b/examples/layout-elk-default-ts/package.json @@ -19,8 +19,7 @@ }, "dependencies": { "@joint/core": "workspace:^", - "@joint/layout-elk": "workspace:^", - "elkjs": "0.12.0" + "@joint/layout-elk": "workspace:^" }, "devDependencies": { "css-loader": "3.5.3", diff --git a/examples/layout-elk-default-ts/src/index.ts b/examples/layout-elk-default-ts/src/index.ts index 6137255664..c73d96fbfe 100644 --- a/examples/layout-elk-default-ts/src/index.ts +++ b/examples/layout-elk-default-ts/src/index.ts @@ -1,6 +1,5 @@ import { dia, shapes, setTheme } from '@joint/core'; import { layout } from '@joint/layout-elk'; -import ELK from 'elkjs/lib/elk-api.js'; import { graphJSON } from './example'; import { Container, Service, InteractionLink } from './shapes'; import './styles.scss'; @@ -45,17 +44,11 @@ const init = () => { // Load the fixed example data. graph.fromJSON(graphJSON); - // Run ELK in a Web Worker, via the `@joint/layout-elk` package. - const elk = new ELK({ - workerUrl: '../node_modules/elkjs/lib/elk-worker.js' - }); - // 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. layout({ graph }, { - elk, elkLayoutOptions: { 'elk.algorithm': 'layered', 'elk.direction': 'RIGHT', diff --git a/examples/layout-elk-flowchart-ts/package.json b/examples/layout-elk-flowchart-ts/package.json index 3dadf40c41..4d4298195b 100644 --- a/examples/layout-elk-flowchart-ts/package.json +++ b/examples/layout-elk-flowchart-ts/package.json @@ -19,8 +19,7 @@ }, "dependencies": { "@joint/core": "workspace:^", - "@joint/layout-elk": "workspace:^", - "elkjs": "0.12.0" + "@joint/layout-elk": "workspace:^" }, "devDependencies": { "css-loader": "3.5.3", diff --git a/examples/layout-elk-flowchart-ts/src/index.ts b/examples/layout-elk-flowchart-ts/src/index.ts index fee412ca87..b0a8e27d7c 100644 --- a/examples/layout-elk-flowchart-ts/src/index.ts +++ b/examples/layout-elk-flowchart-ts/src/index.ts @@ -7,7 +7,6 @@ import { SetPortAttributesCallback, layout } from '@joint/layout-elk'; -import ELK from 'elkjs/lib/elk-api.js'; import { graphJSON } from './example'; import { Decision, FlowchartNode, FlowLink, Process, Terminal } from './shapes'; import './styles.scss'; @@ -162,10 +161,6 @@ const init = () => { 'elk.layered.layering.strategy': 'INTERACTIVE' }; - const elk = new ELK({ - workerUrl: '../node_modules/elkjs/lib/elk-worker.js' - }); - // `FIXED_SIDE` (not the default `FREE`) is what lets ELK reorder a node's ports // along their side to reduce crossings, instead of only routing edges to // wherever a port happens to already be. @@ -219,7 +214,6 @@ const init = () => { const runLayout = (): Promise => { paper.freeze(); return layout({ graph }, { - elk, exportElement, exportPort, exportLinkLabel, diff --git a/examples/layout-elk-rectpacking-ts/package.json b/examples/layout-elk-rectpacking-ts/package.json index 8e73c68a30..5758314958 100644 --- a/examples/layout-elk-rectpacking-ts/package.json +++ b/examples/layout-elk-rectpacking-ts/package.json @@ -19,8 +19,7 @@ }, "dependencies": { "@joint/core": "workspace:^", - "@joint/layout-elk": "workspace:^", - "elkjs": "0.12.0" + "@joint/layout-elk": "workspace:^" }, "devDependencies": { "css-loader": "3.5.3", diff --git a/examples/layout-elk-rectpacking-ts/src/index.ts b/examples/layout-elk-rectpacking-ts/src/index.ts index b6df2b389b..3b3eda3cd3 100644 --- a/examples/layout-elk-rectpacking-ts/src/index.ts +++ b/examples/layout-elk-rectpacking-ts/src/index.ts @@ -1,6 +1,5 @@ import { dia, g, shapes, setTheme, util } from '@joint/core'; import { layout } from '@joint/layout-elk'; -import ELK from 'elkjs/lib/elk-api.js'; import { graphJSON, createFileJSON, type FileKind } from './example'; import { Folder, FileTile, FOLDER_PADDING } from './shapes'; import './styles.scss'; @@ -48,10 +47,6 @@ const init = () => { graph.fromJSON(graphJSON); - const elk = new ELK({ - workerUrl: '../node_modules/elkjs/lib/elk-worker.js' - }); - const controls = getControls(); // The `rectpacking` options, read from the toolbar. ELK doesn't inherit @@ -132,7 +127,6 @@ const init = () => { do { pending = false; const { bbox } = await layout({ graph }, { - elk, elkLayoutOptions: getRootOptions(), exportElement, setElementAttributes diff --git a/examples/layout-elk-ts/package.json b/examples/layout-elk-ts/package.json index 8335c9f058..88ab781867 100644 --- a/examples/layout-elk-ts/package.json +++ b/examples/layout-elk-ts/package.json @@ -19,8 +19,7 @@ }, "dependencies": { "@joint/core": "workspace:^", - "@joint/layout-elk": "workspace:^", - "elkjs": "0.12.0" + "@joint/layout-elk": "workspace:^" }, "devDependencies": { "css-loader": "3.5.3", diff --git a/examples/layout-elk-ts/src/index.ts b/examples/layout-elk-ts/src/index.ts index 0e3516d809..0e33cfa605 100644 --- a/examples/layout-elk-ts/src/index.ts +++ b/examples/layout-elk-ts/src/index.ts @@ -1,6 +1,5 @@ import { dia, shapes, g } from '@joint/core'; import { layout } from '@joint/layout-elk'; -import ELK from 'elkjs/lib/elk-api.js'; import dependenciesJSON from './dependencies.json'; import './styles.scss'; @@ -39,13 +38,7 @@ const init = () => { // Generate JointJS cells from example data generateCells(dependenciesJSON, graph); - // Run ELK in a Web Worker, via the `@joint/layout-elk` package - const elk = new ELK({ - workerUrl: '../node_modules/elkjs/lib/elk-worker.js', - }); - layout({ graph }, { - elk, elkLayoutOptions: { /** * Overall direction of the layout. diff --git a/packages/joint-layout-elk/README.md b/packages/joint-layout-elk/README.md index daa0f28f68..84f7604ad3 100644 --- a/packages/joint-layout-elk/README.md +++ b/packages/joint-layout-elk/README.md @@ -73,8 +73,8 @@ interface LayoutResult { ```ts interface LayoutOptions { - // A custom ELK instance, e.g. one configured to run inside a Web Worker. - elk?: ELK; // Default: a shared, main-thread instance (`elkjs/lib/elk.bundled.js`) + // A custom ELK instance, e.g. one running in a Web Worker of your own. + elk?: ELK; // Default: a shared instance running in a Web Worker (see "Web Worker" below) // ELK layout options, passed through to ELK unmodified. elkLayoutOptions?: ElkLayoutOptions; // Default: { 'elk.algorithm': 'layered', 'elk.hierarchyHandling': 'INCLUDE_CHILDREN', 'elk.json.edgeCoords': 'ROOT' } // A name for the layout batch, grouping everything `layout()` applies into one graph change. @@ -121,13 +121,16 @@ type SetLinkAttributesCallback = (params: { link: dia.Link; attributes: { vertic - **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. - **Ports keep their JointJS-computed position by default** - `layout()` only tells ELK where they already are, so edges route to/from the exact spot the element's port groups place them at. Opt into ELK repositioning/reordering them by setting `elk.portConstraints` (e.g. `'FIXED_SIDE'`/`'FREE'`) via `exportElement`/`exportPort`. -- **Asynchronous** - unlike `@joint/layout-directed-graph`, `layout()` returns a `Promise`, since `elkjs` computes layouts asynchronously (and, optionally, inside a Web Worker). +- **Asynchronous** - unlike `@joint/layout-directed-graph`, `layout()` returns a `Promise`, since `elkjs` computes layouts asynchronously (by default inside a Web Worker). +- **Web Worker** - without an `elk` option, `layout()` runs ELK in a Web Worker the package starts on first use and shares between calls. It is started with `new Worker(new URL('./elk.worker.mjs', import.meta.url), { type: 'module' })`, which webpack 5, Vite and Parcel bundle as a worker file of its own with no extra setup. ELK runs on the main thread instead where no worker can be used: no `Worker` (e.g. Node/SSR), the UMD build (a script tag has no way to locate a worker file), or a worker that fails to load (e.g. a bundler that doesn't emit worker files, or a CSP `worker-src` that blocks it) - a layout in progress when that happens is retried on the main thread. To run ELK in a worker of your own instead (e.g. with the UMD build), pass `elk: new ELK({ workerUrl })` (`elkjs/lib/elk-api.js`). - **ID handling** - ELK requires string ids; element and link ids are converted with `` `${id}` `` internally, but never written back to the graph. ## 📄 License [Mozilla Public License 2.0](https://www.mozilla.org/en-US/MPL/2.0/) -This package depends on [`elkjs`](https://github.com/kieler/elkjs), which is licensed under the [Eclipse Public License 2.0](https://github.com/kieler/elkjs/blob/master/LICENSE.md). It is installed automatically as a regular dependency, but is kept external to (never inlined into) this package's own UMD build. +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 Web Worker file, and the main-thread fallback), 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/karma.conf.js b/packages/joint-layout-elk/karma.conf.js index 886418530c..943296fe15 100644 --- a/packages/joint-layout-elk/karma.conf.js +++ b/packages/joint-layout-elk/karma.conf.js @@ -17,6 +17,8 @@ module.exports = function(config) { 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' diff --git a/packages/joint-layout-elk/rollup.config.mjs b/packages/joint-layout-elk/rollup.config.mjs index da55d044fe..c1ea315710 100644 --- a/packages/joint-layout-elk/rollup.config.mjs +++ b/packages/joint-layout-elk/rollup.config.mjs @@ -12,6 +12,33 @@ const bannerText = `/*! ${packageJson.title} v${packageJson.version} (${formatte const input = ['./dist/esm/index.mjs']; +// A UMD bundle has no way to locate a worker file of its own - replace the module that +// starts one (see `src/workerFactory.mts`) with one that doesn't, so `layout()` runs ELK +// on the main thread by default. +const noWorker = { + name: 'no-worker', + resolveId(source) { + return /(^|\/)workerFactory\.mjs$/.test(source) ? '\0workerFactory' : null; + }, + load(id) { + return (id === '\0workerFactory') ? 'export function createElkWorker() { return undefined; }' : null; + } +}; + +// The unit test bundle starts whichever worker a test hands it (`window.__createElkWorker`), +// so the default worker - and falling back from it - can be tested too (see `test/index.js`). +const testWorker = { + name: 'test-worker', + resolveId(source) { + return /(^|\/)workerFactory\.mjs$/.test(source) ? '\0workerFactory' : null; + }, + load(id) { + return (id === '\0workerFactory') + ? 'export function createElkWorker() { return window.__createElkWorker ? window.__createElkWorker() : undefined; }' + : null; + } +}; + export default [ { input, @@ -52,6 +79,7 @@ export default [ }, ], plugins: [ + noWorker, nodeResolve({ preferBuiltins: false }) @@ -82,6 +110,7 @@ export default [ } ], plugins: [ + testWorker, nodeResolve({ preferBuiltins: false }), diff --git a/packages/joint-layout-elk/src/defaultElk.mts b/packages/joint-layout-elk/src/defaultElk.mts new file mode 100644 index 0000000000..89d46310bb --- /dev/null +++ b/packages/joint-layout-elk/src/defaultElk.mts @@ -0,0 +1,76 @@ +import ElkConstructor from 'elkjs/lib/elk.bundled.js'; +import ElkApi from 'elkjs/lib/elk-api.js'; +import { createElkWorker } from './workerFactory.mjs'; + +import type { ELK, ElkNode } from 'elkjs'; + +// The reason a layout is retried on the main thread, rather than an error of ELK's own. +const WORKER_FAILED = Symbol('workerFailed'); + +// Lazily created on the first `layout()` call, then shared by every later one. +let workerElk: ELK | undefined; +let mainThreadElk: ELK | undefined; +// Rejects (with `WORKER_FAILED`) once the worker fails - `undefined` until it is started. +let workerFailure: Promise | undefined; +// Set once the worker fails - every later layout then runs on the main thread straight away. +let hasWorkerFailed = false; + +function getMainThreadElk(): ELK { + if (!mainThreadElk) { + mainThreadElk = new ElkConstructor(); + } + return mainThreadElk; +} + +function startWorker(): Worker | undefined { + if (typeof Worker === 'undefined') return undefined; + try { + return createElkWorker(); + } catch { + // E.g. a worker script the page's CSP (`worker-src`) doesn't allow. + return undefined; + } +} + +function getWorkerElk(): ELK | undefined { + if (workerElk || hasWorkerFailed) return workerElk; + + // No worker yet is checked for again on the next layout - nothing is started meanwhile. + const worker = startWorker(); + if (!worker) return undefined; + + // ELK itself never listens for a worker's `error` event - a worker script that fails to + // load (e.g. a bundler that doesn't emit worker files) would leave every layout pending. + workerFailure = new Promise((_resolve, reject) => { + worker.addEventListener('error', (event) => { + event.preventDefault(); + hasWorkerFailed = true; + workerElk = undefined; + worker.terminate(); + reject(WORKER_FAILED); + }, { once: true }); + }); + // Nothing may be waiting on it when the worker fails. + workerFailure.catch(() => {}); + + workerElk = new ElkApi({ workerFactory: () => worker }); + return workerElk; +} + +/** + * Lays out `elkGraph` with the default ELK instance - in a Web Worker where one can be + * started, on the main thread otherwise (e.g. no `Worker` in Node/SSR, or the UMD build). + * A layout started while the worker fails is retried on the main thread. + */ +export async function layoutWithDefaultElk(elkGraph: ElkNode): Promise { + const elk = getWorkerElk(); + if (!elk) return getMainThreadElk().layout(elkGraph); + try { + // The worker gets its own (structured) clone of `elkGraph`, so a retry starts from + // the original. + return await Promise.race([elk.layout(elkGraph), workerFailure as Promise]); + } catch (error) { + if (error !== WORKER_FAILED) throw error; + return getMainThreadElk().layout(elkGraph); + } +} 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..cfb92c6d87 --- /dev/null +++ b/packages/joint-layout-elk/src/elk.worker.mts @@ -0,0 +1,3 @@ +// The Web Worker `layout()` runs ELK in by default (see `workerFactory.mts`). 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/layout.mts b/packages/joint-layout-elk/src/layout.mts index 96c5345694..65d6a4d62b 100644 --- a/packages/joint-layout-elk/src/layout.mts +++ b/packages/joint-layout-elk/src/layout.mts @@ -1,6 +1,6 @@ import { util, g } from '@joint/core'; -import ElkConstructor from 'elkjs/lib/elk.bundled.js'; import { importLayout } from './import.mjs'; +import { layoutWithDefaultElk } from './defaultElk.mjs'; import { exportGraph } from './export.mjs'; import type { ExportGraphOptions } from './export.mjs'; @@ -30,18 +30,19 @@ const DEFAULT_OPTIONS: LayoutOptions = { batchName: LAYOUT_BATCH_NAME, }; -let defaultElk: ELK | undefined; - /** * Layout configuration options. */ export interface LayoutOptions extends ImportLayoutOptions, ExportGraphOptions { /** - * A custom ELK instance, e.g. one configured to run inside a Web Worker. - * The instance is not terminated by the package - call `elk.terminateWorker()` - * yourself when it is no longer needed. - * @defaultValue a shared, main-thread instance (`elkjs/lib/elk.bundled.js`) + * A custom ELK instance, e.g. one running in a Web Worker of your own. The instance + * is not terminated by the package - call `elk.terminateWorker()` yourself when it is + * no longer needed. + * @defaultValue a shared instance running in a Web Worker the package starts itself + * (bundled as a worker file of its own by webpack 5, Vite or Parcel). Where no worker can + * be started or loaded - no `Worker` (e.g. Node/SSR), the UMD build, a bundler that + * doesn't emit worker files - a shared main-thread instance (`elkjs/lib/elk.bundled.js`). * @example * import ELK from 'elkjs/lib/elk-api.js'; * const elk = new ELK({ workerUrl: new URL('elkjs/lib/elk-worker.min.js', import.meta.url).href }); @@ -68,13 +69,6 @@ export interface LayoutResult { elkGraph: ElkNode; } -function getDefaultElk(): ELK { - if (!defaultElk) { - defaultElk = new ElkConstructor(); - } - return defaultElk; -} - /** * Tight bounding box of the top-level nodes in an ELK layout result. */ @@ -116,7 +110,6 @@ export async function layout({ graph, elements, links }: LayoutCells, opt?: Layo opt?.elkLayoutOptions || {}, DEFAULT_LAYOUT_OPTIONS ) as ElkLayoutOptions; - const elk = opt?.elk || getDefaultElk(); const batchName = options.batchName as string; const { elkGraph, elementsById, linksById, portsById } = exportGraph( @@ -126,7 +119,8 @@ export async function layout({ graph, elements, links }: LayoutCells, opt?: Layo elkLayoutOptions ); - const result = await elk.layout(elkGraph as unknown as RawElkNode) as ElkNode; + const rawElkGraph = elkGraph as unknown as RawElkNode; + const result = await (opt?.elk ? opt.elk.layout(rawElkGraph) : layoutWithDefaultElk(rawElkGraph)) as ElkNode; // Wraps the import in a single batch, so it emits one combined change instead of // one per element/port/link. diff --git a/packages/joint-layout-elk/src/types/elkEdgeOptions.mts b/packages/joint-layout-elk/src/types/elkEdgeOptions.mts index c88f069794..67855fc619 100644 --- a/packages/joint-layout-elk/src/types/elkEdgeOptions.mts +++ b/packages/joint-layout-elk/src/types/elkEdgeOptions.mts @@ -4,83 +4,64 @@ export interface EdgeElkLayoutOptions { // Falls back to a plain string for any ELK option beyond this package's Core/Layered coverage. [key: string]: string | undefined; /** - * A fixed list of bend points for the edge. This is used by the 'Fixed Layout' algorithm to - * specify a pre-defined routing for an edge. The vector chain must include the source point, any - * bend points, and the target point, so it must have at least two points. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-bendPoints.html */ 'elk.bendPoints'?: string; /** - * Allows to specify individual spacing values for graph elements that shall be different from the - * value specified for the element's parent. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-individual.html */ 'elk.spacing.individual'?: string; /** - * Defines the priority of an object; its meaning depends on the specific layout algorithm and the - * context where it is used. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-priority.html */ 'elk.priority'?: `${number}`; /** - * This option is not used as option, but as output of the layout algorithms. It is attached to - * edges and determines the points where junction symbols should be drawn in order to represent - * hyperedges with orthogonal routing. Whether such points are computed depends on the chosen - * layout algorithm and edge routing style. The points are put into the vector chain with no - * specific order. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-junctionPoints.html * @defaultValue `new KVectorChain()` */ 'elk.junctionPoints'?: string; /** - * No layout is done for the associated element. This is used to mark parts of a diagram to avoid - * their inclusion in the layout graph, or to mark parts of the layout graph to prevent layout - * engines from processing them. If you wish to exclude the contents of a compound node from - * automatic layout, while the node itself is still considered on its own layer, use the 'Fixed - * Layout' algorithm for that node. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-noLayout.html * @defaultValue 'false' */ 'elk.noLayout'?: 'true' | 'false'; /** - * Whether a self loop should be routed inside a node instead of around that node. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-insideSelfLoops-yo.html * @defaultValue 'false' */ 'elk.insideSelfLoops.yo'?: 'true' | 'false'; /** - * The thickness of an edge. This is a hint on the line width used to draw an edge, possibly - * requiring more space to be reserved for it. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-edge-thickness.html * @defaultValue '1' */ 'elk.edge.thickness'?: `${number}`; /** - * The type of an edge. This is usually used for UML class diagrams, where associations must be - * handled differently from generalizations. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-edge-type.html * @defaultValue 'NONE' */ 'elk.edge.type'?: EdgeType; /** - * Defines how important it is to have a certain edge point into the direction of the overall - * layout. This option is evaluated during the cycle breaking phase. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-priority-direction.html * @defaultValue '0' */ 'elk.layered.priority.direction'?: `${number}`; /** - * Defines how important it is to keep an edge as short as possible. This option is evaluated - * during the layering phase. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-priority-shortness.html * @defaultValue '0' */ 'elk.layered.priority.shortness'?: `${number}`; /** - * Defines how important it is to keep an edge straight, i.e. aligned with one of the two axes. - * This option is evaluated during node placement. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-priority-straightness.html * @defaultValue '0' */ 'elk.layered.priority.straightness'?: `${number}`; /** - * Used to define partial ordering groups during crossing minimization. A lower group id means that - * the group is sorted before other groups. A group model order of 0 is the default group. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-groupModelOrder-crossingMinimizationId.html * @defaultValue '0' */ 'elk.layered.considerModelOrder.groupModelOrder.crossingMinimizationId'?: `${number}`; /** - * Used to define partial ordering groups during component packing. A lower group id means that the - * group is sorted before other groups. A group model order of 0 is the default group. + * @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/elkLabelOptions.mts b/packages/joint-layout-elk/src/types/elkLabelOptions.mts index 048f4d8738..446bfceba9 100644 --- a/packages/joint-layout-elk/src/types/elkLabelOptions.mts +++ b/packages/joint-layout-elk/src/types/elkLabelOptions.mts @@ -4,60 +4,48 @@ export interface LabelElkLayoutOptions { // Falls back to a plain string for any ELK option beyond this package's Core/Layered coverage. [key: string]: string | undefined; /** - * Allows to specify individual spacing values for graph elements that shall be different from the - * value specified for the element's parent. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-individual.html */ 'elk.spacing.individual'?: string; /** - * Hints for where node labels are to be placed; if empty, the node label's position is not - * modified. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-nodeLabels-placement.html * @defaultValue `NodeLabelPlacement.fixed` */ 'elk.nodeLabels.placement'?: string; /** - * The position of a node, port, or label. This is used by the 'Fixed Layout' algorithm to specify - * a pre-defined position. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-position.html */ 'elk.position'?: string; /** - * Gives a hint on where to put edge labels. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-edgeLabels-placement.html * @defaultValue 'CENTER' */ 'elk.edgeLabels.placement'?: EdgeLabelPlacement; /** - * If true, an edge label is placed directly on its edge. May only apply to center edge labels. - * This kind of label placement is only advisable if the label's rendering is such that it is not - * crossed by its edge and thus stays legible. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-edgeLabels-inline.html * @defaultValue 'false' */ 'elk.edgeLabels.inline'?: 'true' | 'false'; /** - * Font name used for a label. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-font-name.html */ 'elk.font.name'?: string; /** - * Font size used for a label. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-font-size.html */ 'elk.font.size'?: `${number}`; /** - * Determines the amount of fuzziness to be used when performing softwrapping on labels. The value - * expresses the percent of overhang that is permitted for each line. If the next line would take - * up less space than this threshold, it is appended to the current line instead of being placed in - * a new line. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-softwrappingFuzziness.html * @defaultValue '0.0' */ 'elk.softwrappingFuzziness'?: `${number}`; /** - * No layout is done for the associated element. This is used to mark parts of a diagram to avoid - * their inclusion in the layout graph, or to mark parts of the layout graph to prevent layout - * engines from processing them. If you wish to exclude the contents of a compound node from - * automatic layout, while the node itself is still considered on its own layer, use the 'Fixed - * Layout' algorithm for that node. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-noLayout.html * @defaultValue 'false' */ 'elk.noLayout'?: 'true' | 'false'; /** - * Determines in which layer center labels of long edges should be placed. + * @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 index 5f1ae8be2f..8904ddc780 100644 --- a/packages/joint-layout-elk/src/types/elkLayoutOptions.mts +++ b/packages/joint-layout-elk/src/types/elkLayoutOptions.mts @@ -46,1146 +46,880 @@ export interface ElkLayoutOptions { // Falls back to a plain string for any ELK option beyond this package's Core/Layered coverage. [key: string]: string | undefined; /** - * Configures the packing mode used by the `BoxLayoutProvider`. If SIMPLE is not required (neither - * priorities are used nor the interactive mode), GROUP_DEC can improve the packing and decrease - * the area. GROUP_MIXED and GROUP_INC may, in very specific scenarios, work better. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-box-packingMode.html * @defaultValue `BoxLayoutProvider.PackingMode.SIMPLE` */ 'elk.box.packingMode'?: string; /** - * Select a specific layout algorithm. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-algorithm.html */ 'elk.algorithm'?: ElkAlgorithm; /** - * Alignment of the selected node relative to other nodes; the exact meaning depends on the used - * algorithm. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-alignment.html * @defaultValue 'AUTOMATIC' */ 'elk.alignment'?: Alignment; /** - * The desired aspect ratio of the drawing, that is the quotient of width by height. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-aspectRatio.html */ 'elk.aspectRatio'?: `${number}`; /** - * A fixed list of bend points for the edge. This is used by the 'Fixed Layout' algorithm to - * specify a pre-defined routing for an edge. The vector chain must include the source point, any - * bend points, and the target point, so it must have at least two points. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-bendPoints.html */ 'elk.bendPoints'?: string; /** - * Specifies how the content of a node are aligned. Each node can individually control the - * alignment of its contents. I.e. if a node should be aligned top left in its parent node, the - * parent node should specify that option. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-contentAlignment.html * @defaultValue `ContentAlignment.topLeft()` */ 'elk.contentAlignment'?: string; /** - * Whether additional debug information shall be generated. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-debugMode.html * @defaultValue 'false' */ 'elk.debugMode'?: 'true' | 'false'; /** - * Overall direction of edges: horizontal (right / left) or vertical (down / up). + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-direction.html * @defaultValue 'UNDEFINED' */ 'elk.direction'?: Direction; /** - * What kind of edge routing style should be applied for the content of a parent node. Algorithms - * may also set this option to single edges in order to mark them as splines. The bend point list - * of edges with this option set to SPLINES must be interpreted as control points for a piecewise - * cubic spline. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-edgeRouting.html * @defaultValue 'UNDEFINED' */ 'elk.edgeRouting'?: EdgeRouting; /** - * If active, nodes are expanded to fill the area of their parent. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-expandNodes.html * @defaultValue 'false' */ 'elk.expandNodes'?: 'true' | 'false'; /** - * Determines whether separate layout runs are triggered for different compound nodes in a - * hierarchical graph. Setting a node's hierarchy handling to `INCLUDE_CHILDREN` will lay out that - * node and all of its descendants in a single layout run, until a descendant is encountered which - * has its hierarchy handling set to `SEPARATE_CHILDREN`. In general, `SEPARATE_CHILDREN` will - * ensure that a new layout run is triggered for a node with that setting. Including multiple - * levels of hierarchy in a single layout run may allow cross-hierarchical edges to be laid out - * properly. If the root node is set to `INHERIT` (or not set at all), the default behavior is - * `SEPARATE_CHILDREN`. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-hierarchyHandling.html * @defaultValue 'INHERIT' */ 'elk.hierarchyHandling'?: HierarchyHandling; /** - * The padding to be left to a parent element's border when placing child elements. This can also - * serve as an output option of a layout algorithm if node size calculation is setup appropriately. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-padding.html * @defaultValue `new ElkPadding(12)` */ 'elk.padding'?: string; /** - * Whether the algorithm should be run in interactive mode for the content of a parent node. What - * this means exactly depends on how the specific algorithm interprets this option. Usually in the - * interactive mode algorithms try to modify the current layout as little as possible. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-interactive.html * @defaultValue 'false' */ 'elk.interactive'?: 'true' | 'false'; /** - * Whether the graph should be changeable interactively and by setting constraints + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-interactiveLayout.html * @defaultValue 'false' */ 'elk.interactiveLayout'?: 'true' | 'false'; /** - * Node micro layout comprises the computation of node dimensions (if requested), the placement of - * ports and their labels, and the placement of node labels. The functionality is implemented - * independent of any specific layout algorithm and shouldn't have any negative impact on the - * layout algorithm's performance itself. Yet, if any unforeseen behavior occurs, this option - * allows to deactivate the micro layout. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-omitNodeMicroLayout.html * @defaultValue 'false' */ 'elk.omitNodeMicroLayout'?: 'true' | 'false'; /** - * For layouts transferred into JSON graphs, specify the coordinate system to be used for nodes, - * ports, and labels of nodes and ports. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-json-shapeCoords.html * @defaultValue 'INHERIT' */ 'elk.json.shapeCoords'?: ShapeCoords; /** - * For layouts transferred into JSON graphs, specify the coordinate system to be used for edge - * route points and edge labels. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-json-edgeCoords.html * @defaultValue 'INHERIT' */ 'elk.json.edgeCoords'?: EdgeCoords; /** - * Spacing to be preserved between a comment box and other comment boxes connected to the same - * node. The space left between comment boxes of different nodes is controlled by the node-node - * spacing. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-commentComment.html * @defaultValue '10' */ 'elk.spacing.commentComment'?: `${number}`; /** - * Spacing to be preserved between a node and its connected comment boxes. The space left between a - * node and the comments of another node is controlled by the node-node spacing. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-commentNode.html * @defaultValue '10' */ 'elk.spacing.commentNode'?: `${number}`; /** - * Spacing to be preserved between pairs of connected components. This option is only relevant if - * 'separateConnectedComponents' is activated. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-componentComponent.html * @defaultValue '20' */ 'elk.spacing.componentComponent'?: `${number}`; /** - * Spacing to be preserved between any two edges. Note that while this can somewhat easily be - * satisfied for the segments of orthogonally drawn edges, it is harder for general polylines or - * splines. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-edgeEdge.html * @defaultValue '10' */ 'elk.spacing.edgeEdge'?: `${number}`; /** - * The minimal distance to be preserved between a label and the edge it is associated with. Note - * that the placement of a label is influenced by the 'edgelabels.placement' option. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-edgeLabel.html * @defaultValue '2' */ 'elk.spacing.edgeLabel'?: `${number}`; /** - * Spacing to be preserved between nodes and edges. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-edgeNode.html * @defaultValue '10' */ 'elk.spacing.edgeNode'?: `${number}`; /** - * Determines the amount of space to be left between two labels of the same graph element. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-labelLabel.html * @defaultValue '0' */ 'elk.spacing.labelLabel'?: `${number}`; /** - * Spacing to be preserved between labels and the border of node they are associated with. Note - * that the placement of a label is influenced by the 'nodelabels.placement' option. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-labelNode.html * @defaultValue '5' */ 'elk.spacing.labelNode'?: `${number}`; /** - * Horizontal spacing to be preserved between labels and the ports they are associated with. Note - * that the placement of a label is influenced by the 'portlabels.placement' option. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-labelPortHorizontal.html * @defaultValue '1' */ 'elk.spacing.labelPortHorizontal'?: `${number}`; /** - * Vertical spacing to be preserved between labels and the ports they are associated with. Note - * that the placement of a label is influenced by the 'portlabels.placement' option. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-labelPortVertical.html * @defaultValue '1' */ 'elk.spacing.labelPortVertical'?: `${number}`; /** - * The minimal distance to be preserved between each two nodes. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-nodeNode.html * @defaultValue '20' */ 'elk.spacing.nodeNode'?: `${number}`; /** - * Spacing to be preserved between a node and its self loops. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-nodeSelfLoop.html * @defaultValue '10' */ 'elk.spacing.nodeSelfLoop'?: `${number}`; /** - * Spacing between pairs of ports of the same node. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-portPort.html * @defaultValue '10' */ 'elk.spacing.portPort'?: `${number}`; /** - * Allows to specify individual spacing values for graph elements that shall be different from the - * value specified for the element's parent. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-individual.html */ 'elk.spacing.individual'?: string; /** - * Additional space around the sets of ports on each node side. For each side of a node, this - * option can reserve additional space before and after the ports on each side. For example, a top - * spacing of 20 makes sure that the first port on the western and eastern side is 20 units away - * from the northern border. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-portsSurrounding.html * @defaultValue `new ElkMargin(0)` */ 'elk.spacing.portsSurrounding'?: string; /** - * Partition to which the node belongs. This requires Layout Partitioning to be active. Nodes with - * lower partition IDs will appear to the left of nodes with higher partition IDs (assuming a - * left-to-right layout direction). + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-partitioning-partition.html */ 'elk.partitioning.partition'?: `${number}`; /** - * Whether to activate partitioned layout. This will allow to group nodes through the Layout - * Partition option. a pair of nodes with different partition indices is then placed such that the - * node with lower index is placed to the left of the other node (with left-to-right layout - * direction). Depending on the layout algorithm, this may only be guaranteed to work if all nodes - * have a layout partition configured, or at least if edges that cross partitions are not part of a - * partition-crossing cycle. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-partitioning-activate.html * @defaultValue 'false' */ 'elk.partitioning.activate'?: 'true' | 'false'; /** - * Define padding for node labels that are placed inside of a node. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-nodeLabels-padding.html * @defaultValue `new ElkPadding(5)` */ 'elk.nodeLabels.padding'?: string; /** - * Hints for where node labels are to be placed; if empty, the node label's position is not - * modified. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-nodeLabels-placement.html * @defaultValue `NodeLabelPlacement.fixed` */ 'elk.nodeLabels.placement'?: string; /** - * Defines the default port distribution for a node. May be overridden for each side individually. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portAlignment-default.html * @defaultValue 'DISTRIBUTED' */ 'elk.portAlignment.default'?: PortAlignment; /** - * Defines how ports on the northern side are placed, overriding the node's general port alignment. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portAlignment-north.html */ 'elk.portAlignment.north'?: PortAlignment; /** - * Defines how ports on the southern side are placed, overriding the node's general port alignment. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portAlignment-south.html */ 'elk.portAlignment.south'?: PortAlignment; /** - * Defines how ports on the western side are placed, overriding the node's general port alignment. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portAlignment-west.html */ 'elk.portAlignment.west'?: PortAlignment; /** - * Defines how ports on the eastern side are placed, overriding the node's general port alignment. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portAlignment-east.html */ 'elk.portAlignment.east'?: PortAlignment; /** - * Defines constraints of the position of the ports of a node. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portConstraints.html * @defaultValue 'UNDEFINED' */ 'elk.portConstraints'?: PortConstraints; /** - * The position of a node, port, or label. This is used by the 'Fixed Layout' algorithm to specify - * a pre-defined position. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-position.html */ 'elk.position'?: string; /** - * Defines the priority of an object; its meaning depends on the specific layout algorithm and the - * context where it is used. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-priority.html */ 'elk.priority'?: `${number}`; /** - * Seed used for pseudo-random number generators to control the layout algorithm. If the value is - * 0, the seed shall be determined pseudo-randomly (e.g. from the system time). + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-randomSeed.html */ 'elk.randomSeed'?: `${number}`; /** - * Whether each connected component should be processed separately. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-separateConnectedComponents.html */ 'elk.separateConnectedComponents'?: 'true' | 'false'; /** - * What should be taken into account when calculating a node's size. Empty size constraints specify - * that a node's size is already fixed and should not be changed. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-nodeSize-constraints.html * @defaultValue `EnumSet.noneOf(SizeConstraint)` */ 'elk.nodeSize.constraints'?: string; /** - * Options modifying the behavior of the size constraints set on a node. Each member of the set - * specifies something that should be taken into account when calculating node sizes. The empty set - * corresponds to no further modifications. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-nodeSize-options.html * @defaultValue `EnumSet.of(SizeOptions.DEFAULT_MINIMUM_SIZE)` */ 'elk.nodeSize.options'?: string; /** - * The minimal size to which a node can be reduced. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-nodeSize-minimum.html * @defaultValue `new KVector(0, 0)` */ 'elk.nodeSize.minimum'?: string; /** - * By default, the fixed layout provider will enlarge a graph until it is large enough to contain - * its children. If this option is set, it won't do so. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-nodeSize-fixedGraphSize.html * @defaultValue 'false' */ 'elk.nodeSize.fixedGraphSize'?: 'true' | 'false'; /** - * This option is not used as option, but as output of the layout algorithms. It is attached to - * edges and determines the points where junction symbols should be drawn in order to represent - * hyperedges with orthogonal routing. Whether such points are computed depends on the chosen - * layout algorithm and edge routing style. The points are put into the vector chain with no - * specific order. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-junctionPoints.html * @defaultValue `new KVectorChain()` */ 'elk.junctionPoints'?: string; /** - * Whether the node should be regarded as a comment box instead of a regular node. In that case its - * placement should be similar to how labels are handled. Any edges incident to a comment box - * specify to which graph elements the comment is related. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-commentBox.html * @defaultValue 'false' */ 'elk.commentBox'?: 'true' | 'false'; /** - * Gives a hint on where to put edge labels. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-edgeLabels-placement.html * @defaultValue 'CENTER' */ 'elk.edgeLabels.placement'?: EdgeLabelPlacement; /** - * If true, an edge label is placed directly on its edge. May only apply to center edge labels. - * This kind of label placement is only advisable if the label's rendering is such that it is not - * crossed by its edge and thus stays legible. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-edgeLabels-inline.html * @defaultValue 'false' */ 'elk.edgeLabels.inline'?: 'true' | 'false'; /** - * Font name used for a label. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-font-name.html */ 'elk.font.name'?: string; /** - * Font size used for a label. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-font-size.html */ 'elk.font.size'?: `${number}`; /** - * Whether the node should be handled as a hypernode. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-hypernode.html * @defaultValue 'false' */ 'elk.hypernode'?: 'true' | 'false'; /** - * Determines the amount of fuzziness to be used when performing softwrapping on labels. The value - * expresses the percent of overhang that is permitted for each line. If the next line would take - * up less space than this threshold, it is appended to the current line instead of being placed in - * a new line. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-softwrappingFuzziness.html * @defaultValue '0.0' */ 'elk.softwrappingFuzziness'?: `${number}`; /** - * Margins define additional space around the actual bounds of a graph element. For instance, ports - * or labels being placed on the outside of a node's border might introduce such a margin. The - * margin is used to guarantee non-overlap of other graph elements with those ports or labels. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-margins.html * @defaultValue `new ElkMargin()` */ 'elk.margins'?: string; /** - * No layout is done for the associated element. This is used to mark parts of a diagram to avoid - * their inclusion in the layout graph, or to mark parts of the layout graph to prevent layout - * engines from processing them. If you wish to exclude the contents of a compound node from - * automatic layout, while the node itself is still considered on its own layer, use the 'Fixed - * Layout' algorithm for that node. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-noLayout.html * @defaultValue 'false' */ 'elk.noLayout'?: 'true' | 'false'; /** - * The offset to the port position where connections shall be attached. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-port-anchor.html */ 'elk.port.anchor'?: string; /** - * The index of a port in the fixed order around a node. The order is assumed as clockwise, - * starting with the leftmost port on the top side. This option must be set if 'Port Constraints' - * is set to FIXED_ORDER and no specific positions are given for the ports. Additionally, the - * option 'Port Side' must be defined in this case. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-port-index.html */ 'elk.port.index'?: `${number}`; /** - * The side of a node on which a port is situated. This option must be set if 'Port Constraints' is - * set to FIXED_SIDE or FIXED_ORDER and no specific positions are given for the ports. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-port-side.html * @defaultValue 'UNDEFINED' */ 'elk.port.side'?: PortSide; /** - * The offset of ports on the node border. With a positive offset the port is moved outside of the - * node, while with a negative offset the port is moved towards the inside. An offset of 0 means - * that the port is placed directly on the node border, i.e. if the port side is north, the port's - * south border touches the nodes's north border; if the port side is east, the port's west border - * touches the nodes's east border; if the port side is south, the port's north border touches the - * node's south border; if the port side is west, the port's east border touches the node's west - * border. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-port-borderOffset.html */ 'elk.port.borderOffset'?: `${number}`; /** - * Decides on a placement method for port labels; if empty, the node label's position is not - * modified. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portLabels-placement.html * @defaultValue `PortLabelPlacement.outside` */ 'elk.portLabels.placement'?: string; /** - * Use 'portLabels.placement': NEXT_TO_PORT_OF_POSSIBLE. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portLabels-nextToPortIfPossible.html * @defaultValue 'false' */ 'elk.portLabels.nextToPortIfPossible'?: 'true' | 'false'; /** - * If this option is true (default), the labels of a port will be treated as a group when it comes - * to centering them next to their port. If this option is false, only the first label will be - * centered next to the port, with the others being placed below. This only applies to labels of - * eastern and western ports and will have no effect if labels are not placed next to their port. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portLabels-treatAsGroup.html * @defaultValue 'true' */ 'elk.portLabels.treatAsGroup'?: 'true' | 'false'; /** - * The scaling factor to be applied to the corresponding node in recursive layout. It causes the - * corresponding node's size to be adjusted, and its ports and labels to be sized and placed - * accordingly after the layout of that node has been determined (and before the node itself and - * its siblings are arranged). The scaling is not reverted afterwards, so the resulting layout - * graph contains the adjusted size and position data. This option is currently not supported if - * 'Layout Hierarchy' is set. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-scaleFactor.html * @defaultValue '1' */ 'elk.scaleFactor'?: `${number}`; /** - * The width of the area occupied by the laid out children of a node. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-childAreaWidth.html */ 'elk.childAreaWidth'?: `${number}`; /** - * The height of the area occupied by the laid out children of a node. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-childAreaHeight.html */ 'elk.childAreaHeight'?: `${number}`; /** - * Turns topdown layout on and off. If this option is enabled, hierarchical layout will be computed - * first for the root node and then for its children recursively. Layouts are then scaled down to - * fit the area provided by their parents. Graphs must follow a certain structure for topdown - * layout to work properly. `TopdownNodeTypes.PARALLEL_NODE` nodes must have children of type - * `TopdownNodeTypes.HIERARCHICAL_NODE` and must define `topdown.hierarchicalNodeWidth` and - * `topdown.hierarchicalNodeAspectRatio` for their children. Furthermore they need to be laid out - * using an algorithm that is a `TopdownLayoutProvider`. Hierarchical nodes can also be parents of - * other hierarchical nodes and can optionally use a `TopdownSizeApproximator` to dynamically set - * sizes during topdown layout. In this case `topdown.hierarchicalNodeWidth` and - * `topdown.hierarchicalNodeAspectRatio` should be set on the node itself rather than the parent. - * The values are then used by the size approximator as base values. Hierarchical nodes require the - * layout option `nodeSize.fixedGraphSize` to be true to prevent the algorithm used there from - * resizing the hierarchical node. This option is not supported if 'Hierarchy Handling' is set to - * 'INCLUDE_CHILDREN' + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-topdownLayout.html * @defaultValue 'false' */ 'elk.topdownLayout'?: 'true' | 'false'; /** - * Defines the number of categories to use for the FIXED_INTEGER_RATIO_BOXES size approximator. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-topdown-sizeCategories.html * @defaultValue '3' */ 'elk.topdown.sizeCategories'?: `${number}`; /** - * When determining the graph size for the size categorisation, this value determines how many - * times a node containing children is weighted more than a simple node. For example setting this - * value to four would result in a graph containing a simple node and a hierarchical node to be - * counted as having a size of five. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-topdown-sizeCategoriesHierarchicalNodeWeight.html * @defaultValue '4' */ 'elk.topdown.sizeCategoriesHierarchicalNodeWeight'?: `${number}`; /** - * The scaling factor to be applied to the nodes laid out within the node in recursive topdown - * layout. The difference to 'Scale Factor' is that the node itself is not scaled. This value has - * to be set on hierarchical nodes. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-topdown-scaleFactor.html * @defaultValue '1' */ 'elk.topdown.scaleFactor'?: `${number}`; /** - * The size approximator to be used to set sizes of hierarchical nodes during topdown layout. The - * default value is null, which results in nodes keeping whatever size is defined for them e.g. - * through parent parallel node or by manually setting the size. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-topdown-sizeApproximator.html * @defaultValue `null` */ 'elk.topdown.sizeApproximator'?: string; /** - * The fixed size of a hierarchical node when using topdown layout. If this value is set on a - * parallel node it applies to its children, when set on a hierarchical node it applies to the node - * itself. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-topdown-hierarchicalNodeWidth.html * @defaultValue '150' */ 'elk.topdown.hierarchicalNodeWidth'?: `${number}`; /** - * The fixed aspect ratio of a hierarchical node when using topdown layout. Default is 1/sqrt(2). - * If this value is set on a parallel node it applies to its children, when set on a hierarchical - * node it applies to the node itself. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-topdown-hierarchicalNodeAspectRatio.html * @defaultValue '1.414' */ 'elk.topdown.hierarchicalNodeAspectRatio'?: `${number}`; /** - * The different node types used for topdown layout. If the node type is set to - * `TopdownNodeTypes.PARALLEL_NODE` the algorithm must be set to a `TopdownLayoutProvider` such as - * `TopdownPacking`. The `nodeSize.fixedGraphSize` option is technically only required for - * hierarchical nodes. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-topdown-nodeType.html * @defaultValue `null` */ 'elk.topdown.nodeType'?: TopdownNodeTypes; /** - * Determines the upper limit for the topdown scale factor. The default value is 1.0 which ensures - * that nested children never end up appearing larger than their parents in terms of unit sizes - * such as the font size. If the limit is larger, nodes will fully utilize the available space, but - * it is counteriniuitive for inner nodes to have a larger scale than outer nodes. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-topdown-scaleCap.html * @defaultValue '1' */ 'elk.topdown.scaleCap'?: `${number}`; /** - * Whether this node allows to route self loops inside of it instead of around it. If set to true, - * this will make the node a compound node if it isn't already, and will require the layout - * algorithm to support compound nodes with hierarchical ports. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-insideSelfLoops-activate.html * @defaultValue 'false' */ 'elk.insideSelfLoops.activate'?: 'true' | 'false'; /** - * Whether a self loop should be routed inside a node instead of around that node. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-insideSelfLoops-yo.html * @defaultValue 'false' */ 'elk.insideSelfLoops.yo'?: 'true' | 'false'; /** - * The thickness of an edge. This is a hint on the line width used to draw an edge, possibly - * requiring more space to be reserved for it. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-edge-thickness.html * @defaultValue '1' */ 'elk.edge.thickness'?: `${number}`; /** - * The type of an edge. This is usually used for UML class diagrams, where associations must be - * handled differently from generalizations. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-edge-type.html * @defaultValue 'NONE' */ 'elk.edge.type'?: EdgeType; /** - * Whether the shift from the old layout to the new computed layout shall be animated. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-animate.html * @defaultValue 'true' */ 'elk.animate'?: 'true' | 'false'; /** - * Factor for computation of animation time. The higher the value, the longer the animation time. - * If the value is 0, the resulting time is always equal to the minimum defined by 'Minimal - * Animation Time'. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-animTimeFactor.html * @defaultValue '100' */ 'elk.animTimeFactor'?: `${number}`; /** - * Whether the hierarchy levels on the path from the selected element to the root of the diagram - * shall be included in the layout process. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layoutAncestors.html * @defaultValue 'false' */ 'elk.layoutAncestors'?: 'true' | 'false'; /** - * The maximal time for animations, in milliseconds. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-maxAnimTime.html * @defaultValue '4000' */ 'elk.maxAnimTime'?: `${number}`; /** - * The minimal time for animations, in milliseconds. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-minAnimTime.html * @defaultValue '400' */ 'elk.minAnimTime'?: `${number}`; /** - * Whether a progress bar shall be displayed during layout computations. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-progressBar.html * @defaultValue 'false' */ 'elk.progressBar'?: 'true' | 'false'; /** - * Whether the graph shall be validated before any layout algorithm is applied. If this option is - * enabled and at least one error is found, the layout process is aborted and a message is shown to - * the user. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-validateGraph.html * @defaultValue 'false' */ 'elk.validateGraph'?: 'true' | 'false'; /** - * Whether layout options shall be validated before any layout algorithm is applied. If this option - * is enabled and at least one error is found, the layout process is aborted and a message is shown - * to the user. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-validateOptions.html * @defaultValue 'true' */ 'elk.validateOptions'?: 'true' | 'false'; /** - * Whether the zoom level shall be set to view the whole diagram after layout. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-zoomToFit.html * @defaultValue 'false' */ 'elk.zoomToFit'?: 'true' | 'false'; /** - * Strategy for cycle breaking. Cycle breaking looks for cycles in the graph and determines which - * edges to reverse to break the cycles. Reversed edges will end up pointing to the opposite - * direction of regular edges (that is, reversed edges will point left if edges usually point - * right). + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-cycleBreaking-strategy.html * @defaultValue 'GREEDY' */ 'elk.layered.cycleBreaking.strategy'?: CycleBreakingStrategy; /** - * Strategy for node layering. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layering-strategy.html * @defaultValue 'NETWORK_SIMPLEX' */ 'elk.layered.layering.strategy'?: LayeringStrategy; /** - * Determines a constraint on the placement of the node regarding the layering. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layering-layerConstraint.html * @defaultValue 'NONE' */ 'elk.layered.layering.layerConstraint'?: LayerConstraint; /** - * Allows to set a constraint regarding the layer placement of a node. Let i be the value of teh - * constraint. Assumed the drawing has n layers and i < n. If set to i, it expresses that the node - * should be placed in i-th layer. Should i>=n be true then the node is placed in the last layer of - * the drawing. Note that this option is not part of any of ELK Layered's default configurations - * but is only evaluated as part of the `InteractiveLayeredGraphVisitor`, which must be applied - * manually or used via the `DiagramLayoutEngine. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layering-layerChoiceConstraint.html * @defaultValue `null` */ 'elk.layered.layering.layerChoiceConstraint'?: `${number}`; /** - * Layer identifier that was calculated by ELK Layered for a node. This is only generated if - * interactiveLayot or generatePositionAndLayerIds is set. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layering-layerId.html * @defaultValue '-1' */ 'elk.layered.layering.layerId'?: `${number}`; /** - * Defines a loose upper bound on the width of the MinWidth layerer. If set to '-1' multiple values - * are tested and the best result is selected. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layering-minWidth-upperBoundOnWidth.html * @defaultValue '4' */ 'elk.layered.layering.minWidth.upperBoundOnWidth'?: `${number}`; /** - * Multiplied with Upper Bound On Width for defining an upper bound on the width of layers which - * haven't been determined yet, but whose maximum width had been (roughly) estimated by the - * MinWidth algorithm. Compensates for too high estimations. If set to '-1' multiple values are - * tested and the best result is selected. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layering-minWidth-upperLayerEstimationScalingFactor.html * @defaultValue '2' */ 'elk.layered.layering.minWidth.upperLayerEstimationScalingFactor'?: `${number}`; /** - * Reduces number of dummy nodes after layering phase (if possible). + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layering-nodePromotion-strategy.html * @defaultValue 'NONE' */ 'elk.layered.layering.nodePromotion.strategy'?: NodePromotionStrategy; /** - * Limits the number of iterations for node promotion. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layering-nodePromotion-maxIterations.html * @defaultValue '0' */ 'elk.layered.layering.nodePromotion.maxIterations'?: `${number}`; /** - * The maximum number of nodes allowed per layer. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layering-coffmanGraham-layerBound.html * @defaultValue 'MAX_VALUE' */ 'elk.layered.layering.coffmanGraham.layerBound'?: `${number}`; /** - * Strategy for crossing minimization. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-crossingMinimization-strategy.html * @defaultValue 'LAYER_SWEEP' */ 'elk.layered.crossingMinimization.strategy'?: CrossingMinimizationStrategy; /** - * The node order given by the model does not change to produce a better layout. E.g. if node A is - * before node B in the model this is not changed during crossing minimization. This assumes that - * the node model order is already respected before crossing minimization. This can be achieved by - * setting considerModelOrder.strategy to NODES_AND_EDGES. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-crossingMinimization-forceNodeModelOrder.html * @defaultValue 'false' */ 'elk.layered.crossingMinimization.forceNodeModelOrder'?: 'true' | 'false'; /** - * How likely it is to use cross-hierarchy (1) vs bottom-up (-1). + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-crossingMinimization-hierarchicalSweepiness.html * @defaultValue '0.1' */ 'elk.layered.crossingMinimization.hierarchicalSweepiness'?: `${number}`; /** - * By default it is decided automatically if the greedy switch is activated or not. The decision is - * based on whether the size of the input graph (without dummy nodes) is smaller than the value of - * this option. A '0' enforces the activation. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-crossingMinimization-greedySwitch-activationThreshold.html * @defaultValue '40' */ 'elk.layered.crossingMinimization.greedySwitch.activationThreshold'?: `${number}`; /** - * Greedy Switch strategy for crossing minimization. The greedy switch heuristic is executed after - * the regular crossing minimization as a post-processor. Note that if 'hierarchyHandling' is set - * to 'INCLUDE_CHILDREN', the 'greedySwitchHierarchical.type' option must be used. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-crossingMinimization-greedySwitch-type.html * @defaultValue 'TWO_SIDED' */ 'elk.layered.crossingMinimization.greedySwitch.type'?: GreedySwitchType; /** - * Activates the greedy switch heuristic in case hierarchical layout is used. The differences to - * the non-hierarchical case (see 'greedySwitch.type') are: 1) greedy switch is inactive by - * default, 3) only the option value set on the node at which hierarchical layout starts is - * relevant, and 2) if it's activated by the user, it properly addresses hierarchy-crossing edges. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-crossingMinimization-greedySwitchHierarchical-type.html * @defaultValue 'OFF' */ 'elk.layered.crossingMinimization.greedySwitchHierarchical.type'?: GreedySwitchType; /** - * Preserves the order of nodes within a layer but still minimizes crossings between edges - * connecting long edge dummies. Derives the desired order from positions specified by the - * 'org.eclipse.elk.position' layout option. Requires a crossing minimization strategy that is able - * to process 'in-layer' constraints. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-crossingMinimization-semiInteractive.html * @defaultValue 'false' */ 'elk.layered.crossingMinimization.semiInteractive'?: 'true' | 'false'; /** - * Allows to set a constraint which specifies of which node the current node is the predecessor. If - * set to 's' then the node is the predecessor of 's' and is in the same layer + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-crossingMinimization-inLayerPredOf.html * @defaultValue `null` */ 'elk.layered.crossingMinimization.inLayerPredOf'?: string; /** - * Allows to set a constraint which specifies of which node the current node is the successor. If - * set to 's' then the node is the successor of 's' and is in the same layer + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-crossingMinimization-inLayerSuccOf.html * @defaultValue `null` */ 'elk.layered.crossingMinimization.inLayerSuccOf'?: string; /** - * Allows to set a constraint regarding the position placement of a node in a layer. Assumed the - * layer in which the node placed includes n other nodes and i < n. If set to i, it expresses that - * the node should be placed at the i-th position. Should i>=n be true then the node is placed at - * the last position in the layer. Note that this option is not part of any of ELK Layered's - * default configurations but is only evaluated as part of the `InteractiveLayeredGraphVisitor`, - * which must be applied manually or used via the `DiagramLayoutEngine. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-crossingMinimization-positionChoiceConstraint.html * @defaultValue `null` */ 'elk.layered.crossingMinimization.positionChoiceConstraint'?: `${number}`; /** - * Position within a layer that was determined by ELK Layered for a node. This is only generated if - * interactiveLayot or generatePositionAndLayerIds is set. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-crossingMinimization-positionId.html * @defaultValue '-1' */ 'elk.layered.crossingMinimization.positionId'?: `${number}`; /** - * Strategy for node placement. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-nodePlacement-strategy.html * @defaultValue 'BRANDES_KOEPF' */ 'elk.layered.nodePlacement.strategy'?: NodePlacementStrategy; /** - * Favor straight edges over a balanced node placement. The default behavior is determined - * automatically based on the used 'edgeRouting'. For an orthogonal style it is set to true, for - * all other styles to false. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-nodePlacement-favorStraightEdges.html */ 'elk.layered.nodePlacement.favorStraightEdges'?: 'true' | 'false'; /** - * Specifies whether the Brandes Koepf node placer tries to increase the number of straight edges - * at the expense of diagram size. There is a subtle difference to the 'favorStraightEdges' option, - * which decides whether a balanced placement of the nodes is desired, or not. In bk terms this - * means combining the four alignments into a single balanced one, or not. This option on the other - * hand tries to straighten additional edges during the creation of each of the four alignments. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-nodePlacement-bk-edgeStraightening.html * @defaultValue 'IMPROVE_STRAIGHTNESS' */ 'elk.layered.nodePlacement.bk.edgeStraightening'?: EdgeStraighteningStrategy; /** - * Tells the BK node placer to use a certain alignment (out of its four) instead of the one - * producing the smallest height, or the combination of all four. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-nodePlacement-bk-fixedAlignment.html * @defaultValue 'NONE' */ 'elk.layered.nodePlacement.bk.fixedAlignment'?: FixedAlignment; /** - * Dampens the movement of nodes to keep the diagram from getting too large. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-nodePlacement-linearSegments-deflectionDampening.html * @defaultValue '0.3' */ 'elk.layered.nodePlacement.linearSegments.deflectionDampening'?: `${number}`; /** - * Aims at shorter and straighter edges. Two configurations are possible: (a) allow ports to move - * freely on the side they are assigned to (the order is always defined beforehand), (b) - * additionally allow to enlarge a node wherever it helps. If this option is not configured for a - * node, the 'nodeFlexibility.default' value is used, which is specified for the node's parent. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-nodePlacement-networkSimplex-nodeFlexibility.html */ 'elk.layered.nodePlacement.networkSimplex.nodeFlexibility'?: NodeFlexibility; /** - * Default value of the 'nodeFlexibility' option for the children of a hierarchical node. + * @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; /** - * Run a second node placement algorithm after the initial network simplex with node flexibility. - * In the second run, node flexibility is disabled and a different node placer can be used. The - * node sizes determined by the node flexibility option are used as fixed node sizes in the second - * run. If set to null (default) no second run is performed. + * @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; /** - * Specifies the way control points are assembled for each individual edge. CONSERVATIVE ensures - * that edges are properly routed around the nodes but feels rather orthogonal at times. SLOPPY - * uses fewer control points to obtain curvier edge routes but may result in edges overlapping - * nodes. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-edgeRouting-splines-mode.html * @defaultValue 'SLOPPY' */ 'elk.layered.edgeRouting.splines.mode'?: SplineRoutingMode; /** - * Spacing factor for routing area between layers when using sloppy spline routing. + * @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}`; /** - * Width of the strip to the left and to the right of each layer where the polyline edge router is - * allowed to refrain from ensuring that edges are routed horizontally. This prevents awkward bend - * points for nodes that extent almost to the edge of their layer. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-edgeRouting-polyline-slopedEdgeZoneWidth.html * @defaultValue '2.0' */ 'elk.layered.edgeRouting.polyline.slopedEdgeZoneWidth'?: `${number}`; /** - * Alter the distribution of the loops around the node. It only takes effect for - * PortConstraints.FREE. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-edgeRouting-selfLoopDistribution.html * @defaultValue 'NORTH' */ 'elk.layered.edgeRouting.selfLoopDistribution'?: SelfLoopDistributionStrategy; /** - * Alter the ordering of the loops they can either be stacked or sequenced. It only takes effect - * for PortConstraints.FREE. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-edgeRouting-selfLoopOrdering.html * @defaultValue 'STACKED' */ 'elk.layered.edgeRouting.selfLoopOrdering'?: SelfLoopOrderingStrategy; /** - * An optional base value for all other layout options of the 'spacing' group. It can be used to - * conveniently alter the overall 'spaciousness' of the drawing. Whenever an explicit value is set - * for the other layout options, this base value will have no effect. The base value is not - * inherited, i.e. it must be set for each hierarchical node. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-spacing-baseValue.html */ 'elk.layered.spacing.baseValue'?: `${number}`; /** - * The spacing to be preserved between nodes and edges that are routed next to the node's layer. - * For the spacing between nodes and edges that cross the node's layer 'spacing.edgeNode' is used. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-spacing-edgeNodeBetweenLayers.html * @defaultValue '10' */ 'elk.layered.spacing.edgeNodeBetweenLayers'?: `${number}`; /** - * Spacing to be preserved between pairs of edges that are routed between the same pair of layers. - * Note that 'spacing.edgeEdge' is used for the spacing between pairs of edges crossing the same - * layer. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-spacing-edgeEdgeBetweenLayers.html * @defaultValue '10' */ 'elk.layered.spacing.edgeEdgeBetweenLayers'?: `${number}`; /** - * The spacing to be preserved between any pair of nodes of two adjacent layers. Note that - * 'spacing.nodeNode' is used for the spacing between nodes within the layer itself. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-spacing-nodeNodeBetweenLayers.html * @defaultValue '20' */ 'elk.layered.spacing.nodeNodeBetweenLayers'?: `${number}`; /** - * Defines how important it is to have a certain edge point into the direction of the overall - * layout. This option is evaluated during the cycle breaking phase. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-priority-direction.html * @defaultValue '0' */ 'elk.layered.priority.direction'?: `${number}`; /** - * Defines how important it is to keep an edge as short as possible. This option is evaluated - * during the layering phase. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-priority-shortness.html * @defaultValue '0' */ 'elk.layered.priority.shortness'?: `${number}`; /** - * Defines how important it is to keep an edge straight, i.e. aligned with one of the two axes. - * This option is evaluated during node placement. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-priority-straightness.html * @defaultValue '0' */ 'elk.layered.priority.straightness'?: `${number}`; /** - * Specifies whether and how post-process compaction is applied. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-compaction-postCompaction-strategy.html * @defaultValue 'NONE' */ 'elk.layered.compaction.postCompaction.strategy'?: GraphCompactionStrategy; /** - * Specifies whether and how post-process compaction is applied. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-compaction-postCompaction-constraints.html * @defaultValue 'SCANLINE' */ 'elk.layered.compaction.postCompaction.constraints'?: ConstraintCalculationStrategy; /** - * Tries to further compact components (disconnected sub-graphs). + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-compaction-connectedComponents.html * @defaultValue 'false' */ 'elk.layered.compaction.connectedComponents'?: 'true' | 'false'; /** - * Makes room around high degree nodes to place leafs and trees. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-highDegreeNodes-treatment.html * @defaultValue 'false' */ 'elk.layered.highDegreeNodes.treatment'?: 'true' | 'false'; /** - * Whether a node is considered to have a high degree. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-highDegreeNodes-threshold.html * @defaultValue '16' */ 'elk.layered.highDegreeNodes.threshold'?: `${number}`; /** - * Maximum height of a subtree connected to a high degree node to be moved to separate layers. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-highDegreeNodes-treeHeight.html * @defaultValue '5' */ 'elk.layered.highDegreeNodes.treeHeight'?: `${number}`; /** - * For certain graphs and certain prescribed drawing areas it may be desirable to split the laid - * out graph into chunks that are placed side by side. The edges that connect different chunks are - * 'wrapped' around from the end of one chunk to the start of the other chunk. The points between - * the chunks are referred to as 'cuts'. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-wrapping-strategy.html * @defaultValue 'OFF' */ 'elk.layered.wrapping.strategy'?: WrappingStrategy; /** - * To visually separate edges that are wrapped from regularly routed edges an additional spacing - * value can be specified in form of this layout option. The spacing is added to the regular - * edgeNode spacing. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-wrapping-additionalEdgeSpacing.html * @defaultValue '10' */ 'elk.layered.wrapping.additionalEdgeSpacing'?: `${number}`; /** - * At times and for certain types of graphs the executed wrapping may produce results that are - * consistently biased in the same fashion: either wrapping to often or to rarely. This factor can - * be used to correct the bias. Internally, it is simply multiplied with the 'aspect ratio' layout - * option. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-wrapping-correctionFactor.html * @defaultValue '1.0' */ 'elk.layered.wrapping.correctionFactor'?: `${number}`; /** - * The strategy by which the layer indexes are determined at which the layering crumbles into - * chunks. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-wrapping-cutting-strategy.html * @defaultValue 'MSD' */ 'elk.layered.wrapping.cutting.strategy'?: CuttingStrategy; /** - * Allows the user to specify her own cuts for a certain graph. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-wrapping-cutting-cuts.html */ 'elk.layered.wrapping.cutting.cuts'?: string; /** - * The MSD cutting strategy starts with an initial guess on the number of chunks the graph should - * be split into. The freedom specifies how much the strategy may deviate from this guess. E.g. if - * an initial number of 3 is computed, a freedom of 1 allows 2, 3, and 4 cuts. + * @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}`; /** - * When wrapping graphs, one can specify indices that are not allowed as split points. The - * validification strategy makes sure every computed split point is allowed. + * @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; /** - * For general graphs it is important that not too many edges wrap backwards. Thus a compromise - * between evenly-distributed cuts and the total number of cut edges is sought. + * @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}`; /** - * The initial wrapping is performed in a very simple way. As a consequence, edges that wrap from - * one chunk to another may be unnecessarily long. Activating this option tries to shorten such - * edges. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-wrapping-multiEdge-improveWrappedEdges.html * @defaultValue 'true' */ 'elk.layered.wrapping.multiEdge.improveWrappedEdges'?: 'true' | 'false'; /** - * The strategy to use for unzipping a layer into multiple sublayers while maintaining the existing - * ordering of nodes and edges after crossing minimization. The default value is 'NONE'. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layerUnzipping-strategy.html * @defaultValue 'NONE' */ 'elk.layered.layerUnzipping.strategy'?: LayerUnzippingStrategy; /** - * Use a heuristic to decide whether or not to actually perform the layer split with the goal of - * minimizing the total edge length. This option only works when layerSplit is set to 2. The - * property can be set to the nodes in a layer, which then applies the property for the layer. If - * any node sets the value to true, then the value is set to true for the entire layer. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layerUnzipping-minimizeEdgeLength.html * @defaultValue 'false' */ 'elk.layered.layerUnzipping.minimizeEdgeLength'?: 'true' | 'false'; /** - * Defines the number of sublayers to split a layer into. The property can be set to the nodes in a - * layer, which then applies the property for the layer. If multiple nodes set the value to - * different values, then the lowest value is chosen. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layerUnzipping-layerSplit.html * @defaultValue '2' */ 'elk.layered.layerUnzipping.layerSplit'?: `${number}`; /** - * If set to true, nodes will always be placed in the first sublayer after a long edge when using - * the ALTERNATING strategy. Otherwise long edge dummies are treated the same as regular nodes. The - * default value is true. The property can be set to the nodes in a layer, which then applies the - * property for the layer. If any node sets the value to false, then the value is set to false for - * the entire layer. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layerUnzipping-resetOnLongEdges.html * @defaultValue 'true' */ 'elk.layered.layerUnzipping.resetOnLongEdges'?: 'true' | 'false'; /** - * Method to decide on edge label sides. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-edgeLabels-sideSelection.html * @defaultValue 'SMART_DOWN' */ 'elk.layered.edgeLabels.sideSelection'?: EdgeLabelSideSelection; /** - * Determines in which layer center labels of long edges should be placed. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-edgeLabels-centerLabelPlacementStrategy.html * @defaultValue 'MEDIAN_LAYER' */ 'elk.layered.edgeLabels.centerLabelPlacementStrategy'?: CenterEdgeLabelPlacementStrategy; /** - * Preserves the order of nodes and edges in the model file if this does not lead to additional - * edge crossings. Depending on the strategy this is not always possible since the node and edge - * order might be conflicting. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-strategy.html * @defaultValue 'NONE' */ 'elk.layered.considerModelOrder.strategy'?: OrderingStrategy; /** - * If disabled the port order of output ports is derived from the edge order and input ports are - * ordered by their incoming connections. If enabled all ports are ordered by the port model order. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-portModelOrder.html * @defaultValue 'false' */ 'elk.layered.considerModelOrder.portModelOrder'?: 'true' | 'false'; /** - * Set on a node to not set a model order for this node even though it is a real node. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-noModelOrder.html * @defaultValue 'false' */ 'elk.layered.considerModelOrder.noModelOrder'?: 'true' | 'false'; /** - * If set to NONE the usual ordering strategy (by cumulative node priority and size of nodes) is - * used. INSIDE_PORT_SIDES orders the components with external ports only inside the groups with - * the same port side. FORCE_MODEL_ORDER enforces the mode order on components. This option might - * produce bad alignments and sub optimal drawings in terms of used area since the ordering should - * be respected. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-components.html * @defaultValue 'NONE' */ 'elk.layered.considerModelOrder.components'?: ComponentOrderingStrategy; /** - * Indicates whether long edges are sorted under, over, or equal to nodes that have no connection - * to a previous layer in a left-to-right or right-to-left layout. Under and over changes to right - * and left in a vertical layout. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-longEdgeStrategy.html * @defaultValue 'DUMMY_NODE_OVER' */ 'elk.layered.considerModelOrder.longEdgeStrategy'?: LongEdgeOrderingStrategy; /** - * Indicates with what percentage (1 for 100%) violations of the node model order are weighted - * against the crossings e.g. a value of 0.5 means two model order violations are as important as - * on edge crossing. This allows some edge crossings in favor of preserving the model order. It is - * advised to set this value to a very small positive value (e.g. 0.001) to have minimal crossing - * and a optimal node order. Defaults to no influence (0). + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-crossingCounterNodeInfluence.html * @defaultValue '0' */ 'elk.layered.considerModelOrder.crossingCounterNodeInfluence'?: `${number}`; /** - * Indicates with what percentage (1 for 100%) violations of the port model order are weighted - * against the crossings e.g. a value of 0.5 means two model order violations are as important as - * on edge crossing. This allows some edge crossings in favor of preserving the model order. It is - * advised to set this value to a very small positive value (e.g. 0.001) to have minimal crossing - * and a optimal port order. Defaults to no influence (0). + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-crossingCounterPortInfluence.html * @defaultValue '0' */ 'elk.layered.considerModelOrder.crossingCounterPortInfluence'?: `${number}`; /** - * Used to define partial ordering groups during cycle breaking. A lower group id means that the - * group is sorted before other groups. A group model order of 0 is the default group. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-groupModelOrder-cycleBreakingId.html * @defaultValue '0' */ 'elk.layered.considerModelOrder.groupModelOrder.cycleBreakingId'?: `${number}`; /** - * Used to define partial ordering groups during crossing minimization. A lower group id means that - * the group is sorted before other groups. A group model order of 0 is the default group. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-groupModelOrder-crossingMinimizationId.html * @defaultValue '0' */ 'elk.layered.considerModelOrder.groupModelOrder.crossingMinimizationId'?: `${number}`; /** - * Used to define partial ordering groups during component packing. A lower group id means that the - * group is sorted before other groups. A group model order of 0 is the default group. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-groupModelOrder-componentGroupId.html * @defaultValue '0' */ 'elk.layered.considerModelOrder.groupModelOrder.componentGroupId'?: `${number}`; /** - * Determines how to count ordering violations during cycle breaking. NONE: They do not count. - * ENFORCED: A group with a higher model order is before a node with a smaller. MODEL_ORDER: The - * model order counts instead of the model order group id ordering. + * @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; /** - * The model order group id for which should be preferred as a source if possible. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-groupModelOrder-cbPreferredSourceId.html */ 'elk.layered.considerModelOrder.groupModelOrder.cbPreferredSourceId'?: `${number}`; /** - * The model order group id for which should be preferred as a target if possible. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-groupModelOrder-cbPreferredTargetId.html */ 'elk.layered.considerModelOrder.groupModelOrder.cbPreferredTargetId'?: `${number}`; /** - * Determines how to count ordering violations during crossing minimization. NONE: They do not - * count. ENFORCED: A group with a lower id is before a group with a higher id. MODEL_ORDER: The - * model order counts instead of the model order group id ordering. + * @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; /** - * Holds all group ids which are enforcing their order during crossing minimization strategies. - * E.g. if only groups 2 and -1 (default) enforce their ordering. Other groups e.g. the group of - * timer nodes can be ordered arbitrarily if it helps and the mentioned groups may not change their - * order. + * @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; /** - * Specifies how drawings of the same graph with different layout directions compare to each other: - * either a natural reading direction is preserved or the drawings are rotated versions of each - * other. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-directionCongruency.html * @defaultValue 'READING_DIRECTION' */ 'elk.layered.directionCongruency'?: DirectionCongruency; /** - * Whether feedback edges should be highlighted by routing around the nodes. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-feedbackEdges.html * @defaultValue 'false' */ 'elk.layered.feedbackEdges'?: 'true' | 'false'; /** - * Determines which point of a node is considered by interactive layout phases. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-interactiveReferencePoint.html * @defaultValue 'CENTER' */ 'elk.layered.interactiveReferencePoint'?: InteractiveReferencePoint; /** - * Edges that have no ports are merged so they touch the connected nodes at the same points. When - * this option is disabled, one port is created for each edge directly connected to a node. When it - * is enabled, all such incoming edges share an input port, and all outgoing edges share an output - * port. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-mergeEdges.html * @defaultValue 'false' */ 'elk.layered.mergeEdges'?: 'true' | 'false'; /** - * If hierarchical layout is active, hierarchy-crossing edges use as few hierarchical ports as - * possible. They are broken by the algorithm, with hierarchical ports inserted as required. - * Usually, one such port is created for each edge at each hierarchy crossing point. With this - * option set to true, we try to create as few hierarchical ports as possible in the process. In - * particular, all edges that form a hyperedge can share a port. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-mergeHierarchyEdges.html * @defaultValue 'true' */ 'elk.layered.mergeHierarchyEdges'?: 'true' | 'false'; /** - * Specifies whether non-flow ports may switch sides if their node's port constraints are either - * FIXED_SIDE or FIXED_ORDER. A non-flow port is a port on a side that is not part of the currently - * configured layout flow. For instance, given a left-to-right layout direction, north and south - * ports would be considered non-flow ports. Further note that the underlying criterium whether to - * switch sides or not solely relies on the minimization of edge crossings. Hence, edge length and - * other aesthetics criteria are not addressed. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-allowNonFlowPortsToSwitchSides.html * @defaultValue 'false' */ 'elk.layered.allowNonFlowPortsToSwitchSides'?: 'true' | 'false'; /** - * Only relevant for nodes with FIXED_SIDE port constraints. Determines the way a node's ports are - * distributed on the sides of a node if their order is not prescribed. The option is set on parent - * nodes. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-portSortingStrategy.html * @defaultValue 'INPUT_ORDER' */ 'elk.layered.portSortingStrategy'?: PortSortingStrategy; /** - * How much effort should be spent to produce a nice layout. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-thoroughness.html * @defaultValue '7' */ 'elk.layered.thoroughness'?: `${number}`; /** - * Adds bend points even if an edge does not change direction. If true, each long edge dummy will - * contribute a bend point to its edges and hierarchy-crossing edges will always get a bend point - * where they cross hierarchy boundaries. By default, bend points are only added where an edge - * changes direction. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-unnecessaryBendpoints.html * @defaultValue 'false' */ 'elk.layered.unnecessaryBendpoints'?: 'true' | 'false'; /** - * If enabled position id and layer id are generated, which are usually only used internally when - * setting the interactiveLayout option. This option should be specified on the root node. + * @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 index 31591ace56..a246f33ce4 100644 --- a/packages/joint-layout-elk/src/types/elkNodeOptions.mts +++ b/packages/joint-layout-elk/src/types/elkNodeOptions.mts @@ -14,310 +14,233 @@ export interface NodeElkLayoutOptions { // Falls back to a plain string for any ELK option beyond this package's Core/Layered coverage. [key: string]: string | undefined; /** - * Alignment of the selected node relative to other nodes; the exact meaning depends on the used - * algorithm. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-alignment.html * @defaultValue 'AUTOMATIC' */ 'elk.alignment'?: Alignment; /** - * Determines whether separate layout runs are triggered for different compound nodes in a - * hierarchical graph. Setting a node's hierarchy handling to `INCLUDE_CHILDREN` will lay out that - * node and all of its descendants in a single layout run, until a descendant is encountered which - * has its hierarchy handling set to `SEPARATE_CHILDREN`. In general, `SEPARATE_CHILDREN` will - * ensure that a new layout run is triggered for a node with that setting. Including multiple - * levels of hierarchy in a single layout run may allow cross-hierarchical edges to be laid out - * properly. If the root node is set to `INHERIT` (or not set at all), the default behavior is - * `SEPARATE_CHILDREN`. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-hierarchyHandling.html * @defaultValue 'INHERIT' */ 'elk.hierarchyHandling'?: HierarchyHandling; /** - * The padding to be left to a parent element's border when placing child elements. This can also - * serve as an output option of a layout algorithm if node size calculation is setup appropriately. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-padding.html * @defaultValue `new ElkPadding(12)` */ 'elk.padding'?: string; /** - * Spacing between pairs of ports of the same node. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-portPort.html * @defaultValue '10' */ 'elk.spacing.portPort'?: `${number}`; /** - * Allows to specify individual spacing values for graph elements that shall be different from the - * value specified for the element's parent. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-individual.html */ 'elk.spacing.individual'?: string; /** - * Partition to which the node belongs. This requires Layout Partitioning to be active. Nodes with - * lower partition IDs will appear to the left of nodes with higher partition IDs (assuming a - * left-to-right layout direction). + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-partitioning-partition.html */ 'elk.partitioning.partition'?: `${number}`; /** - * Hints for where node labels are to be placed; if empty, the node label's position is not - * modified. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-nodeLabels-placement.html * @defaultValue `NodeLabelPlacement.fixed` */ 'elk.nodeLabels.placement'?: string; /** - * Defines the default port distribution for a node. May be overridden for each side individually. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portAlignment-default.html * @defaultValue 'DISTRIBUTED' */ 'elk.portAlignment.default'?: PortAlignment; /** - * Defines how ports on the northern side are placed, overriding the node's general port alignment. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portAlignment-north.html */ 'elk.portAlignment.north'?: PortAlignment; /** - * Defines how ports on the southern side are placed, overriding the node's general port alignment. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portAlignment-south.html */ 'elk.portAlignment.south'?: PortAlignment; /** - * Defines how ports on the western side are placed, overriding the node's general port alignment. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portAlignment-west.html */ 'elk.portAlignment.west'?: PortAlignment; /** - * Defines how ports on the eastern side are placed, overriding the node's general port alignment. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portAlignment-east.html */ 'elk.portAlignment.east'?: PortAlignment; /** - * Defines constraints of the position of the ports of a node. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portConstraints.html * @defaultValue 'UNDEFINED' */ 'elk.portConstraints'?: PortConstraints; /** - * The position of a node, port, or label. This is used by the 'Fixed Layout' algorithm to specify - * a pre-defined position. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-position.html */ 'elk.position'?: string; /** - * Defines the priority of an object; its meaning depends on the specific layout algorithm and the - * context where it is used. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-priority.html */ 'elk.priority'?: `${number}`; /** - * What should be taken into account when calculating a node's size. Empty size constraints specify - * that a node's size is already fixed and should not be changed. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-nodeSize-constraints.html * @defaultValue `EnumSet.noneOf(SizeConstraint)` */ 'elk.nodeSize.constraints'?: string; /** - * Options modifying the behavior of the size constraints set on a node. Each member of the set - * specifies something that should be taken into account when calculating node sizes. The empty set - * corresponds to no further modifications. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-nodeSize-options.html * @defaultValue `EnumSet.of(SizeOptions.DEFAULT_MINIMUM_SIZE)` */ 'elk.nodeSize.options'?: string; /** - * The minimal size to which a node can be reduced. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-nodeSize-minimum.html * @defaultValue `new KVector(0, 0)` */ 'elk.nodeSize.minimum'?: string; /** - * Whether the node should be regarded as a comment box instead of a regular node. In that case its - * placement should be similar to how labels are handled. Any edges incident to a comment box - * specify to which graph elements the comment is related. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-commentBox.html * @defaultValue 'false' */ 'elk.commentBox'?: 'true' | 'false'; /** - * Whether the node should be handled as a hypernode. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-hypernode.html * @defaultValue 'false' */ 'elk.hypernode'?: 'true' | 'false'; /** - * Margins define additional space around the actual bounds of a graph element. For instance, ports - * or labels being placed on the outside of a node's border might introduce such a margin. The - * margin is used to guarantee non-overlap of other graph elements with those ports or labels. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-margins.html * @defaultValue `new ElkMargin()` */ 'elk.margins'?: string; /** - * No layout is done for the associated element. This is used to mark parts of a diagram to avoid - * their inclusion in the layout graph, or to mark parts of the layout graph to prevent layout - * engines from processing them. If you wish to exclude the contents of a compound node from - * automatic layout, while the node itself is still considered on its own layer, use the 'Fixed - * Layout' algorithm for that node. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-noLayout.html * @defaultValue 'false' */ 'elk.noLayout'?: 'true' | 'false'; /** - * Decides on a placement method for port labels; if empty, the node label's position is not - * modified. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portLabels-placement.html * @defaultValue `PortLabelPlacement.outside` */ 'elk.portLabels.placement'?: string; /** - * Use 'portLabels.placement': NEXT_TO_PORT_OF_POSSIBLE. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portLabels-nextToPortIfPossible.html * @defaultValue 'false' */ 'elk.portLabels.nextToPortIfPossible'?: 'true' | 'false'; /** - * If this option is true (default), the labels of a port will be treated as a group when it comes - * to centering them next to their port. If this option is false, only the first label will be - * centered next to the port, with the others being placed below. This only applies to labels of - * eastern and western ports and will have no effect if labels are not placed next to their port. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portLabels-treatAsGroup.html * @defaultValue 'true' */ 'elk.portLabels.treatAsGroup'?: 'true' | 'false'; /** - * The scaling factor to be applied to the corresponding node in recursive layout. It causes the - * corresponding node's size to be adjusted, and its ports and labels to be sized and placed - * accordingly after the layout of that node has been determined (and before the node itself and - * its siblings are arranged). The scaling is not reverted afterwards, so the resulting layout - * graph contains the adjusted size and position data. This option is currently not supported if - * 'Layout Hierarchy' is set. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-scaleFactor.html * @defaultValue '1' */ 'elk.scaleFactor'?: `${number}`; /** - * The size approximator to be used to set sizes of hierarchical nodes during topdown layout. The - * default value is null, which results in nodes keeping whatever size is defined for them e.g. - * through parent parallel node or by manually setting the size. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-topdown-sizeApproximator.html * @defaultValue `null` */ 'elk.topdown.sizeApproximator'?: string; /** - * The fixed size of a hierarchical node when using topdown layout. If this value is set on a - * parallel node it applies to its children, when set on a hierarchical node it applies to the node - * itself. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-topdown-hierarchicalNodeWidth.html * @defaultValue '150' */ 'elk.topdown.hierarchicalNodeWidth'?: `${number}`; /** - * The fixed aspect ratio of a hierarchical node when using topdown layout. Default is 1/sqrt(2). - * If this value is set on a parallel node it applies to its children, when set on a hierarchical - * node it applies to the node itself. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-topdown-hierarchicalNodeAspectRatio.html * @defaultValue '1.414' */ 'elk.topdown.hierarchicalNodeAspectRatio'?: `${number}`; /** - * The different node types used for topdown layout. If the node type is set to - * `TopdownNodeTypes.PARALLEL_NODE` the algorithm must be set to a `TopdownLayoutProvider` such as - * `TopdownPacking`. The `nodeSize.fixedGraphSize` option is technically only required for - * hierarchical nodes. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-topdown-nodeType.html * @defaultValue `null` */ 'elk.topdown.nodeType'?: TopdownNodeTypes; /** - * Whether this node allows to route self loops inside of it instead of around it. If set to true, - * this will make the node a compound node if it isn't already, and will require the layout - * algorithm to support compound nodes with hierarchical ports. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-insideSelfLoops-activate.html * @defaultValue 'false' */ 'elk.insideSelfLoops.activate'?: 'true' | 'false'; /** - * Determines a constraint on the placement of the node regarding the layering. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layering-layerConstraint.html * @defaultValue 'NONE' */ 'elk.layered.layering.layerConstraint'?: LayerConstraint; /** - * Allows to set a constraint regarding the layer placement of a node. Let i be the value of teh - * constraint. Assumed the drawing has n layers and i < n. If set to i, it expresses that the node - * should be placed in i-th layer. Should i>=n be true then the node is placed in the last layer of - * the drawing. Note that this option is not part of any of ELK Layered's default configurations - * but is only evaluated as part of the `InteractiveLayeredGraphVisitor`, which must be applied - * manually or used via the `DiagramLayoutEngine. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layering-layerChoiceConstraint.html * @defaultValue `null` */ 'elk.layered.layering.layerChoiceConstraint'?: `${number}`; /** - * Layer identifier that was calculated by ELK Layered for a node. This is only generated if - * interactiveLayot or generatePositionAndLayerIds is set. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layering-layerId.html * @defaultValue '-1' */ 'elk.layered.layering.layerId'?: `${number}`; /** - * Allows to set a constraint which specifies of which node the current node is the predecessor. If - * set to 's' then the node is the predecessor of 's' and is in the same layer + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-crossingMinimization-inLayerPredOf.html * @defaultValue `null` */ 'elk.layered.crossingMinimization.inLayerPredOf'?: string; /** - * Allows to set a constraint which specifies of which node the current node is the successor. If - * set to 's' then the node is the successor of 's' and is in the same layer + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-crossingMinimization-inLayerSuccOf.html * @defaultValue `null` */ 'elk.layered.crossingMinimization.inLayerSuccOf'?: string; /** - * Allows to set a constraint regarding the position placement of a node in a layer. Assumed the - * layer in which the node placed includes n other nodes and i < n. If set to i, it expresses that - * the node should be placed at the i-th position. Should i>=n be true then the node is placed at - * the last position in the layer. Note that this option is not part of any of ELK Layered's - * default configurations but is only evaluated as part of the `InteractiveLayeredGraphVisitor`, - * which must be applied manually or used via the `DiagramLayoutEngine. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-crossingMinimization-positionChoiceConstraint.html * @defaultValue `null` */ 'elk.layered.crossingMinimization.positionChoiceConstraint'?: `${number}`; /** - * Position within a layer that was determined by ELK Layered for a node. This is only generated if - * interactiveLayot or generatePositionAndLayerIds is set. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-crossingMinimization-positionId.html * @defaultValue '-1' */ 'elk.layered.crossingMinimization.positionId'?: `${number}`; /** - * Aims at shorter and straighter edges. Two configurations are possible: (a) allow ports to move - * freely on the side they are assigned to (the order is always defined beforehand), (b) - * additionally allow to enlarge a node wherever it helps. If this option is not configured for a - * node, the 'nodeFlexibility.default' value is used, which is specified for the node's parent. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-nodePlacement-networkSimplex-nodeFlexibility.html */ 'elk.layered.nodePlacement.networkSimplex.nodeFlexibility'?: NodeFlexibility; /** - * Alter the distribution of the loops around the node. It only takes effect for - * PortConstraints.FREE. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-edgeRouting-selfLoopDistribution.html * @defaultValue 'NORTH' */ 'elk.layered.edgeRouting.selfLoopDistribution'?: SelfLoopDistributionStrategy; /** - * Alter the ordering of the loops they can either be stacked or sequenced. It only takes effect - * for PortConstraints.FREE. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-edgeRouting-selfLoopOrdering.html * @defaultValue 'STACKED' */ 'elk.layered.edgeRouting.selfLoopOrdering'?: SelfLoopOrderingStrategy; /** - * Use a heuristic to decide whether or not to actually perform the layer split with the goal of - * minimizing the total edge length. This option only works when layerSplit is set to 2. The - * property can be set to the nodes in a layer, which then applies the property for the layer. If - * any node sets the value to true, then the value is set to true for the entire layer. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layerUnzipping-minimizeEdgeLength.html * @defaultValue 'false' */ 'elk.layered.layerUnzipping.minimizeEdgeLength'?: 'true' | 'false'; /** - * Defines the number of sublayers to split a layer into. The property can be set to the nodes in a - * layer, which then applies the property for the layer. If multiple nodes set the value to - * different values, then the lowest value is chosen. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layerUnzipping-layerSplit.html * @defaultValue '2' */ 'elk.layered.layerUnzipping.layerSplit'?: `${number}`; /** - * If set to true, nodes will always be placed in the first sublayer after a long edge when using - * the ALTERNATING strategy. Otherwise long edge dummies are treated the same as regular nodes. The - * default value is true. The property can be set to the nodes in a layer, which then applies the - * property for the layer. If any node sets the value to false, then the value is set to false for - * the entire layer. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layerUnzipping-resetOnLongEdges.html * @defaultValue 'true' */ 'elk.layered.layerUnzipping.resetOnLongEdges'?: 'true' | 'false'; /** - * Set on a node to not set a model order for this node even though it is a real node. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-noModelOrder.html * @defaultValue 'false' */ 'elk.layered.considerModelOrder.noModelOrder'?: 'true' | 'false'; /** - * Used to define partial ordering groups during cycle breaking. A lower group id means that the - * group is sorted before other groups. A group model order of 0 is the default group. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-groupModelOrder-cycleBreakingId.html * @defaultValue '0' */ 'elk.layered.considerModelOrder.groupModelOrder.cycleBreakingId'?: `${number}`; /** - * Used to define partial ordering groups during crossing minimization. A lower group id means that - * the group is sorted before other groups. A group model order of 0 is the default group. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-groupModelOrder-crossingMinimizationId.html * @defaultValue '0' */ 'elk.layered.considerModelOrder.groupModelOrder.crossingMinimizationId'?: `${number}`; /** - * Used to define partial ordering groups during component packing. A lower group id means that the - * group is sorted before other groups. A group model order of 0 is the default group. + * @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 index 65da992035..048f83a8c9 100644 --- a/packages/joint-layout-elk/src/types/elkPortOptions.mts +++ b/packages/joint-layout-elk/src/types/elkPortOptions.mts @@ -4,70 +4,47 @@ export interface PortElkLayoutOptions { // Falls back to a plain string for any ELK option beyond this package's Core/Layered coverage. [key: string]: string | undefined; /** - * Allows to specify individual spacing values for graph elements that shall be different from the - * value specified for the element's parent. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-individual.html */ 'elk.spacing.individual'?: string; /** - * The position of a node, port, or label. This is used by the 'Fixed Layout' algorithm to specify - * a pre-defined position. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-position.html */ 'elk.position'?: string; /** - * No layout is done for the associated element. This is used to mark parts of a diagram to avoid - * their inclusion in the layout graph, or to mark parts of the layout graph to prevent layout - * engines from processing them. If you wish to exclude the contents of a compound node from - * automatic layout, while the node itself is still considered on its own layer, use the 'Fixed - * Layout' algorithm for that node. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-noLayout.html * @defaultValue 'false' */ 'elk.noLayout'?: 'true' | 'false'; /** - * The offset to the port position where connections shall be attached. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-port-anchor.html */ 'elk.port.anchor'?: string; /** - * The index of a port in the fixed order around a node. The order is assumed as clockwise, - * starting with the leftmost port on the top side. This option must be set if 'Port Constraints' - * is set to FIXED_ORDER and no specific positions are given for the ports. Additionally, the - * option 'Port Side' must be defined in this case. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-port-index.html */ 'elk.port.index'?: `${number}`; /** - * The side of a node on which a port is situated. This option must be set if 'Port Constraints' is - * set to FIXED_SIDE or FIXED_ORDER and no specific positions are given for the ports. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-port-side.html * @defaultValue 'UNDEFINED' */ 'elk.port.side'?: PortSide; /** - * The offset of ports on the node border. With a positive offset the port is moved outside of the - * node, while with a negative offset the port is moved towards the inside. An offset of 0 means - * that the port is placed directly on the node border, i.e. if the port side is north, the port's - * south border touches the nodes's north border; if the port side is east, the port's west border - * touches the nodes's east border; if the port side is south, the port's north border touches the - * node's south border; if the port side is west, the port's east border touches the node's west - * border. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-port-borderOffset.html */ 'elk.port.borderOffset'?: `${number}`; /** - * Used to define partial ordering groups during crossing minimization. A lower group id means that - * the group is sorted before other groups. A group model order of 0 is the default group. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-groupModelOrder-crossingMinimizationId.html * @defaultValue '0' */ 'elk.layered.considerModelOrder.groupModelOrder.crossingMinimizationId'?: `${number}`; /** - * Used to define partial ordering groups during component packing. A lower group id means that the - * group is sorted before other groups. A group model order of 0 is the default group. + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-groupModelOrder-componentGroupId.html * @defaultValue '0' */ 'elk.layered.considerModelOrder.groupModelOrder.componentGroupId'?: `${number}`; /** - * Specifies whether non-flow ports may switch sides if their node's port constraints are either - * FIXED_SIDE or FIXED_ORDER. A non-flow port is a port on a side that is not part of the currently - * configured layout flow. For instance, given a left-to-right layout direction, north and south - * ports would be considered non-flow ports. Further note that the underlying criterium whether to - * switch sides or not solely relies on the minimization of edge crossings. Hence, edge length and - * other aesthetics criteria are not addressed. + * @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/workerFactory.mts b/packages/joint-layout-elk/src/workerFactory.mts new file mode 100644 index 0000000000..75e2a06ca8 --- /dev/null +++ b/packages/joint-layout-elk/src/workerFactory.mts @@ -0,0 +1,12 @@ +/** + * Starts the Web Worker `layout()` runs ELK in by default (see `defaultElk.mts`). + * + * Written as `new Worker(new URL(..., import.meta.url))` with literal arguments - the + * pattern bundlers (webpack 5, Vite, Parcel) look for to emit `elk.worker.mts` as a + * worker file of its own. In its own module so the UMD build, which has no way to + * locate a worker file, can replace it with one returning `undefined` (see + * `rollup.config.mjs`). + */ +export function createElkWorker(): Worker | undefined { + return new Worker(new URL('./elk.worker.mjs', import.meta.url), { type: 'module' }); +} diff --git a/packages/joint-layout-elk/test/index.js b/packages/joint-layout-elk/test/index.js index 18d92a49d1..a3ea29dac5 100644 --- a/packages/joint-layout-elk/test/index.js +++ b/packages/joint-layout-elk/test/index.js @@ -907,3 +907,71 @@ QUnit.module('layout()', () => { assert.deepEqual(elkEdge.labels, []); }); }); + +// Last: the default ELK instance is shared by every `layout()` call without an `elk` +// option - once its worker fails (the second test), it stays on the main thread. +QUnit.module('the default ELK instance', (hooks) => { + + // Every worker the default instance has started (see `rollup.config.mjs`'s `testWorker`), + // and how many messages they've sent back. + const startedWorkers = []; + let workerMessageCount = 0; + + hooks.before(() => { + window.__createElkWorker = () => { + const worker = new Worker('/base/node_modules/elkjs/lib/elk-worker.min.js'); + worker.addEventListener('message', () => workerMessageCount++); + startedWorkers.push(worker); + return worker; + }; + }); + + hooks.after(() => { + delete window.__createElkWorker; + }); + + 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 run in a Web Worker - one started on first use, then shared', async(assert) => { + + const { graph, el1, el2 } = createGraph(); + + await joint.layout.ELK.layout({ graph }); + + assert.equal(startedWorkers.length, 1); + // The layout came from the worker, not from a main-thread fallback. + assert.ok(workerMessageCount > 0); + assert.notOk(joint.g.intersection.exists(el1.getBBox(), el2.getBBox())); + + const messageCount = workerMessageCount; + await joint.layout.ELK.layout(createGraph()); + assert.equal(startedWorkers.length, 1); + assert.ok(workerMessageCount > messageCount); + }); + + QUnit.test('should retry a layout on the main thread when the worker fails - and stay there', async(assert) => { + + const { graph, el1, el2 } = createGraph(); + + const result = joint.layout.ELK.layout({ graph }); + // E.g. a worker script a bundler didn't emit - ELK itself would never settle `result`. + startedWorkers[startedWorkers.length - 1].dispatchEvent(new ErrorEvent('error')); + await result; + + assert.notOk(joint.g.intersection.exists(el1.getBBox(), el2.getBBox())); + + // No new worker is started for later layouts. + const workerCount = startedWorkers.length; + const { graph: nextGraph, el1: nextEl1, el2: nextEl2 } = createGraph(); + await joint.layout.ELK.layout({ graph: nextGraph }); + assert.equal(startedWorkers.length, workerCount); + assert.notOk(joint.g.intersection.exists(nextEl1.getBBox(), nextEl2.getBBox())); + }); +}); From 462c2bb86550fee41102cb258f43f5174e9a6129 Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Mon, 5 Oct 2026 15:35:36 +0200 Subject: [PATCH 50/75] optional graph --- packages/joint-layout-elk/src/layout.mts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/joint-layout-elk/src/layout.mts b/packages/joint-layout-elk/src/layout.mts index 65d6a4d62b..fca90fc6bd 100644 --- a/packages/joint-layout-elk/src/layout.mts +++ b/packages/joint-layout-elk/src/layout.mts @@ -81,20 +81,24 @@ function getBBox(elkGraph: ElkNode): g.Rect { * What `layout()` lays out: the graph, and optionally which of its elements/links. */ export interface LayoutCells { - /** The graph the elements and links belong to - also where the layout's batch runs. */ - graph: dia.Graph; + /** + * The graph the elements and links belong to - also where the layout's batch runs. + * Without it, the layout is applied outside of any batch, and `elements` and `links` + * default to none. + */ + graph?: dia.Graph; /** * The elements to lay out, in this order - the top-level ones follow it, and so do * each container's own children (instead of `getEmbeddedCells()` order). An element * whose parent isn't listed is laid out as a top-level one. Each element must be * listed only once. - * @defaultValue all of the graph's elements + * @defaultValue all of the graph's elements (none without `graph`) */ elements?: dia.Element[]; /** * The links to lay out, in this order - a link is laid out only if both its ends are too. * Each link must be listed only once. - * @defaultValue all of the graph's links + * @defaultValue all of the graph's links (none without `graph`) */ links?: dia.Link[]; } @@ -113,8 +117,8 @@ export async function layout({ graph, elements, links }: LayoutCells, opt?: Layo const batchName = options.batchName as string; const { elkGraph, elementsById, linksById, portsById } = exportGraph( - elements ?? graph.getElements(), - links ?? graph.getLinks(), + elements ?? graph?.getElements() ?? [], + links ?? graph?.getLinks() ?? [], options as ExportGraphOptions, elkLayoutOptions ); @@ -124,9 +128,9 @@ export async function layout({ graph, elements, links }: LayoutCells, opt?: Layo // Wraps the import in a single batch, so it emits one combined change instead of // one per element/port/link. - graph.startBatch(batchName); + graph?.startBatch(batchName); importLayout(result, elementsById, linksById, portsById, options); - graph.stopBatch(batchName); + graph?.stopBatch(batchName); return { bbox: getBBox(result), From 60978116148c4336c9545049b9a2d4e2ab93dcfc Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Mon, 5 Oct 2026 16:38:32 +0200 Subject: [PATCH 51/75] fix --- packages/joint-core/test/ts/index.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/joint-core/test/ts/index.test.ts b/packages/joint-core/test/ts/index.test.ts index 11c3ded3f3..dbff7c3c02 100644 --- a/packages/joint-core/test/ts/index.test.ts +++ b/packages/joint-core/test/ts/index.test.ts @@ -74,7 +74,7 @@ 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: any = rectangle.portProp('port1', 'group'); +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 }}, From 36317fda5a9a489fc4c19f22ab8338cf59e0aa4b Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Tue, 6 Oct 2026 15:18:27 +0200 Subject: [PATCH 52/75] fix(layout-elk): apply laid out link labels by id, not by position in the ELK result exportLinkLabel can drop labels, so an ELK label's index no longer matched the link label it was made for. Each ELK label now carries an id with its link label index, which importLayout reads back. Co-Authored-By: Claude Opus 5.5 --- packages/joint-layout-elk/src/export.mts | 2 ++ packages/joint-layout-elk/src/import.mts | 7 +++++- packages/joint-layout-elk/src/labelIds.mts | 17 +++++++++++++ packages/joint-layout-elk/test/index.js | 29 ++++++++++++++++++++++ 4 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 packages/joint-layout-elk/src/labelIds.mts diff --git a/packages/joint-layout-elk/src/export.mts b/packages/joint-layout-elk/src/export.mts index c2a657d2c3..90193b0270 100644 --- a/packages/joint-layout-elk/src/export.mts +++ b/packages/joint-layout-elk/src/export.mts @@ -1,4 +1,5 @@ import { type dia } from '@joint/core'; +import { getLinkLabelId } from './labelIds.mjs'; import type { ElkNode, @@ -389,6 +390,7 @@ function buildEdge(link: dia.Link): void { result.push({ ...labelDraft, + id: getLinkLabelId(id, labelIndex), text: ELK_LABEL_TEXT }); return result; diff --git a/packages/joint-layout-elk/src/import.mts b/packages/joint-layout-elk/src/import.mts index 34d6fdbf4d..d4f3ce5bbc 100644 --- a/packages/joint-layout-elk/src/import.mts +++ b/packages/joint-layout-elk/src/import.mts @@ -2,6 +2,7 @@ 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 './labelIds.mjs'; /** Applies the ELK-computed position (and, for a container, size) to `element`. */ export type SetElementAttributesCallback = (params: SetElementAttributesCallbackParameters) => void; @@ -158,7 +159,11 @@ function importEdges(edges: ElkExtendedEdge[] | undefined): void { // resolved `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, index) => { + 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 distance = polyline.closestPointLength(center); diff --git a/packages/joint-layout-elk/src/labelIds.mts b/packages/joint-layout-elk/src/labelIds.mts new file mode 100644 index 0000000000..dbfa451b1f --- /dev/null +++ b/packages/joint-layout-elk/src/labelIds.mts @@ -0,0 +1,17 @@ +// Not part of the public API (not re-exported from `index.mts`). + +// 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/test/index.js b/packages/joint-layout-elk/test/index.js index a3ea29dac5..1bd6b2677d 100644 --- a/packages/joint-layout-elk/test/index.js +++ b/packages/joint-layout-elk/test/index.js @@ -906,6 +906,35 @@ QUnit.module('layout()', () => { 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'); + }); }); // Last: the default ELK instance is shared by every `layout()` call without an `elk` From 5623464e45ca73422bc941a952469333e993202c Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Tue, 6 Oct 2026 15:19:49 +0200 Subject: [PATCH 53/75] fix(layout-elk): export elements with ports with FIXED_POS port constraints by default Without port constraints ELK moved ports to another side or reordered them, contrary to the documented behavior of keeping ports where JointJS places them. Co-Authored-By: Claude Opus 5.5 --- packages/joint-layout-elk/README.md | 2 +- packages/joint-layout-elk/src/export.mts | 5 ++++- packages/joint-layout-elk/test/index.js | 16 ++++++++++++++-- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/joint-layout-elk/README.md b/packages/joint-layout-elk/README.md index 84f7604ad3..a3226f1186 100644 --- a/packages/joint-layout-elk/README.md +++ b/packages/joint-layout-elk/README.md @@ -120,7 +120,7 @@ type SetLinkAttributesCallback = (params: { link: dia.Link; attributes: { vertic - **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. -- **Ports keep their JointJS-computed position by default** - `layout()` only tells ELK where they already are, so edges route to/from the exact spot the element's port groups place them at. Opt into ELK repositioning/reordering them by setting `elk.portConstraints` (e.g. `'FIXED_SIDE'`/`'FREE'`) via `exportElement`/`exportPort`. +- **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. - **Asynchronous** - unlike `@joint/layout-directed-graph`, `layout()` returns a `Promise`, since `elkjs` computes layouts asynchronously (by default inside a Web Worker). - **Web Worker** - without an `elk` option, `layout()` runs ELK in a Web Worker the package starts on first use and shares between calls. It is started with `new Worker(new URL('./elk.worker.mjs', import.meta.url), { type: 'module' })`, which webpack 5, Vite and Parcel bundle as a worker file of its own with no extra setup. ELK runs on the main thread instead where no worker can be used: no `Worker` (e.g. Node/SSR), the UMD build (a script tag has no way to locate a worker file), or a worker that fails to load (e.g. a bundler that doesn't emit worker files, or a CSP `worker-src` that blocks it) - a layout in progress when that happens is retried on the main thread. To run ELK in a worker of your own instead (e.g. with the UMD build), pass `elk: new ELK({ workerUrl })` (`elkjs/lib/elk-api.js`). - **ID handling** - ELK requires string ids; element and link ids are converted with `` `${id}` `` internally, but never written back to the graph. diff --git a/packages/joint-layout-elk/src/export.mts b/packages/joint-layout-elk/src/export.mts index 90193b0270..9db1d24f4d 100644 --- a/packages/joint-layout-elk/src/export.mts +++ b/packages/joint-layout-elk/src/export.mts @@ -36,6 +36,7 @@ export interface ElkNodeDraft { /** `0` for a container: ELK sizes it to fit its content. */ 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 @@ -275,7 +276,9 @@ function buildElkNode(element: dia.Element, parentId?: string): ElkNode | null { id, width, height, - layoutOptions: {} + // 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) diff --git a/packages/joint-layout-elk/test/index.js b/packages/joint-layout-elk/test/index.js index 1bd6b2677d..2169550080 100644 --- a/packages/joint-layout-elk/test/index.js +++ b/packages/joint-layout-elk/test/index.js @@ -340,12 +340,24 @@ QUnit.module('layout()', () => { } }); - graph.resetCells([el1]); + // 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' }}); - await joint.layout.ELK.layout({ graph }); + 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) => { From 3f4addd5950a62fe61cc47047cc20521f0071186 Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Tue, 6 Oct 2026 15:20:31 +0200 Subject: [PATCH 54/75] fix(layout-elk): close the layout batch when an import callback throws Co-Authored-By: Claude Opus 5.5 --- packages/joint-layout-elk/src/layout.mts | 10 +++++--- packages/joint-layout-elk/test/index.js | 30 ++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/packages/joint-layout-elk/src/layout.mts b/packages/joint-layout-elk/src/layout.mts index fca90fc6bd..a3b73d903f 100644 --- a/packages/joint-layout-elk/src/layout.mts +++ b/packages/joint-layout-elk/src/layout.mts @@ -127,10 +127,14 @@ export async function layout({ graph, elements, links }: LayoutCells, opt?: Layo const result = await (opt?.elk ? opt.elk.layout(rawElkGraph) : layoutWithDefaultElk(rawElkGraph)) as ElkNode; // Wraps the import in a single batch, so it emits one combined change instead of - // one per element/port/link. + // 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. graph?.startBatch(batchName); - importLayout(result, elementsById, linksById, portsById, options); - graph?.stopBatch(batchName); + try { + importLayout(result, elementsById, linksById, portsById, options); + } finally { + graph?.stopBatch(batchName); + } return { bbox: getBBox(result), diff --git a/packages/joint-layout-elk/test/index.js b/packages/joint-layout-elk/test/index.js index 2169550080..e147e6527d 100644 --- a/packages/joint-layout-elk/test/index.js +++ b/packages/joint-layout-elk/test/index.js @@ -716,6 +716,36 @@ QUnit.module('layout()', () => { assert.deepEqual(elkGraph.children, []); }); + QUnit.test('should apply the layout in a single `batchName` 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 }, { batchName: 'my-layout' }); + + assert.deepEqual(batches, ['start:my-layout', 'stop:my-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.module('given `elements`/`links`', () => { const rect = (id, x = 500, y = 500) => new joint.shapes.standard.Rectangle({ id, size: { width: 50, height: 50 }, position: { x, y }}); From 9ef0fdfeb82ac2819817dce17a5a0b6fe213683f Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Tue, 6 Oct 2026 15:23:17 +0200 Subject: [PATCH 55/75] feat(layout-elk): add `signal` option to abort a layout An aborted layout() rejects with the signal's reason and applies nothing to the graph. The default worker is now driven by a small client for ELK's own worker script instead of elk-api.js, which can neither cancel a layout nor settle one whose worker is terminated: a layout the worker is busy with is stopped by terminating it, and a new worker takes over the layouts still waiting. ELK on the main thread or a custom instance can't be stopped, so only its result is ignored. Co-Authored-By: Claude Opus 5.5 --- packages/joint-layout-elk/README.md | 26 +++ packages/joint-layout-elk/rollup.config.mjs | 15 +- packages/joint-layout-elk/src/abort.mts | 29 +++ packages/joint-layout-elk/src/defaultElk.mts | 188 +++++++++++++++---- packages/joint-layout-elk/src/layout.mts | 23 ++- packages/joint-layout-elk/test/index.js | 101 ++++++++++ 6 files changed, 334 insertions(+), 48 deletions(-) create mode 100644 packages/joint-layout-elk/src/abort.mts diff --git a/packages/joint-layout-elk/README.md b/packages/joint-layout-elk/README.md index a3226f1186..6a79ec1455 100644 --- a/packages/joint-layout-elk/README.md +++ b/packages/joint-layout-elk/README.md @@ -79,6 +79,8 @@ interface LayoutOptions { elkLayoutOptions?: ElkLayoutOptions; // Default: { 'elk.algorithm': 'layered', 'elk.hierarchyHandling': 'INCLUDE_CHILDREN', 'elk.json.edgeCoords': 'ROOT' } // A name for the layout batch, grouping everything `layout()` applies into one graph change. batchName?: string; // Default: 'layout' + // 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; @@ -116,6 +118,30 @@ type SetPortAttributesCallback = (params: { element: dia.Element; portId: string 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; ``` +### 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 the default Web 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 a custom `elk` instance, keeps running - only its result is ignored (call `elk.terminateWorker()` yourself to stop a custom one). + ## ⚠️ 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. diff --git a/packages/joint-layout-elk/rollup.config.mjs b/packages/joint-layout-elk/rollup.config.mjs index c1ea315710..b27c66c690 100644 --- a/packages/joint-layout-elk/rollup.config.mjs +++ b/packages/joint-layout-elk/rollup.config.mjs @@ -44,8 +44,7 @@ export default [ input, external: [ '@joint/core', - 'elkjs/lib/elk.bundled.js', - 'elkjs/lib/elk-api.js' + 'elkjs/lib/elk.bundled.js' ], output: [ { @@ -55,8 +54,7 @@ export default [ extend: true, globals: { '@joint/core': 'joint', - 'elkjs/lib/elk.bundled.js': 'ELK', - 'elkjs/lib/elk-api.js': 'ELK' + 'elkjs/lib/elk.bundled.js': 'ELK' }, plugins: [ banner(() => bannerText) @@ -69,8 +67,7 @@ export default [ extend: true, globals: { '@joint/core': 'joint', - 'elkjs/lib/elk.bundled.js': 'ELK', - 'elkjs/lib/elk-api.js': 'ELK' + 'elkjs/lib/elk.bundled.js': 'ELK' }, plugins: [ terser({ format: { ascii_only: true }}), @@ -92,8 +89,7 @@ export default [ input: ['./src/index.mts'], external: [ '@joint/core', - 'elkjs/lib/elk.bundled.js', - 'elkjs/lib/elk-api.js' + 'elkjs/lib/elk.bundled.js' ], output: [ { @@ -103,8 +99,7 @@ export default [ extend: true, globals: { '@joint/core': 'joint', - 'elkjs/lib/elk.bundled.js': 'ELK', - 'elkjs/lib/elk-api.js': 'ELK' + 'elkjs/lib/elk.bundled.js': 'ELK' }, sourcemap: true } diff --git a/packages/joint-layout-elk/src/abort.mts b/packages/joint-layout-elk/src/abort.mts new file mode 100644 index 0000000000..c8060e0259 --- /dev/null +++ b/packages/joint-layout-elk/src/abort.mts @@ -0,0 +1,29 @@ +// Not part of the public API (not re-exported from `index.mts`). + +export function getAbortReason(signal: AbortSignal): unknown { + return 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 index 89d46310bb..a2fe9c9805 100644 --- a/packages/joint-layout-elk/src/defaultElk.mts +++ b/packages/joint-layout-elk/src/defaultElk.mts @@ -1,19 +1,24 @@ import ElkConstructor from 'elkjs/lib/elk.bundled.js'; -import ElkApi from 'elkjs/lib/elk-api.js'; import { createElkWorker } from './workerFactory.mjs'; +import { abortable, getAbortReason } from './abort.mjs'; import type { ELK, ElkNode } from 'elkjs'; -// The reason a layout is retried on the main thread, rather than an error of ELK's own. -const WORKER_FAILED = Symbol('workerFailed'); +// 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; +} // Lazily created on the first `layout()` call, then shared by every later one. -let workerElk: ELK | undefined; let mainThreadElk: ELK | undefined; -// Rejects (with `WORKER_FAILED`) once the worker fails - `undefined` until it is started. -let workerFailure: Promise | undefined; -// Set once the worker fails - every later layout then runs on the main thread straight away. -let hasWorkerFailed = false; function getMainThreadElk(): ELK { if (!mainThreadElk) { @@ -22,6 +27,10 @@ function getMainThreadElk(): ELK { return mainThreadElk; } +function runOnMainThread(job: LayoutJob): void { + getMainThreadElk().layout(job.graph).then(job.resolve, job.reject); +} + function startWorker(): Worker | undefined { if (typeof Worker === 'undefined') return undefined; try { @@ -32,45 +41,150 @@ function startWorker(): Worker | undefined { } } -function getWorkerElk(): ELK | undefined { - if (workerElk || hasWorkerFailed) return workerElk; +/** + * Talks to ELK's own worker script (`elkjs/lib/elk-worker.min.js`, see `elk.worker.mts`) + * 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` keeps + * that order, so its first job is the one the worker is busy with. + */ +class ElkWorkerClient { - // No worker yet is checked for again on the next layout - nothing is started meanwhile. - const worker = startWorker(); - if (!worker) return undefined; + private worker: Worker | undefined; + private readonly jobs = new Map(); + private nextId = REGISTER_ID + 1; + + /** + * @param onFailure Called with the jobs left unsettled once the worker fails. + */ + constructor(private readonly onFailure: (jobs: LayoutJob[]) => void) {} - // ELK itself never listens for a worker's `error` event - a worker script that fails to - // load (e.g. a bundler that doesn't emit worker files) would leave every layout pending. - workerFailure = new Promise((_resolve, reject) => { + /** + * Starts a worker, and posts it every job not settled yet (e.g. after a restart). + * Returns `false` if no worker could be started. + */ + start(): boolean { + const worker = startWorker(); + if (!worker) return false; + this.worker = worker; + 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. a bundler that doesn't emit worker files) would leave every layout + // pending. worker.addEventListener('error', (event) => { event.preventDefault(); - hasWorkerFailed = true; - workerElk = undefined; - worker.terminate(); - reject(WORKER_FAILED); - }, { once: true }); - }); - // Nothing may be waiting on it when the worker fails. - workerFailure.catch(() => {}); + if (worker === this.worker) this.fail(); + }); + worker.postMessage({ id: REGISTER_ID, cmd: 'register', algorithms: ALGORITHMS }); + this.jobs.forEach((job) => this.post(job)); + return true; + } + + layout(graph: ElkNode, 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); + } + }; + signal?.addEventListener('abort', onAbort, { once: true }); + this.jobs.set(job.id, job); + this.post(job); + }); + } + + private post(job: LayoutJob): void { + // The worker gets its own (structured) clone of `graph` - a job re-posted after a + // restart, or retried on the main thread, starts from the original. + this.worker?.postMessage({ id: job.id, cmd: 'layout', graph: job.graph, layoutOptions: {}, options: {}}); + } - workerElk = new ElkApi({ workerFactory: () => worker }); - return workerElk; + private receive(data: { id: number, data?: ElkNode, error?: unknown }): void { + 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; + const isRunning = this.jobs.keys().next().value === job.id; + this.jobs.delete(job.id); + if (!isRunning) return; + this.terminate(); + if (!this.start()) this.fail(); + } + + private fail(): void { + this.terminate(); + const jobs = Array.from(this.jobs.values()); + this.jobs.clear(); + this.onFailure(jobs); + } + + private terminate(): void { + this.worker?.terminate(); + this.worker = undefined; + } +} + +let workerClient: ElkWorkerClient | undefined; +// Set once the worker fails - every later layout then runs on the main thread straight away. +let hasWorkerFailed = false; + +function getWorkerClient(): ElkWorkerClient | undefined { + if (workerClient || hasWorkerFailed) return workerClient; + const client = new ElkWorkerClient((jobs) => { + hasWorkerFailed = true; + workerClient = undefined; + // Retried on the main thread. + jobs.forEach(runOnMainThread); + }); + // No worker yet is checked for again on the next layout - nothing is started meanwhile. + if (!client.start()) return undefined; + workerClient = client; + return client; } /** * Lays out `elkGraph` with the default ELK instance - in a Web Worker where one can be * started, on the main thread otherwise (e.g. no `Worker` in Node/SSR, or the UMD build). * A layout started while the worker fails is retried on the main thread. + * + * Aborting `signal` rejects with its reason straight away. A layout the worker is busy + * with is stopped by terminating the worker - one on the main thread can't be stopped, + * its result is only ignored. */ -export async function layoutWithDefaultElk(elkGraph: ElkNode): Promise { - const elk = getWorkerElk(); - if (!elk) return getMainThreadElk().layout(elkGraph); - try { - // The worker gets its own (structured) clone of `elkGraph`, so a retry starts from - // the original. - return await Promise.race([elk.layout(elkGraph), workerFailure as Promise]); - } catch (error) { - if (error !== WORKER_FAILED) throw error; - return getMainThreadElk().layout(elkGraph); - } +export function layoutWithDefaultElk(elkGraph: ElkNode, signal?: AbortSignal): Promise { + const client = getWorkerClient(); + if (client) return client.layout(elkGraph, signal); + return abortable(getMainThreadElk().layout(elkGraph), signal); } diff --git a/packages/joint-layout-elk/src/layout.mts b/packages/joint-layout-elk/src/layout.mts index a3b73d903f..463635effe 100644 --- a/packages/joint-layout-elk/src/layout.mts +++ b/packages/joint-layout-elk/src/layout.mts @@ -2,6 +2,7 @@ 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'; @@ -60,6 +61,18 @@ export interface LayoutOptions extends ImportLayoutOptions, ExportGraphOptions { * @defaultValue 'layout' */ batchName?: string; + /** + * 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 the default 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 a custom + * `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 { @@ -115,6 +128,9 @@ export async function layout({ graph, elements, links }: LayoutCells, opt?: Layo DEFAULT_LAYOUT_OPTIONS ) as ElkLayoutOptions; const batchName = options.batchName as string; + const signal = opt?.signal; + + throwIfAborted(signal); const { elkGraph, elementsById, linksById, portsById } = exportGraph( elements ?? graph?.getElements() ?? [], @@ -124,7 +140,12 @@ export async function layout({ graph, elements, links }: LayoutCells, opt?: Layo ); const rawElkGraph = elkGraph as unknown as RawElkNode; - const result = await (opt?.elk ? opt.elk.layout(rawElkGraph) : layoutWithDefaultElk(rawElkGraph)) as ElkNode; + const result = await (opt?.elk + ? abortable(opt.elk.layout(rawElkGraph), signal) + : layoutWithDefaultElk(rawElkGraph, signal)) as ElkNode; + + // 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 - diff --git a/packages/joint-layout-elk/test/index.js b/packages/joint-layout-elk/test/index.js index e147e6527d..c6b6324226 100644 --- a/packages/joint-layout-elk/test/index.js +++ b/packages/joint-layout-elk/test/index.js @@ -746,6 +746,69 @@ QUnit.module('layout()', () => { assert.notOk(graph.hasActiveBatch()); }); + 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 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 `elements`/`links`', () => { const rect = (id, x = 500, y = 500) => new joint.shapes.standard.Rectangle({ id, size: { width: 50, height: 50 }, position: { x, y }}); @@ -1027,6 +1090,44 @@ QUnit.module('the default ELK instance', (hooks) => { assert.ok(workerMessageCount > messageCount); }); + QUnit.test('should terminate the worker busy with an aborted layout - a new one takes over the layouts still waiting', async(assert) => { + + const aborted = createGraph(); + const waiting = createGraph(); + const workerCount = startedWorkers.length; + const controller = new AbortController(); + + const abortedResult = joint.layout.ELK.layout({ graph: aborted.graph }, { signal: controller.signal }); + const waitingResult = joint.layout.ELK.layout({ graph: waiting.graph }); + controller.abort(); + + await assert.rejects(abortedResult, (error) => error.name === 'AbortError'); + await waitingResult; + + assert.equal(startedWorkers.length, workerCount + 1); + assert.ok(joint.g.intersection.exists(aborted.el1.getBBox(), aborted.el2.getBBox())); + assert.notOk(joint.g.intersection.exists(waiting.el1.getBBox(), waiting.el2.getBBox())); + }); + + QUnit.test('should keep the worker when a layout still waiting its turn is aborted', async(assert) => { + + const busy = createGraph(); + const aborted = createGraph(); + const workerCount = startedWorkers.length; + const controller = new AbortController(); + + const busyResult = joint.layout.ELK.layout({ graph: busy.graph }); + const abortedResult = joint.layout.ELK.layout({ graph: aborted.graph }, { signal: controller.signal }); + controller.abort(); + + await assert.rejects(abortedResult, (error) => error.name === 'AbortError'); + await busyResult; + + assert.equal(startedWorkers.length, workerCount); + assert.notOk(joint.g.intersection.exists(busy.el1.getBBox(), busy.el2.getBBox())); + assert.ok(joint.g.intersection.exists(aborted.el1.getBBox(), aborted.el2.getBBox())); + }); + QUnit.test('should retry a layout on the main thread when the worker fails - and stay there', async(assert) => { const { graph, el1, el2 } = createGraph(); From 8d62218d7e7c3f7d8edc3d94a7aa8438493442c9 Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Tue, 6 Oct 2026 15:25:42 +0200 Subject: [PATCH 56/75] perf(layout-elk): load main-thread ELK only when a layout runs on the main thread elk.bundled.js was imported statically, so every app shipped ELK twice - in the main bundle and in the worker - even though the main-thread copy is only a fallback. It is now imported dynamically, so bundlers split it into a chunk of its own (in webpack, the example's main bundle shrinks from 3.5 MB to 1.9 MB). The UMD build, which can't load chunks, still imports it statically. Co-Authored-By: Claude Opus 5.5 --- packages/joint-layout-elk/rollup.config.mjs | 57 ++++++++++++------- packages/joint-layout-elk/src/defaultElk.mts | 31 +++++++--- .../joint-layout-elk/src/mainThreadElk.mts | 14 +++++ packages/joint-layout-elk/test/index.js | 26 +++++++++ 4 files changed, 100 insertions(+), 28 deletions(-) create mode 100644 packages/joint-layout-elk/src/mainThreadElk.mts diff --git a/packages/joint-layout-elk/rollup.config.mjs b/packages/joint-layout-elk/rollup.config.mjs index b27c66c690..dc61c8ab68 100644 --- a/packages/joint-layout-elk/rollup.config.mjs +++ b/packages/joint-layout-elk/rollup.config.mjs @@ -12,32 +12,47 @@ const bannerText = `/*! ${packageJson.title} v${packageJson.version} (${formatte 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 has no way to locate a worker file of its own - replace the module that // starts one (see `src/workerFactory.mts`) with one that doesn't, so `layout()` runs ELK // on the main thread by default. -const noWorker = { - name: 'no-worker', - resolveId(source) { - return /(^|\/)workerFactory\.mjs$/.test(source) ? '\0workerFactory' : null; - }, - load(id) { - return (id === '\0workerFactory') ? 'export function createElkWorker() { return undefined; }' : null; - } -}; +const noWorker = replaceModule('workerFactory', 'export function createElkWorker() { return undefined; }'); // The unit test bundle starts whichever worker a test hands it (`window.__createElkWorker`), // so the default worker - and falling back from it - can be tested too (see `test/index.js`). -const testWorker = { - name: 'test-worker', - resolveId(source) { - return /(^|\/)workerFactory\.mjs$/.test(source) ? '\0workerFactory' : null; - }, - load(id) { - return (id === '\0workerFactory') - ? 'export function createElkWorker() { return window.__createElkWorker ? window.__createElkWorker() : undefined; }' - : null; - } -}; +const testWorker = replaceModule( + 'workerFactory', + 'export function createElkWorker() { return window.__createElkWorker ? window.__createElkWorker() : undefined; }' +); + +// A UMD bundle can't load a chunk of its own either - 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 [ { @@ -77,6 +92,7 @@ export default [ ], plugins: [ noWorker, + staticMainThreadElk, nodeResolve({ preferBuiltins: false }) @@ -106,6 +122,7 @@ export default [ ], plugins: [ testWorker, + testMainThreadElk, nodeResolve({ preferBuiltins: false }), diff --git a/packages/joint-layout-elk/src/defaultElk.mts b/packages/joint-layout-elk/src/defaultElk.mts index a2fe9c9805..edf54811f5 100644 --- a/packages/joint-layout-elk/src/defaultElk.mts +++ b/packages/joint-layout-elk/src/defaultElk.mts @@ -1,6 +1,6 @@ -import ElkConstructor from 'elkjs/lib/elk.bundled.js'; import { createElkWorker } from './workerFactory.mjs'; -import { abortable, getAbortReason } from './abort.mjs'; +import { loadMainThreadElk } from './mainThreadElk.mjs'; +import { abortable, getAbortReason, throwIfAborted } from './abort.mjs'; import type { ELK, ElkNode } from 'elkjs'; @@ -17,18 +17,33 @@ interface LayoutJob { reject: (reason: unknown) => void; } -// Lazily created on the first `layout()` call, then shared by every later one. -let mainThreadElk: ELK | undefined; +// Loaded on the first layout that runs on the main thread, then shared by every later one. +let mainThreadElk: Promise | undefined; -function getMainThreadElk(): ELK { +function getMainThreadElk(): Promise { if (!mainThreadElk) { - mainThreadElk = new ElkConstructor(); + mainThreadElk = loadMainThreadElk().then( + (ElkConstructor) => new ElkConstructor(), + (error) => { + // E.g. a chunk that failed to load - tried again on the next layout. + mainThreadElk = undefined; + throw error; + } + ); } return mainThreadElk; } +function layoutOnMainThread(graph: ElkNode, signal?: AbortSignal): Promise { + return getMainThreadElk().then((elk) => { + // Aborted while ELK was loading - no need to start it. + throwIfAborted(signal); + return elk.layout(graph); + }); +} + function runOnMainThread(job: LayoutJob): void { - getMainThreadElk().layout(job.graph).then(job.resolve, job.reject); + layoutOnMainThread(job.graph).then(job.resolve, job.reject); } function startWorker(): Worker | undefined { @@ -186,5 +201,5 @@ function getWorkerClient(): ElkWorkerClient | undefined { export function layoutWithDefaultElk(elkGraph: ElkNode, signal?: AbortSignal): Promise { const client = getWorkerClient(); if (client) return client.layout(elkGraph, signal); - return abortable(getMainThreadElk().layout(elkGraph), signal); + return abortable(layoutOnMainThread(elkGraph, signal), signal); } diff --git a/packages/joint-layout-elk/src/mainThreadElk.mts b/packages/joint-layout-elk/src/mainThreadElk.mts new file mode 100644 index 0000000000..28f9ed9eaf --- /dev/null +++ b/packages/joint-layout-elk/src/mainThreadElk.mts @@ -0,0 +1,14 @@ +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 every app that runs ELK in the worker, + * which has a copy of ELK of its own. 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/test/index.js b/packages/joint-layout-elk/test/index.js index c6b6324226..0c1c2e9217 100644 --- a/packages/joint-layout-elk/test/index.js +++ b/packages/joint-layout-elk/test/index.js @@ -5,6 +5,32 @@ QUnit.module('sanity check', () => { }); }); +// 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() { From 002138140dae43ff2f49e04b9e8ead93da316377 Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Tue, 6 Oct 2026 15:27:08 +0200 Subject: [PATCH 57/75] fix(layout-elk): fall back to the main thread only when the worker fails to load Any worker error used to send the layout in progress to the main thread for good - including a crash during the layout (e.g. out of memory), which the same graph would then repeat on the main thread, freezing or crashing the page. The worker now counts as loaded once it answers its first message: an error before that still falls back to the main thread, a crash after it rejects the layout it was busy with, and a new worker takes over the rest. Co-Authored-By: Claude Opus 5.5 --- packages/joint-layout-elk/README.md | 2 +- packages/joint-layout-elk/src/defaultElk.mts | 51 +++++++++++++++++--- packages/joint-layout-elk/test/index.js | 46 +++++++++++++++--- 3 files changed, 84 insertions(+), 15 deletions(-) diff --git a/packages/joint-layout-elk/README.md b/packages/joint-layout-elk/README.md index 6a79ec1455..1f75d041ec 100644 --- a/packages/joint-layout-elk/README.md +++ b/packages/joint-layout-elk/README.md @@ -148,7 +148,7 @@ ELK can't stop a layout in progress, so a layout the default Web Worker is busy - **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. - **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. - **Asynchronous** - unlike `@joint/layout-directed-graph`, `layout()` returns a `Promise`, since `elkjs` computes layouts asynchronously (by default inside a Web Worker). -- **Web Worker** - without an `elk` option, `layout()` runs ELK in a Web Worker the package starts on first use and shares between calls. It is started with `new Worker(new URL('./elk.worker.mjs', import.meta.url), { type: 'module' })`, which webpack 5, Vite and Parcel bundle as a worker file of its own with no extra setup. ELK runs on the main thread instead where no worker can be used: no `Worker` (e.g. Node/SSR), the UMD build (a script tag has no way to locate a worker file), or a worker that fails to load (e.g. a bundler that doesn't emit worker files, or a CSP `worker-src` that blocks it) - a layout in progress when that happens is retried on the main thread. To run ELK in a worker of your own instead (e.g. with the UMD build), pass `elk: new ELK({ workerUrl })` (`elkjs/lib/elk-api.js`). +- **Web Worker** - without an `elk` option, `layout()` runs ELK in a Web Worker the package starts on first use and shares between calls. It is started with `new Worker(new URL('./elk.worker.mjs', import.meta.url), { type: 'module' })`, which webpack 5, Vite and Parcel bundle as a worker file of its own with no extra setup. ELK runs on the main thread instead where no worker can be used: no `Worker` (e.g. Node/SSR), the UMD build (a script tag has no way to locate a worker file), or a worker that fails to load (e.g. a bundler that doesn't emit worker files, or a CSP `worker-src` that blocks it) - a layout in progress when that happens is retried on the main thread. A worker that crashes once loaded (e.g. out of memory) is different: the layout it was busy with is rejected rather than retried on the main thread (where the same crash would take the page down), and a new worker takes over the layouts still waiting. To run ELK in a worker of your own instead (e.g. with the UMD build), pass `elk: new ELK({ workerUrl })` (`elkjs/lib/elk-api.js`). - **ID handling** - ELK requires string ids; element and link ids are converted with `` `${id}` `` internally, but never written back to the graph. ## 📄 License diff --git a/packages/joint-layout-elk/src/defaultElk.mts b/packages/joint-layout-elk/src/defaultElk.mts index edf54811f5..fd50e50e37 100644 --- a/packages/joint-layout-elk/src/defaultElk.mts +++ b/packages/joint-layout-elk/src/defaultElk.mts @@ -67,11 +67,13 @@ function startWorker(): Worker | undefined { class ElkWorkerClient { 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 nextId = REGISTER_ID + 1; /** - * @param onFailure Called with the jobs left unsettled once the worker fails. + * @param onFailure Called with the jobs left unsettled once the worker fails to load. */ constructor(private readonly onFailure: (jobs: LayoutJob[]) => void) {} @@ -83,15 +85,21 @@ class ElkWorkerClient { const worker = startWorker(); if (!worker) return false; 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. a bundler that doesn't emit worker files) would leave every layout - // pending. + // to load (e.g. a bundler that doesn't emit worker files), 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) this.fail(); + if (worker !== this.worker) return; + if (this.isLoaded) { + this.crash(event); + } else { + this.fail(); + } }); worker.postMessage({ id: REGISTER_ID, cmd: 'register', algorithms: ALGORITHMS }); this.jobs.forEach((job) => this.post(job)); @@ -134,6 +142,10 @@ class ElkWorkerClient { } private receive(data: { id: number, data?: ElkNode, error?: unknown }): void { + if (data.id === REGISTER_ID) { + this.isLoaded = true; + return; + } const job = this.jobs.get(data.id); if (!job) return; this.jobs.delete(data.id); @@ -153,11 +165,34 @@ class ElkWorkerClient { if (!this.jobs.has(job.id)) return; const isRunning = this.jobs.keys().next().value === job.id; this.jobs.delete(job.id); - if (!isRunning) return; + if (isRunning) this.restart(); + } + + /** + * The worker crashed (after it loaded) - most likely because of the job it was busy + * with, which is rejected rather than retried, on the main thread least of all, where + * the same crash would take the page down with it. A new worker takes over the jobs + * still waiting. + */ + private crash(event: ErrorEvent): void { + const [running] = this.jobs.values(); + 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(); + } + + private restart(): void { this.terminate(); if (!this.start()) this.fail(); } + /** + * The worker failed to load - every job not settled yet is retried on the main thread + * (see `getWorkerClient`). + */ private fail(): void { this.terminate(); const jobs = Array.from(this.jobs.values()); @@ -172,7 +207,8 @@ class ElkWorkerClient { } let workerClient: ElkWorkerClient | undefined; -// Set once the worker fails - every later layout then runs on the main thread straight away. +// Set once the worker fails to load - every later layout then runs on the main thread +// straight away. let hasWorkerFailed = false; function getWorkerClient(): ElkWorkerClient | undefined { @@ -192,7 +228,8 @@ function getWorkerClient(): ElkWorkerClient | undefined { /** * Lays out `elkGraph` with the default ELK instance - in a Web Worker where one can be * started, on the main thread otherwise (e.g. no `Worker` in Node/SSR, or the UMD build). - * A layout started while the worker fails is retried on the main thread. + * A layout started while the worker fails to load is retried on the main thread - one the + * worker crashes during is rejected. * * Aborting `signal` rejects with its reason straight away. A layout the worker is busy * with is stopped by terminating the worker - one on the main thread can't be stopped, diff --git a/packages/joint-layout-elk/test/index.js b/packages/joint-layout-elk/test/index.js index 0c1c2e9217..8396b99fa4 100644 --- a/packages/joint-layout-elk/test/index.js +++ b/packages/joint-layout-elk/test/index.js @@ -1069,17 +1069,20 @@ QUnit.module('layout()', () => { }); // Last: the default ELK instance is shared by every `layout()` call without an `elk` -// option - once its worker fails (the second test), it stays on the main thread. +// option - once its worker fails to load (the last test), it stays on the main thread. QUnit.module('the default ELK instance', (hooks) => { // Every worker the default instance has started (see `rollup.config.mjs`'s `testWorker`), // and how many messages they've sent back. const startedWorkers = []; let workerMessageCount = 0; + // The script the next worker is started with - one that doesn't exist fails to load. + const WORKER_URL = '/base/node_modules/elkjs/lib/elk-worker.min.js'; + let workerUrl = WORKER_URL; hooks.before(() => { window.__createElkWorker = () => { - const worker = new Worker('/base/node_modules/elkjs/lib/elk-worker.min.js'); + const worker = new Worker(workerUrl); worker.addEventListener('message', () => workerMessageCount++); startedWorkers.push(worker); return worker; @@ -1154,18 +1157,47 @@ QUnit.module('the default ELK instance', (hooks) => { assert.ok(joint.g.intersection.exists(aborted.el1.getBBox(), aborted.el2.getBBox())); }); - QUnit.test('should retry a layout on the main thread when the worker fails - and stay there', async(assert) => { + QUnit.test('should reject a layout the worker crashes during - a new worker takes over the layouts still waiting', async(assert) => { - const { graph, el1, el2 } = createGraph(); + const crashed = createGraph(); + const waiting = createGraph(); + const workerCount = startedWorkers.length; + + const crashedResult = joint.layout.ELK.layout({ graph: crashed.graph }); + const waitingResult = joint.layout.ELK.layout({ graph: waiting.graph }); + // E.g. out of memory - not retried on the main thread. + startedWorkers[startedWorkers.length - 1].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, workerCount + 1); + assert.ok(joint.g.intersection.exists(crashed.el1.getBBox(), crashed.el2.getBBox())); + assert.notOk(joint.g.intersection.exists(waiting.el1.getBBox(), waiting.el2.getBBox())); - const result = joint.layout.ELK.layout({ graph }); - // E.g. a worker script a bundler didn't emit - ELK itself would never settle `result`. + // The new worker lays out later layouts too. + const messageCount = workerMessageCount; + await joint.layout.ELK.layout(createGraph()); + assert.equal(startedWorkers.length, workerCount + 1); + assert.ok(workerMessageCount > messageCount); + }); + + QUnit.test('should retry a layout on the main thread when the worker fails to load - and stay there', async(assert) => { + + // A crash restarts the worker - with a script that doesn't exist (e.g. one a bundler + // didn't emit), which fails to load. + workerUrl = '/base/missing-elk-worker.js'; + const crashedResult = joint.layout.ELK.layout(createGraph()); startedWorkers[startedWorkers.length - 1].dispatchEvent(new ErrorEvent('error')); - await result; + await assert.rejects(crashedResult); + const { graph, el1, el2 } = createGraph(); + // Posted to the worker still loading - ELK itself would never settle it. + await joint.layout.ELK.layout({ graph }); assert.notOk(joint.g.intersection.exists(el1.getBBox(), el2.getBBox())); // No new worker is started for later layouts. + workerUrl = WORKER_URL; const workerCount = startedWorkers.length; const { graph: nextGraph, el1: nextEl1, el2: nextEl2 } = createGraph(); await joint.layout.ELK.layout({ graph: nextGraph }); From 630b5d1476627377276ddfd02d7f9c17004b44e3 Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Tue, 6 Oct 2026 15:29:50 +0200 Subject: [PATCH 58/75] feat(layout-elk): warn once when ELK falls back to the main thread A worker that can't be started or fails to load used to fall back to the main thread silently - layouts kept working, only blocking the page, so a misconfigured bundler went unnoticed. It is now reported once with a console.warn. The README lists the common causes, among them the Vite dev server, whose dependency pre-bundling breaks the worker file's URL. Co-Authored-By: Claude Opus 5.5 --- packages/joint-layout-elk/README.md | 6 +++++- packages/joint-layout-elk/src/defaultElk.mts | 20 ++++++++++++++++++-- packages/joint-layout-elk/test/index.js | 12 +++++++++++- 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/packages/joint-layout-elk/README.md b/packages/joint-layout-elk/README.md index 1f75d041ec..44ae5b52aa 100644 --- a/packages/joint-layout-elk/README.md +++ b/packages/joint-layout-elk/README.md @@ -148,7 +148,11 @@ ELK can't stop a layout in progress, so a layout the default Web Worker is busy - **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. - **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. - **Asynchronous** - unlike `@joint/layout-directed-graph`, `layout()` returns a `Promise`, since `elkjs` computes layouts asynchronously (by default inside a Web Worker). -- **Web Worker** - without an `elk` option, `layout()` runs ELK in a Web Worker the package starts on first use and shares between calls. It is started with `new Worker(new URL('./elk.worker.mjs', import.meta.url), { type: 'module' })`, which webpack 5, Vite and Parcel bundle as a worker file of its own with no extra setup. ELK runs on the main thread instead where no worker can be used: no `Worker` (e.g. Node/SSR), the UMD build (a script tag has no way to locate a worker file), or a worker that fails to load (e.g. a bundler that doesn't emit worker files, or a CSP `worker-src` that blocks it) - a layout in progress when that happens is retried on the main thread. A worker that crashes once loaded (e.g. out of memory) is different: the layout it was busy with is rejected rather than retried on the main thread (where the same crash would take the page down), and a new worker takes over the layouts still waiting. To run ELK in a worker of your own instead (e.g. with the UMD build), pass `elk: new ELK({ workerUrl })` (`elkjs/lib/elk-api.js`). +- **Web Worker** - without an `elk` option, `layout()` runs ELK in a Web Worker the package starts on first use and shares between calls. It is started with `new Worker(new URL('./elk.worker.mjs', import.meta.url), { type: 'module' })`, which webpack 5, Vite and Parcel bundle as a worker file of its own with no extra setup. The main-thread copy of ELK (`elkjs/lib/elk.bundled.js`) is imported dynamically, so it is split into a chunk of its own, only loaded if a layout ever runs on the main thread. ELK runs on the main thread instead where no worker can be used: no `Worker` (e.g. Node/SSR), the UMD build (a script tag has no way to locate a worker file), or a worker that can't be started or fails to load (e.g. a bundler that doesn't emit worker files, or a CSP `worker-src` that blocks it) - a layout in progress when that happens is retried on the main thread. A worker that crashes once loaded (e.g. out of memory) is different: the layout it was busy with is rejected rather than retried on the main thread (where the same crash would take the page down), and a new worker takes over the layouts still waiting. To run ELK in a worker of your own instead (e.g. with the UMD build), pass `elk: new ELK({ workerUrl })` (`elkjs/lib/elk-api.js`). +- **Worker troubleshooting** - a worker that can't be started or fails to load is reported once with a `console.warn` (layouts still work, but block the page while ELK runs). Common causes: + - **Vite dev server** - Vite pre-bundles dependencies into `node_modules/.vite/deps`, where the worker file isn't found (production builds are not affected). Exclude the package from pre-bundling: `optimizeDeps: { exclude: ['@joint/layout-elk'] }` in `vite.config.js`. + - **An absolute `publicPath`** (webpack `output.publicPath: '/dist/'`) - the worker file is requested from that path, so it is not found once the app is served from anywhere else. Prefer `'auto'` (webpack's default). + - **esbuild** (and other bundlers that don't follow `new URL(..., import.meta.url)`) - the worker file isn't emitted. Pass `elk: new ELK({ workerUrl })` instead, pointing at a copy of `elkjs/lib/elk-worker.min.js` you serve yourself. - **ID handling** - ELK requires string ids; element and link ids are converted with `` `${id}` `` internally, but never written back to the graph. ## 📄 License diff --git a/packages/joint-layout-elk/src/defaultElk.mts b/packages/joint-layout-elk/src/defaultElk.mts index fd50e50e37..b97016cc99 100644 --- a/packages/joint-layout-elk/src/defaultElk.mts +++ b/packages/joint-layout-elk/src/defaultElk.mts @@ -46,12 +46,26 @@ function runOnMainThread(job: LayoutJob): void { layoutOnMainThread(job.graph).then(job.resolve, job.reject); } +let hasWarned = false; + +// Running ELK on the main thread where a worker was expected is easy to miss (layouts +// still work, they only block the page) - so it is reported once. +function warnMainThreadFallback(reason: string): void { + if (hasWarned) return; + hasWarned = true; + console.warn(`@joint/layout-elk: ${reason} - running ELK on the main thread instead. See "Web Worker" in the README of @joint/layout-elk.`); +} + function startWorker(): Worker | undefined { + // E.g. Node/SSR - expected, not reported. if (typeof Worker === 'undefined') return undefined; try { + // `undefined` in the UMD build - expected, not reported. return createElkWorker(); - } catch { - // E.g. a worker script the page's CSP (`worker-src`) doesn't allow. + } catch (error) { + // E.g. a worker script the page's CSP (`worker-src`) doesn't allow, or no + // `import.meta.url` to resolve it against. + warnMainThreadFallback(`the ELK Web Worker could not be started (${error})`); return undefined; } } @@ -216,6 +230,8 @@ function getWorkerClient(): ElkWorkerClient | undefined { const client = new ElkWorkerClient((jobs) => { hasWorkerFailed = true; workerClient = undefined; + // E.g. a worker file the bundler didn't emit, or doesn't serve where it says it is. + warnMainThreadFallback('the ELK Web Worker failed to load'); // Retried on the main thread. jobs.forEach(runOnMainThread); }); diff --git a/packages/joint-layout-elk/test/index.js b/packages/joint-layout-elk/test/index.js index 8396b99fa4..6fc63332ed 100644 --- a/packages/joint-layout-elk/test/index.js +++ b/packages/joint-layout-elk/test/index.js @@ -1187,14 +1187,24 @@ QUnit.module('the default ELK instance', (hooks) => { // A crash restarts the worker - with a script that doesn't exist (e.g. one a bundler // didn't emit), which fails to load. workerUrl = '/base/missing-elk-worker.js'; + const warnings = []; + const warn = console.warn; + console.warn = (message) => warnings.push(message); const crashedResult = joint.layout.ELK.layout(createGraph()); startedWorkers[startedWorkers.length - 1].dispatchEvent(new ErrorEvent('error')); await assert.rejects(crashedResult); const { graph, el1, el2 } = createGraph(); // Posted to the worker still loading - ELK itself would never settle it. - await joint.layout.ELK.layout({ graph }); + try { + await joint.layout.ELK.layout({ graph }); + } finally { + console.warn = warn; + } assert.notOk(joint.g.intersection.exists(el1.getBBox(), el2.getBBox())); + // Reported - layouts still work, but now block the page. + assert.equal(warnings.length, 1); + assert.ok(/the ELK Web Worker failed to load - running ELK on the main thread instead/.test(warnings[0])); // No new worker is started for later layouts. workerUrl = WORKER_URL; From 600a5b7a88f9cf13037c546799c745cdf830e939 Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Tue, 6 Oct 2026 15:32:45 +0200 Subject: [PATCH 59/75] feat(layout-elk): add `thread` option to choose where the default ELK instance runs 'auto' (default) keeps running ELK in the Web Worker where one can be used and on the main thread otherwise. 'worker' rejects instead of falling back to the main thread, so a large layout never blocks the page. 'main' runs ELK on the main thread without starting a worker, e.g. for tests or debugging. Co-Authored-By: Claude Opus 5.5 --- packages/joint-layout-elk/README.md | 17 ++++- packages/joint-layout-elk/src/defaultElk.mts | 71 ++++++++++++++------ packages/joint-layout-elk/src/layout.mts | 16 ++++- packages/joint-layout-elk/test/index.js | 53 ++++++++++++++- 4 files changed, 135 insertions(+), 22 deletions(-) diff --git a/packages/joint-layout-elk/README.md b/packages/joint-layout-elk/README.md index 44ae5b52aa..f333a9958f 100644 --- a/packages/joint-layout-elk/README.md +++ b/packages/joint-layout-elk/README.md @@ -75,6 +75,8 @@ interface LayoutResult { interface LayoutOptions { // A custom ELK instance, e.g. one running in a Web Worker of your own. elk?: ELK; // Default: a shared instance running in a Web Worker (see "Web Worker" below) + // Where the default ELK instance runs the layout - ignored with a custom `elk`. + thread?: 'auto' | 'worker' | 'main'; // Default: 'auto' // ELK layout options, passed through to ELK unmodified. elkLayoutOptions?: ElkLayoutOptions; // Default: { 'elk.algorithm': 'layered', 'elk.hierarchyHandling': 'INCLUDE_CHILDREN', 'elk.json.edgeCoords': 'ROOT' } // A name for the layout batch, grouping everything `layout()` applies into one graph change. @@ -118,6 +120,19 @@ type SetPortAttributesCallback = (params: { element: dia.Element; portId: string 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; ``` +### Choosing the thread + +Without an `elk` option, `thread` sets where ELK runs the layout: + +- `'auto'` (default) - in the package's Web Worker where one can be used, on the main thread otherwise (see "Web Worker" below). +- `'worker'` - in the Web Worker only. Where none can be used (e.g. Node/SSR, the UMD build, or a worker that fails to load), `layout()` rejects instead of blocking the page. +- `'main'` - on the main thread, without starting a worker - e.g. for tests or debugging. The page is blocked while ELK runs. + +```ts +// Never block the page - e.g. for a graph large enough to take seconds to lay out. +await layout({ graph }, { thread: 'worker' }); +``` + ### 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. @@ -148,7 +163,7 @@ ELK can't stop a layout in progress, so a layout the default Web Worker is busy - **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. - **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. - **Asynchronous** - unlike `@joint/layout-directed-graph`, `layout()` returns a `Promise`, since `elkjs` computes layouts asynchronously (by default inside a Web Worker). -- **Web Worker** - without an `elk` option, `layout()` runs ELK in a Web Worker the package starts on first use and shares between calls. It is started with `new Worker(new URL('./elk.worker.mjs', import.meta.url), { type: 'module' })`, which webpack 5, Vite and Parcel bundle as a worker file of its own with no extra setup. The main-thread copy of ELK (`elkjs/lib/elk.bundled.js`) is imported dynamically, so it is split into a chunk of its own, only loaded if a layout ever runs on the main thread. ELK runs on the main thread instead where no worker can be used: no `Worker` (e.g. Node/SSR), the UMD build (a script tag has no way to locate a worker file), or a worker that can't be started or fails to load (e.g. a bundler that doesn't emit worker files, or a CSP `worker-src` that blocks it) - a layout in progress when that happens is retried on the main thread. A worker that crashes once loaded (e.g. out of memory) is different: the layout it was busy with is rejected rather than retried on the main thread (where the same crash would take the page down), and a new worker takes over the layouts still waiting. To run ELK in a worker of your own instead (e.g. with the UMD build), pass `elk: new ELK({ workerUrl })` (`elkjs/lib/elk-api.js`). +- **Web Worker** - without an `elk` option, `layout()` runs ELK in a Web Worker the package starts on first use and shares between calls. It is started with `new Worker(new URL('./elk.worker.mjs', import.meta.url), { type: 'module' })`, which webpack 5, Vite and Parcel bundle as a worker file of its own with no extra setup. The main-thread copy of ELK (`elkjs/lib/elk.bundled.js`) is imported dynamically, so it is split into a chunk of its own, only loaded if a layout ever runs on the main thread. With `thread: 'auto'`, ELK runs on the main thread instead where no worker can be used: no `Worker` (e.g. Node/SSR), the UMD build (a script tag has no way to locate a worker file), or a worker that can't be started or fails to load (e.g. a bundler that doesn't emit worker files, or a CSP `worker-src` that blocks it) - a layout in progress when that happens is retried on the main thread (or rejected, with `thread: 'worker'`). A worker that crashes once loaded (e.g. out of memory) is different: the layout it was busy with is rejected rather than retried on the main thread (where the same crash would take the page down), and a new worker takes over the layouts still waiting. To run ELK in a worker of your own instead (e.g. with the UMD build), pass `elk: new ELK({ workerUrl })` (`elkjs/lib/elk-api.js`). - **Worker troubleshooting** - a worker that can't be started or fails to load is reported once with a `console.warn` (layouts still work, but block the page while ELK runs). Common causes: - **Vite dev server** - Vite pre-bundles dependencies into `node_modules/.vite/deps`, where the worker file isn't found (production builds are not affected). Exclude the package from pre-bundling: `optimizeDeps: { exclude: ['@joint/layout-elk'] }` in `vite.config.js`. - **An absolute `publicPath`** (webpack `output.publicPath: '/dist/'`) - the worker file is requested from that path, so it is not found once the app is served from anywhere else. Prefer `'auto'` (webpack's default). diff --git a/packages/joint-layout-elk/src/defaultElk.mts b/packages/joint-layout-elk/src/defaultElk.mts index b97016cc99..6850488185 100644 --- a/packages/joint-layout-elk/src/defaultElk.mts +++ b/packages/joint-layout-elk/src/defaultElk.mts @@ -4,6 +4,14 @@ import { abortable, getAbortReason, throwIfAborted } from './abort.mjs'; import type { ELK, ElkNode } from 'elkjs'; +/** + * Where the default ELK instance runs a layout: + * - `'auto'` - in a Web Worker where one can be used, on the main thread otherwise. + * - `'worker'` - in a Web Worker only - the layout is rejected where none can be used. + * - `'main'` - on the main thread (no worker is started). + */ +export type LayoutThread = 'auto' | 'worker' | 'main'; + // The algorithms `elkjs/lib/elk-api.js` registers with a worker by default. const ALGORITHMS = ['layered', 'stress', 'mrtree', 'radial', 'force', 'disco', 'sporeOverlap', 'sporeCompaction', 'rectpacking']; @@ -13,6 +21,8 @@ const REGISTER_ID = 0; interface LayoutJob { id: number; graph: ElkNode; + // Whether the job may be retried on the main thread if the worker fails to load. + canRunOnMainThread: boolean; resolve: (result: ElkNode) => void; reject: (reason: unknown) => void; } @@ -46,26 +56,40 @@ function runOnMainThread(job: LayoutJob): void { layoutOnMainThread(job.graph).then(job.resolve, job.reject); } +// Why the default worker can't be used - `undefined` while it can, or where no worker is +// expected in the first place (no `Worker`, e.g. Node/SSR, or the UMD build). +let workerFailureReason: string | undefined; let hasWarned = false; // Running ELK on the main thread where a worker was expected is easy to miss (layouts // still work, they only block the page) - so it is reported once. -function warnMainThreadFallback(reason: string): void { - if (hasWarned) return; +function warnMainThreadFallback(): void { + if (hasWarned || !workerFailureReason) return; hasWarned = true; - console.warn(`@joint/layout-elk: ${reason} - running ELK on the main thread instead. See "Web Worker" in the README of @joint/layout-elk.`); + console.warn(`@joint/layout-elk: ${workerFailureReason} - running ELK on the main thread instead. See "Web Worker" in the README of @joint/layout-elk.`); +} + +function fallBackToMainThread(job: LayoutJob): void { + warnMainThreadFallback(); + runOnMainThread(job); +} + +// Rejects a layout for which `thread: 'worker'` rules out the main thread. +function createNoWorkerError(): Error { + const reason = workerFailureReason || 'no Web Worker can be used here (e.g. Node/SSR, or the UMD build)'; + return new Error(`@joint/layout-elk: ${reason}, and \`thread: 'worker'\` rules out running ELK on the main thread.`); } function startWorker(): Worker | undefined { - // E.g. Node/SSR - expected, not reported. + // E.g. Node/SSR. if (typeof Worker === 'undefined') return undefined; try { - // `undefined` in the UMD build - expected, not reported. + // `undefined` in the UMD build. return createElkWorker(); } catch (error) { // E.g. a worker script the page's CSP (`worker-src`) doesn't allow, or no // `import.meta.url` to resolve it against. - warnMainThreadFallback(`the ELK Web Worker could not be started (${error})`); + workerFailureReason = `the ELK Web Worker could not be started (${error})`; return undefined; } } @@ -120,7 +144,7 @@ class ElkWorkerClient { return true; } - layout(graph: ElkNode, signal?: AbortSignal): Promise { + layout(graph: ElkNode, signal: AbortSignal | undefined, canRunOnMainThread: boolean): Promise { return new Promise((resolve, reject) => { if (signal?.aborted) { reject(getAbortReason(signal)); @@ -134,6 +158,7 @@ class ElkWorkerClient { const job: LayoutJob = { id: this.nextId++, graph, + canRunOnMainThread, resolve: (result) => { settle(); resolve(result); @@ -204,8 +229,8 @@ class ElkWorkerClient { } /** - * The worker failed to load - every job not settled yet is retried on the main thread - * (see `getWorkerClient`). + * The worker failed to load - every job not settled yet is retried on the main thread, + * or rejected if it can't run there (see `getWorkerClient`). */ private fail(): void { this.terminate(); @@ -231,9 +256,14 @@ function getWorkerClient(): ElkWorkerClient | undefined { hasWorkerFailed = true; workerClient = undefined; // E.g. a worker file the bundler didn't emit, or doesn't serve where it says it is. - warnMainThreadFallback('the ELK Web Worker failed to load'); - // Retried on the main thread. - jobs.forEach(runOnMainThread); + workerFailureReason = 'the ELK Web Worker failed to load'; + jobs.forEach((job) => { + if (job.canRunOnMainThread) { + fallBackToMainThread(job); + } else { + job.reject(createNoWorkerError()); + } + }); }); // No worker yet is checked for again on the next layout - nothing is started meanwhile. if (!client.start()) return undefined; @@ -242,17 +272,20 @@ function getWorkerClient(): ElkWorkerClient | undefined { } /** - * Lays out `elkGraph` with the default ELK instance - in a Web Worker where one can be - * started, on the main thread otherwise (e.g. no `Worker` in Node/SSR, or the UMD build). - * A layout started while the worker fails to load is retried on the main thread - one the - * worker crashes during is rejected. + * Lays out `elkGraph` with the default ELK instance - where `thread` says (see + * `LayoutThread`). With `'auto'`, a layout started while the worker fails to load is + * retried on the main thread - one the worker crashes during is rejected. * * Aborting `signal` rejects with its reason straight away. A layout the worker is busy * with is stopped by terminating the worker - one on the main thread can't be stopped, * its result is only ignored. */ -export function layoutWithDefaultElk(elkGraph: ElkNode, signal?: AbortSignal): Promise { - const client = getWorkerClient(); - if (client) return client.layout(elkGraph, signal); +export function layoutWithDefaultElk(elkGraph: ElkNode, signal?: AbortSignal, thread: LayoutThread = 'auto'): Promise { + if (thread !== 'main') { + const client = getWorkerClient(); + if (client) return client.layout(elkGraph, signal, thread === 'auto'); + if (thread === 'worker') return Promise.reject(createNoWorkerError()); + warnMainThreadFallback(); + } return abortable(layoutOnMainThread(elkGraph, signal), signal); } diff --git a/packages/joint-layout-elk/src/layout.mts b/packages/joint-layout-elk/src/layout.mts index 463635effe..e40aae548a 100644 --- a/packages/joint-layout-elk/src/layout.mts +++ b/packages/joint-layout-elk/src/layout.mts @@ -6,6 +6,9 @@ import { abortable, throwIfAborted } from './abort.mjs'; import type { ExportGraphOptions } from './export.mjs'; import type { ImportLayoutOptions } from './import.mjs'; +import type { LayoutThread } from './defaultElk.mjs'; + +export type { LayoutThread }; import type { ElkLayoutOptions, ElkNode } from './types/index.mjs'; import type { dia } from '@joint/core'; import type { ELK, ElkNode as RawElkNode } from 'elkjs'; @@ -44,12 +47,23 @@ export interface LayoutOptions extends ImportLayoutOptions, ExportGraphOptions { * (bundled as a worker file of its own by webpack 5, Vite or Parcel). Where no worker can * be started or loaded - no `Worker` (e.g. Node/SSR), the UMD build, a bundler that * doesn't emit worker files - a shared main-thread instance (`elkjs/lib/elk.bundled.js`). + * See `thread`. * @example * import ELK from 'elkjs/lib/elk-api.js'; * const elk = new ELK({ workerUrl: new URL('elkjs/lib/elk-worker.min.js', import.meta.url).href }); * layout({ graph }, { elk }); */ elk?: ELK; + /** + * Where the default ELK instance runs the layout - ignored with a custom `elk`. + * - `'auto'` - in a Web Worker where one can be used, on the main thread otherwise. + * - `'worker'` - in a Web Worker only: where none can be used (e.g. Node/SSR, the UMD + * build, or a worker that fails to load), `layout()` rejects instead. + * - `'main'` - on the main thread, without starting a worker (e.g. for tests or + * debugging) - it blocks the page while ELK runs. + * @defaultValue 'auto' + */ + thread?: LayoutThread; /** * ELK layout options, passed through to ELK unmodified. * @see https://eclipse.dev/elk/reference/options.html @@ -142,7 +156,7 @@ export async function layout({ graph, elements, links }: LayoutCells, opt?: Layo const rawElkGraph = elkGraph as unknown as RawElkNode; const result = await (opt?.elk ? abortable(opt.elk.layout(rawElkGraph), signal) - : layoutWithDefaultElk(rawElkGraph, signal)) as ElkNode; + : layoutWithDefaultElk(rawElkGraph, signal, opt?.thread)) as ElkNode; // Aborted after ELK settled, but before the result was applied. throwIfAborted(signal); diff --git a/packages/joint-layout-elk/test/index.js b/packages/joint-layout-elk/test/index.js index 6fc63332ed..b9e3ce8c8a 100644 --- a/packages/joint-layout-elk/test/index.js +++ b/packages/joint-layout-elk/test/index.js @@ -821,6 +821,24 @@ QUnit.module('layout()', () => { assert.notOk(joint.g.intersection.exists(el1.getBBox(), el2.getBBox())); }); + QUnit.test('should run on the main thread given `thread: main`', async(assert) => { + + const { graph, el1, el2 } = createGraph(); + + await joint.layout.ELK.layout({ graph }, { thread: 'main' }); + + assert.notOk(joint.g.intersection.exists(el1.getBBox(), el2.getBBox())); + }); + + QUnit.test('should reject given `thread: worker` where no worker can be used', async(assert) => { + + // No worker in the unit test bundle, unless a test hands it one. + const { graph, el1, el2 } = createGraph(); + + await assert.rejects(joint.layout.ELK.layout({ graph }, { thread: 'worker' }), /no Web Worker can be used here/); + assert.ok(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(); @@ -1157,6 +1175,30 @@ QUnit.module('the default ELK instance', (hooks) => { assert.ok(joint.g.intersection.exists(aborted.el1.getBBox(), aborted.el2.getBBox())); }); + QUnit.test('should not start a worker given `thread: main`', async(assert) => { + + const { graph, el1, el2 } = createGraph(); + const workerCount = startedWorkers.length; + const messageCount = workerMessageCount; + + await joint.layout.ELK.layout({ graph }, { thread: 'main' }); + + assert.equal(startedWorkers.length, workerCount); + assert.equal(workerMessageCount, messageCount); + assert.notOk(joint.g.intersection.exists(el1.getBBox(), el2.getBBox())); + }); + + QUnit.test('should run in the worker given `thread: worker`', async(assert) => { + + const { graph, el1, el2 } = createGraph(); + const messageCount = workerMessageCount; + + await joint.layout.ELK.layout({ graph }, { thread: 'worker' }); + + assert.ok(workerMessageCount > messageCount); + assert.notOk(joint.g.intersection.exists(el1.getBBox(), el2.getBBox())); + }); + QUnit.test('should reject a layout the worker crashes during - a new worker takes over the layouts still waiting', async(assert) => { const crashed = createGraph(); @@ -1195,13 +1237,21 @@ QUnit.module('the default ELK instance', (hooks) => { await assert.rejects(crashedResult); const { graph, el1, el2 } = createGraph(); - // Posted to the worker still loading - ELK itself would never settle it. + const workerOnly = createGraph(); + // Posted to the worker still loading - ELK itself would never settle them. + // Not retried on the main thread - rejected (handled right away: before the layout below). + const workerOnlyRejected = assert.rejects( + joint.layout.ELK.layout({ graph: workerOnly.graph }, { thread: 'worker' }), + /the ELK Web Worker failed to load, and `thread: 'worker'` rules out/ + ); try { await joint.layout.ELK.layout({ graph }); } finally { console.warn = warn; } assert.notOk(joint.g.intersection.exists(el1.getBBox(), el2.getBBox())); + await workerOnlyRejected; + assert.ok(joint.g.intersection.exists(workerOnly.el1.getBBox(), workerOnly.el2.getBBox())); // Reported - layouts still work, but now block the page. assert.equal(warnings.length, 1); assert.ok(/the ELK Web Worker failed to load - running ELK on the main thread instead/.test(warnings[0])); @@ -1213,5 +1263,6 @@ QUnit.module('the default ELK instance', (hooks) => { await joint.layout.ELK.layout({ graph: nextGraph }); assert.equal(startedWorkers.length, workerCount); assert.notOk(joint.g.intersection.exists(nextEl1.getBBox(), nextEl2.getBBox())); + await assert.rejects(joint.layout.ELK.layout(createGraph(), { thread: 'worker' }), /the ELK Web Worker failed to load/); }); }); From 97cb60f2380d653c4cb5f91f30d69cc42773e4d6 Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Tue, 6 Oct 2026 15:36:43 +0200 Subject: [PATCH 60/75] chore(layout-elk): set version to 4.3.0 The new package's `minor` changeset releases it as 4.4.0, alongside @joint/core. Co-Authored-By: Claude Opus 5.5 --- packages/joint-layout-elk/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/joint-layout-elk/package.json b/packages/joint-layout-elk/package.json index 663a015233..88d97b3a06 100644 --- a/packages/joint-layout-elk/package.json +++ b/packages/joint-layout-elk/package.json @@ -1,7 +1,7 @@ { "name": "@joint/layout-elk", "title": "JointJS ELK Layout", - "version": "4.4.0", + "version": "4.3.0", "description": "ELK Layout module for JointJS", "sideEffects": false, "main": "./dist/esm/index.mjs", From 621114aa21800c7c17a3a3a52487d79e4a7a5fd9 Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Tue, 6 Oct 2026 15:39:31 +0200 Subject: [PATCH 61/75] fix(examples): fix ELK examples - flowchart: abort a layout still running when a new one starts, so only the latest is applied and an earlier one no longer unfreezes the paper too soon; keep the zoom level when refitting after a layout; lay out again only after a drop that actually reorders; drop the redundant exportLinkLabel and the comment describing elk.port.index code that doesn't exist; explain the INTERACTIVE layering strategy - containers-ports: drop the edge-only elk.layered.priority.direction set on the root, the unused import and the stale ELK_MAX_WIDTH reference - rectpacking: keep re-running a layout requested while one fails - layout-elk-ts: unfreeze the paper when the layout fails; describe the demo in its README - default: don't repeat the package's default algorithm - webpack: 'auto' publicPath, so the ELK worker and main-thread ELK chunks load from wherever the demo is served (the dev server keeps /dist/), and anchor the .m?js rule Co-Authored-By: Claude Opus 5.5 --- .../src/index.ts | 23 +++-- .../webpack.config.js | 9 +- examples/layout-elk-default-ts/src/index.ts | 2 +- .../layout-elk-default-ts/webpack.config.js | 9 +- examples/layout-elk-flowchart-ts/src/index.ts | 95 ++++++++++--------- .../layout-elk-flowchart-ts/webpack.config.js | 9 +- .../layout-elk-rectpacking-ts/src/index.ts | 20 ++-- .../webpack.config.js | 9 +- examples/layout-elk-ts/README.md | 4 +- examples/layout-elk-ts/src/index.ts | 1 + examples/layout-elk-ts/webpack.config.js | 9 +- 11 files changed, 111 insertions(+), 79 deletions(-) diff --git a/examples/layout-elk-containers-ports-ts/src/index.ts b/examples/layout-elk-containers-ports-ts/src/index.ts index b02c93f457..6990f920ec 100644 --- a/examples/layout-elk-containers-ports-ts/src/index.ts +++ b/examples/layout-elk-containers-ports-ts/src/index.ts @@ -3,9 +3,8 @@ import { ElkLayoutOptions, ExportElementCallback, ExportPortCallback, - layout, - ExportLinkLabelCallback, - ExportPortLabelCallback + ExportPortLabelCallback, + layout } from '@joint/layout-elk'; import { graphJSON } from './example'; import { Container, HubService, InteractionLink, Service } from './shapes'; @@ -84,8 +83,8 @@ const init = () => { /** * 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, to keep this - * particular graph within `ELK_MAX_WIDTH` (see the check below). + * onto. Tuned, together with the spacing above, for this + * particular graph. */ 'elk.aspectRatio': '1.2', /** @@ -93,10 +92,11 @@ const init = () => { * "wrap" edges, instead of growing a single row indefinitely. * 'NONE' | 'SINGLE_EDGE' | 'MULTI_EDGE' */ - 'elk.layered.wrapping.strategy': 'MULTI_EDGE', - 'elk.layered.priority.direction': '40' - } + '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'; @@ -125,9 +125,8 @@ const init = () => { elkPortLabel.height = height; }; - // Wraps every `layout()` call the example makes - freezing the paper for its - // (async) duration, so nothing renders mid-layout, and reporting any error the - // same way regardless of which caller triggered the layout. + // Freezes the paper for the (async) duration of the layout, so nothing renders + // mid-layout. const runLayout = (): Promise => { paper.freeze(); return layout({ graph }, { @@ -145,7 +144,7 @@ const init = () => { // Initial layout of the fixed example data, fit to the paper's viewport. runLayout().then(() => { - zoom(paper, 1) + zoom(paper, 1); }); }; diff --git a/examples/layout-elk-containers-ports-ts/webpack.config.js b/examples/layout-elk-containers-ports-ts/webpack.config.js index 7b10ca335d..7e6cc42763 100644 --- a/examples/layout-elk-containers-ports-ts/webpack.config.js +++ b/examples/layout-elk-containers-ports-ts/webpack.config.js @@ -8,13 +8,15 @@ module.exports = { output: { filename: 'bundle.js', path: path.resolve(__dirname, 'dist'), - publicPath: '/dist/', + // Resolved from the bundle's own URL - the ELK worker and main-thread ELK are + // chunks of their own, loaded from next to it wherever the demo is served. + publicPath: 'auto', }, mode: 'development', module: { rules: [ { - test: /\.m?js/, + test: /\.m?js$/, resolve: { fullySpecified: false, }, @@ -34,6 +36,9 @@ module.exports = { static: { directory: __dirname, }, + devMiddleware: { + publicPath: '/dist/', + }, compress: true, }, }; diff --git a/examples/layout-elk-default-ts/src/index.ts b/examples/layout-elk-default-ts/src/index.ts index c73d96fbfe..467f6c7ad6 100644 --- a/examples/layout-elk-default-ts/src/index.ts +++ b/examples/layout-elk-default-ts/src/index.ts @@ -50,7 +50,7 @@ const init = () => { // defaults alone. layout({ graph }, { elkLayoutOptions: { - 'elk.algorithm': 'layered', + // `'elk.algorithm': 'layered'` is the package's default - not repeated here. 'elk.direction': 'RIGHT', 'elk.edgeRouting': 'ORTHOGONAL', 'elk.spacing.nodeNode': '30', diff --git a/examples/layout-elk-default-ts/webpack.config.js b/examples/layout-elk-default-ts/webpack.config.js index 7b10ca335d..7e6cc42763 100644 --- a/examples/layout-elk-default-ts/webpack.config.js +++ b/examples/layout-elk-default-ts/webpack.config.js @@ -8,13 +8,15 @@ module.exports = { output: { filename: 'bundle.js', path: path.resolve(__dirname, 'dist'), - publicPath: '/dist/', + // Resolved from the bundle's own URL - the ELK worker and main-thread ELK are + // chunks of their own, loaded from next to it wherever the demo is served. + publicPath: 'auto', }, mode: 'development', module: { rules: [ { - test: /\.m?js/, + test: /\.m?js$/, resolve: { fullySpecified: false, }, @@ -34,6 +36,9 @@ module.exports = { static: { directory: __dirname, }, + devMiddleware: { + publicPath: '/dist/', + }, compress: true, }, }; diff --git a/examples/layout-elk-flowchart-ts/src/index.ts b/examples/layout-elk-flowchart-ts/src/index.ts index b0a8e27d7c..56585ede1e 100644 --- a/examples/layout-elk-flowchart-ts/src/index.ts +++ b/examples/layout-elk-flowchart-ts/src/index.ts @@ -3,7 +3,6 @@ import { ElkLayoutOptions, ExportElementCallback, ExportPortCallback, - ExportLinkLabelCallback, SetPortAttributesCallback, layout } from '@joint/layout-elk'; @@ -158,12 +157,15 @@ const init = () => { // ignore the model order entirely. This makes it absolute instead, so the // reorder feature's `order` (via `z`) always actually has a visible effect. 'elk.layered.crossingMinimization.forceNodeModelOrder': 'true', + // Layers follow the nodes' current positions (passed to ELK in `exportElement` + // below), so a re-layout after an edit keeps every existing step in the layer it + // is already in, instead of re-ranking the whole flowchart from scratch. 'elk.layered.layering.strategy': 'INTERACTIVE' }; - // `FIXED_SIDE` (not the default `FREE`) is what lets ELK reorder a node's ports - // along their side to reduce crossings, instead of only routing edges to - // wherever a port happens to already be. + // `FIXED_SIDE` (not the package's default `FIXED_POS`) is what lets ELK reorder a + // node's ports along their side to reduce crossings, instead of only routing edges + // to wherever a port happens to already be. const exportElement: ExportElementCallback = ({ elkNode, element }) => { elkNode.layoutOptions['elk.portConstraints'] = 'FIXED_SIDE'; const position = element.position(); @@ -173,27 +175,12 @@ const init = () => { // Every 'in' port sits on the node's top, every 'out' port on its bottom - // matching the top-to-bottom flow and `FlowchartNode`'s own port groups. - // `elk.port.index` gives crossing minimization (which, under `FIXED_SIDE`, - // still uses it as its initial/tie-break order rather than requiring it - // outright) an explicit left-to-right order to start from, rather than - // leaving a brand new, still-unconnected port (added via the "+" buttons) - // to whatever an edgeless port happens to fall back to. ELK's own - // documented convention for that index is clockwise starting at the - // top-left, which makes the *south* side's index count right-to-left, the - // opposite of north's left-to-right - so it has to be reversed for 'out' - // ports specifically, or a newly-added one (last in `getGroupPorts()`, - // meant to land on the *right*, matching a new 'in' port) would instead - // end up leftmost. + // `FIXED_SIDE` keeps each port on that side, while still letting ELK order + // them along it. const exportPort: ExportPortCallback = ({ portId, element, elkPort }) => { elkPort.layoutOptions['elk.port.side'] = (element.getPort(portId).group === 'in') ? 'NORTH' : 'SOUTH'; }; - // Every branch condition ("Valid"/"Invalid", ...) sits directly on its edge, - // rather than floating beside it. - const exportLinkLabel: ExportLinkLabelCallback = ({ elkEdgeLabel }) => { - elkEdgeLabel.layoutOptions['elk.edgeLabels.inline'] = 'true'; - }; - // ELK (and the built-in 'top'/'bottom' port position functions) place a port // on the node's *bounding box* border - correct for `Process`/`Terminal`'s // rectangles, but not for `Decision`'s diamond, whose actual edge sits at @@ -211,21 +198,31 @@ const init = () => { element.portProp(portId, attributes); }; - const runLayout = (): Promise => { + // Every edit (a new step, port or connection, a reorder) runs a new layout - one + // made while an earlier layout is still running supersedes it: the earlier one is + // aborted, so only the latest layout (which already includes every edit) is applied. + let layoutController: AbortController | null = null; + const runLayout = async(): Promise => { + layoutController?.abort(); + const controller = new AbortController(); + layoutController = controller; paper.freeze(); - return layout({ graph }, { - exportElement, - exportPort, - exportLinkLabel, - setPortAttributes, - elkLayoutOptions - }).then(() => { - paper.unfreeze(); - zoom(paper, 1); - }).catch((error) => { - paper.unfreeze(); - console.error('ELK layout error:', error.message); - }); + try { + await layout({ graph }, { + exportElement, + exportPort, + setPortAttributes, + elkLayoutOptions, + signal: controller.signal + }); + } catch (error) { + // Superseded - the layout that aborted it unfreezes the paper once it's done. + if (controller.signal.aborted) return; + console.error('ELK layout error:', (error as Error).message); + } + paper.unfreeze(); + // Refit the paper to the new layout, keeping the current zoom level. + zoom(paper, paper.scale().sx); }; runLayout(); @@ -348,9 +345,8 @@ const init = () => { // Always prevent JointJS's own native move, reorderable or not - an // element with no siblings (e.g. `Start`/`End`) would otherwise still // be freely draggable around the canvas by default, just with no - // preview and no effect on drop (`runLayout()` snaps it right back). - // Blocking the native move outright means it's simply not possible to - // move it around in the first place. + // preview and no effect on drop. Blocking the native move outright + // means it's simply not possible to move it around in the first place. elementView.preventDefaultInteraction(evt); const y = element.position().y; @@ -375,16 +371,18 @@ const init = () => { }); paper.on('element:pointerup', (elementView: dia.ElementView, _evt: dia.Event, x: number) => { - if (elementView.model === draggedElement && draggedSiblings) { - reorderAmongSiblings(elementView.model, draggedSiblings, x); - } + // Only a drop that actually changes the order needs a new layout - not a plain + // click, nor a drop back where the element already was. + const isReordered = (elementView.model === draggedElement && draggedSiblings) + ? reorderAmongSiblings(elementView.model, draggedSiblings, x) + : false; clearPreview(); draggedElement = null; draggedSiblings = null; draggedBBox = null; - runLayout(); + if (isReordered) runLayout(); }); }; @@ -398,12 +396,19 @@ const init = () => { // that's supposed to be purely local to this layer. `z` (hence // `graph.getElements()`'s own order, hence `exportGraph`, hence // `considerModelOrder.strategy`) follows automatically, via the -// `change:order` listener registered in `init()`. -function reorderAmongSiblings(element: dia.Element, siblings: dia.Element[], dropX: number): void { +// `change:order` listener registered in `init()`. Returns whether any element's +// `order` changed. +function reorderAmongSiblings(element: dia.Element, siblings: dia.Element[], dropX: number): boolean { const group = [element, ...siblings]; const orderValues: number[] = group.map((el) => el.get('order')).sort((a, b) => a - b); const sorted = util.sortBy(group, (el) => (el === element) ? dropX : el.getBBox().center().x); - sorted.forEach((el, i) => el.set('order', orderValues[i])); + let isChanged = false; + sorted.forEach((el, i) => { + if (el.get('order') === orderValues[i]) return; + el.set('order', orderValues[i]); + isChanged = true; + }); + return isChanged; } function zoom(paper: dia.Paper, zoomLevel: number): void { diff --git a/examples/layout-elk-flowchart-ts/webpack.config.js b/examples/layout-elk-flowchart-ts/webpack.config.js index 7b10ca335d..7e6cc42763 100644 --- a/examples/layout-elk-flowchart-ts/webpack.config.js +++ b/examples/layout-elk-flowchart-ts/webpack.config.js @@ -8,13 +8,15 @@ module.exports = { output: { filename: 'bundle.js', path: path.resolve(__dirname, 'dist'), - publicPath: '/dist/', + // Resolved from the bundle's own URL - the ELK worker and main-thread ELK are + // chunks of their own, loaded from next to it wherever the demo is served. + publicPath: 'auto', }, mode: 'development', module: { rules: [ { - test: /\.m?js/, + test: /\.m?js$/, resolve: { fullySpecified: false, }, @@ -34,6 +36,9 @@ module.exports = { static: { directory: __dirname, }, + devMiddleware: { + publicPath: '/dist/', + }, compress: true, }, }; diff --git a/examples/layout-elk-rectpacking-ts/src/index.ts b/examples/layout-elk-rectpacking-ts/src/index.ts index 3b3eda3cd3..2d12278667 100644 --- a/examples/layout-elk-rectpacking-ts/src/index.ts +++ b/examples/layout-elk-rectpacking-ts/src/index.ts @@ -123,9 +123,9 @@ const init = () => { return; } running = true; - try { - do { - pending = false; + do { + pending = false; + try { const { bbox } = await layout({ graph }, { elkLayoutOptions: getRootOptions(), exportElement, @@ -134,13 +134,13 @@ const init = () => { contentArea = bbox; paper.unfreeze(); fit(); - } while (pending); - } catch (error) { - paper.unfreeze(); - console.error('ELK layout error:', (error as Error).message); - } finally { - running = false; - } + } 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', () => { diff --git a/examples/layout-elk-rectpacking-ts/webpack.config.js b/examples/layout-elk-rectpacking-ts/webpack.config.js index 7b10ca335d..7e6cc42763 100644 --- a/examples/layout-elk-rectpacking-ts/webpack.config.js +++ b/examples/layout-elk-rectpacking-ts/webpack.config.js @@ -8,13 +8,15 @@ module.exports = { output: { filename: 'bundle.js', path: path.resolve(__dirname, 'dist'), - publicPath: '/dist/', + // Resolved from the bundle's own URL - the ELK worker and main-thread ELK are + // chunks of their own, loaded from next to it wherever the demo is served. + publicPath: 'auto', }, mode: 'development', module: { rules: [ { - test: /\.m?js/, + test: /\.m?js$/, resolve: { fullySpecified: false, }, @@ -34,6 +36,9 @@ module.exports = { static: { directory: __dirname, }, + devMiddleware: { + publicPath: '/dist/', + }, compress: true, }, }; diff --git a/examples/layout-elk-ts/README.md b/examples/layout-elk-ts/README.md index d9eaedf7e5..e006273034 100644 --- a/examples/layout-elk-ts/README.md +++ b/examples/layout-elk-ts/README.md @@ -1,4 +1,6 @@ -# JointJS ELK Demo +# JointJS ELK Layout Demo + +Lays out a JointJS graph with [`@joint/layout-elk`](../../packages/joint-layout-elk) - the Eclipse Layout Kernel (ELK), running in a Web Worker. ## Setup diff --git a/examples/layout-elk-ts/src/index.ts b/examples/layout-elk-ts/src/index.ts index 0e33cfa605..a6b8e20611 100644 --- a/examples/layout-elk-ts/src/index.ts +++ b/examples/layout-elk-ts/src/index.ts @@ -95,6 +95,7 @@ const init = () => { // Scroll into a busy area of the example window.scroll(650, 560); }).catch((error) => { + paper.unfreeze(); console.error('ELK layout error:', error.message); }); }; diff --git a/examples/layout-elk-ts/webpack.config.js b/examples/layout-elk-ts/webpack.config.js index 7b10ca335d..7e6cc42763 100644 --- a/examples/layout-elk-ts/webpack.config.js +++ b/examples/layout-elk-ts/webpack.config.js @@ -8,13 +8,15 @@ module.exports = { output: { filename: 'bundle.js', path: path.resolve(__dirname, 'dist'), - publicPath: '/dist/', + // Resolved from the bundle's own URL - the ELK worker and main-thread ELK are + // chunks of their own, loaded from next to it wherever the demo is served. + publicPath: 'auto', }, mode: 'development', module: { rules: [ { - test: /\.m?js/, + test: /\.m?js$/, resolve: { fullySpecified: false, }, @@ -34,6 +36,9 @@ module.exports = { static: { directory: __dirname, }, + devMiddleware: { + publicPath: '/dist/', + }, compress: true, }, }; From 1fa655c84a68d689303dac5fe7351f48ff84e7c7 Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Tue, 6 Oct 2026 16:30:48 +0200 Subject: [PATCH 62/75] feat(layout-elk)!: run ELK on the main thread by default, add createWorkerElk() The package no longer starts a Web Worker of its own. Locating the worker script depends on the bundler (Vite's dev server, esbuild, an absolute publicPath, the UMD build and CSP all needed workarounds), so starting it is now up to the app, which knows its bundler: - Without an `elk` option, layout() runs ELK on the main thread - it works anywhere with no setup. The main-thread ELK chunk is still loaded lazily. - createWorkerElk(createWorker) returns an ELK instance running in a worker the app starts, e.g. new Worker(new URL('@joint/layout-elk/worker', import.meta.url), { type: 'module' }) - tested with webpack 5 and Vite (dev and build). @joint/layout-elk/worker is a new subpath export, so elkjs resolves from this package rather than from the app. - The instance keeps the worker client's behavior - an aborted layout stops the worker busy with it, a crash rejects only the layout it was running - but a worker that fails to load now rejects its layouts instead of falling back to the main thread. terminate() stops it. - The `thread` option and the fallback warning are removed. Co-Authored-By: Claude Opus 5.5 --- packages/joint-layout-elk/README.md | 55 ++-- packages/joint-layout-elk/package.json | 3 +- packages/joint-layout-elk/rollup.config.mjs | 16 +- packages/joint-layout-elk/src/defaultElk.mts | 277 +----------------- packages/joint-layout-elk/src/elk.worker.mts | 6 +- packages/joint-layout-elk/src/index.mts | 2 + packages/joint-layout-elk/src/layout.mts | 53 ++-- .../joint-layout-elk/src/mainThreadElk.mts | 4 +- packages/joint-layout-elk/src/workerElk.mts | 213 ++++++++++++++ .../joint-layout-elk/src/workerFactory.mts | 12 - packages/joint-layout-elk/test/index.js | 234 +++++++-------- 11 files changed, 397 insertions(+), 478 deletions(-) create mode 100644 packages/joint-layout-elk/src/workerElk.mts delete mode 100644 packages/joint-layout-elk/src/workerFactory.mts diff --git a/packages/joint-layout-elk/README.md b/packages/joint-layout-elk/README.md index f333a9958f..f261f2c570 100644 --- a/packages/joint-layout-elk/README.md +++ b/packages/joint-layout-elk/README.md @@ -73,10 +73,8 @@ interface LayoutResult { ```ts interface LayoutOptions { - // A custom ELK instance, e.g. one running in a Web Worker of your own. - elk?: ELK; // Default: a shared instance running in a Web Worker (see "Web Worker" below) - // Where the default ELK instance runs the layout - ignored with a custom `elk`. - thread?: 'auto' | 'worker' | 'main'; // Default: 'auto' + // 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.json.edgeCoords': 'ROOT' } // A name for the layout batch, grouping everything `layout()` applies into one graph change. @@ -120,19 +118,39 @@ type SetPortAttributesCallback = (params: { element: dia.Element; portId: string 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; ``` -### Choosing the thread +### Running ELK in a Web Worker -Without an `elk` option, `thread` sets where ELK runs the layout: - -- `'auto'` (default) - in the package's Web Worker where one can be used, on the main thread otherwise (see "Web Worker" below). -- `'worker'` - in the Web Worker only. Where none can be used (e.g. Node/SSR, the UMD build, or a worker that fails to load), `layout()` rejects instead of blocking the page. -- `'main'` - on the main thread, without starting a worker - e.g. for tests or debugging. The page is blocked while ELK runs. +By default, `layout()` runs ELK on the main thread - nothing to set up, and it works anywhere (browsers, Node/SSR, tests, the UMD build). 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 -// Never block the page - e.g. for a graph large enough to take seconds to lay out. -await layout({ graph }, { thread: 'worker' }); +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. @@ -155,20 +173,15 @@ async function runLayout() { await layout({ graph }, { signal: AbortSignal.timeout(5000) }); ``` -ELK can't stop a layout in progress, so a layout the default Web 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 a custom `elk` instance, keeps running - only its result is ignored (call `elk.terminateWorker()` yourself to stop a custom one). +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. - **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. -- **Asynchronous** - unlike `@joint/layout-directed-graph`, `layout()` returns a `Promise`, since `elkjs` computes layouts asynchronously (by default inside a Web Worker). -- **Web Worker** - without an `elk` option, `layout()` runs ELK in a Web Worker the package starts on first use and shares between calls. It is started with `new Worker(new URL('./elk.worker.mjs', import.meta.url), { type: 'module' })`, which webpack 5, Vite and Parcel bundle as a worker file of its own with no extra setup. The main-thread copy of ELK (`elkjs/lib/elk.bundled.js`) is imported dynamically, so it is split into a chunk of its own, only loaded if a layout ever runs on the main thread. With `thread: 'auto'`, ELK runs on the main thread instead where no worker can be used: no `Worker` (e.g. Node/SSR), the UMD build (a script tag has no way to locate a worker file), or a worker that can't be started or fails to load (e.g. a bundler that doesn't emit worker files, or a CSP `worker-src` that blocks it) - a layout in progress when that happens is retried on the main thread (or rejected, with `thread: 'worker'`). A worker that crashes once loaded (e.g. out of memory) is different: the layout it was busy with is rejected rather than retried on the main thread (where the same crash would take the page down), and a new worker takes over the layouts still waiting. To run ELK in a worker of your own instead (e.g. with the UMD build), pass `elk: new ELK({ workerUrl })` (`elkjs/lib/elk-api.js`). -- **Worker troubleshooting** - a worker that can't be started or fails to load is reported once with a `console.warn` (layouts still work, but block the page while ELK runs). Common causes: - - **Vite dev server** - Vite pre-bundles dependencies into `node_modules/.vite/deps`, where the worker file isn't found (production builds are not affected). Exclude the package from pre-bundling: `optimizeDeps: { exclude: ['@joint/layout-elk'] }` in `vite.config.js`. - - **An absolute `publicPath`** (webpack `output.publicPath: '/dist/'`) - the worker file is requested from that path, so it is not found once the app is served from anywhere else. Prefer `'auto'` (webpack's default). - - **esbuild** (and other bundlers that don't follow `new URL(..., import.meta.url)`) - the worker file isn't emitted. Pass `elk: new ELK({ workerUrl })` instead, pointing at a copy of `elkjs/lib/elk-worker.min.js` you serve yourself. -- **ID handling** - ELK requires string ids; element and link ids are converted with `` `${id}` `` internally, but never written back to the graph. +- **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. 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. ## 📄 License @@ -176,6 +189,6 @@ ELK can't stop a layout in progress, so a layout the default Web Worker is busy 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 Web Worker file, and the main-thread fallback), 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)). +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/package.json b/packages/joint-layout-elk/package.json index 88d97b3a06..4c95c5bab6 100644 --- a/packages/joint-layout-elk/package.json +++ b/packages/joint-layout-elk/package.json @@ -47,7 +47,8 @@ "types": "./dist/esm/index.d.mts", "import": "./dist/esm/index.mjs", "default": "./dist/esm/index.mjs" - } + }, + "./worker": "./dist/esm/elk.worker.mjs" }, "files": [ "dist", diff --git a/packages/joint-layout-elk/rollup.config.mjs b/packages/joint-layout-elk/rollup.config.mjs index dc61c8ab68..f5f3f4ee43 100644 --- a/packages/joint-layout-elk/rollup.config.mjs +++ b/packages/joint-layout-elk/rollup.config.mjs @@ -27,19 +27,7 @@ const replaceModule = (name, code) => { }; }; -// A UMD bundle has no way to locate a worker file of its own - replace the module that -// starts one (see `src/workerFactory.mts`) with one that doesn't, so `layout()` runs ELK -// on the main thread by default. -const noWorker = replaceModule('workerFactory', 'export function createElkWorker() { return undefined; }'); - -// The unit test bundle starts whichever worker a test hands it (`window.__createElkWorker`), -// so the default worker - and falling back from it - can be tested too (see `test/index.js`). -const testWorker = replaceModule( - 'workerFactory', - 'export function createElkWorker() { return window.__createElkWorker ? window.__createElkWorker() : undefined; }' -); - -// A UMD bundle can't load a chunk of its own either - replace the module that imports +// 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( @@ -91,7 +79,6 @@ export default [ }, ], plugins: [ - noWorker, staticMainThreadElk, nodeResolve({ preferBuiltins: false @@ -121,7 +108,6 @@ export default [ } ], plugins: [ - testWorker, testMainThreadElk, nodeResolve({ preferBuiltins: false diff --git a/packages/joint-layout-elk/src/defaultElk.mts b/packages/joint-layout-elk/src/defaultElk.mts index 6850488185..8fe10cfaa5 100644 --- a/packages/joint-layout-elk/src/defaultElk.mts +++ b/packages/joint-layout-elk/src/defaultElk.mts @@ -1,33 +1,9 @@ -import { createElkWorker } from './workerFactory.mjs'; import { loadMainThreadElk } from './mainThreadElk.mjs'; -import { abortable, getAbortReason, throwIfAborted } from './abort.mjs'; +import { abortable, throwIfAborted } from './abort.mjs'; import type { ELK, ElkNode } from 'elkjs'; -/** - * Where the default ELK instance runs a layout: - * - `'auto'` - in a Web Worker where one can be used, on the main thread otherwise. - * - `'worker'` - in a Web Worker only - the layout is rejected where none can be used. - * - `'main'` - on the main thread (no worker is started). - */ -export type LayoutThread = 'auto' | 'worker' | 'main'; - -// 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; - // Whether the job may be retried on the main thread if the worker fails to load. - canRunOnMainThread: boolean; - resolve: (result: ElkNode) => void; - reject: (reason: unknown) => void; -} - -// Loaded on the first layout that runs on the main thread, then shared by every later one. +// Loaded on the first layout without an `elk` option, then shared by every later one. let mainThreadElk: Promise | undefined; function getMainThreadElk(): Promise { @@ -44,248 +20,17 @@ function getMainThreadElk(): Promise { return mainThreadElk; } -function layoutOnMainThread(graph: ElkNode, signal?: AbortSignal): Promise { - return getMainThreadElk().then((elk) => { - // Aborted while ELK was loading - no need to start it. - throwIfAborted(signal); - return elk.layout(graph); - }); -} - -function runOnMainThread(job: LayoutJob): void { - layoutOnMainThread(job.graph).then(job.resolve, job.reject); -} - -// Why the default worker can't be used - `undefined` while it can, or where no worker is -// expected in the first place (no `Worker`, e.g. Node/SSR, or the UMD build). -let workerFailureReason: string | undefined; -let hasWarned = false; - -// Running ELK on the main thread where a worker was expected is easy to miss (layouts -// still work, they only block the page) - so it is reported once. -function warnMainThreadFallback(): void { - if (hasWarned || !workerFailureReason) return; - hasWarned = true; - console.warn(`@joint/layout-elk: ${workerFailureReason} - running ELK on the main thread instead. See "Web Worker" in the README of @joint/layout-elk.`); -} - -function fallBackToMainThread(job: LayoutJob): void { - warnMainThreadFallback(); - runOnMainThread(job); -} - -// Rejects a layout for which `thread: 'worker'` rules out the main thread. -function createNoWorkerError(): Error { - const reason = workerFailureReason || 'no Web Worker can be used here (e.g. Node/SSR, or the UMD build)'; - return new Error(`@joint/layout-elk: ${reason}, and \`thread: 'worker'\` rules out running ELK on the main thread.`); -} - -function startWorker(): Worker | undefined { - // E.g. Node/SSR. - if (typeof Worker === 'undefined') return undefined; - try { - // `undefined` in the UMD build. - return createElkWorker(); - } catch (error) { - // E.g. a worker script the page's CSP (`worker-src`) doesn't allow, or no - // `import.meta.url` to resolve it against. - workerFailureReason = `the ELK Web Worker could not be started (${error})`; - return undefined; - } -} - /** - * Talks to ELK's own worker script (`elkjs/lib/elk-worker.min.js`, see `elk.worker.mts`) - * in place of `elkjs/lib/elk-api.js`, which can neither cancel a layout nor settle one - * whose worker fails or is terminated. + * Lays out `elkGraph` with the default ELK instance, on the main thread. * - * The worker lays out one graph at a time, in the order they were posted - `jobs` keeps - * that order, so its first job is the one the worker is busy with. + * 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. */ -class ElkWorkerClient { - - 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 nextId = REGISTER_ID + 1; - - /** - * @param onFailure Called with the jobs left unsettled once the worker fails to load. - */ - constructor(private readonly onFailure: (jobs: LayoutJob[]) => void) {} - - /** - * Starts a worker, and posts it every job not settled yet (e.g. after a restart). - * Returns `false` if no worker could be started. - */ - start(): boolean { - const worker = startWorker(); - if (!worker) return false; - 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. a bundler that doesn't emit worker files), 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 }); - this.jobs.forEach((job) => this.post(job)); - return true; - } - - layout(graph: ElkNode, signal: AbortSignal | undefined, canRunOnMainThread: boolean): 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, - canRunOnMainThread, - resolve: (result) => { - settle(); - resolve(result); - }, - reject: (reason) => { - settle(); - reject(reason); - } - }; - signal?.addEventListener('abort', onAbort, { once: true }); - this.jobs.set(job.id, job); - this.post(job); - }); - } - - private post(job: LayoutJob): void { - // The worker gets its own (structured) clone of `graph` - a job re-posted after a - // restart, or retried on the main thread, starts from the original. - this.worker?.postMessage({ id: job.id, cmd: 'layout', graph: job.graph, layoutOptions: {}, options: {}}); - } - - private receive(data: { id: number, data?: ElkNode, error?: unknown }): void { - if (data.id === REGISTER_ID) { - this.isLoaded = true; - return; - } - 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; - const isRunning = this.jobs.keys().next().value === job.id; - this.jobs.delete(job.id); - if (isRunning) this.restart(); - } - - /** - * The worker crashed (after it loaded) - most likely because of the job it was busy - * with, which is rejected rather than retried, on the main thread least of all, where - * the same crash would take the page down with it. A new worker takes over the jobs - * still waiting. - */ - private crash(event: ErrorEvent): void { - const [running] = this.jobs.values(); - 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(); - } - - private restart(): void { - this.terminate(); - if (!this.start()) this.fail(); - } - - /** - * The worker failed to load - every job not settled yet is retried on the main thread, - * or rejected if it can't run there (see `getWorkerClient`). - */ - private fail(): void { - this.terminate(); - const jobs = Array.from(this.jobs.values()); - this.jobs.clear(); - this.onFailure(jobs); - } - - private terminate(): void { - this.worker?.terminate(); - this.worker = undefined; - } -} - -let workerClient: ElkWorkerClient | undefined; -// Set once the worker fails to load - every later layout then runs on the main thread -// straight away. -let hasWorkerFailed = false; - -function getWorkerClient(): ElkWorkerClient | undefined { - if (workerClient || hasWorkerFailed) return workerClient; - const client = new ElkWorkerClient((jobs) => { - hasWorkerFailed = true; - workerClient = undefined; - // E.g. a worker file the bundler didn't emit, or doesn't serve where it says it is. - workerFailureReason = 'the ELK Web Worker failed to load'; - jobs.forEach((job) => { - if (job.canRunOnMainThread) { - fallBackToMainThread(job); - } else { - job.reject(createNoWorkerError()); - } - }); +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); }); - // No worker yet is checked for again on the next layout - nothing is started meanwhile. - if (!client.start()) return undefined; - workerClient = client; - return client; -} - -/** - * Lays out `elkGraph` with the default ELK instance - where `thread` says (see - * `LayoutThread`). With `'auto'`, a layout started while the worker fails to load is - * retried on the main thread - one the worker crashes during is rejected. - * - * Aborting `signal` rejects with its reason straight away. A layout the worker is busy - * with is stopped by terminating the worker - one on the main thread can't be stopped, - * its result is only ignored. - */ -export function layoutWithDefaultElk(elkGraph: ElkNode, signal?: AbortSignal, thread: LayoutThread = 'auto'): Promise { - if (thread !== 'main') { - const client = getWorkerClient(); - if (client) return client.layout(elkGraph, signal, thread === 'auto'); - if (thread === 'worker') return Promise.reject(createNoWorkerError()); - warnMainThreadFallback(); - } - return abortable(layoutOnMainThread(elkGraph, signal), signal); + return abortable(layout, signal); } diff --git a/packages/joint-layout-elk/src/elk.worker.mts b/packages/joint-layout-elk/src/elk.worker.mts index cfb92c6d87..844a8d2bc4 100644 --- a/packages/joint-layout-elk/src/elk.worker.mts +++ b/packages/joint-layout-elk/src/elk.worker.mts @@ -1,3 +1,5 @@ -// The Web Worker `layout()` runs ELK in by default (see `workerFactory.mts`). ELK's own -// worker script sets up the worker's message handling as soon as it's loaded. +// 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/index.mts b/packages/joint-layout-elk/src/index.mts index 1653d5b3d4..826d32c16a 100644 --- a/packages/joint-layout-elk/src/index.mts +++ b/packages/joint-layout-elk/src/index.mts @@ -3,5 +3,7 @@ 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 index e40aae548a..5bfff40773 100644 --- a/packages/joint-layout-elk/src/layout.mts +++ b/packages/joint-layout-elk/src/layout.mts @@ -3,12 +3,11 @@ import { importLayout } from './import.mjs'; import { layoutWithDefaultElk } from './defaultElk.mjs'; import { exportGraph } from './export.mjs'; import { abortable, throwIfAborted } from './abort.mjs'; +import { ElkWorkerClient } from './workerElk.mjs'; import type { ExportGraphOptions } from './export.mjs'; import type { ImportLayoutOptions } from './import.mjs'; -import type { LayoutThread } from './defaultElk.mjs'; - -export type { LayoutThread }; +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'; @@ -40,30 +39,16 @@ const DEFAULT_OPTIONS: LayoutOptions = { export interface LayoutOptions extends ImportLayoutOptions, ExportGraphOptions { /** - * A custom ELK instance, e.g. one running in a Web Worker of your own. The instance - * is not terminated by the package - call `elk.terminateWorker()` yourself when it is - * no longer needed. - * @defaultValue a shared instance running in a Web Worker the package starts itself - * (bundled as a worker file of its own by webpack 5, Vite or Parcel). Where no worker can - * be started or loaded - no `Worker` (e.g. Node/SSR), the UMD build, a bundler that - * doesn't emit worker files - a shared main-thread instance (`elkjs/lib/elk.bundled.js`). - * See `thread`. + * 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 - * import ELK from 'elkjs/lib/elk-api.js'; - * const elk = new ELK({ workerUrl: new URL('elkjs/lib/elk-worker.min.js', import.meta.url).href }); + * const elk = createWorkerElk(() => new Worker(new URL('elkjs/lib/elk-worker.min.js', import.meta.url))); * layout({ graph }, { elk }); */ - elk?: ELK; - /** - * Where the default ELK instance runs the layout - ignored with a custom `elk`. - * - `'auto'` - in a Web Worker where one can be used, on the main thread otherwise. - * - `'worker'` - in a Web Worker only: where none can be used (e.g. Node/SSR, the UMD - * build, or a worker that fails to load), `layout()` rejects instead. - * - `'main'` - on the main thread, without starting a worker (e.g. for tests or - * debugging) - it blocks the page while ELK runs. - * @defaultValue 'auto' - */ - thread?: LayoutThread; + elk?: WorkerElk | ELK; /** * ELK layout options, passed through to ELK unmodified. * @see https://eclipse.dev/elk/reference/options.html @@ -78,9 +63,9 @@ export interface LayoutOptions extends ImportLayoutOptions, ExportGraphOptions { /** * 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 the default 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 a custom - * `elk` instance, can't be stopped - its result is only ignored. + * 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 }); @@ -154,9 +139,17 @@ export async function layout({ graph, elements, links }: LayoutCells, opt?: Layo ); const rawElkGraph = elkGraph as unknown as RawElkNode; - const result = await (opt?.elk - ? abortable(opt.elk.layout(rawElkGraph), signal) - : layoutWithDefaultElk(rawElkGraph, signal, opt?.thread)) as ElkNode; + const elk = opt?.elk; + let layoutResult: Promise; + if (!elk) { + layoutResult = layoutWithDefaultElk(rawElkGraph, signal); + } else if (elk instanceof ElkWorkerClient) { + // Stops the worker's layout when aborted, rather than only ignoring its result. + layoutResult = elk.layout(rawElkGraph, { signal }); + } else { + layoutResult = abortable((elk as ELK).layout(rawElkGraph), signal); + } + const result = await layoutResult as ElkNode; // Aborted after ELK settled, but before the result was applied. throwIfAborted(signal); diff --git a/packages/joint-layout-elk/src/mainThreadElk.mts b/packages/joint-layout-elk/src/mainThreadElk.mts index 28f9ed9eaf..a3ee735d79 100644 --- a/packages/joint-layout-elk/src/mainThreadElk.mts +++ b/packages/joint-layout-elk/src/mainThreadElk.mts @@ -4,8 +4,8 @@ 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 every app that runs ELK in the worker, - * which has a copy of ELK of its own. In its own module so the UMD build, which can't + * 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> { diff --git a/packages/joint-layout-elk/src/workerElk.mts b/packages/joint-layout-elk/src/workerElk.mts new file mode 100644 index 0000000000..9efaec780b --- /dev/null +++ b/packages/joint-layout-elk/src/workerElk.mts @@ -0,0 +1,213 @@ +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` keeps + * that order, so its first job 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 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 }); + this.jobs.forEach((job) => this.post(job)); + } + + private post(job: LayoutJob): void { + // 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: {}}); + } + + private receive(data: { id: number, data?: ElkNode, error?: unknown }): void { + if (data.id === REGISTER_ID) { + this.isLoaded = true; + return; + } + 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; + const isRunning = this.jobs.keys().next().value === job.id; + this.jobs.delete(job.id); + if (isRunning) this.restart(); + } + + /** + * The worker crashed (after it loaded) - most likely because of the job it was busy + * with, which is rejected rather than retried. A new worker takes over the jobs still + * waiting. + */ + private crash(event: ErrorEvent): void { + const [running] = this.jobs.values(); + 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; + } +} + +/** + * 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 + * ELK's own worker script (`elkjs/lib/elk-worker.min.js`) - however your bundler loads a + * worker script (see "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('elkjs/lib/elk-worker.min.js', import.meta.url))); + * await layout({ graph }, { elk }); + */ +export function createWorkerElk(createWorker: () => Worker): WorkerElk { + return new ElkWorkerClient(createWorker); +} diff --git a/packages/joint-layout-elk/src/workerFactory.mts b/packages/joint-layout-elk/src/workerFactory.mts deleted file mode 100644 index 75e2a06ca8..0000000000 --- a/packages/joint-layout-elk/src/workerFactory.mts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Starts the Web Worker `layout()` runs ELK in by default (see `defaultElk.mts`). - * - * Written as `new Worker(new URL(..., import.meta.url))` with literal arguments - the - * pattern bundlers (webpack 5, Vite, Parcel) look for to emit `elk.worker.mts` as a - * worker file of its own. In its own module so the UMD build, which has no way to - * locate a worker file, can replace it with one returning `undefined` (see - * `rollup.config.mjs`). - */ -export function createElkWorker(): Worker | undefined { - return new Worker(new URL('./elk.worker.mjs', import.meta.url), { type: 'module' }); -} diff --git a/packages/joint-layout-elk/test/index.js b/packages/joint-layout-elk/test/index.js index b9e3ce8c8a..b0f4a2dd09 100644 --- a/packages/joint-layout-elk/test/index.js +++ b/packages/joint-layout-elk/test/index.js @@ -821,24 +821,6 @@ QUnit.module('layout()', () => { assert.notOk(joint.g.intersection.exists(el1.getBBox(), el2.getBBox())); }); - QUnit.test('should run on the main thread given `thread: main`', async(assert) => { - - const { graph, el1, el2 } = createGraph(); - - await joint.layout.ELK.layout({ graph }, { thread: 'main' }); - - assert.notOk(joint.g.intersection.exists(el1.getBBox(), el2.getBBox())); - }); - - QUnit.test('should reject given `thread: worker` where no worker can be used', async(assert) => { - - // No worker in the unit test bundle, unless a test hands it one. - const { graph, el1, el2 } = createGraph(); - - await assert.rejects(joint.layout.ELK.layout({ graph }, { thread: 'worker' }), /no Web Worker can be used here/); - assert.ok(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(); @@ -1086,30 +1068,38 @@ QUnit.module('layout()', () => { }); }); -// Last: the default ELK instance is shared by every `layout()` call without an `elk` -// option - once its worker fails to load (the last test), it stays on the main thread. -QUnit.module('the default ELK instance', (hooks) => { +QUnit.module('createWorkerElk()', (hooks) => { - // Every worker the default instance has started (see `rollup.config.mjs`'s `testWorker`), - // and how many messages they've sent back. - const startedWorkers = []; - let workerMessageCount = 0; - // The script the next worker is started with - one that doesn't exist fails to load. const WORKER_URL = '/base/node_modules/elkjs/lib/elk-worker.min.js'; - let workerUrl = WORKER_URL; + // 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.before(() => { - window.__createElkWorker = () => { - const worker = new Worker(workerUrl); + 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; - }; - }); - - hooks.after(() => { - delete window.__createElkWorker; - }); + }); + elks.push(elk); + return elk; + }; const createGraph = () => { const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); @@ -1120,149 +1110,135 @@ QUnit.module('the default ELK instance', (hooks) => { return { graph, el1, el2 }; }; - QUnit.test('should run in a Web Worker - one started on first use, then shared', async(assert) => { + const isLaidOut = ({ el1, el2 }) => !joint.g.intersection.exists(el1.getBBox(), el2.getBBox()); - const { graph, el1, el2 } = createGraph(); + QUnit.test('should lay out in a worker - started on the first layout, then shared', async(assert) => { - await joint.layout.ELK.layout({ graph }); + const elk = createElk(); + assert.equal(startedWorkers.length, 0); + + const first = createGraph(); + await joint.layout.ELK.layout({ graph: first.graph }, { elk }); assert.equal(startedWorkers.length, 1); - // The layout came from the worker, not from a main-thread fallback. + // The layout came from the worker. assert.ok(workerMessageCount > 0); - assert.notOk(joint.g.intersection.exists(el1.getBBox(), el2.getBBox())); + assert.ok(isLaidOut(first)); const messageCount = workerMessageCount; - await joint.layout.ELK.layout(createGraph()); + const second = createGraph(); + await joint.layout.ELK.layout({ graph: 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 workerCount = startedWorkers.length; const controller = new AbortController(); - const abortedResult = joint.layout.ELK.layout({ graph: aborted.graph }, { signal: controller.signal }); - const waitingResult = joint.layout.ELK.layout({ graph: waiting.graph }); + const abortedResult = joint.layout.ELK.layout({ graph: aborted.graph }, { elk, signal: controller.signal }); + const waitingResult = joint.layout.ELK.layout({ graph: waiting.graph }, { elk }); controller.abort(); await assert.rejects(abortedResult, (error) => error.name === 'AbortError'); await waitingResult; - assert.equal(startedWorkers.length, workerCount + 1); - assert.ok(joint.g.intersection.exists(aborted.el1.getBBox(), aborted.el2.getBBox())); - assert.notOk(joint.g.intersection.exists(waiting.el1.getBBox(), waiting.el2.getBBox())); + 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 workerCount = startedWorkers.length; const controller = new AbortController(); - const busyResult = joint.layout.ELK.layout({ graph: busy.graph }); - const abortedResult = joint.layout.ELK.layout({ graph: aborted.graph }, { signal: controller.signal }); + const busyResult = joint.layout.ELK.layout({ graph: busy.graph }, { elk }); + const abortedResult = joint.layout.ELK.layout({ graph: aborted.graph }, { elk, signal: controller.signal }); controller.abort(); await assert.rejects(abortedResult, (error) => error.name === 'AbortError'); await busyResult; - assert.equal(startedWorkers.length, workerCount); - assert.notOk(joint.g.intersection.exists(busy.el1.getBBox(), busy.el2.getBBox())); - assert.ok(joint.g.intersection.exists(aborted.el1.getBBox(), aborted.el2.getBBox())); - }); - - QUnit.test('should not start a worker given `thread: main`', async(assert) => { - - const { graph, el1, el2 } = createGraph(); - const workerCount = startedWorkers.length; - const messageCount = workerMessageCount; - - await joint.layout.ELK.layout({ graph }, { thread: 'main' }); - - assert.equal(startedWorkers.length, workerCount); - assert.equal(workerMessageCount, messageCount); - assert.notOk(joint.g.intersection.exists(el1.getBBox(), el2.getBBox())); - }); - - QUnit.test('should run in the worker given `thread: worker`', async(assert) => { - - const { graph, el1, el2 } = createGraph(); - const messageCount = workerMessageCount; - - await joint.layout.ELK.layout({ graph }, { thread: 'worker' }); - - assert.ok(workerMessageCount > messageCount); - assert.notOk(joint.g.intersection.exists(el1.getBBox(), el2.getBBox())); + 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 workerCount = startedWorkers.length; - const crashedResult = joint.layout.ELK.layout({ graph: crashed.graph }); - const waitingResult = joint.layout.ELK.layout({ graph: waiting.graph }); - // E.g. out of memory - not retried on the main thread. - startedWorkers[startedWorkers.length - 1].dispatchEvent(new ErrorEvent('error', { message: 'out of memory' })); + const crashedResult = joint.layout.ELK.layout({ graph: crashed.graph }, { elk }); + const waitingResult = joint.layout.ELK.layout({ graph: 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, workerCount + 1); - assert.ok(joint.g.intersection.exists(crashed.el1.getBBox(), crashed.el2.getBBox())); - assert.notOk(joint.g.intersection.exists(waiting.el1.getBBox(), waiting.el2.getBBox())); - - // The new worker lays out later layouts too. - const messageCount = workerMessageCount; - await joint.layout.ELK.layout(createGraph()); - assert.equal(startedWorkers.length, workerCount + 1); - assert.ok(workerMessageCount > messageCount); + assert.equal(startedWorkers.length, 2); + assert.notOk(isLaidOut(crashed)); + assert.ok(isLaidOut(waiting)); }); - QUnit.test('should retry a layout on the main thread when the worker fails to load - and stay there', async(assert) => { + 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({ graph: first.graph }, { elk }), /the ELK worker failed to load/), + assert.rejects(joint.layout.ELK.layout({ graph: 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({ graph: next.graph }, { elk }); + assert.equal(startedWorkers.length, 2); + assert.ok(isLaidOut(next)); + }); - // A crash restarts the worker - with a script that doesn't exist (e.g. one a bundler - // didn't emit), which fails to load. - workerUrl = '/base/missing-elk-worker.js'; - const warnings = []; - const warn = console.warn; - console.warn = (message) => warnings.push(message); - const crashedResult = joint.layout.ELK.layout(createGraph()); - startedWorkers[startedWorkers.length - 1].dispatchEvent(new ErrorEvent('error')); - await assert.rejects(crashedResult); + 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(); - const workerOnly = createGraph(); - // Posted to the worker still loading - ELK itself would never settle them. - // Not retried on the main thread - rejected (handled right away: before the layout below). - const workerOnlyRejected = assert.rejects( - joint.layout.ELK.layout({ graph: workerOnly.graph }, { thread: 'worker' }), - /the ELK Web Worker failed to load, and `thread: 'worker'` rules out/ - ); - try { - await joint.layout.ELK.layout({ graph }); - } finally { - console.warn = warn; - } - assert.notOk(joint.g.intersection.exists(el1.getBBox(), el2.getBBox())); - await workerOnlyRejected; - assert.ok(joint.g.intersection.exists(workerOnly.el1.getBBox(), workerOnly.el2.getBBox())); - // Reported - layouts still work, but now block the page. - assert.equal(warnings.length, 1); - assert.ok(/the ELK Web Worker failed to load - running ELK on the main thread instead/.test(warnings[0])); - - // No new worker is started for later layouts. - workerUrl = WORKER_URL; - const workerCount = startedWorkers.length; - const { graph: nextGraph, el1: nextEl1, el2: nextEl2 } = createGraph(); - await joint.layout.ELK.layout({ graph: nextGraph }); - assert.equal(startedWorkers.length, workerCount); - assert.notOk(joint.g.intersection.exists(nextEl1.getBBox(), nextEl2.getBBox())); - await assert.rejects(joint.layout.ELK.layout(createGraph(), { thread: 'worker' }), /the ELK Web Worker failed to load/); + + 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({ graph: 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({ graph: next.graph }, { elk }); + assert.equal(startedWorkers.length, 2); + assert.ok(isLaidOut(next)); }); }); From fc75f03ac0f27b2517a99b5cfeaaaf709635574f Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Tue, 6 Oct 2026 16:32:54 +0200 Subject: [PATCH 63/75] fix(examples): run ELK in a Web Worker with createWorkerElk() Every ELK example except the default one now starts a worker of its own (@joint/layout-elk/worker) and passes it to layout() - the flowchart's superseded layouts now stop the worker busy with them. The default example keeps layout() at its simplest, on the main thread. The examples' TypeScript module target is raised to ES2020 for import.meta. Co-Authored-By: Claude Opus 5.5 --- examples/layout-elk-containers-ports-ts/src/index.ts | 6 ++++++ examples/layout-elk-containers-ports-ts/tsconfig.json | 2 +- examples/layout-elk-containers-ports-ts/webpack.config.js | 4 ++-- examples/layout-elk-default-ts/src/index.ts | 3 ++- examples/layout-elk-default-ts/webpack.config.js | 4 ++-- examples/layout-elk-flowchart-ts/src/index.ts | 6 ++++++ examples/layout-elk-flowchart-ts/tsconfig.json | 2 +- examples/layout-elk-flowchart-ts/webpack.config.js | 4 ++-- examples/layout-elk-rectpacking-ts/src/index.ts | 7 ++++++- examples/layout-elk-rectpacking-ts/tsconfig.json | 2 +- examples/layout-elk-rectpacking-ts/webpack.config.js | 4 ++-- examples/layout-elk-ts/src/index.ts | 7 ++++++- examples/layout-elk-ts/tsconfig.json | 2 +- examples/layout-elk-ts/webpack.config.js | 4 ++-- 14 files changed, 40 insertions(+), 17 deletions(-) diff --git a/examples/layout-elk-containers-ports-ts/src/index.ts b/examples/layout-elk-containers-ports-ts/src/index.ts index 6990f920ec..db567acf7b 100644 --- a/examples/layout-elk-containers-ports-ts/src/index.ts +++ b/examples/layout-elk-containers-ports-ts/src/index.ts @@ -4,6 +4,7 @@ import { ExportElementCallback, ExportPortCallback, ExportPortLabelCallback, + createWorkerElk, layout } from '@joint/layout-elk'; import { graphJSON } from './example'; @@ -22,6 +23,10 @@ const cellNamespace = { } }; +// 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 - @@ -130,6 +135,7 @@ const init = () => { const runLayout = (): Promise => { paper.freeze(); return layout({ graph }, { + elk, exportElement, exportPort, exportPortLabel, diff --git a/examples/layout-elk-containers-ports-ts/tsconfig.json b/examples/layout-elk-containers-ports-ts/tsconfig.json index 04a61c7d89..5ac26d8fe9 100644 --- a/examples/layout-elk-containers-ports-ts/tsconfig.json +++ b/examples/layout-elk-containers-ports-ts/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "module": "ES6", + "module": "ES2020", "moduleResolution": "bundler", "target": "es6", "lib": [ diff --git a/examples/layout-elk-containers-ports-ts/webpack.config.js b/examples/layout-elk-containers-ports-ts/webpack.config.js index 7e6cc42763..6a72fec1a9 100644 --- a/examples/layout-elk-containers-ports-ts/webpack.config.js +++ b/examples/layout-elk-containers-ports-ts/webpack.config.js @@ -8,8 +8,8 @@ module.exports = { output: { filename: 'bundle.js', path: path.resolve(__dirname, 'dist'), - // Resolved from the bundle's own URL - the ELK worker and main-thread ELK are - // chunks of their own, loaded from next to it wherever the demo is served. + // Resolved from the bundle's own URL - the ELK worker is a file of its own, + // loaded from next to it wherever the demo is served. publicPath: 'auto', }, mode: 'development', diff --git a/examples/layout-elk-default-ts/src/index.ts b/examples/layout-elk-default-ts/src/index.ts index 467f6c7ad6..9b003c2552 100644 --- a/examples/layout-elk-default-ts/src/index.ts +++ b/examples/layout-elk-default-ts/src/index.ts @@ -47,7 +47,8 @@ const init = () => { // 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. + // 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. diff --git a/examples/layout-elk-default-ts/webpack.config.js b/examples/layout-elk-default-ts/webpack.config.js index 7e6cc42763..fc95bbbb97 100644 --- a/examples/layout-elk-default-ts/webpack.config.js +++ b/examples/layout-elk-default-ts/webpack.config.js @@ -8,8 +8,8 @@ module.exports = { output: { filename: 'bundle.js', path: path.resolve(__dirname, 'dist'), - // Resolved from the bundle's own URL - the ELK worker and main-thread ELK are - // chunks of their own, loaded from next to it wherever the demo is served. + // Resolved from the bundle's own URL - main-thread ELK is a chunk of its own, + // loaded from next to it wherever the demo is served. publicPath: 'auto', }, mode: 'development', diff --git a/examples/layout-elk-flowchart-ts/src/index.ts b/examples/layout-elk-flowchart-ts/src/index.ts index 56585ede1e..00a221208a 100644 --- a/examples/layout-elk-flowchart-ts/src/index.ts +++ b/examples/layout-elk-flowchart-ts/src/index.ts @@ -4,6 +4,7 @@ import { ExportElementCallback, ExportPortCallback, SetPortAttributesCallback, + createWorkerElk, layout } from '@joint/layout-elk'; import { graphJSON } from './example'; @@ -26,6 +27,10 @@ class AddPortButton extends elementTools.Button { ]; } +// 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 - @@ -209,6 +214,7 @@ const init = () => { paper.freeze(); try { await layout({ graph }, { + elk, exportElement, exportPort, setPortAttributes, diff --git a/examples/layout-elk-flowchart-ts/tsconfig.json b/examples/layout-elk-flowchart-ts/tsconfig.json index 04a61c7d89..5ac26d8fe9 100644 --- a/examples/layout-elk-flowchart-ts/tsconfig.json +++ b/examples/layout-elk-flowchart-ts/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "module": "ES6", + "module": "ES2020", "moduleResolution": "bundler", "target": "es6", "lib": [ diff --git a/examples/layout-elk-flowchart-ts/webpack.config.js b/examples/layout-elk-flowchart-ts/webpack.config.js index 7e6cc42763..6a72fec1a9 100644 --- a/examples/layout-elk-flowchart-ts/webpack.config.js +++ b/examples/layout-elk-flowchart-ts/webpack.config.js @@ -8,8 +8,8 @@ module.exports = { output: { filename: 'bundle.js', path: path.resolve(__dirname, 'dist'), - // Resolved from the bundle's own URL - the ELK worker and main-thread ELK are - // chunks of their own, loaded from next to it wherever the demo is served. + // Resolved from the bundle's own URL - the ELK worker is a file of its own, + // loaded from next to it wherever the demo is served. publicPath: 'auto', }, mode: 'development', diff --git a/examples/layout-elk-rectpacking-ts/src/index.ts b/examples/layout-elk-rectpacking-ts/src/index.ts index 2d12278667..9f9f7a02e6 100644 --- a/examples/layout-elk-rectpacking-ts/src/index.ts +++ b/examples/layout-elk-rectpacking-ts/src/index.ts @@ -1,5 +1,5 @@ import { dia, g, shapes, setTheme, util } from '@joint/core'; -import { layout } from '@joint/layout-elk'; +import { createWorkerElk, layout } from '@joint/layout-elk'; import { graphJSON, createFileJSON, type FileKind } from './example'; import { Folder, FileTile, FOLDER_PADDING } from './shapes'; import './styles.scss'; @@ -28,6 +28,10 @@ const FILE_EXTENSIONS: Record = { 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'); @@ -127,6 +131,7 @@ const init = () => { pending = false; try { const { bbox } = await layout({ graph }, { + elk, elkLayoutOptions: getRootOptions(), exportElement, setElementAttributes diff --git a/examples/layout-elk-rectpacking-ts/tsconfig.json b/examples/layout-elk-rectpacking-ts/tsconfig.json index 04a61c7d89..5ac26d8fe9 100644 --- a/examples/layout-elk-rectpacking-ts/tsconfig.json +++ b/examples/layout-elk-rectpacking-ts/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "module": "ES6", + "module": "ES2020", "moduleResolution": "bundler", "target": "es6", "lib": [ diff --git a/examples/layout-elk-rectpacking-ts/webpack.config.js b/examples/layout-elk-rectpacking-ts/webpack.config.js index 7e6cc42763..6a72fec1a9 100644 --- a/examples/layout-elk-rectpacking-ts/webpack.config.js +++ b/examples/layout-elk-rectpacking-ts/webpack.config.js @@ -8,8 +8,8 @@ module.exports = { output: { filename: 'bundle.js', path: path.resolve(__dirname, 'dist'), - // Resolved from the bundle's own URL - the ELK worker and main-thread ELK are - // chunks of their own, loaded from next to it wherever the demo is served. + // Resolved from the bundle's own URL - the ELK worker is a file of its own, + // loaded from next to it wherever the demo is served. publicPath: 'auto', }, mode: 'development', diff --git a/examples/layout-elk-ts/src/index.ts b/examples/layout-elk-ts/src/index.ts index a6b8e20611..45b3a16236 100644 --- a/examples/layout-elk-ts/src/index.ts +++ b/examples/layout-elk-ts/src/index.ts @@ -1,5 +1,5 @@ import { dia, shapes, g } from '@joint/core'; -import { layout } from '@joint/layout-elk'; +import { createWorkerElk, layout } from '@joint/layout-elk'; import dependenciesJSON from './dependencies.json'; import './styles.scss'; @@ -8,6 +8,10 @@ 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 @@ -39,6 +43,7 @@ const init = () => { generateCells(dependenciesJSON, graph); layout({ graph }, { + elk, elkLayoutOptions: { /** * Overall direction of the layout. diff --git a/examples/layout-elk-ts/tsconfig.json b/examples/layout-elk-ts/tsconfig.json index 04a61c7d89..5ac26d8fe9 100644 --- a/examples/layout-elk-ts/tsconfig.json +++ b/examples/layout-elk-ts/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "module": "ES6", + "module": "ES2020", "moduleResolution": "bundler", "target": "es6", "lib": [ diff --git a/examples/layout-elk-ts/webpack.config.js b/examples/layout-elk-ts/webpack.config.js index 7e6cc42763..6a72fec1a9 100644 --- a/examples/layout-elk-ts/webpack.config.js +++ b/examples/layout-elk-ts/webpack.config.js @@ -8,8 +8,8 @@ module.exports = { output: { filename: 'bundle.js', path: path.resolve(__dirname, 'dist'), - // Resolved from the bundle's own URL - the ELK worker and main-thread ELK are - // chunks of their own, loaded from next to it wherever the demo is served. + // Resolved from the bundle's own URL - the ELK worker is a file of its own, + // loaded from next to it wherever the demo is served. publicPath: 'auto', }, mode: 'development', From cddc7cfacf62863c380dc6d10800e645b8421c18 Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Wed, 7 Oct 2026 10:34:37 +0200 Subject: [PATCH 64/75] comments fixes --- packages/joint-layout-elk/src/layout.mts | 2 +- packages/joint-layout-elk/src/mainThreadElk.mts | 5 +++-- packages/joint-layout-elk/src/workerElk.mts | 9 +++++---- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/joint-layout-elk/src/layout.mts b/packages/joint-layout-elk/src/layout.mts index 5bfff40773..743d8e9561 100644 --- a/packages/joint-layout-elk/src/layout.mts +++ b/packages/joint-layout-elk/src/layout.mts @@ -45,7 +45,7 @@ export interface LayoutOptions extends ImportLayoutOptions, ExportGraphOptions { * @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('elkjs/lib/elk-worker.min.js', import.meta.url))); + * const elk = createWorkerElk(() => new Worker(new URL('@joint/layout-elk/worker', import.meta.url), { type: 'module' })); * layout({ graph }, { elk }); */ elk?: WorkerElk | ELK; diff --git a/packages/joint-layout-elk/src/mainThreadElk.mts b/packages/joint-layout-elk/src/mainThreadElk.mts index a3ee735d79..31b67f95d8 100644 --- a/packages/joint-layout-elk/src/mainThreadElk.mts +++ b/packages/joint-layout-elk/src/mainThreadElk.mts @@ -5,8 +5,9 @@ import type { ELK } from 'elkjs'; * * 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`). + * 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'); diff --git a/packages/joint-layout-elk/src/workerElk.mts b/packages/joint-layout-elk/src/workerElk.mts index 9efaec780b..40ba574596 100644 --- a/packages/joint-layout-elk/src/workerElk.mts +++ b/packages/joint-layout-elk/src/workerElk.mts @@ -201,11 +201,12 @@ export class ElkWorkerClient implements WorkerElk { /** * 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 - * ELK's own worker script (`elkjs/lib/elk-worker.min.js`) - however your bundler loads a - * worker script (see "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). + * `@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('elkjs/lib/elk-worker.min.js', import.meta.url))); + * 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 { From 2e11128403d91650f980e907c72027a62dacb29d Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Thu, 8 Oct 2026 12:05:24 +0200 Subject: [PATCH 65/75] cleaning examples --- examples/layout-elk-flowchart-ts/.gitignore | 3 - examples/layout-elk-flowchart-ts/README.md | 24 - examples/layout-elk-flowchart-ts/index.html | 29 -- examples/layout-elk-flowchart-ts/package.json | 39 -- .../layout-elk-flowchart-ts/src/example.ts | 121 ----- examples/layout-elk-flowchart-ts/src/index.ts | 463 ------------------ .../layout-elk-flowchart-ts/src/shapes.ts | 187 ------- .../layout-elk-flowchart-ts/src/styles.scss | 144 ------ .../layout-elk-flowchart-ts/tsconfig.json | 18 - .../layout-elk-flowchart-ts/webpack.config.js | 44 -- 10 files changed, 1072 deletions(-) delete mode 100644 examples/layout-elk-flowchart-ts/.gitignore delete mode 100644 examples/layout-elk-flowchart-ts/README.md delete mode 100644 examples/layout-elk-flowchart-ts/index.html delete mode 100644 examples/layout-elk-flowchart-ts/package.json delete mode 100644 examples/layout-elk-flowchart-ts/src/example.ts delete mode 100644 examples/layout-elk-flowchart-ts/src/index.ts delete mode 100644 examples/layout-elk-flowchart-ts/src/shapes.ts delete mode 100644 examples/layout-elk-flowchart-ts/src/styles.scss delete mode 100644 examples/layout-elk-flowchart-ts/tsconfig.json delete mode 100644 examples/layout-elk-flowchart-ts/webpack.config.js diff --git a/examples/layout-elk-flowchart-ts/.gitignore b/examples/layout-elk-flowchart-ts/.gitignore deleted file mode 100644 index 69c575d17f..0000000000 --- a/examples/layout-elk-flowchart-ts/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -build/ -dist/ -node_modules/ diff --git a/examples/layout-elk-flowchart-ts/README.md b/examples/layout-elk-flowchart-ts/README.md deleted file mode 100644 index ce9499f8df..0000000000 --- a/examples/layout-elk-flowchart-ts/README.md +++ /dev/null @@ -1,24 +0,0 @@ -# JointJS ELK Interactive Flowchart Demo - -A top-to-bottom login flowchart - with two genuine cycles (retry loops) - laid out automatically with `@joint/layout-elk`. Click a step's top "+" to give it another input, or its bottom "+" to give it another output; click any of its unconnected output ports to grow a new, connected step from it, or drag a link between two unconnected ports to wire two existing steps together directly. Drag one step onto another step in the same layer (e.g. a decision's outcomes) to reorder them - a semitransparent floating copy follows the pointer while you drag, with nothing in the graph itself moving until you drop, at which point the graph re-lays out for real (ELK reordering ports as needed to keep crossings down, and respecting the new order). - -## 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 start -``` - -## 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-flowchart-ts/index.html b/examples/layout-elk-flowchart-ts/index.html deleted file mode 100644 index 28c3e84c4c..0000000000 --- a/examples/layout-elk-flowchart-ts/index.html +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - ELK Interactive Flowchart | JointJS - - - -
- Zoom Out - Zoom In -
-
- Click a step's top "+" to add an input, or its bottom "+" to add an - output. Click any (unconnected) output port to grow a new step from - it, or drag between two unconnected ports to connect two existing - steps. Drag a step onto another in the same row to reorder it - ELK - re-lays out to match. -
-
- - - - - diff --git a/examples/layout-elk-flowchart-ts/package.json b/examples/layout-elk-flowchart-ts/package.json deleted file mode 100644 index 4d4298195b..0000000000 --- a/examples/layout-elk-flowchart-ts/package.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "name": "@joint/demo-layout-elk-flowchart-ts", - "version": "4.3.1", - "description": "JointJS - ELK Layout Interactive Flowchart Demo", - "main": "dist/bundle.js", - "homepage": "https://jointjs.com", - "author": { - "name": "client IO", - "url": "https://client.io" - }, - "license": "MPL-2.0", - "private": true, - "installConfig": { - "hoistingLimits": "workspaces" - }, - "scripts": { - "start": "webpack-dev-server", - "build": "webpack" - }, - "dependencies": { - "@joint/core": "workspace:^", - "@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", - "typescript": "5.8.2", - "webpack": "5.98.0", - "webpack-cli": "6.0.1", - "webpack-dev-server": "5.2.0" - }, - "volta": { - "node": "22.14.0", - "npm": "11.2.0", - "yarn": "4.18.0" - } -} diff --git a/examples/layout-elk-flowchart-ts/src/example.ts b/examples/layout-elk-flowchart-ts/src/example.ts deleted file mode 100644 index 849d33a91b..0000000000 --- a/examples/layout-elk-flowchart-ts/src/example.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { dia } from '@joint/core'; - -// A fixed (non-random) login flowchart - two genuine cycles (a failed-validation -// retry back to "Enter Credentials", and a failed-session retry back to "Check -// Account Status"), "Generate Session" starting out with two 'out' ports of its -// own (success/error) - a plain step can have more than one outgoing path too, -// not just a `Decision` - and "Active?" starting out with three siblings -// ("Account Locked"/"Generate Session"/"Account Suspended") sharing its layer, -// to demonstrate reordering more than a plain pair. Try either "+" button (any -// node) or clicking a port (any 'out' one) to grow the flowchart further, or -// drag a link between two still-unconnected ports to wire two existing steps -// together - see `index.ts`. -export const graphJSON: dia.Graph.JSON = { - cells: [ - { - id: 'start', - type: 'flowchart.Terminal', - attrs: { label: { text: 'Start' } }, - ports: { items: [{ id: 'out', group: 'out' }] } - }, - { - id: 'enterCredentials', - type: 'flowchart.Process', - attrs: { label: { text: 'Enter Credentials' } }, - ports: { items: [{ id: 'in', group: 'in' }, { id: 'out', group: 'out' }] } - }, - { - id: 'validateCredentials', - type: 'flowchart.Decision', - attrs: { label: { text: 'Valid?' } }, - ports: { - items: [ - { id: 'in', group: 'in' }, - { id: 'valid', group: 'out' }, - { id: 'invalid', group: 'out' } - ] - } - }, - { - id: 'showError', - type: 'flowchart.Process', - attrs: { label: { text: 'Show Error' } }, - ports: { items: [{ id: 'in', group: 'in' }, { id: 'out', group: 'out' }] } - }, - { - id: 'checkAccountStatus', - type: 'flowchart.Decision', - attrs: { label: { text: 'Active?' } }, - ports: { - items: [ - { id: 'in', group: 'in' }, - { id: 'active', group: 'out' }, - { id: 'locked', group: 'out' }, - { id: 'suspended', group: 'out' } - ] - } - }, - { - id: 'showLockedMessage', - type: 'flowchart.Process', - attrs: { label: { text: 'Account Locked' } }, - ports: { items: [{ id: 'in', group: 'in' }, { id: 'out', group: 'out' }] } - }, - { - id: 'showSuspendedMessage', - type: 'flowchart.Process', - attrs: { label: { text: 'Account Suspended' } }, - ports: { items: [{ id: 'in', group: 'in' }, { id: 'out', group: 'out' }] } - }, - { - id: 'generateSession', - type: 'flowchart.Process', - attrs: { label: { text: 'Generate Session' } }, - ports: { - items: [ - { id: 'in', group: 'in' }, - { id: 'success', group: 'out' }, - { id: 'error', group: 'out' } - ] - } - }, - { - id: 'sessionError', - type: 'flowchart.Process', - attrs: { label: { text: 'Session Error' } }, - ports: { items: [{ id: 'in', group: 'in' }, { id: 'out', group: 'out' }] } - }, - { - id: 'grantAccess', - type: 'flowchart.Process', - attrs: { label: { text: 'Grant Access' } }, - ports: { items: [{ id: 'in', group: 'in' }, { id: 'out', group: 'out' }] } - }, - { - id: 'end', - type: 'flowchart.Terminal', - attrs: { label: { text: 'End' } }, - ports: { items: [{ id: 'in', group: 'in' }] } - }, - - // Links - { id: 'l1', type: 'flowchart.FlowLink', source: { id: 'start', port: 'out' }, target: { id: 'enterCredentials', port: 'in' } }, - { id: 'l2', type: 'flowchart.FlowLink', source: { id: 'enterCredentials', port: 'out' }, target: { id: 'validateCredentials', port: 'in' } }, - { id: 'l3', type: 'flowchart.FlowLink', source: { id: 'validateCredentials', port: 'invalid' }, target: { id: 'showError', port: 'in' }, labels: [{ attrs: { text: { text: 'Invalid' } } }] }, - { id: 'l4', type: 'flowchart.FlowLink', source: { id: 'validateCredentials', port: 'valid' }, target: { id: 'checkAccountStatus', port: 'in' }, labels: [{ attrs: { text: { text: 'Valid' } } }] }, - // Cycle 1: back up to "Enter Credentials" for another attempt. - { id: 'l5', type: 'flowchart.FlowLink', source: { id: 'showError', port: 'out' }, target: { id: 'enterCredentials', port: 'in' } }, - { id: 'l6', type: 'flowchart.FlowLink', source: { id: 'checkAccountStatus', port: 'locked' }, target: { id: 'showLockedMessage', port: 'in' }, labels: [{ attrs: { text: { text: 'Locked' } } }] }, - { id: 'l7', type: 'flowchart.FlowLink', source: { id: 'checkAccountStatus', port: 'active' }, target: { id: 'generateSession', port: 'in' }, labels: [{ attrs: { text: { text: 'Active' } } }] }, - // A third sibling alongside "Account Locked"/"Generate Session" - all three share - // "Active?" as their layer (see `index.ts`'s sibling-scoped drag-to-reorder). - { id: 'l13', type: 'flowchart.FlowLink', source: { id: 'checkAccountStatus', port: 'suspended' }, target: { id: 'showSuspendedMessage', port: 'in' }, labels: [{ attrs: { text: { text: 'Suspended' } } }] }, - { id: 'l8', type: 'flowchart.FlowLink', source: { id: 'showLockedMessage', port: 'out' }, target: { id: 'end', port: 'in' } }, - { id: 'l14', type: 'flowchart.FlowLink', source: { id: 'showSuspendedMessage', port: 'out' }, target: { id: 'end', port: 'in' } }, - { id: 'l9', type: 'flowchart.FlowLink', source: { id: 'generateSession', port: 'success' }, target: { id: 'grantAccess', port: 'in' }, labels: [{ attrs: { text: { text: 'Success' } } }] }, - { id: 'l10', type: 'flowchart.FlowLink', source: { id: 'generateSession', port: 'error' }, target: { id: 'sessionError', port: 'in' }, labels: [{ attrs: { text: { text: 'Error' } } }] }, - // Cycle 2: back up to re-check the account before retrying. - { id: 'l11', type: 'flowchart.FlowLink', source: { id: 'sessionError', port: 'out' }, target: { id: 'checkAccountStatus', port: 'in' } }, - { id: 'l12', type: 'flowchart.FlowLink', source: { id: 'grantAccess', port: 'out' }, target: { id: 'end', port: 'in' } } - ] -}; diff --git a/examples/layout-elk-flowchart-ts/src/index.ts b/examples/layout-elk-flowchart-ts/src/index.ts deleted file mode 100644 index 00a221208a..0000000000 --- a/examples/layout-elk-flowchart-ts/src/index.ts +++ /dev/null @@ -1,463 +0,0 @@ -import { dia, elementTools, setTheme, util } from '@joint/core'; -import { - ElkLayoutOptions, - ExportElementCallback, - ExportPortCallback, - SetPortAttributesCallback, - createWorkerElk, - layout -} from '@joint/layout-elk'; -import { graphJSON } from './example'; -import { Decision, FlowchartNode, FlowLink, Process, Terminal } from './shapes'; -import './styles.scss'; - -const cellNamespace = { - flowchart: { Process, Decision, Terminal, FlowLink } -}; - -// A "+" button - two of them, permanently shown on any `FlowchartNode` (a -// `Process`/`Decision` - not a `Terminal`, which stays a fixed entry/exit -// point): one just under the top border (`addInPort()`), one just above the -// bottom border (`addOutPort()`) - see the `render:done` handler below for -// how each one is positioned and which port group it grows. -class AddPortButton extends elementTools.Button { - children = [ - { tagName: 'circle', selector: 'button', attributes: { r: 6, class: 'add-button' } }, - { tagName: 'path', selector: 'icon', attributes: { d: 'M -3 0 3 0 M 0 -3 0 3', class: 'add-button-icon' } } - ]; -} - -// 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'); - - const graph = new dia.Graph({}, { cellNamespace }); - - // A port is "free" - eligible both to start a new link from (an 'out' - // port) and to drop one onto (an 'in' port) - only while nothing already - // connects to it. Checked by exact port id, not just "does this element - // have any free port", since a node can carry several of either group - // (a `Decision`'s two branches, or any node grown via the "+" buttons). - const isPortFree = (element: dia.Element, portId: string | null): boolean => { - if (!portId) return false; - return graph.getConnectedLinks(element).every((link) => { - const source = link.source(); - const target = link.target(); - return !(source.id === element.id && source.port === portId) && - !(target.id === element.id && target.port === portId); - }); - }; - - const paper = new dia.Paper({ - model: graph, - cellViewNamespace: cellNamespace, - width: 1100, - height: 750, - gridSize: 1, - async: true, - frozen: true, - defaultConnector: { - name: 'straight', - args: { cornerType: 'cubic', cornerRadius: 8 } - }, - defaultConnectionPoint: { name: 'boundary' }, - // A plain, undecorated link isn't a `FlowLink` - but dragging a *free* - // port to connect it to another one now needs a real link to drag, so - // this is a `FlowLink` too, exactly like `element:magnet:pointerclick`'s - // own new-step-and-link. It only ever actually gets added to the graph - // on a valid drop, per `validateMagnet`/`validateConnection` below. - defaultLink: () => new FlowLink(), - // A link dragged from a magnet and dropped anywhere else (a blank - // spot, or - critically - nowhere at all, i.e. a plain click with no - // movement on a now-valid, *free* port) must not stick around - // half-connected to a bare point - every `FlowLink` always connects - // two actual ports, or doesn't exist. Without this, clicking a free - // 'out' port (to spawn a new step, see `element:magnet:pointerclick`) - // would *also* leave behind a second, dangling, nowhere-connected link - // from that same click - `linkPinning`'s default (`true`) is what - // otherwise keeps it "pinned" to that unconnected point instead of - // discarding it. - linkPinning: false, - // A magnet only supports starting JointJS's own native drag-a-link - // gesture (as opposed to just a *click*, see - // `element:magnet:pointerclick` below) from a *free* 'out' port - the - // source side of a new connection. Every other magnet (an 'in' port, - // or any already-connected port) stays "passive" to it, same as if it - // had no `magnet` attr at all for that purpose, and falls back to - // plain element dragging instead - which doesn't take anything away - // from reordering (`element:pointerdown` below). - validateMagnet: (cellView, magnet) => { - const element = cellView.model; - if (!(element instanceof dia.Element)) return false; - if (cellView.findAttribute('port-group', magnet) !== 'out') return false; - return isPortFree(element, cellView.findAttribute('port', magnet)); - }, - // The other end of that new connection has to land on a *free* 'in' - // port, on a *different* element - never back onto the same node - // (a flowchart step never loops directly into itself). - validateConnection: (cellViewS, _magnetS, cellViewT, magnetT, end) => { - if (end !== 'target' || !magnetT || cellViewS === cellViewT) return false; - const targetElement = cellViewT.model; - if (!(targetElement instanceof dia.Element)) return false; - if (cellViewT.findAttribute('port-group', magnetT) !== 'in') return false; - return isPortFree(targetElement, cellViewT.findAttribute('port', magnetT)); - } - // `interactive` stays at its default (`true`) - dragging an element is how - // this example lets a user reorder it (see `element:pointerup` below); a - // newly clicked-from port's new step starts out wherever, since ELK - // repositions everything on the next layout pass regardless. - }); - document.getElementById('canvas')!.appendChild(paper.el); - addZoomAndPanListeners(paper); - - // A successful manual connection between two previously free ports - - // re-lay out so ELK routes the new edge properly instead of leaving it - // wherever the native drag happened to draw it. - paper.on('link:connect', () => { - runLayout(); - }); - - // `order` is the model order ELK respects (see `elk.layered.considerModelOrder.strategy` - // below) - `reorderAfter` reassigns it among siblings on drop, and `graph.getElements()` - // needs to come back in that same sequence for it to have any effect, which means the - // graph's cells (sorted by `z` - see `dia.CellCollection`'s `comparator`) need their `z` - // kept in lockstep with it. Doing that here, once, reactively, means nothing that sets - // `order` (below, and `reorderAfter`) ever also has to remember to update `z` itself. - const ORDER_Z_OFFSET = 2; - graph.on('change:order', (element: dia.Element, order: number) => { - element.set('z', ORDER_Z_OFFSET + order); - }); - - graph.fromJSON(graphJSON); - // Initial order: insertion order, i.e. `example.ts`'s own array order. - graph.getElements().forEach((element, index) => element.set('order', index)); - - const elkLayoutOptions: ElkLayoutOptions = { - // Top-to-bottom flowchart. - 'elk.direction': 'DOWN', - 'elk.spacing.nodeNode': '48', - 'elk.layered.spacing.nodeNodeBetweenLayers': '68', - 'elk.edgeRouting': 'ORTHOGONAL', - // Cycles are genuine here (see `example.ts`) - `MODEL_ORDER` always breaks - // a cycle at the edge whose target was added before its source (a "back" - // reference, by construction order), the same, deterministic way every - // run - unlike a plain greedy search, which can just as easily reverse a - // *forward* edge instead, leaving the graph's overall rank order confusing. - 'elk.layered.cycleBreaking.strategy': 'MODEL_ORDER', - // Keep new elements/links (added interactively, appended to the graph) from - // being freely reshuffled among the existing ones wherever ELK's crossing - // minimizer would otherwise put them - it still may reorder *ports* (see - // `exportPort` below) to reduce crossings, just not the elements themselves. - 'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES', - // `considerModelOrder.strategy` above is only a preference crossing - // minimization can still override wherever it believes another arrangement - // has fewer crossings - which, for two siblings whose crossing count is the - // same either way (a common case for two plain leaf branches), can silently - // ignore the model order entirely. This makes it absolute instead, so the - // reorder feature's `order` (via `z`) always actually has a visible effect. - 'elk.layered.crossingMinimization.forceNodeModelOrder': 'true', - // Layers follow the nodes' current positions (passed to ELK in `exportElement` - // below), so a re-layout after an edit keeps every existing step in the layer it - // is already in, instead of re-ranking the whole flowchart from scratch. - 'elk.layered.layering.strategy': 'INTERACTIVE' - }; - - // `FIXED_SIDE` (not the package's default `FIXED_POS`) is what lets ELK reorder a - // node's ports along their side to reduce crossings, instead of only routing edges - // to wherever a port happens to already be. - const exportElement: ExportElementCallback = ({ elkNode, element }) => { - elkNode.layoutOptions['elk.portConstraints'] = 'FIXED_SIDE'; - const position = element.position(); - elkNode.x = position.x; - elkNode.y = position.y; - }; - - // Every 'in' port sits on the node's top, every 'out' port on its bottom - - // matching the top-to-bottom flow and `FlowchartNode`'s own port groups. - // `FIXED_SIDE` keeps each port on that side, while still letting ELK order - // them along it. - const exportPort: ExportPortCallback = ({ portId, element, elkPort }) => { - elkPort.layoutOptions['elk.port.side'] = (element.getPort(portId).group === 'in') ? 'NORTH' : 'SOUTH'; - }; - - // ELK (and the built-in 'top'/'bottom' port position functions) place a port - // on the node's *bounding box* border - correct for `Process`/`Terminal`'s - // rectangles, but not for `Decision`'s diamond, whose actual edge sits at - // that same y for only one x (the tip). This projects the port back onto - // the diamond's real slanted edge - only its y moves; x (which side, and - // where along it) stays exactly what ELK computed. - const setPortAttributes: SetPortAttributesCallback = ({ element, portId, attributes }) => { - if (element instanceof Decision && attributes.position) { - const { width, height } = element.size(); - const { x, y } = attributes.position.args; - const distanceFromCenter = Math.abs(x - width / 2); - const edgeY = distanceFromCenter / (width / 2) * (height / 2); - attributes.position.args.y = (y < height / 2) ? edgeY : height - edgeY; - } - element.portProp(portId, attributes); - }; - - // Every edit (a new step, port or connection, a reorder) runs a new layout - one - // made while an earlier layout is still running supersedes it: the earlier one is - // aborted, so only the latest layout (which already includes every edit) is applied. - let layoutController: AbortController | null = null; - const runLayout = async(): Promise => { - layoutController?.abort(); - const controller = new AbortController(); - layoutController = controller; - paper.freeze(); - try { - await layout({ graph }, { - elk, - exportElement, - exportPort, - setPortAttributes, - elkLayoutOptions, - signal: controller.signal - }); - } catch (error) { - // Superseded - the layout that aborted it unfreezes the paper once it's done. - if (controller.signal.aborted) return; - console.error('ELK layout error:', (error as Error).message); - } - paper.unfreeze(); - // Refit the paper to the new layout, keeping the current zoom level. - zoom(paper, paper.scale().sx); - }; - - runLayout(); - - // "+" buttons - two per `FlowchartNode`, always shown (not just on hover). - // `render:done` fires after every render pass - initial load, and every - // re-layout/new-element pass alike - so this both covers the initial set - // of elements and keeps picking up any added afterwards; `hasTools()` - // makes it idempotent, since the same view's `render:done` fires again - // on every later pass too. `x`/`y` position each button relative to the - // element's own (current) bbox top-left corner - a fixed inset down from - // the top border for the 'in' button, and, since node height varies - // (`Decision` vs `Process`/`Terminal`), a percentage-of-height position - // (`'100%'`, the bottom border) plus a negative pixel `offset` for the - // 'out' button, so it always ends up the same fixed inset *up* from - // whatever the bottom border actually is. - const ADD_BUTTON_INSET = 20; - paper.on('render:done', () => { - graph.getElements().forEach((element) => { - if (!(element instanceof FlowchartNode)) return; - const elementView = paper.findViewByModel(element); - if (!elementView || elementView.hasTools()) return; - elementView.addTools(new dia.ToolsView({ - tools: [ - new AddPortButton({ - x: '50%', - y: ADD_BUTTON_INSET, - action: () => { - element.addInPort(); - runLayout(); - } - }), - new AddPortButton({ - x: '50%', - y: '100%', - offset: { y: -ADD_BUTTON_INSET }, - action: () => { - element.addOutPort(); - runLayout(); - } - }) - ] - })); - }); - }); - - // Click a port to grow the flowchart from it: a new step, connected from that - // port - only 'out' ports make sense as a starting point for a new downstream - // step. Prompts for the new step's label; leaving it blank falls back to an - // auto-numbered one. - let newStepCount = 0; - paper.on('element:magnet:pointerclick', (elementView: dia.ElementView, evt: dia.Event, magnet: SVGElement) => { - const element = elementView.model; - const portId = elementView.findAttribute('port', magnet); - const portGroup = elementView.findAttribute('port-group', magnet); - if (!portId || portGroup !== 'out') return; - - newStepCount++; - const label = window.prompt('New step name:', `Step ${newStepCount}`) || `Step ${newStepCount}`; - - // Appended at the end of the model order - set directly (not via `.set()` - // afterwards) since a constructor's initial attributes don't trigger - // `change:order`, so `z` needs setting alongside it here, just this once. - const order = graph.getElements().length; - const newStep = new Process({ - position: element.position(), - attrs: { label: { text: label } }, - ports: { items: [{ group: 'in' }, { group: 'out' }] }, - order, - z: ORDER_Z_OFFSET + order - }); - const newLink = new FlowLink({ - source: { id: element.id, port: portId }, - target: { id: newStep.id } - }); - graph.addCells([newStep, newLink]); - - runLayout(); - }); - - // Drag an element onto another one to reorder it - only among actual - // *siblings*, i.e. the other elements ELK placed in the same *layer* of - // this top-to-bottom layout - approximated here by comparing each - // element's current *y*, since a layered layout always aligns every - // element of one layer to the same y regardless of its own height (see - // `LAYER_Y_EPSILON`). This is deliberately about the *layout*, not the - // graph's parent/child structure: e.g. "Active?" (`checkAccountStatus`) - // and "Show Error" don't share a single parent - a cycle also feeds - // "Active?" from "Session Error", so it has two - but they DO sit in the - // same layer, and reordering them relative to each other is exactly what - // dragging one onto the other should do. An element alone in its own - // layer (nothing else at a matching y - true of `Start`/`End`, always - // alone at the very first/last rank) has nothing to reorder against, so - // `element:pointerdown` leaves it to drag natively, with no preview and - // no reorder on drop (same as a plain click). - // - // Nothing in the graph itself moves during the drag - not the dragged - // element (`preventDefaultInteraction` stops its own native move), not - // any sibling either. A cloned, semitransparent copy of the dragged - // element is what actually follows the pointer - horizontally only, - // reordering being a left-right rearrangement among siblings that all - // sit at the same rank - appended directly to the paper's front layer, - // outside the graph entirely. Only the actual drop (`element:pointerup`) - // touches the model at all: it compares the drop position against every - // sibling's own (real, never moved) position to work out the new order, - // then a real, full ELK `layout()` runs. - const LAYER_Y_EPSILON = 1; - let draggedElement: dia.Element | null = null; - let draggedSiblings: dia.Element[] | null = null; - let draggedBBox: dia.BBox | null = null; - let previewNode: SVGElement | null = null; - - const clearPreview = (): void => { - previewNode?.remove(); - previewNode = null; - }; - - paper.on('element:pointerdown', (elementView: dia.ElementView, evt: dia.Event) => { - const element = elementView.model; - // Always prevent JointJS's own native move, reorderable or not - an - // element with no siblings (e.g. `Start`/`End`) would otherwise still - // be freely draggable around the canvas by default, just with no - // preview and no effect on drop. Blocking the native move outright - // means it's simply not possible to move it around in the first place. - elementView.preventDefaultInteraction(evt); - - const y = element.position().y; - const siblings = graph.getElements().filter((el) => ( - el !== element && Math.abs(el.position().y - y) < LAYER_Y_EPSILON - )); - if (siblings.length === 0) return; - - draggedElement = element; - draggedSiblings = siblings; - draggedBBox = element.getBBox().toJSON(); - - previewNode = elementView.el.cloneNode(true) as SVGElement; - previewNode.setAttribute('class', `${previewNode.getAttribute('class') || ''} drag-preview`); - previewNode.setAttribute('transform', `translate(${draggedBBox.x}, ${draggedBBox.y})`); - paper.getLayerView(dia.Paper.Layers.FRONT).el.appendChild(previewNode); - }); - - paper.on('element:pointermove', (elementView: dia.ElementView, _evt: dia.Event, x: number) => { - if (elementView.model !== draggedElement || !previewNode || !draggedBBox) return; - previewNode.setAttribute('transform', `translate(${x - draggedBBox.width / 2}, ${draggedBBox.y})`); - }); - - paper.on('element:pointerup', (elementView: dia.ElementView, _evt: dia.Event, x: number) => { - // Only a drop that actually changes the order needs a new layout - not a plain - // click, nor a drop back where the element already was. - const isReordered = (elementView.model === draggedElement && draggedSiblings) - ? reorderAmongSiblings(elementView.model, draggedSiblings, x) - : false; - clearPreview(); - - draggedElement = null; - draggedSiblings = null; - draggedBBox = null; - - if (isReordered) runLayout(); - }); -}; - -// Reassigns `element`'s `order` attribute among `siblings` (the other -// elements in its current layer), based on where it was dropped (`dropX`, -// its would-be center) - every sibling sorts by its own real bbox center -// instead, since none of them ever actually moved during the drag. Only the -// group's own, already-assigned `order` values are reused, permuted into the -// new sequence - not reassigned from scratch - so no element outside the -// group (with its own unrelated `order` value) is ever touched by a reorder -// that's supposed to be purely local to this layer. `z` (hence -// `graph.getElements()`'s own order, hence `exportGraph`, hence -// `considerModelOrder.strategy`) follows automatically, via the -// `change:order` listener registered in `init()`. Returns whether any element's -// `order` changed. -function reorderAmongSiblings(element: dia.Element, siblings: dia.Element[], dropX: number): boolean { - const group = [element, ...siblings]; - const orderValues: number[] = group.map((el) => el.get('order')).sort((a, b) => a - b); - const sorted = util.sortBy(group, (el) => (el === element) ? dropX : el.getBBox().center().x); - let isChanged = false; - sorted.forEach((el, i) => { - if (el.get('order') === orderValues[i]) return; - el.set('order', orderValues[i]); - isChanged = true; - }); - return isChanged; -} - -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-flowchart-ts/src/shapes.ts b/examples/layout-elk-flowchart-ts/src/shapes.ts deleted file mode 100644 index f18d11c6d3..0000000000 --- a/examples/layout-elk-flowchart-ts/src/shapes.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { dia, shapes, util } from '@joint/core'; - -const PORT_ATTRS = { - circle: { - r: 6, - class: 'port', - // Without this, a port is just a circle - not a magnet `index.ts`'s - // `element:magnet:pointerclick` (or JointJS's own link-dragging) can ever - // hit-test against (see `Paper#pointerdown`'s `target.closest('[magnet]')`). - magnet: true - } -}; - -/** - * Shared by `Process` and `Decision` - a top 'in' port group and a bottom 'out' - * port group (ELK positions both, see `index.ts`'s `exportPort`), plus - * `addInPort()`/`addOutPort()`, used by the two "+" buttons (`index.ts`) to - * grow a node an extra port interactively, without needing a distinct - * "decision" type - any node can end up with more than one incoming or - * outgoing path. - */ -export class FlowchartNode extends shapes.standard.Rectangle { - defaults() { - return util.defaultsDeep({ - size: { width: 160, height: 70 }, - // No fixed `z` here - `index.ts` derives it from this element's `order` - // (the model-order ELK respects, see `reorderAmongSiblings`), always - // keeping it above `FlowLink`'s own fixed `z: 1` so a port stays clickable (see - // `element:magnet:pointerclick`) even where a link already connects to - // it, which paint order would otherwise put on top of it. - ports: { - groups: { - in: { - position: { name: 'top' }, - attrs: PORT_ATTRS, - markup: [{ tagName: 'circle', selector: 'circle' }] - }, - out: { - position: { name: 'bottom' }, - attrs: PORT_ATTRS, - markup: [{ tagName: 'circle', selector: 'circle' }] - } - } - } - }, super.defaults); - } - - addInPort(): string { - const portId = `${this.generatePortId()}`; - this.addPort({ id: portId, group: 'in' }); - return portId; - } - - addOutPort(): string { - const portId = `${this.generatePortId()}`; - this.addPort({ id: portId, group: 'out' }); - return portId; - } -} - -/** - * A step - a plain rectangle. Starts with one 'in' and one 'out' port, but - * `addInPort()`/`addOutPort()` (see `FlowchartNode`) let it grow extra ports, - * the same as `Decision` - the shape is just a visual hint, not a structural - * limit. - */ -export class Process extends FlowchartNode { - defaults() { - return util.defaultsDeep({ - type: 'flowchart.Process', - attrs: { - body: { class: 'node node--process' }, - label: { class: 'node-label' } - }, - ports: { - items: [ - { group: 'in' }, - { group: 'out' } - ] - } - }, super.defaults()); - } -} - -/** - * A decision - a diamond, custom-drawn since `standard.Rectangle`'s markup has - * no such shape. Starts with two 'out' ports (its two usual branches), but - * inherits `addInPort()`/`addOutPort()` too, for extra ones. - */ -export class Decision extends FlowchartNode { - preinitialize() { - this.markup = [ - { tagName: 'path', selector: 'body' }, - { tagName: 'text', selector: 'label' } - ]; - } - - defaults() { - return util.defaultsDeep({ - type: 'flowchart.Decision', - size: { width: 172, height: 104 }, - attrs: { - body: { - class: 'node node--decision', - d: 'M calc(0.5*w) 0 L calc(w) calc(0.5*h) L calc(0.5*w) calc(h) L 0 calc(0.5*h) z' - }, - label: { class: 'node-label' } - }, - ports: { - items: [ - { group: 'in' }, - { group: 'out' }, - { group: 'out' } - ] - } - }, super.defaults()); - } -} - -/** - * Start/end - a pill (its own class sets `rx`/`ry` to a `calc(h/2)` CSS value, - * see `styles.scss`). One port only, 'in' for an end, 'out' for a start - - * `example.ts` picks which by only ever adding one `ports.items` entry. - */ -export class Terminal extends shapes.standard.Rectangle { - defaults() { - return util.defaultsDeep({ - type: 'flowchart.Terminal', - size: { width: 125, height: 46 }, - // See `FlowchartNode`'s own comment on `z` - derived from `order`, same reason. - attrs: { - body: { class: 'node node--terminal' }, - label: { class: 'node-label node-label--on-primary' } - }, - ports: { - groups: { - in: { - position: { name: 'top' }, - attrs: PORT_ATTRS, - markup: [{ tagName: 'circle', selector: 'circle' }] - }, - out: { - position: { name: 'bottom' }, - attrs: PORT_ATTRS, - markup: [{ tagName: 'circle', selector: 'circle' }] - } - } - } - }, super.defaults); - } -} - -/** - * A flow edge - a plain arrow, with an optional label for a branch's condition - * (e.g. "Yes"/"No") when its source has more than one outgoing path. - */ -export class FlowLink extends shapes.standard.Link { - defaults() { - return util.defaultsDeep({ - type: 'flowchart.FlowLink', - // Fixed, and lower than every node's - so a link never paints over (and - // steals the click from) the port it connects to. - z: 1, - attrs: { - line: { - class: 'link', - stroke: '#78909C' - } - }, - defaultLabel: { - size: { width: 60, height: 18 }, - attrs: { - text: { class: 'link-label-text' }, - rect: { - ref: null, - x: 'calc(x - calc(w / 2))', - y: 'calc(y - calc(h / 2))', - width: 'calc(w)', - height: 'calc(h)', - class: 'link-label-bg' - } - }, - position: 0.5 - } - }, super.defaults); - } -} diff --git a/examples/layout-elk-flowchart-ts/src/styles.scss b/examples/layout-elk-flowchart-ts/src/styles.scss deleted file mode 100644 index e5bd59dd5a..0000000000 --- a/examples/layout-elk-flowchart-ts/src/styles.scss +++ /dev/null @@ -1,144 +0,0 @@ -:root { - --primary: #3F51B5; - --primary-tint: #E8EAF6; - --on-primary-tint: #283593; - --surface: #FFFFFF; - --outline: #C7CBDD; - --on-surface: rgba(0, 0, 0, 0.87); - --on-surface-variant: rgba(0, 0, 0, 0.6); -} - -html, body { - margin: 0; - padding: 0; - font-family: 'Segoe UI', Roboto, sans-serif; -} - -#canvas { - position: absolute; - margin-top: 50px; - margin-left: 20px; - border: 1px solid var(--outline); - background-color: #FAFAFA; - overflow: hidden; -} - -.toolbar { - display: flex; - position: fixed; - width: 100%; - top: 10px; - margin-left: 30px; - z-index: 1; -} - -.toolbar-button { - outline: none; - background: var(--surface); - border: 1px solid var(--outline); - border-radius: 4px; - font-family: inherit; - font-size: 13px; - padding: 6px 12px; - color: var(--primary); - cursor: pointer; - user-select: none; - margin: 0 2px; - - &:hover { - background: var(--primary-tint); - } -} - -.help-text { - position: fixed; - top: 10px; - right: 20px; - max-width: 300px; - font-family: inherit; - font-size: 12px; - color: var(--on-surface-variant); - text-align: right; - line-height: 1.4; -} - -.joint-theme-material { - - .node { - fill: var(--surface); - stroke: var(--outline); - stroke-width: 1.5px; - filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.25)); - } - - .node--process { - rx: 6px; - ry: 6px; - } - - .node--decision { - stroke: var(--primary); - } - - .node--terminal { - rx: 20px; - ry: 20px; - fill: var(--primary); - stroke: none; - } - - .node-label { - font-family: inherit; - font-size: 13px; - font-weight: 500; - fill: var(--on-surface); - } - - // `node--terminal`'s fill is already the primary color - its own label needs - // the "on primary" (light) text color instead of the default dark one above. - .node-label--on-primary { - fill: #FFFFFF; - } - - .port { - fill: var(--surface); - stroke: var(--primary); - stroke-width: 2px; - } - - .link { - stroke-linecap: round; - } - - .link-label-bg { - fill: var(--primary-tint); - rx: 4px; - ry: 4px; - } - - .link-label-text { - font-family: inherit; - font-size: 11px; - font-weight: 500; - fill: var(--on-primary-tint); - } - - .add-button { - fill: var(--primary); - cursor: pointer; - } - - .add-button-icon { - stroke: #FFFFFF; - stroke-width: 2px; - pointer-events: none; - } - - // The cloned, floating preview of whichever element is currently being - // dragged (see `index.ts`'s `element:pointerdown`/`pointermove`) - not the - // real element, which never moves during the drag. - .drag-preview { - opacity: 0.5; - pointer-events: none; - } -} diff --git a/examples/layout-elk-flowchart-ts/tsconfig.json b/examples/layout-elk-flowchart-ts/tsconfig.json deleted file mode 100644 index 5ac26d8fe9..0000000000 --- a/examples/layout-elk-flowchart-ts/tsconfig.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "compilerOptions": { - "module": "ES2020", - "moduleResolution": "bundler", - "target": "es6", - "lib": [ - "es2022", - "dom" - ], - "noImplicitAny": false, - "sourceMap": false, - "rootDir": "./src", - "outDir": "./build", - "noUncheckedSideEffectImports": false, - "resolveJsonModule": true, - "esModuleInterop": true - } -} diff --git a/examples/layout-elk-flowchart-ts/webpack.config.js b/examples/layout-elk-flowchart-ts/webpack.config.js deleted file mode 100644 index 6a72fec1a9..0000000000 --- a/examples/layout-elk-flowchart-ts/webpack.config.js +++ /dev/null @@ -1,44 +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'), - // Resolved from the bundle's own URL - the ELK worker is a file of its own, - // loaded from next to it wherever the demo is served. - publicPath: 'auto', - }, - mode: 'development', - module: { - rules: [ - { - test: /\.m?js$/, - resolve: { - fullySpecified: false, - }, - }, - { test: /\.ts$/, loader: 'ts-loader' }, - { - test: /\.s[ac]ss$/i, - use: [ - 'style-loader', - 'css-loader', - 'sass-loader', - ], - }, - ], - }, - devServer: { - static: { - directory: __dirname, - }, - devMiddleware: { - publicPath: '/dist/', - }, - compress: true, - }, -}; From 9372f0c715b0ab68fd69b602d07f77e9c002fccd Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Thu, 8 Oct 2026 12:28:19 +0200 Subject: [PATCH 66/75] fix(layout-elk): track the layouts actually posted to the ELK worker createWorkerElk()'s client took the first unsettled layout to be the one the worker was busy with. Two cases broke that: - A layout aborted while waiting its turn is still laid out by the worker - a crash during it was blamed on the next layout, which never ran. - A layout whose graph can't be posted (e.g. DataCloneError for a function an export callback left in it) was rejected but left queued - an abort then no longer terminated the busy worker, and a restart stopped re-posting at it, leaving the layouts behind it pending forever. The client now keeps the ids posted to the current worker in order, and rejects a layout that can't be posted instead of queuing it. Co-Authored-By: Claude Opus 5.5 --- packages/joint-layout-elk/src/workerElk.mts | 40 ++++++++---- packages/joint-layout-elk/test/index.js | 69 +++++++++++++++++++++ 2 files changed, 98 insertions(+), 11 deletions(-) diff --git a/packages/joint-layout-elk/src/workerElk.mts b/packages/joint-layout-elk/src/workerElk.mts index 40ba574596..7c56f92298 100644 --- a/packages/joint-layout-elk/src/workerElk.mts +++ b/packages/joint-layout-elk/src/workerElk.mts @@ -37,8 +37,10 @@ export interface WorkerElk { * `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` keeps - * that order, so its first job is the one the worker is busy with. + * 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 { @@ -46,6 +48,7 @@ export class ElkWorkerClient implements WorkerElk { // 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) {} @@ -120,13 +123,26 @@ export class ElkWorkerClient implements WorkerElk { } }); worker.postMessage({ id: REGISTER_ID, cmd: 'register', algorithms: ALGORITHMS }); - this.jobs.forEach((job) => this.post(job)); + // 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 { - // 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: {}}); + 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 { @@ -134,6 +150,7 @@ export class ElkWorkerClient implements WorkerElk { 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); @@ -151,18 +168,18 @@ export class ElkWorkerClient implements WorkerElk { */ private cancel(job: LayoutJob): void { if (!this.jobs.has(job.id)) return; - const isRunning = this.jobs.keys().next().value === job.id; this.jobs.delete(job.id); - if (isRunning) this.restart(); + 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. A new worker takes over the jobs still - * waiting. + * 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 [running] = this.jobs.values(); + 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})` : ''; @@ -195,6 +212,7 @@ export class ElkWorkerClient implements WorkerElk { private stopWorker(): void { this.worker?.terminate(); this.worker = undefined; + this.posted = []; } } diff --git a/packages/joint-layout-elk/test/index.js b/packages/joint-layout-elk/test/index.js index b0f4a2dd09..bb130cba66 100644 --- a/packages/joint-layout-elk/test/index.js +++ b/packages/joint-layout-elk/test/index.js @@ -1241,4 +1241,73 @@ QUnit.module('createWorkerElk()', (hooks) => { 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({ graph: first.graph }, { elk }); + const abortedResult = joint.layout.ELK.layout({ graph: 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({ graph: 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(), { elk }); + + // A function can't be cloned into the worker. + const unpostable = createGraph(); + await assert.rejects(joint.layout.ELK.layout({ graph: 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(), { elk, signal: controller.signal }); + const waiting = createGraph(); + const waitingResult = joint.layout.ELK.layout({ graph: 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(), { elk }); + const afterCrash = createGraph(); + const afterCrashResult = joint.layout.ELK.layout({ graph: 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)); + }); }); From a1f74066bf53a4f46c2f042714c6351ac7b85875 Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Thu, 8 Oct 2026 12:29:05 +0200 Subject: [PATCH 67/75] fix(layout-elk): drop a port label when exportPortLabel returns false ExportPortLabelCallback is typed to return `false` to drop the label, and the README promises that for every export callback, but its return value was ignored. Co-Authored-By: Claude Opus 5.5 --- packages/joint-layout-elk/src/export.mts | 8 +++++-- packages/joint-layout-elk/test/index.js | 28 ++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/joint-layout-elk/src/export.mts b/packages/joint-layout-elk/src/export.mts index 9db1d24f4d..be6a3be982 100644 --- a/packages/joint-layout-elk/src/export.mts +++ b/packages/joint-layout-elk/src/export.mts @@ -94,6 +94,10 @@ export type ExportLinkCallbackParameters = { 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: ExportPortLabelCallbackParameters) => void | false; export type ExportPortLabelCallbackParameters = { portId: string; @@ -232,10 +236,10 @@ function buildPorts(element: dia.Element): ElkPort[] | undefined { layoutOptions: {} }; - exportGraphOptions.exportPortLabel?.({ portId, element, elkPortLabel: portLabel }); + const isLabelExported = exportGraphOptions.exportPortLabel?.({ portId, element, elkPortLabel: portLabel }) !== false; let labels: ElkLabel[] = []; - if (portLabel.width && portLabel.height) { + if (isLabelExported && portLabel.width && portLabel.height) { labels = [{ ...portLabel, text: ELK_LABEL_TEXT diff --git a/packages/joint-layout-elk/test/index.js b/packages/joint-layout-elk/test/index.js index bb130cba66..0ca8223734 100644 --- a/packages/joint-layout-elk/test/index.js +++ b/packages/joint-layout-elk/test/index.js @@ -731,6 +731,34 @@ QUnit.module('layout()', () => { 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 return a zero-size bbox for an empty graph', async(assert) => { const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); From 673c8d02c5a860ce8b82bdb7ca691d29c94abc48 Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Thu, 8 Oct 2026 12:29:45 +0200 Subject: [PATCH 68/75] fix(layout-elk): reject an aborted layout with a `null` abort reason as it is `??` replaced a `null` reason with a new AbortError - the default is now only made up where the browser doesn't support `AbortSignal.reason`. Co-Authored-By: Claude Opus 5.5 --- packages/joint-layout-elk/src/abort.mts | 4 +++- packages/joint-layout-elk/test/index.js | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/joint-layout-elk/src/abort.mts b/packages/joint-layout-elk/src/abort.mts index c8060e0259..6bb6d00d1e 100644 --- a/packages/joint-layout-elk/src/abort.mts +++ b/packages/joint-layout-elk/src/abort.mts @@ -1,7 +1,9 @@ // 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 ?? new DOMException('The layout was aborted.', 'AbortError'); + return (signal.reason !== undefined) ? signal.reason : new DOMException('The layout was aborted.', 'AbortError'); } export function throwIfAborted(signal: AbortSignal | undefined): void { diff --git a/packages/joint-layout-elk/test/index.js b/packages/joint-layout-elk/test/index.js index 0ca8223734..a6da359ed8 100644 --- a/packages/joint-layout-elk/test/index.js +++ b/packages/joint-layout-elk/test/index.js @@ -840,6 +840,23 @@ QUnit.module('layout()', () => { 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(); From 67d44c36cd33351565ec78c3bf5b765a6aa8fb2c Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Thu, 8 Oct 2026 16:55:24 +0200 Subject: [PATCH 69/75] fix(dia.Element): type portProp(portId) and portProp(portId, object, opt), and PortLabelPositionType Re-applies the port typing fixes from the port-label-position-type and port-prop-overloads changesets on top of master's dia.d.ts. Co-Authored-By: Claude Opus 5.5 --- packages/joint-core/types/dia.d.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/joint-core/types/dia.d.ts b/packages/joint-core/types/dia.d.ts index 5f7f51d274..1458fd7d0e 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; @@ -977,8 +977,12 @@ export class Element, opt?: S): Element; + portProp(portId: string, path: Path, value?: any, opt?: S): Element; protected generatePortId(): string | number; From f12a73ae91d00b9b8f3365ce422f95914ed1d9eb Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Thu, 8 Oct 2026 16:55:24 +0200 Subject: [PATCH 70/75] chore: add @joint/layout-elk and its examples to yarn.lock Co-Authored-By: Claude Opus 5.5 --- yarn.lock | 86 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 81 insertions(+), 5 deletions(-) diff --git a/yarn.lock b/yarn.lock index b8fe3b8093..88c04e14a5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6348,13 +6348,64 @@ __metadata: languageName: unknown linkType: soft +"@joint/demo-layout-elk-containers-ports-ts@workspace:examples/layout-elk-containers-ports-ts": + version: 0.0.0-use.local + resolution: "@joint/demo-layout-elk-containers-ports-ts@workspace:examples/layout-elk-containers-ports-ts" + dependencies: + "@joint/core": "workspace:^" + "@joint/layout-elk": "workspace:^" + css-loader: "npm:3.5.3" + sass-loader: "npm:8.0.2" + style-loader: "npm:1.2.1" + ts-loader: "npm:^9.2.5" + typescript: "npm:5.8.2" + webpack: "npm:5.98.0" + webpack-cli: "npm:6.0.1" + webpack-dev-server: "npm:5.2.0" + languageName: unknown + linkType: soft + +"@joint/demo-layout-elk-default-ts@workspace:examples/layout-elk-default-ts": + version: 0.0.0-use.local + resolution: "@joint/demo-layout-elk-default-ts@workspace:examples/layout-elk-default-ts" + dependencies: + "@joint/core": "workspace:^" + "@joint/layout-elk": "workspace:^" + css-loader: "npm:3.5.3" + sass-loader: "npm:8.0.2" + style-loader: "npm:1.2.1" + ts-loader: "npm:^9.2.5" + typescript: "npm:5.8.2" + webpack: "npm:5.98.0" + webpack-cli: "npm:6.0.1" + webpack-dev-server: "npm:5.2.0" + 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:^" + css-loader: "npm:3.5.3" + sass-loader: "npm:8.0.2" + style-loader: "npm:1.2.1" + ts-loader: "npm:^9.2.5" + typescript: "npm:5.8.2" + webpack: "npm:5.98.0" + webpack-cli: "npm:6.0.1" + webpack-dev-server: "npm:5.2.0" + languageName: unknown + linkType: soft + "@joint/demo-layout-elk-ts@workspace:examples/layout-elk-ts": version: 0.0.0-use.local resolution: "@joint/demo-layout-elk-ts@workspace:examples/layout-elk-ts" dependencies: "@joint/core": "workspace:^" + "@joint/layout-elk": "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" @@ -6710,6 +6761,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" @@ -15761,10 +15837,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 From d0625d4e82487c953e64be377fa18215a8ae1862 Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Thu, 8 Oct 2026 17:15:57 +0200 Subject: [PATCH 71/75] fix(layout-elk): read resolved link labels through the protected getComputedLabels() @joint/core made dia.Link#getComputedLabels() protected in its types (#3528). Co-Authored-By: Claude Opus 5.5 --- packages/joint-layout-elk/src/export.mts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/joint-layout-elk/src/export.mts b/packages/joint-layout-elk/src/export.mts index be6a3be982..ec556c0cf1 100644 --- a/packages/joint-layout-elk/src/export.mts +++ b/packages/joint-layout-elk/src/export.mts @@ -378,8 +378,12 @@ function buildEdge(link: dia.Link): void { // Resolved (`link.getComputedLabels()`) - `size` falls back through `defaultLabel`/the // built-in default the same way `@joint/core` itself resolves it for rendering, so it - // can be read directly here instead of from the label's raw JSON. - const resolvedLabels = link.getComputedLabels(); + // can be read directly here instead of from the label's raw JSON. The method is + // `protected` in `@joint/core`'s types, but it is on every link at runtime - and this + // package is released together with `@joint/core`. + const resolvedLabels = (link as unknown as { + getComputedLabels(): dia.Link.ComputedLabel[]; + }).getComputedLabels(); let labels: ElkLabel[] = []; if (resolvedLabels.length > 0) { labels = resolvedLabels.reduce((result: ElkLabel[], label, labelIndex) => { From 731ab172d88f42f4e479aa7457893f96f197fe29 Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Thu, 8 Oct 2026 17:22:37 +0200 Subject: [PATCH 72/75] up --- packages/joint-layout-elk/src/export.mts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/joint-layout-elk/src/export.mts b/packages/joint-layout-elk/src/export.mts index ec556c0cf1..1ea208b607 100644 --- a/packages/joint-layout-elk/src/export.mts +++ b/packages/joint-layout-elk/src/export.mts @@ -378,12 +378,10 @@ function buildEdge(link: dia.Link): void { // Resolved (`link.getComputedLabels()`) - `size` falls back through `defaultLabel`/the // built-in default the same way `@joint/core` itself resolves it for rendering, so it - // can be read directly here instead of from the label's raw JSON. The method is - // `protected` in `@joint/core`'s types, but it is on every link at runtime - and this - // package is released together with `@joint/core`. - const resolvedLabels = (link as unknown as { - getComputedLabels(): dia.Link.ComputedLabel[]; - }).getComputedLabels(); + // 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 resolvedLabels: dia.Link.ComputedLabel[] = link.getComputedLabels(); let labels: ElkLabel[] = []; if (resolvedLabels.length > 0) { labels = resolvedLabels.reduce((result: ElkLabel[], label, labelIndex) => { From bae803b5ec808978d55434c96ce236bff9c5d5e9 Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Thu, 8 Oct 2026 17:41:20 +0200 Subject: [PATCH 73/75] fix(layout-elk): keep ELK ids unique, and lay out a container without exported embeds as a leaf - Element, port, link and label ids went into the ELK graph as they were: element `a`'s port `p` and element `a:p` shared the ELK id `a:p`, and an element could share the root's id `root`. Cell and port ids are now escaped (`\` and `:`), so nodes, ports, labels and the root (now `::root`) can't collide - ordinary ids stay as they are. - A container whose embeds exportElement all dropped went to ELK as a 0x0 container, so ELK made no room for it. It is now laid out as a leaf, with its own size. Co-Authored-By: Claude Opus 5.5 --- packages/joint-layout-elk/src/elkIds.mts | 50 +++++++++++++++++ packages/joint-layout-elk/src/export.mts | 42 +++++++++----- packages/joint-layout-elk/src/import.mts | 2 +- packages/joint-layout-elk/src/labelIds.mts | 17 ------ packages/joint-layout-elk/test/index.js | 65 ++++++++++++++++++++++ 5 files changed, 143 insertions(+), 33 deletions(-) create mode 100644 packages/joint-layout-elk/src/elkIds.mts delete mode 100644 packages/joint-layout-elk/src/labelIds.mts diff --git a/packages/joint-layout-elk/src/elkIds.mts b/packages/joint-layout-elk/src/elkIds.mts new file mode 100644 index 0000000000..d4a6065685 --- /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: string): 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 index 1ea208b607..ec7ef3102a 100644 --- a/packages/joint-layout-elk/src/export.mts +++ b/packages/joint-layout-elk/src/export.mts @@ -1,5 +1,5 @@ import { type dia } from '@joint/core'; -import { getLinkLabelId } from './labelIds.mjs'; +import { ELK_ROOT_ID, getElkEdgeId, getElkNodeId, getElkPortId, getLinkLabelId } from './elkIds.mjs'; import type { ElkNode, @@ -29,6 +29,7 @@ export interface ElkLabelDraft { /** 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; @@ -58,6 +59,7 @@ export type ExportElementCallbackParameters = { /** 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; @@ -80,6 +82,7 @@ export type ExportPortCallbackParameters = { /** 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; } @@ -209,7 +212,7 @@ function buildPorts(element: dia.Element): ElkPort[] | undefined { element.getPorts().forEach((port) => { const portId = `${port.id}`; - const elkPortId = `${element.id}:${portId}`; + 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 @@ -264,7 +267,7 @@ function buildPorts(element: dia.Element): ElkPort[] | undefined { * can end up referencing it. */ function buildElkNode(element: dia.Element, parentId?: string): ElkNode | null { - const id = `${element.id}`; + const id = getElkNodeId(element); const embeds = getEmbeddedElements(element); @@ -299,10 +302,19 @@ function buildElkNode(element: dia.Element, parentId?: string): ElkNode | null { children = embeds .map((embed) => buildElkNode(embed, id)) .filter((node): node is ElkNode => node !== null); - // 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); + 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, with its own size + // in place of the container placeholder (unless `exportElement` set one). + children = undefined; + if (elkNode.width === 0 && elkNode.height === 0) { + ({ width: elkNode.width, height: elkNode.height } = element.size()); + } + } } return { @@ -318,7 +330,7 @@ function buildElkNode(element: dia.Element, parentId?: string): ElkNode | null { // immediate parent. function getAncestorPath(element: dia.Element): string[] { const path: string[] = []; - let parentId = elkParentIdsById.get(`${element.id}`); + let parentId = elkParentIdsById.get(getElkNodeId(element)); while (parentId !== undefined) { path.unshift(parentId); parentId = elkParentIdsById.get(parentId); @@ -350,9 +362,9 @@ function buildEdge(link: dia.Link): void { if (!sourceElement || !targetElement) return; // 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(`${sourceElement.id}`) || !elementsById.has(`${targetElement.id}`)) return; + if (!elementsById.has(getElkNodeId(sourceElement)) || !elementsById.has(getElkNodeId(targetElement))) return; - const id = `${link.id}`; + const id = getElkEdgeId(link); const sourcePort = link.source().port; const targetPort = link.target().port; @@ -360,11 +372,11 @@ function buildEdge(link: dia.Link): void { // A port `exportPort` dropped falls back to anchoring the edge on the element // itself, same as a naturally portless connection. const sources = (sourcePort && !isPortExcluded(sourceElement, sourcePort)) - ? [`${sourceElement.id}:${sourcePort}`] - : [`${sourceElement.id}`]; + ? [getElkPortId(sourceElement, `${sourcePort}`)] + : [getElkNodeId(sourceElement)]; const targets = (targetPort && !isPortExcluded(targetElement, targetPort)) - ? [`${targetElement.id}:${targetPort}`] - : [`${targetElement.id}`]; + ? [getElkPortId(targetElement, `${targetPort}`)] + : [getElkNodeId(targetElement)]; const elkEdge: ElkEdgeDraft = { id, @@ -446,7 +458,7 @@ export function exportGraph( .filter((node): node is ElkNode => node !== null); const elkGraph: ElkNode = { - id: 'root', + id: ELK_ROOT_ID, layoutOptions: elkLayoutOptions, children, edges: [] diff --git a/packages/joint-layout-elk/src/import.mts b/packages/joint-layout-elk/src/import.mts index d4f3ce5bbc..a9f6d9ab54 100644 --- a/packages/joint-layout-elk/src/import.mts +++ b/packages/joint-layout-elk/src/import.mts @@ -2,7 +2,7 @@ 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 './labelIds.mjs'; +import { getLinkLabelIndex } from './elkIds.mjs'; /** Applies the ELK-computed position (and, for a container, size) to `element`. */ export type SetElementAttributesCallback = (params: SetElementAttributesCallbackParameters) => void; diff --git a/packages/joint-layout-elk/src/labelIds.mts b/packages/joint-layout-elk/src/labelIds.mts deleted file mode 100644 index dbfa451b1f..0000000000 --- a/packages/joint-layout-elk/src/labelIds.mts +++ /dev/null @@ -1,17 +0,0 @@ -// Not part of the public API (not re-exported from `index.mts`). - -// 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/test/index.js b/packages/joint-layout-elk/test/index.js index a6da359ed8..27b20f15d6 100644 --- a/packages/joint-layout-elk/test/index.js +++ b/packages/joint-layout-elk/test/index.js @@ -1018,6 +1018,71 @@ QUnit.module('layout()', () => { 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 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, falling the edge back to the element', async(assert) => { const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); From 248c765145d7828678aca20d50138fdc9bd0c7c6 Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Thu, 8 Oct 2026 17:41:20 +0200 Subject: [PATCH 74/75] fix(examples): drop the containers-ports example's stale link label `inline` override Nothing reads a link label's `inline` property any more - the package places every link label inline by default. Co-Authored-By: Claude Opus 5.5 --- examples/layout-elk-containers-ports-ts/src/example.ts | 3 --- examples/layout-elk-containers-ports-ts/src/shapes.ts | 1 - 2 files changed, 4 deletions(-) diff --git a/examples/layout-elk-containers-ports-ts/src/example.ts b/examples/layout-elk-containers-ports-ts/src/example.ts index a70d8bb79d..f2245ecf9f 100644 --- a/examples/layout-elk-containers-ports-ts/src/example.ts +++ b/examples/layout-elk-containers-ports-ts/src/example.ts @@ -195,9 +195,6 @@ export const graphJSON: dia.Graph.JSON = { type: 'example.InteractionLink', source: { id: 'backend' }, target: { id: 'observability' }, - // Overrides `InteractionLink.defaultLabel`'s `inline` (own value wins - - // see `Link#labels`) - floated beside the edge instead of centered directly on - // it, so it doesn't obscure a long aggregate link's whole path. labels: [{ attrs: { text: { text: 'metrics' } } }] }, { diff --git a/examples/layout-elk-containers-ports-ts/src/shapes.ts b/examples/layout-elk-containers-ports-ts/src/shapes.ts index cbb2473001..831d1c665d 100644 --- a/examples/layout-elk-containers-ports-ts/src/shapes.ts +++ b/examples/layout-elk-containers-ports-ts/src/shapes.ts @@ -205,7 +205,6 @@ export class InteractionLink extends shapes.standard.Link { }, defaultLabel: { size: { width: 80, height: 20 }, - inline: true, attrs: { text: { class: 'md-chip-text' From f7279be6407771037bdd571e0d1c1c7452a2fd90 Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Thu, 8 Oct 2026 17:41:20 +0200 Subject: [PATCH 75/75] docs(layout-elk): document loading elkjs before the UMD build The UMD build keeps elkjs external and runs ELK through the `ELK` global - the README said it needed no setup, and that main-thread ELK is always imported dynamically. Co-Authored-By: Claude Opus 5.5 --- packages/joint-layout-elk/README.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/joint-layout-elk/README.md b/packages/joint-layout-elk/README.md index f261f2c570..1c38ea0dc6 100644 --- a/packages/joint-layout-elk/README.md +++ b/packages/joint-layout-elk/README.md @@ -14,6 +14,19 @@ This library depends on [JointJS](https://github.com/clientio/joint) (*>=4.0*), 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 @@ -120,7 +133,7 @@ type SetLinkAttributesCallback = (params: { link: dia.Link; attributes: { vertic ### Running ELK in a Web Worker -By default, `layout()` runs ELK on the main thread - nothing to set up, and it works anywhere (browsers, Node/SSR, tests, the UMD build). 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`: +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'; @@ -181,7 +194,7 @@ ELK can't stop a layout in progress, so a layout a `createWorkerElk()` worker is - **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. - **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. - **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. 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. +- **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