diff --git a/.changeset/brave-labels-resolve.md b/.changeset/brave-labels-resolve.md new file mode 100644 index 0000000000..8ec6a7999e --- /dev/null +++ b/.changeset/brave-labels-resolve.md @@ -0,0 +1,5 @@ +--- +"@joint/core": minor +--- + +dia.Link - add `getComputedLabel()`/`getComputedLabels()`, resolved against `defaultLabel` diff --git a/.changeset/layout-elk-new-package.md b/.changeset/layout-elk-new-package.md new file mode 100644 index 0000000000..05f10cfef9 --- /dev/null +++ b/.changeset/layout-elk-new-package.md @@ -0,0 +1,5 @@ +--- +"@joint/layout-elk": minor +--- + +new package - automatic layout for JointJS graphs using the Eclipse Layout Kernel (ELK), including layout of embedded elements as containers and of element ports diff --git a/.changeset/port-label-position-type.md b/.changeset/port-label-position-type.md new file mode 100644 index 0000000000..59e97a88bc --- /dev/null +++ b/.changeset/port-label-position-type.md @@ -0,0 +1,5 @@ +--- +"@joint/core": patch +--- + +dia.Element - fix `PortLabelPositionType` type to use `PortLabelPositionJSON` for a port label's position diff --git a/.changeset/port-prop-overloads.md b/.changeset/port-prop-overloads.md new file mode 100644 index 0000000000..c69ec19c08 --- /dev/null +++ b/.changeset/port-prop-overloads.md @@ -0,0 +1,5 @@ +--- +"@joint/core": patch +--- + +dia.Element - fix types to allow `portProp(portId)` and `portProp(portId, object, opt)` diff --git a/examples/layout-elk-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..787818cf89 --- /dev/null +++ b/examples/layout-elk-containers-ports-ts/README.md @@ -0,0 +1,24 @@ +# JointJS ELK Containers & Ports Demo + +A fixed (non-random) web platform reference architecture laid out automatically with `@joint/layout-elk`: nested containers ("Client Layer" > "Edge", "Core Services" > "Data Layer", "Observability") grouping services that connect through ports, plus a couple of links connecting containers directly - including one that crosses from one container into another. Styled with Material Design, via JointJS's theme mechanism and CSS. + +## Setup + +Use Yarn to run this demo. + +You need to build *JointJS* first. Navigate to the root folder and run: +```bash +yarn install +yarn run build +``` + +Navigate to this directory, then run: +```bash +yarn 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..4fece7d404 --- /dev/null +++ b/examples/layout-elk-containers-ports-ts/index.html @@ -0,0 +1,25 @@ + + + + + + + + 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..1b466fbe2a --- /dev/null +++ b/examples/layout-elk-containers-ports-ts/package.json @@ -0,0 +1,39 @@ +{ + "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:^" + }, + "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/example.ts b/examples/layout-elk-containers-ports-ts/src/example.ts new file mode 100644 index 0000000000..a70d8bb79d --- /dev/null +++ b/examples/layout-elk-containers-ports-ts/src/example.ts @@ -0,0 +1,211 @@ +import { dia } from '@joint/core'; + +// A fixed (non-random) system diagram: three top-level containers, two of +// them with a nested container of their own, grouping eight services that +// communicate over ports - plus a couple of links that connect two +// containers directly, rather than a pair of ports, including one that +// crosses container boundaries. A recognizable, if simplified, web platform +// reference architecture: a client layer talking through an edge (load +// balancer + API gateway) to core services (auth, guarding a data layer of +// cache + database), with logs/metrics/analytics flowing to observability. +// Every plain `example.Service` has exactly one 'in' and one 'out' port; the +// four "hub" services (Load Balancer, API Gateway, Auth Service, Monitoring) +// are `example.HubService` instead, with a custom number of ports and ELK's +// `FIXED_SIDE` port constraint, so it can reorder them to minimize crossings +// (see `index.ts`). +export const graphJSON: dia.Graph.JSON = { + cells: [ + // Containers + { + id: 'frontend', + type: 'example.Container', + attrs: { label: { text: 'Client Layer' } } + }, + { + id: 'edge', + type: 'example.Container', + parent: 'frontend', + attrs: { label: { text: 'Edge' } } + }, + { + id: 'backend', + type: 'example.Container', + attrs: { label: { text: 'Core Services' } } + }, + { + id: 'storage', + type: 'example.Container', + parent: 'backend', + attrs: { label: { text: 'Data Layer' } } + }, + { + id: 'observability', + type: 'example.Container', + attrs: { label: { text: 'Observability' } } + }, + + // Client Layer + { + id: 'webui', + type: 'example.Service', + parent: 'frontend', + attrs: { label: { text: 'Web App' } } + }, + { + id: 'mobileui', + type: 'example.Service', + parent: 'frontend', + attrs: { label: { text: 'Mobile App' } } + }, + { + id: 'lb', + type: 'example.HubService', + parent: 'edge', + size: { width: 130, height: 80 }, + attrs: { label: { text: 'Load Balancer' } }, + ports: { + items: [ + { id: 'in1', group: 'in', attrs: { text: { text: 'in1' } } }, + { id: 'in2', group: 'in', attrs: { text: { text: 'in2' } } }, + { id: 'out', group: 'out', attrs: { text: { text: 'out' } } } + ] + } + }, + { + id: 'gateway', + type: 'example.HubService', + parent: 'edge', + size: { width: 130, height: 80 }, + attrs: { label: { text: 'API Gateway' } }, + ports: { + items: [ + { id: 'in', group: 'in', attrs: { text: { text: 'in' } } }, + { id: 'out1', group: 'out', attrs: { text: { text: 'out1' } } }, + { id: 'out2', group: 'out', attrs: { text: { text: 'out2' } } } + ] + } + }, + + // Core Services + { + id: 'auth', + type: 'example.HubService', + parent: 'backend', + size: { width: 130, height: 80 }, + attrs: { label: { text: 'Auth Service' } }, + ports: { + items: [ + { id: 'in', group: 'in', attrs: { text: { text: 'in' } } }, + { id: 'out1', group: 'out', attrs: { text: { text: 'out1' } } }, + { id: 'out2', group: 'out', attrs: { text: { text: 'out2' } } } + ] + } + }, + { + id: 'cache', + type: 'example.Service', + parent: 'storage', + attrs: { label: { text: 'Redis Cache' } } + }, + { + id: 'db', + type: 'example.Service', + parent: 'storage', + attrs: { label: { text: 'PostgreSQL' } } + }, + + // Observability + { + id: 'monitoring', + type: 'example.HubService', + parent: 'observability', + size: { width: 130, height: 80 }, + attrs: { label: { text: 'Monitoring' } }, + ports: { + items: [ + { id: 'in1', group: 'in', attrs: { text: { text: 'in1' } } }, + { id: 'in2', group: 'in', attrs: { text: { text: 'in2' } } } + ] + } + }, + + // Links + { + id: 'l1', + type: 'example.InteractionLink', + source: { id: 'webui', port: 'out' }, + target: { id: 'lb', port: 'in1' }, + labels: [{ attrs: { text: { text: 'request' } } }] + }, + { + id: 'l2', + type: 'example.InteractionLink', + source: { id: 'mobileui', port: 'out' }, + target: { id: 'lb', port: 'in2' }, + labels: [{ attrs: { text: { text: 'request' } } }] + }, + { + id: 'l3', + type: 'example.InteractionLink', + source: { id: 'lb', port: 'out' }, + target: { id: 'gateway', port: 'in' }, + labels: [{ attrs: { text: { text: 'route' } } }] + }, + { + id: 'l4', + type: 'example.InteractionLink', + source: { id: 'gateway', port: 'out1' }, + target: { id: 'auth', port: 'in' }, + labels: [{ attrs: { text: { text: 'authenticate' } } }] + }, + { + id: 'l5', + type: 'example.InteractionLink', + source: { id: 'gateway', port: 'out2' }, + target: { id: 'monitoring', port: 'in1' }, + labels: [{ attrs: { text: { text: 'metrics' } } }] + }, + { + id: 'l6', + type: 'example.InteractionLink', + source: { id: 'auth', port: 'out1' }, + target: { id: 'cache', port: 'in' }, + labels: [{ attrs: { text: { text: 'lookup' } } }] + }, + { + id: 'l7', + type: 'example.InteractionLink', + source: { id: 'auth', port: 'out2' }, + target: { id: 'monitoring', port: 'in2' }, + labels: [{ attrs: { text: { text: 'logs' } } }] + }, + { + id: 'l8', + type: 'example.InteractionLink', + source: { id: 'cache', port: 'out' }, + target: { id: 'db', port: 'in' }, + labels: [{ attrs: { text: { text: 'query' } } }] + }, + + // Container-to-container links - connected to a `example.Container` cell + // itself rather than to one of its ports, aggregating what the individual + // service-to-service links above already carry. + { + id: 'l9', + type: 'example.InteractionLink', + source: { id: 'backend' }, + target: { id: 'observability' }, + // 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' } } }] + } + ] +}; 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..db567acf7b --- /dev/null +++ b/examples/layout-elk-containers-ports-ts/src/index.ts @@ -0,0 +1,200 @@ +import { dia, shapes, setTheme } from '@joint/core'; +import { + ElkLayoutOptions, + ExportElementCallback, + ExportPortCallback, + ExportPortLabelCallback, + createWorkerElk, + layout +} from '@joint/layout-elk'; +import { graphJSON } from './example'; +import { Container, HubService, InteractionLink, Service } from './shapes'; +import './styles.scss'; + +const ELK_DIRECTION = 'RIGHT'; + +const cellNamespace = { + ...shapes, + example: { + Container, + Service, + HubService, + InteractionLink + } +}; + +// ELK runs in a Web Worker, so a layout doesn't block the page - the worker is started on +// the first layout, then shared by every later one. +const elk = createWorkerElk(() => new Worker(new URL('@joint/layout-elk/worker', import.meta.url), { type: 'module' })); + +const init = () => { + + // Every view (paper and cells alike) picks up a `joint-theme-material` class - + // this example's own CSS gives that class its actual meaning (see `styles.scss`). + setTheme('material'); + + // Create JointJS graph and paper + const graph = new dia.Graph({}, { cellNamespace }); + const paper = new dia.Paper({ + model: graph, + cellViewNamespace: cellNamespace, + width: 1200, + height: 700, + gridSize: 1, + interactive: false, + async: true, + frozen: true, + defaultConnector: { + name: 'straight', + args: { + cornerType: 'cubic', + cornerRadius: 5 + } + } + }); + document.getElementById('canvas')!.appendChild(paper.el); + addZoomAndPanListeners(paper); + + // Load the fixed example data - `mergeArrays` merges each cell's array + // attributes (e.g. a link's `labels`) into its class defaults index by + // index, instead of the default behavior of replacing them outright. + graph.fromJSON(graphJSON, { mergeArrays: true }); + + const elkLayoutOptions: ElkLayoutOptions = { + /** + * Overall direction of the layout. + * 'UP' | 'DOWN' | 'LEFT' | 'RIGHT' + */ + 'elk.direction': ELK_DIRECTION, + + /** + * Spacing between nodes (siblings). + * A number value as a string. + */ + 'elk.spacing.nodeNode': '30', + + /** + * Spacing between layers (for layered algorithm). + * A number value as a string. + */ + 'elk.layered.spacing.nodeNodeBetweenLayers': '40', + + /** + * Edge routing style. + * 'ORTHOGONAL' | 'SPLINES' | 'POLYLINE' + */ + 'elk.edgeRouting': 'ORTHOGONAL', + + /** + * Desired width-to-height ratio of the drawing - ELK's wrapping + * strategy (below) targets this to decide how many rows to wrap + * onto. Tuned, together with the spacing above, for this + * particular graph. + */ + 'elk.aspectRatio': '1.2', + /** + * Wraps layers onto additional rows, connected by dedicated + * "wrap" edges, instead of growing a single row indefinitely. + * 'NONE' | 'SINGLE_EDGE' | 'MULTI_EDGE' + */ + 'elk.layered.wrapping.strategy': 'MULTI_EDGE' + }; + + // `FIXED_SIDE` (not the package's default `FIXED_POS`) lets ELK move each port + // along the side `exportPort` puts it on, to reduce crossings. + const exportElement: ExportElementCallback = ({ element, elkNode }) => { + if (element.hasPorts()) { + elkNode.layoutOptions['elk.portConstraints'] = 'FIXED_SIDE'; + } + + const padding = element.get('padding'); + if (padding) { + elkNode.layoutOptions['elk.padding'] = padding; + } + }; + + const exportPort: ExportPortCallback = ({ portId, element, elkPort }) => { + switch (element.getPort(portId).group) { + case 'in': + elkPort.layoutOptions['elk.port.side'] = 'WEST'; + break; + case 'out': + elkPort.layoutOptions['elk.port.side'] = 'SOUTH'; + break; + } + }; + + const exportPortLabel: ExportPortLabelCallback = ({ portId, element, elkPortLabel }) => { + const { width, height} = element.portProp(portId, 'label/size'); + elkPortLabel.width = width; + elkPortLabel.height = height; + }; + + // Freezes the paper for the (async) duration of the layout, so nothing renders + // mid-layout. + const runLayout = (): Promise => { + paper.freeze(); + return layout({ graph }, { + elk, + exportElement, + exportPort, + exportPortLabel, + elkLayoutOptions + }).then(() => { + paper.unfreeze(); + }).catch((error) => { + paper.unfreeze(); + console.error('ELK layout error:', error.message); + }); + }; + + // Initial layout of the fixed example data, fit to the paper's viewport. + runLayout().then(() => { + zoom(paper, 1); + }); +}; + +function zoom(paper: dia.Paper, zoomLevel: number): void { + paper.scale(zoomLevel); + paper.fitToContent({ + useModelGeometry: true, + padding: 40 * zoomLevel, + allowNewOrigin: 'any' + }); +} + +/** + * Add toolbar zoom in/out listeners to the paper and setup panning. + */ +function addZoomAndPanListeners(paper: dia.Paper): void { + + let zoomLevel = paper.scale().sx; + + document.getElementById('zoom-in')!.addEventListener('click', () => { + zoomLevel = Math.min(3, zoomLevel + 0.2); + zoom(paper, zoomLevel); + }); + + document.getElementById('zoom-out')!.addEventListener('click', () => { + zoomLevel = Math.max(0.2, zoomLevel - 0.2); + zoom(paper, zoomLevel); + }); + + paper.on('blank:pointerdown', (evt) => { + evt.data = { + scrollX: window.scrollX, + clientX: evt.clientX, + scrollY: window.scrollY, + clientY: evt.clientY + }; + }); + + paper.on('blank:pointermove', (evt) => { + window.scroll( + evt.data.scrollX + (evt.data.clientX - evt.clientX!), + evt.data.scrollY + (evt.data.clientY - evt.clientY!) + ); + }); +} + +init(); diff --git a/examples/layout-elk-containers-ports-ts/src/shapes.ts b/examples/layout-elk-containers-ports-ts/src/shapes.ts new file mode 100644 index 0000000000..cbb2473001 --- /dev/null +++ b/examples/layout-elk-containers-ports-ts/src/shapes.ts @@ -0,0 +1,228 @@ +import { dia, shapes, util } from '@joint/core'; + +const PORT_SIZE = { width: 12, height: 12 }; +const PORT_ATTRS = { + circle: { + r: 6, + class: 'md-port' + }, + text: { + class: 'md-port-label' + } +}; + +// `@joint/layout-elk` reads a port label's `size` directly rather than measuring the +// rendered text itself, so it has to be estimated from the label text up front. +// `Service` (below) computes and assigns it for every port as soon as the port is +// added, from that port's own label text length. The estimate only needs to roughly +// match `.md-port-label`'s CSS font size/weight, since it never has to be exact. +const PORT_LABEL_FONT_SIZE = 11; +const PORT_LABEL_AVERAGE_CHAR_WIDTH = PORT_LABEL_FONT_SIZE * 0.6; +const PORT_LABEL_HEIGHT = PORT_LABEL_FONT_SIZE + 5; + +function estimatePortLabelSize(text: string): dia.Size { + return { + width: Math.ceil(text.length * PORT_LABEL_AVERAGE_CHAR_WIDTH), + height: PORT_LABEL_HEIGHT + }; +} + +// Square ports (rather than `PORT_ATTRS`' circles) set `HubService` apart as +// a hub with several ports fanning in/out on the same side. +const HUB_PORT_SIZE = { width: 14, height: 8 }; +const HUB_PORT_MARKUP = [{ + tagName: 'rect', + selector: 'rect' +}]; +const HUB_PORT_ATTRS = { + rect: { + x: -HUB_PORT_SIZE.width / 2, + y: -HUB_PORT_SIZE.height / 2, + width: HUB_PORT_SIZE.width, + height: HUB_PORT_SIZE.height, + class: 'md-port' + }, + text: { + class: 'md-port-label' + } +}; + +const CONTAINER_PADDING = '[top=40,left=20,bottom=20,right=20]'; + +/** + * A tonal container surface - its final size and position are computed by ELK to fit + * whatever gets embedded into it. Its label sits in the top-left corner, out of the + * way of embedded elements. + */ +export class Container extends shapes.standard.Rectangle { + defaults() { + return util.defaultsDeep({ + type: 'example.Container', + size: { width: 100, height: 100 }, + padding: CONTAINER_PADDING, + attrs: { + body: { + class: 'md-container' + }, + label: { + x: 12, + y: 10, + textAnchor: 'start', + textVerticalAnchor: 'top', + class: 'md-container-label' + } + } + }, super.defaults); + } +} + +/** + * A service node with exactly one 'in' (left) and one 'out' (right) port, + * always - only the label `text` is left for each instance to fill in. See + * `HubService` for a service with a custom number of ports. + */ +export class Service extends shapes.standard.Rectangle { + defaults() { + return util.defaultsDeep({ + type: 'example.Service', + size: { width: 130, height: 50 }, + attrs: { + body: { + class: 'md-card' + }, + label: { + class: 'md-card-label' + } + }, + ports: { + groups: { + in: { + position: { + name: 'absolute' + }, + label: { + position: { + name: 'manual' + } + }, + size: PORT_SIZE, + attrs: PORT_ATTRS, + }, + out: { + position: { + name: 'absolute' + }, + label: { + position: { + name: 'manual' + } + }, + size: PORT_SIZE, + attrs: PORT_ATTRS, + } + }, + items: [ + { id: 'in', group: 'in', attrs: { text: { text: 'in' } } }, + { id: 'out', group: 'out', attrs: { text: { text: 'out' } } } + ] + } + }, super.defaults); + } + + initialize(...args: any[]) { + super.initialize(...args); + + // Ports present from the start don't go through `ports:add` (it only fires for + // ports added after the element already exists), so size them here too. + this._sizePortLabels(this.getPorts()); + this.on('ports:add', (_element: this, addedPorts: dia.Element.Port[]) => { + this._sizePortLabels(addedPorts); + }); + } + + private _sizePortLabels(ports: dia.Element.Port[]) { + ports.forEach((port) => { + const text = port.attrs?.text?.text; + if (!port.id || typeof text !== 'string') return; + this.portProp(port.id, 'label/size', estimatePortLabelSize(text)); + }); + } +} + +/** + * A service with a custom (per-instance) number of ports - `ports.items` + * always comes from the instance, replacing `Service`'s fixed pair. Its own + * `md-card--hub` outline (an emphasis color, not a shape change) sets it apart + * as a hub with several ports fanning in/out on the same side. + */ +export class HubService extends Service { + defaults() { + // `super.defaults` (unlike extending a built-in `shapes.standard.*` + // class, whose `defaults` is a plain object) is a method here too - + // it must be called to get the merged object, not just referenced. + return util.defaultsDeep({ + type: 'example.HubService', + attrs: { + body: { + class: 'md-card md-card--hub' + } + }, + ports: { + groups: { + in: { + markup: HUB_PORT_MARKUP, + size: HUB_PORT_SIZE, + attrs: HUB_PORT_ATTRS + }, + out: { + markup: HUB_PORT_MARKUP, + size: HUB_PORT_SIZE, + attrs: HUB_PORT_ATTRS + } + } + } + }, super.defaults()); + } +} + +/** + * A link with a labelled, pill-shaped Material "assist chip" background - only + * the label `text` is left for each instance to fill in. + */ +export class InteractionLink extends shapes.standard.Link { + defaults() { + return util.defaultsDeep({ + type: 'example.InteractionLink', + attrs: { + // A CSS class alone can't color this: the arrowhead is a separate + // `` def (in ``, so it isn't reached by a class on the + // line) whose own color JointJS derives from this `stroke` value - + // see `attributes/defs.mjs`'s `contextMarker()`. + line: { + stroke: '#78909C', + class: 'md-link' + } + }, + defaultLabel: { + size: { width: 80, height: 20 }, + inline: true, + attrs: { + text: { + class: 'md-chip-text' + }, + rect: { + ref: null, + x: 'calc(x - calc(w / 2))', + y: 'calc(y - calc(h / 2))', + width: 'calc(w)', + height: 'calc(h)', + rx: 'calc(h / 2)', + ry: 'calc(h / 2)', + class: 'md-chip-bg' + } + }, + position: 0.5 + } + }, super.defaults); + } +} diff --git a/examples/layout-elk-containers-ports-ts/src/styles.scss b/examples/layout-elk-containers-ports-ts/src/styles.scss new file mode 100644 index 0000000000..431995d02f --- /dev/null +++ b/examples/layout-elk-containers-ports-ts/src/styles.scss @@ -0,0 +1,161 @@ +// A restrained Material Design palette + elevation system, scoped under the +// `joint-theme-material` class every view gets from this example's own +// `setTheme('material')` call (`index.ts`) - JointJS's theme mechanism is just that +// class; giving it meaning is entirely up to this stylesheet (see `mvc.View#setTheme`). +// Applied almost entirely through the classes below (see `shapes.ts`) - JointJS attrs +// are only used where CSS alone cannot reach: geometry (`calc(...)`-driven `rx`/ +// `width`/...), text content, and the link's `stroke` (its arrowhead marker is a +// separate `` def that derives its own color from that attrs value, not +// from the `line`'s CSS - see `shapes.ts`'s `InteractionLink`). +:root { + --md-primary: #3F51B5; + --md-primary-tint: #E8EAF6; + --md-on-primary-tint: #283593; + --md-surface: #FFFFFF; + --md-surface-container: #F3F4F9; + --md-outline: #D0D3E3; + --md-on-surface: rgba(0, 0, 0, 0.87); + --md-on-surface-variant: rgba(0, 0, 0, 0.6); +} + +html, body { + margin: 0; + padding: 0; + font-family: 'Roboto', 'Segoe UI', sans-serif; +} + +#canvas { + position: absolute; + margin-top: 50px; + margin-left: 20px; + border: 1px solid var(--md-outline); + background-color: #FAFAFA; + overflow: hidden; +} + +.toolbar { + display: flex; + position: fixed; + width: 100%; + top: 10px; + margin-left: 30px; + text-align: center; + justify-content: left; + z-index: 1; +} + +.toolbar-button { + outline: none; + background: var(--md-surface); + border: none; + border-radius: 4px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2), 0 1px 1px rgba(0, 0, 0, 0.14); + text-align: center; + font-family: inherit; + font-size: 13px; + font-weight: 500; + text-transform: uppercase; + padding: 8px 14px; + letter-spacing: 0.4px; + color: var(--md-primary); + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + margin: 0 4px; + transition: box-shadow 0.15s ease, background 0.15s ease; + + &:hover { + background: var(--md-primary-tint); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.24), 0 1px 2px rgba(0, 0, 0, 0.16); + } +} + +// Everything from here on only ever applies to a `joint-theme-material` view. +.joint-theme-material { + + // Containers - a tonal surface (no shadow: Material's own elevated surfaces + // don't float above the canvas the way a card does, they just sit at a + // slightly different tone). Containment itself already reads as hierarchy, so + // nested and top-level containers share one treatment rather than a color per + // nesting depth. + .md-container { + fill: var(--md-surface-container); + stroke: var(--md-outline); + stroke-width: 1px; + stroke-dasharray: 4 3; + rx: 8px; + ry: 8px; + } + + .md-container-label { + font-family: inherit; + font-weight: 500; + font-size: 12px; + letter-spacing: 0.5px; + text-transform: uppercase; + fill: var(--md-on-surface-variant); + } + + // Service cards - a plain elevated surface (Material's shadow is two stacked + // shadows, a tighter "key" one and a softer, larger "ambient" one; `filter: + // drop-shadow(...)`, unlike `box-shadow`, works on SVG shapes, and stacks the + // same way across multiple `drop-shadow()`s in one `filter`). A hub gets the + // primary color as its own outline (an emphasis Material otherwise expresses + // via a "tonal"/"outlined" variant) plus one step more elevation, instead of a + // one-off fill color per shape. + .md-card { + fill: var(--md-surface); + stroke: var(--md-outline); + stroke-width: 1px; + rx: 6px; + ry: 6px; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.3)) drop-shadow(0 1px 3px rgba(0, 0, 0, 0.15)); + } + + .md-card--hub { + stroke: var(--md-primary); + stroke-width: 2px; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.3)) drop-shadow(0 2px 6px rgba(0, 0, 0, 0.18)); + } + + .md-card-label { + font-family: inherit; + font-weight: 500; + font-size: 13px; + fill: var(--md-on-surface); + } + + // Ports - one consistent treatment (a small primary-outlined dot) regardless + // of direction or shape (`HubService`'s square ports reuse the same classes). + .md-port { + fill: var(--md-surface); + stroke: var(--md-primary); + stroke-width: 2px; + } + + .md-port-label { + font-family: inherit; + font-size: 11px; + fill: var(--md-on-surface-variant); + } + + // Links - `stroke` itself stays an attrs value (see the file-level comment + // above); this class only covers what CSS can fully own. + .md-link { + stroke-linecap: round; + } + + // A link label rendered as a Material "assist chip" - a filled pill. + .md-chip-bg { + fill: var(--md-primary-tint); + } + + .md-chip-text { + font-family: inherit; + font-size: 11px; + font-weight: 500; + fill: var(--md-on-primary-tint); + } +} diff --git a/examples/layout-elk-containers-ports-ts/tsconfig.json b/examples/layout-elk-containers-ports-ts/tsconfig.json new file mode 100644 index 0000000000..5ac26d8fe9 --- /dev/null +++ b/examples/layout-elk-containers-ports-ts/tsconfig.json @@ -0,0 +1,18 @@ +{ + "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-containers-ports-ts/webpack.config.js b/examples/layout-elk-containers-ports-ts/webpack.config.js new file mode 100644 index 0000000000..6a72fec1a9 --- /dev/null +++ b/examples/layout-elk-containers-ports-ts/webpack.config.js @@ -0,0 +1,44 @@ +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, + }, +}; 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..23a202cf34 --- /dev/null +++ b/examples/layout-elk-default-ts/package.json @@ -0,0 +1,39 @@ +{ + "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:^" + }, + "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..9b003c2552 --- /dev/null +++ b/examples/layout-elk-default-ts/src/index.ts @@ -0,0 +1,112 @@ +import { dia, shapes, setTheme } from '@joint/core'; +import { layout } from '@joint/layout-elk'; +import { graphJSON } from './example'; +import { Container, Service, InteractionLink } from './shapes'; +import './styles.scss'; + +const cellNamespace = { + ...shapes, + example: { + Container, + Service, + InteractionLink + } +}; + +const init = () => { + + // Every view (paper and cells alike) picks up a `joint-theme-material` class - + // this example's own CSS gives that class its actual meaning (see `styles.scss`). + setTheme('material'); + + // Create JointJS graph and paper + const graph = new dia.Graph({}, { cellNamespace }); + const paper = new dia.Paper({ + model: graph, + cellViewNamespace: cellNamespace, + width: 900, + height: 600, + gridSize: 1, + interactive: false, + async: true, + frozen: true, + defaultConnector: { + name: 'straight', + args: { + cornerType: 'cubic', + cornerRadius: 5 + } + } + }); + document.getElementById('canvas')!.appendChild(paper.el); + addZoomAndPanListeners(paper); + + // Load the fixed example data. + graph.fromJSON(graphJSON); + + // No `exportElement`/`exportPort`/`setPortAttributes`/... callbacks - this is + // `layout()` at its simplest, with only plain ELK layout options passed through. + // Containers, ports and link labels are all laid out from this package's own + // defaults alone - and with no `elk` option, ELK runs on the main thread (the + // other ELK examples run it in a Web Worker, see `createWorkerElk()`). + layout({ graph }, { + elkLayoutOptions: { + // `'elk.algorithm': 'layered'` is the package's default - not repeated here. + 'elk.direction': 'RIGHT', + 'elk.edgeRouting': 'ORTHOGONAL', + 'elk.spacing.nodeNode': '30', + 'elk.layered.spacing.nodeNodeBetweenLayers': '50' + }, + }).then(() => { + paper.unfreeze(); + zoom(paper, 1); + }).catch((error) => { + paper.unfreeze(); + console.error('ELK layout error:', error.message); + }); +}; + +function zoom(paper: dia.Paper, zoomLevel: number): void { + paper.scale(zoomLevel); + paper.fitToContent({ + useModelGeometry: true, + padding: 40 * zoomLevel, + allowNewOrigin: 'any' + }); +} + +/** + * Add toolbar zoom in/out listeners to the paper and setup panning. + */ +function addZoomAndPanListeners(paper: dia.Paper): void { + + let zoomLevel = paper.scale().sx; + + document.getElementById('zoom-in')!.addEventListener('click', () => { + zoomLevel = Math.min(3, zoomLevel + 0.2); + zoom(paper, zoomLevel); + }); + + document.getElementById('zoom-out')!.addEventListener('click', () => { + zoomLevel = Math.max(0.2, zoomLevel - 0.2); + zoom(paper, zoomLevel); + }); + + paper.on('blank:pointerdown', (evt) => { + evt.data = { + scrollX: window.scrollX, + clientX: evt.clientX, + scrollY: window.scrollY, + clientY: evt.clientY + }; + }); + + paper.on('blank:pointermove', (evt) => { + window.scroll( + evt.data.scrollX + (evt.data.clientX - evt.clientX!), + evt.data.scrollY + (evt.data.clientY - evt.clientY!) + ); + }); +} + +init(); diff --git a/examples/layout-elk-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..fc95bbbb97 --- /dev/null +++ b/examples/layout-elk-default-ts/webpack.config.js @@ -0,0 +1,44 @@ +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 - main-thread ELK is a chunk 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, + }, +}; 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..4d4298195b --- /dev/null +++ b/examples/layout-elk-flowchart-ts/package.json @@ -0,0 +1,39 @@ +{ + "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 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..00a221208a --- /dev/null +++ b/examples/layout-elk-flowchart-ts/src/index.ts @@ -0,0 +1,463 @@ +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 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..5ac26d8fe9 --- /dev/null +++ b/examples/layout-elk-flowchart-ts/tsconfig.json @@ -0,0 +1,18 @@ +{ + "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 new file mode 100644 index 0000000000..6a72fec1a9 --- /dev/null +++ b/examples/layout-elk-flowchart-ts/webpack.config.js @@ -0,0 +1,44 @@ +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, + }, +}; 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..5758314958 --- /dev/null +++ b/examples/layout-elk-rectpacking-ts/package.json @@ -0,0 +1,39 @@ +{ + "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:^" + }, + "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..9f9f7a02e6 --- /dev/null +++ b/examples/layout-elk-rectpacking-ts/src/index.ts @@ -0,0 +1,232 @@ +import { dia, g, shapes, setTheme, util } from '@joint/core'; +import { createWorkerElk, layout } from '@joint/layout-elk'; +import { graphJSON, createFileJSON, type FileKind } from './example'; +import { Folder, FileTile, FOLDER_PADDING } from './shapes'; +import './styles.scss'; + +import type { + ElkLayoutOptions, + ExportElementCallback, + 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' +}; + +// ELK runs in a Web Worker, so a layout doesn't block the page - the worker is started on +// the first layout, then shared by every later one. +const elk = createWorkerElk(() => new Worker(new URL('@joint/layout-elk/worker', import.meta.url), { type: 'module' })); + +const init = () => { + + setTheme('material'); + + const graph = new dia.Graph({}, { cellNamespace }); + const paper = new dia.Paper({ + model: graph, + cellViewNamespace: cellNamespace, + width: 900, + height: 600, + gridSize: 1, + interactive: false, + async: true, + frozen: true + }); + document.getElementById('canvas')!.appendChild(paper.el); + + graph.fromJSON(graphJSON); + + const controls = getControls(); + + // The `rectpacking` options, read from the toolbar. ELK doesn't inherit + // layout options down the hierarchy, so these go both on the root (to pack + // the folders) and on every folder (to pack its files) - see `exportElement`. + const getPackingOptions = (): ElkLayoutOptions => ({ + 'elk.algorithm': 'rectpacking', + 'elk.aspectRatio': controls.aspectRatio.value as `${number}`, + 'elk.rectpacking.widthApproximation.optimizationGoal': controls.optimizationGoal.value, + 'elk.rectpacking.orderBySize': `${controls.orderBySize.checked}` + }); + + // Every folder is packed on its own (its size then fixed), before the root + // packs the folders - `@joint/layout-elk`'s default `INCLUDE_CHILDREN` is meant + // for edges crossing container boundaries, and `rectpacking` has no edges. + const getRootOptions = (): ElkLayoutOptions => ({ + ...getPackingOptions(), + 'elk.hierarchyHandling': 'SEPARATE_CHILDREN', + 'elk.spacing.nodeNode': '20', + 'elk.padding': '[top=0,left=0,bottom=0,right=0]' + }); + + const exportElement: ExportElementCallback = ({ element, elkNode }) => { + if (element instanceof Folder) { + const { top, left, bottom, right } = FOLDER_PADDING; + Object.assign(elkNode.layoutOptions, getPackingOptions(), { + 'elk.spacing.nodeNode': '6', + 'elk.padding': `[top=${top},left=${left},bottom=${bottom},right=${right}]`, + // Applied to the files only, not to the root: stretching a folder + // (already packed on its own) would just leave empty space inside it. + 'elk.rectpacking.whiteSpaceElimination.strategy': controls.whiteSpaceElimination.value + }); + return; + } + // White space elimination stretches tiles - start every layout from the + // tile's original size, not from whatever the previous layout made of it. + const { width, height } = element.get('baseSize'); + elkNode.width = width; + elkNode.height = height; + }; + + // `@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; + do { + pending = false; + try { + const { bbox } = await layout({ graph }, { + elk, + elkLayoutOptions: getRootOptions(), + exportElement, + setElementAttributes + }); + contentArea = bbox; + paper.unfreeze(); + fit(); + } catch (error) { + // A failed layout doesn't drop a change made meanwhile - the loop goes on. + paper.unfreeze(); + console.error('ELK layout error:', (error as Error).message); + } + } while (pending); + running = false; + }; + + controls.aspectRatio.addEventListener('input', () => { + controls.aspectRatioValue.textContent = Number(controls.aspectRatio.value).toFixed(1); + runLayout(); + }); + controls.optimizationGoal.addEventListener('change', runLayout); + controls.whiteSpaceElimination.addEventListener('change', runLayout); + controls.orderBySize.addEventListener('change', runLayout); + + // `rectpacking` packs in model order (unless ordering by size) - so a new + // order of the same files gives a different packing. + document.getElementById('shuffle')!.addEventListener('click', () => { + const folders = shuffle(graph.getElements().filter((element) => element instanceof Folder)); + const cells: dia.Cell[] = folders.flatMap((folder) => [folder, ...shuffle(folder.getEmbeddedCells())]); + // The graph orders cells (and so `getEmbeddedCells()`) by `z` - a reset rebuilds + // that order. Each folder still comes right before its own files. + cells.forEach((cell, index) => cell.set('z', index + 1)); + graph.resetCells(cells); + runLayout(); + }); + + let fileCount = 0; + document.getElementById('add-file')!.addEventListener('click', () => { + const [folder] = shuffle(graph.getElements().filter((element) => element instanceof Folder)); + const kind: FileKind = folder.get('kind'); + const tile = new FileTile(createFileJSON(`${folder.id}`, kind, { + name: `new-${++fileCount}.${FILE_EXTENSIONS[kind]}`, + sizeMB: Math.round(10 + Math.random() * 150), + ratio: [0.75, 1, 1.5, 1.8][Math.floor(Math.random() * 4)] + })); + // Appears at its folder's corner, then moves to wherever ELK packs it. + tile.set({ + // 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..5ac26d8fe9 --- /dev/null +++ b/examples/layout-elk-rectpacking-ts/tsconfig.json @@ -0,0 +1,18 @@ +{ + "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-rectpacking-ts/webpack.config.js b/examples/layout-elk-rectpacking-ts/webpack.config.js new file mode 100644 index 0000000000..6a72fec1a9 --- /dev/null +++ b/examples/layout-elk-rectpacking-ts/webpack.config.js @@ -0,0 +1,44 @@ +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, + }, +}; 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/package.json b/examples/layout-elk-ts/package.json index 1706be066c..88ab781867 100644 --- a/examples/layout-elk-ts/package.json +++ b/examples/layout-elk-ts/package.json @@ -19,7 +19,7 @@ }, "dependencies": { "@joint/core": "workspace:^", - "elkjs": "^0.11.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 551512f4c5..45b3a16236 100644 --- a/examples/layout-elk-ts/src/index.ts +++ b/examples/layout-elk-ts/src/index.ts @@ -1,17 +1,17 @@ -import { dia, shapes, util, g } from '@joint/core'; -import ELK from 'elkjs/lib/elk-api.js'; -import type { ElkNode, ElkExtendedEdge, ElkLabel } from 'elkjs/lib/elk-api.d.ts'; +import { dia, shapes, g } from '@joint/core'; +import { createWorkerElk, layout } from '@joint/layout-elk'; import dependenciesJSON from './dependencies.json'; import './styles.scss'; -type Require = T & { [P in K]-?: T[P] }; -type ElkGraph = Require; - const colors = ['#F8FCDA', '#E3E9C2', '#F9FBB2', '#C89F9C']; const ELK_DIRECTION = 'RIGHT'; const DEFAULT_LABEL_WIDTH = 50; const DEFAULT_LABEL_HEIGHT = 20; +// ELK runs in a Web Worker, so a layout doesn't block the page - the worker is started on +// the first layout, then shared by every later one. +const elk = createWorkerElk(() => new Worker(new URL('@joint/layout-elk/worker', import.meta.url), { type: 'module' })); + const init = () => { // Create JointJS graph and paper @@ -42,17 +42,65 @@ const init = () => { // Generate JointJS cells from example data generateCells(dependenciesJSON, graph); - // Perform ELK layout - const elk = new ELK({ - workerUrl: '../node_modules/elkjs/lib/elk-worker.js', - }); - elk.layout(getElkGraph(graph)).then((elkGraph: ElkGraph) => { - updateGraph(elkGraph, graph); + layout({ graph }, { + elk, + elkLayoutOptions: { + /** + * Overall direction of the layout. + * 'UP' | 'DOWN' | 'LEFT' | 'RIGHT' + */ + 'elk.direction': ELK_DIRECTION, + + /** + * Spacing between nodes (siblings). + * A number value as a string. + */ + 'elk.spacing.nodeNode': '20', + + /** + * Spacing between layers (for layered algorithm). + * A number value as a string. + */ + 'elk.layered.spacing.nodeNodeBetweenLayers': '50', + + /** + * Edge routing style. + * 'ORTHOGONAL' | 'SPLINES' | 'POLYLINE' + */ + 'elk.edgeRouting': 'ORTHOGONAL', + + /** + * Node placement strategy for layered layout. + * 'SIMPLE' | 'BRANDES_KOEPF' | 'INTERACTIVE' | 'LINEAR_SEGMENTS' | 'NETWORK_SIMPLEX' + */ + 'elk.layered.nodePlacement.strategy': 'NETWORK_SIMPLEX', + + /** + * Merging edges that share the same source and target nodes into a single edge. + * 'true' | 'false' + */ + 'elk.layered.mergeEdges': 'false', + + /** + * Distance between edge labels and the edge itself. + * A number value as a string. + */ + 'elk.spacing.edgeLabel': '3', + + /** + * Enable partitioning i.e., assigning nodes to layers. You need to add + * `partitioning.partition` attribute to nodes for this to take effect. + * 'true' | 'false' + */ + 'elk.partitioning.activate': 'false', + } + }).then(() => { paper.unfreeze(); zoom(paper, 1); // Scroll into a busy area of the example window.scroll(650, 560); }).catch((error) => { + paper.unfreeze(); console.error('ELK layout error:', error.message); }); }; @@ -94,8 +142,8 @@ function addZoomAndPanListeners(paper: dia.Paper): void { paper.on('blank:pointermove', (evt) => { window.scroll( - evt.data.scrollX + (evt.data.clientX - evt.clientX), - evt.data.scrollY + (evt.data.clientY - evt.clientY) + evt.data.scrollX + (evt.data.clientX - evt.clientX!), + evt.data.scrollY + (evt.data.clientY - evt.clientY!) ); }); } @@ -169,7 +217,7 @@ function generateCells( graph: dia.Graph ): void { const elementMap = new Map(); - const cells = []; + const cells: dia.Cell[] = []; dependencies.forEach((dep) => { // The ELK graph uses string IDs const sourceId = `${dep.source}`; @@ -206,215 +254,4 @@ function generateCells( graph.resetCells(cells); } -/** - * Converts JointJS graph to ELK graph structure. - * @param {dia.Graph} graph - * @returns {Object} ELK graph structure - */ -function getElkGraph(graph: dia.Graph): ElkGraph { - const elkGraph: ElkGraph = { - id: 'root', - layoutOptions: { - /** - * Layout algorithm to use. - * 'box' | 'layered' | 'mrtree' | 'radial' | 'force' - */ - 'elk.algorithm': 'layered', - - /** - * Overall direction of the layout. - * 'UP' | 'DOWN' | 'LEFT' | 'RIGHT' - */ - 'elk.direction': ELK_DIRECTION, - - /** - * Spacing between nodes (siblings). - * A number value as a string. - */ - 'elk.spacing.nodeNode': '20', - - /** - * Spacing between layers (for layered algorithm). - * A number value as a string. - */ - 'elk.layered.spacing.nodeNodeBetweenLayers': '50', - - /** - * Edge routing style. - * 'ORTHOGONAL' | 'SPLINES' | 'POLYLINE' - */ - 'elk.edgeRouting': 'ORTHOGONAL', - - /** - * Node placement strategy for layered layout. - * 'SIMPLE' | 'BRANDES_KOEPF' | 'INTERACTIVE' | 'LINEAR_SEGMENTS' | 'NETWORK_SIMPLEX' - */ - 'elk.layered.nodePlacement.strategy': 'NETWORK_SIMPLEX', - - /** - * Merging edges that share the same source and target nodes into a single edge. - * 'true' | 'false' - */ - 'elk.layered.mergeEdges': 'false', - - /** - * Distance between edge labels and the edge itself. - * A number value as a string. - */ - 'elk.spacing.edgeLabel': '3', - - /** - * Enable partitioning i.e., assigning nodes to layers. You need to add - * `partitioning.partition` attribute to nodes for this to take effect. - * 'true' | 'false' - */ - 'elk.partitioning.activate': 'false', - - // Does not seem to work as expected: - // 'elk.layered.edgeLabels.centerLabelPlacementStrategy': 'HEAD_LAYER', - // 'elk.edgeLabels.placement': 'TAIL' - }, - children: [], - edges: [] - }; - - graph.getElements().forEach((element) => { - const size = element.size(); - const elkNode: ElkNode = { - id: `${element.id}`, - width: size.width, - height: size.height, - ports: [], - children: [] - }; - elkGraph.children.push(elkNode); - }); - - graph.getLinks().forEach((link) => { - const sourceId = `${link.source().id}`; - const targetId = `${link.target().id}`; - if (!sourceId || !targetId) { - return; // Skip if source or target is not defined - } - elkGraph.edges.push({ - id: `${link.id}`, - sources: [sourceId], - targets: [targetId], - labels: link.labels().map((label) => ({ - text: '-', // some text is required (ELK ignores empty labels) - width: label.size?.width || DEFAULT_LABEL_WIDTH, - height: label.size?.height || DEFAULT_LABEL_HEIGHT, - layoutOptions: { - // Place label directly on the edge. - 'edgeLabels.inline': 'true', - - // This works, but does not allocate space for the label - // 'edgeLabels.placement': 'HEAD' // 'CENTER' | 'HEAD' | 'TAIL' - } - })) - }); - }); - - return elkGraph; -} - -/** - * Update JointJS graph based on ELK layout result. - */ -function updateGraph(elkGraph: ElkGraph, graph: dia.Graph): void { - updateElements(elkGraph.children, graph); - updateLinks(elkGraph.edges, graph); -} - -/** - * Update JointJS elements based on ELK node layout. - */ -function updateElements(nodes: ElkNode[], graph: dia.Graph): void { - for (const node of nodes) { - const el = graph.getCell(node.id) as dia.Element; - el.position(node.x, node.y); - } -} - -/** - * Update JointJS links based on ELK edge layout. - */ -function updateLinks(edges: ElkExtendedEdge[], graph: dia.Graph): void { - for (const edge of edges) { - const { sections, labels: edgeLabels } = edge; - if (!sections) continue; - const linkAttributes: dia.Link.Attributes = {}; - const [{ bendPoints = [], endPoint, startPoint }] = sections; - // Update link vertices (bend points) - linkAttributes.vertices = bendPoints; - // Update link source and target anchors (startPoint, endPoint) - const link = graph.getCell(edge.id) as dia.Link; - linkAttributes.source = getLinkEnd(link.getSourceElement(), startPoint); - linkAttributes.target = getLinkEnd(link.getTargetElement(), endPoint); - // Update link labels positions - if (edgeLabels) { - const polyline = new g.Polyline([startPoint, ...bendPoints, endPoint]); - linkAttributes.labels = getLinkLabels(link, edgeLabels, polyline); - } - // Apply the updated attributes to the link - link.set(linkAttributes); - } -} - -/** - * Convert absolute label position to relative position on the link polyline. - */ -function getLinkLabelPosition( - polyline: g.Polyline, - edgeLabel: ElkLabel -): dia.Link.LabelPosition { - const labelPosition = { - x: edgeLabel.x + edgeLabel.width / 2, - y: edgeLabel.y + edgeLabel.height / 2 - }; - const length = polyline.closestPointLength(labelPosition); - const closestPoint = polyline.pointAtLength(length); - const distance = (length / polyline.length()); - const offset = new g.Point(labelPosition).difference(closestPoint).toJSON(); - return { - distance: distance, - offset: offset - }; -} - -/** - * Get link end definition for given element and absolute end point. - */ -function getLinkEnd( - endElement: dia.Element, - endPoint: dia.Point -): dia.Link.EndJSON { - const delta = endElement.getRelativePointFromAbsolute(endPoint); - return { - id: endElement.id, - anchor: { - name: 'topLeft', - args: { - dx: delta.x, - dy: delta.y, - useModelGeometry: true - } - } - }; -} - -function getLinkLabels( - link: dia.Link, - edgeLabels: ElkLabel[], - polyline: g.Polyline -): dia.Link.Label[] { - const labels = util.cloneDeep(link.labels()); - edgeLabels.forEach((edgeLabel, index) => { - // Note: If the diagram is meant to stay static, - // we could also create JointJS elements instead of using link labels. - labels[index].position = getLinkLabelPosition(polyline, edgeLabel); - }); - return labels; -} - init(); diff --git a/examples/layout-elk-ts/tsconfig.json b/examples/layout-elk-ts/tsconfig.json index 39951cb836..5ac26d8fe9 100644 --- a/examples/layout-elk-ts/tsconfig.json +++ b/examples/layout-elk-ts/tsconfig.json @@ -1,10 +1,17 @@ { "compilerOptions": { - "module": "commonjs", - "target": "es5", + "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-ts/webpack.config.js b/examples/layout-elk-ts/webpack.config.js index 525bf11dc1..6a72fec1a9 100644 --- a/examples/layout-elk-ts/webpack.config.js +++ b/examples/layout-elk-ts/webpack.config.js @@ -8,11 +8,19 @@ module.exports = { output: { filename: 'bundle.js', path: path.resolve(__dirname, 'dist'), - publicPath: '/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, @@ -28,6 +36,9 @@ module.exports = { static: { directory: __dirname, }, + devMiddleware: { + publicPath: '/dist/', + }, compress: true, }, }; diff --git a/package.json b/package.json index 1dd4762cd0..5ea6857c58 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "test-e2e-all": "yarn workspaces foreach --all -tvv run test-e2e-all", "lint": "yarn workspaces foreach --all -tvv run lint", "lint-fix": "yarn workspaces foreach --all -tvv run lint-fix", - "pack-all": "yarn workspaces foreach --all -tvv --include \"@joint/core\" --include \"@joint/layout-directed-graph\" --include \"@joint/layout-msagl\" --include \"@joint/router-avoid\" pack --out %s-%v.tgz" + "pack-all": "yarn workspaces foreach --all -tvv --include \"@joint/core\" --include \"@joint/layout-directed-graph\" --include \"@joint/layout-msagl\" --include \"@joint/router-avoid\" --include \"@joint/layout-elk\" pack --out %s-%v.tgz" }, "devDependencies": { "@changesets/cli": "3.0.0" diff --git a/packages/joint-core/src/dia/Link.mjs b/packages/joint-core/src/dia/Link.mjs index 8071f85ad9..183edccfc0 100644 --- a/packages/joint-core/src/dia/Link.mjs +++ b/packages/joint-core/src/dia/Link.mjs @@ -1,6 +1,7 @@ import { Cell } from './Cell.mjs'; -import { clone, isPlainObject, isFunction, isString, isNumber } from '../util/index.mjs'; +import { clone, isPlainObject, isFunction, isString, isNumber, assign } from '../util/index.mjs'; import { Point, Polyline } from '../g/index.mjs'; +import { getComputedLabel } from './link-labels.mjs'; // Link base model. // -------------------------- @@ -55,7 +56,13 @@ export const Link = Cell.extend({ // used if neither defaultLabel.position // nor label.position is set position: { - distance: 0.5 + distance: 0.5, + offset: 0, + angle: 0 + }, + size: { + width: 0, + height: 0 } } }, @@ -216,6 +223,31 @@ export const Link = Cell.extend({ return this.prop(['labels', idx], label, opt); }, + /** + * Returns the label at the given index, resolved against the link's `defaultLabel` + * and the built-in default label. + * + * Unlike the `label()` getter, which returns the label exactly as stored, the result + * has `markup`, `attrs`, `size` and `position` merged with the defaults. + * The stored label is not modified. + * + * @param {number} [idx=0] - The index of the label. Negative values count from the end. + * @returns {dia.Link.ComputedLabel | null} A new object with the resolved label, or `null` + * if there is no label at `idx`. + */ + getComputedLabel: function(idx) { + + const labels = this.labels(); + + let index = (isFinite(idx) && idx !== null) ? (idx | 0) : 0; + if (index < 0) { + index = labels.length + index; + } + + const label = labels[index]; + return (label === undefined) ? null : getComputedLabel(this, label); + }, + labels: function(labels, opt) { // getter @@ -229,6 +261,22 @@ export const Link = Cell.extend({ return this.set('labels', labels, opt); }, + /** + * Returns all labels of the link, each resolved against the link's `defaultLabel` + * and the built-in default label. + * + * Unlike the `labels()` getter, which returns the labels exactly as stored, each + * has `markup`, `attrs`, `size` and `position` merged with the defaults. + * The stored labels are not modified. + * + * @returns {dia.Link.ComputedLabel[]} A new array of resolved labels, in the same order as + * `labels()`. Empty if the link has no labels. + */ + getComputedLabels: function() { + + return this.labels().map((label) => getComputedLabel(this, label)); + }, + hasLabels: function() { const { labels } = this.attributes; return Array.isArray(labels) && labels.length > 0; @@ -541,16 +589,15 @@ export const Link = Cell.extend({ return !!ancestor && (ancestor.id === cellId || ancestor.isEmbeddedIn(cellId)); }, - // Get resolved default label. + // Get resolved default label. Kept as-is (including any custom property, e.g. a + // `@joint/layout-elk` `elkLayoutOptions`) - not just the known `markup`/`attrs`/ + // `size`/`position` - so `getComputedLabel` (`link-labels.mjs`) can pass it through too. _getDefaultLabel: function() { - var defaultLabel = this.get('defaultLabel') || this.defaultLabel || {}; + const defaultLabel = this.get('defaultLabel') || this.defaultLabel || {}; - var label = {}; + const label = assign({}, defaultLabel); label.markup = defaultLabel.markup || this.get('labelMarkup') || this.labelMarkup; - label.position = defaultLabel.position; - label.attrs = defaultLabel.attrs; - label.size = defaultLabel.size; return label; } diff --git a/packages/joint-core/src/dia/LinkView.mjs b/packages/joint-core/src/dia/LinkView.mjs index 05c2a57a94..8f05f9bd9a 100644 --- a/packages/joint-core/src/dia/LinkView.mjs +++ b/packages/joint-core/src/dia/LinkView.mjs @@ -331,7 +331,9 @@ export const LinkView = CellView.extend({ var labelCache = this._labelCache = {}; var labelSelectors = this._labelSelectors = {}; var model = this.model; - var labels = model.attributes.labels || []; + // Resolved (see `Link#getComputedLabels`) - `label.markup` is always set (falls + // back all the way to the built-in default), so there's no need to resolve it again here. + var labels = model.getComputedLabels(); var labelsCount = labels.length; if (labelsCount === 0) { @@ -355,24 +357,8 @@ export const LinkView = CellView.extend({ var label = labels[i]; var labelMarkup = this._normalizeLabelMarkup(this._getLabelMarkup(label.markup)); - var labelNode; - var selectors; - if (labelMarkup) { - - labelNode = labelMarkup.node; - selectors = labelMarkup.selectors; - - } else { - - var builtinDefaultLabel = model._builtins.defaultLabel; - var builtinDefaultLabelMarkup = this._normalizeLabelMarkup(this._getLabelMarkup(builtinDefaultLabel.markup)); - var defaultLabel = model._getDefaultLabel(); - var defaultLabelMarkup = this._normalizeLabelMarkup(this._getLabelMarkup(defaultLabel.markup)); - var defaultMarkup = defaultLabelMarkup || builtinDefaultLabelMarkup; - - labelNode = defaultMarkup.node; - selectors = defaultMarkup.selectors; - } + var labelNode = labelMarkup.node; + var selectors = labelMarkup.selectors; labelNode.setAttribute('label-idx', i); // assign label-idx vLabels.append(labelNode); @@ -428,44 +414,6 @@ export const LinkView = CellView.extend({ return node; }, - // merge default label attrs into label attrs (or use built-in default label attrs if neither is provided) - // keep `undefined` or `null` because `{}` means something else - _mergeLabelAttrs: function(hasCustomMarkup, labelAttrs, defaultLabelAttrs, builtinDefaultLabelAttrs) { - - if (labelAttrs === null) return null; - if (labelAttrs === undefined) { - - if (defaultLabelAttrs === null) return null; - if (defaultLabelAttrs === undefined) { - - if (hasCustomMarkup) return undefined; - return builtinDefaultLabelAttrs; - } - - if (hasCustomMarkup) return defaultLabelAttrs; - return merge({}, builtinDefaultLabelAttrs, defaultLabelAttrs); - } - - if (hasCustomMarkup) return merge({}, defaultLabelAttrs, labelAttrs); - return merge({}, builtinDefaultLabelAttrs, defaultLabelAttrs, labelAttrs); - }, - - // merge default label size into label size (no built-in default) - // keep `undefined` or `null` because `{}` means something else - _mergeLabelSize: function(labelSize, defaultLabelSize) { - - if (labelSize === null) return null; - if (labelSize === undefined) { - - if (defaultLabelSize === null) return null; - if (defaultLabelSize === undefined) return undefined; - - return defaultLabelSize; - } - - return merge({}, defaultLabelSize, labelSize); - }, - updateLabels: function() { if (!this._V.labels) return this; @@ -477,44 +425,21 @@ export const LinkView = CellView.extend({ this.cleanNodeCache(this.el); } - var model = this.model; - var labels = model.get('labels') || []; + // Resolved (see `Link#getComputedLabels`) - `attrs`/`size` are already merged with + // `defaultLabel`/the built-in default. + var labels = this.model.getComputedLabels(); var canLabelMove = this.can('labelMove'); - var builtinDefaultLabel = model._builtins.defaultLabel; - var builtinDefaultLabelAttrs = builtinDefaultLabel.attrs; - - var defaultLabel = model._getDefaultLabel(); - var defaultLabelMarkup = defaultLabel.markup; - var defaultLabelAttrs = defaultLabel.attrs; - var defaultLabelSize = defaultLabel.size; - for (var i = 0, n = labels.length; i < n; i++) { var labelNode = this._labelCache[i]; labelNode.setAttribute('cursor', (canLabelMove ? 'move' : 'default')); var selectors = this._labelSelectors[i]; - var label = labels[i]; - var labelMarkup = label.markup; - var labelAttrs = label.attrs; - var labelSize = label.size; - - var attrs = this._mergeLabelAttrs( - (labelMarkup || defaultLabelMarkup), - labelAttrs, - defaultLabelAttrs, - builtinDefaultLabelAttrs - ); - var size = this._mergeLabelSize( - labelSize, - defaultLabelSize - ); - - this.updateDOMSubtreeAttributes(labelNode, attrs, { - rootBBox: new Rect(size), + this.updateDOMSubtreeAttributes(labelNode, label.attrs, { + rootBBox: new Rect(label.size), selectors: selectors }); } @@ -860,42 +785,6 @@ export const LinkView = CellView.extend({ return this.isIntersecting(area); }, - // combine default label position with built-in default label position - _getDefaultLabelPositionProperty: function() { - - var model = this.model; - - var builtinDefaultLabel = model._builtins.defaultLabel; - var builtinDefaultLabelPosition = builtinDefaultLabel.position; - - var defaultLabel = model._getDefaultLabel(); - var defaultLabelPosition = this._normalizeLabelPosition(defaultLabel.position); - - return merge({}, builtinDefaultLabelPosition, defaultLabelPosition); - }, - - // if label position is a number, normalize it to a position object - // this makes sure that label positions can be merged properly - _normalizeLabelPosition: function(labelPosition) { - - if (typeof labelPosition === 'number') return { distance: labelPosition, offset: null, angle: 0, args: null }; - return labelPosition; - }, - - // expects normalized position properties - // e.g. `this._normalizeLabelPosition(labelPosition)` and `this._getDefaultLabelPositionProperty()` - _mergeLabelPositionProperty: function(normalizedLabelPosition, normalizedDefaultLabelPosition) { - - if (normalizedLabelPosition === null) return null; - if (normalizedLabelPosition === undefined) { - - if (normalizedDefaultLabelPosition === null) return null; - return normalizedDefaultLabelPosition; - } - - return merge({}, normalizedDefaultLabelPosition, normalizedLabelPosition); - }, - updateLabelPositions: function() { if (!this._V.labels) return this; @@ -906,18 +795,15 @@ export const LinkView = CellView.extend({ // This method assumes all the label nodes are stored in the `this._labelCache` hash table // by their indices in the `this.get('labels')` array. This is done in the `renderLabels()` method. - var model = this.model; - var labels = model.get('labels') || []; + // Resolved (see `Link#getComputedLabels`) - `position` is already merged with + // `defaultLabel`/the built-in default. + var labels = this.model.getComputedLabels(); if (!labels.length) return this; - var defaultLabelPosition = this._getDefaultLabelPositionProperty(); - for (var idx = 0, n = labels.length; idx < n; idx++) { var labelNode = this._labelCache[idx]; if (!labelNode) continue; - var label = labels[idx]; - var labelPosition = this._normalizeLabelPosition(label.position); - var position = this._mergeLabelPositionProperty(labelPosition, defaultLabelPosition); + var position = labels[idx].position; var transformationMatrix = this._getLabelTransformationMatrix(position); labelNode.setAttribute('transform', V.matrixToTransformString(transformationMatrix)); this._cleanLabelMatrices(idx); @@ -947,23 +833,8 @@ export const LinkView = CellView.extend({ } }, - _getLabelPositionProperty: function(idx) { - - return (this.model.label(idx).position || {}); - }, - - _getLabelPositionAngle: function(idx) { - - var labelPosition = this._getLabelPositionProperty(idx); - return (labelPosition.angle || 0); - }, - - _getLabelPositionArgs: function(idx) { - - var labelPosition = this._getLabelPositionProperty(idx); - return labelPosition.args; - }, - + // Used by `addLabel` below, for a not-yet-existing label - unlike `getComputedLabel` + // (`link-labels.mjs`), does not need the built-in default (which never has `position.args`). _getDefaultLabelPositionArgs: function() { var defaultLabel = this.model._getDefaultLabel(); @@ -1360,33 +1231,29 @@ export const LinkView = CellView.extend({ return position; }, + // Expects a label position object (e.g. `Link#getComputedLabel(idx).position`). _getLabelTransformationMatrix: function(labelPosition) { - var labelDistance; - var labelAngle = 0; - var args = {}; - if (typeof labelPosition === 'number') { - labelDistance = labelPosition; - } else if (typeof labelPosition.distance === 'number') { - args = labelPosition.args || {}; - labelDistance = labelPosition.distance; - labelAngle = labelPosition.angle || 0; - } else { + if (!labelPosition) { + throw new Error('dia.LinkView: invalid label position.'); + } + + var labelDistance = labelPosition.distance; + if (typeof labelDistance !== 'number') { throw new Error('dia.LinkView: invalid label position distance.'); } - var isDistanceRelative = ((labelDistance > 0) && (labelDistance <= 1)); + var labelAngle = labelPosition.angle || 0; + var args = labelPosition.args || {}; - var labelOffset = 0; - var labelOffsetCoordinates = { x: 0, y: 0 }; - if (labelPosition.offset) { - var positionOffset = labelPosition.offset; - if (typeof positionOffset === 'number') labelOffset = positionOffset; - if (positionOffset.x) labelOffsetCoordinates.x = positionOffset.x; - if (positionOffset.y) labelOffsetCoordinates.y = positionOffset.y; - } + var isDistanceRelative = ((labelDistance > 0) && (labelDistance <= 1)); - var isOffsetAbsolute = ((labelOffsetCoordinates.x !== 0) || (labelOffsetCoordinates.y !== 0) || labelOffset === 0); + // a number offset is perpendicular to the path, an `{ x, y }` offset is absolute + var labelOffset = labelPosition.offset || 0; + var isOffsetAbsolute = (typeof labelOffset !== 'number'); + var labelOffsetCoordinates = isOffsetAbsolute + ? { x: labelOffset.x || 0, y: labelOffset.y || 0 } + : { x: 0, y: 0 }; var isKeepGradient = args.keepGradient; var isEnsureLegibility = args.ensureLegibility; @@ -1602,26 +1469,19 @@ export const LinkView = CellView.extend({ var labelNode = evt.currentTarget; var labelIdx = parseInt(labelNode.getAttribute('label-idx'), 10); - var defaultLabelPosition = this._getDefaultLabelPositionProperty(); - var initialLabelPosition = this._normalizeLabelPosition(this._getLabelPositionProperty(labelIdx)); - var position = this._mergeLabelPositionProperty(initialLabelPosition, defaultLabelPosition); + var position = this.model.getComputedLabel(labelIdx).position; var coords = this.getLabelCoordinates(position); var dx = coords.x - x; // how much needs to be added to cursor x to get to label x var dy = coords.y - y; // how much needs to be added to cursor y to get to label y - var positionAngle = this._getLabelPositionAngle(labelIdx); - var labelPositionArgs = this._getLabelPositionArgs(labelIdx); - var defaultLabelPositionArgs = this._getDefaultLabelPositionArgs(); - var positionArgs = this._mergeLabelPositionArgs(labelPositionArgs, defaultLabelPositionArgs); - this.eventData(evt, { action: 'label-move', labelIdx: labelIdx, dx: dx, dy: dy, - positionAngle: positionAngle, - positionArgs: positionArgs, + positionAngle: position.angle || 0, + positionArgs: position.args, stopPropagation: true }); diff --git a/packages/joint-core/src/dia/link-labels.mjs b/packages/joint-core/src/dia/link-labels.mjs new file mode 100644 index 0000000000..334ad038c9 --- /dev/null +++ b/packages/joint-core/src/dia/link-labels.mjs @@ -0,0 +1,83 @@ +import { cloneDeep, merge } from '../util/index.mjs'; + +// A label as given (own `markup`/`attrs`/`size`/`position`, any of which may be missing), +// resolved against `link`'s `defaultLabel` and its built-in default. +export function getComputedLabel(link, label) { + + label = label || {}; + + const builtinDefaultLabel = link._builtins.defaultLabel; + const defaultLabel = link._getDefaultLabel(); + + // A label's own or `defaultLabel`'s markup, if either is set, is "custom" - the + // built-in default attrs (`builtinDefaultLabelAttrs`) only make sense for the + // built-in markup, so they don't apply once a custom one is in play. + const hasCustomMarkup = !!(label.markup || defaultLabel.markup); + + // The resolved `markup`/`attrs`/`size`/`position` are always new objects - never the + // stored label's, `defaultLabel`'s or the (shared by all links) built-in default's - + // so mutating a computed label can't change them. Custom properties are passed through as-is. + return Object.assign({}, defaultLabel, label, { + markup: cloneDeep(label.markup || defaultLabel.markup || builtinDefaultLabel.markup), + attrs: mergeLabelAttrs(hasCustomMarkup, label.attrs, defaultLabel.attrs, builtinDefaultLabel.attrs), + size: mergeLabelSize(label.size, defaultLabel.size, builtinDefaultLabel.size), + position: mergeLabelPosition(label.position, defaultLabel.position, builtinDefaultLabel.position) + }); +} + +// merge default label attrs into label attrs (or use built-in default label attrs if neither is provided) +// keep `undefined` or `null` because `{}` means something else +function mergeLabelAttrs(hasCustomMarkup, labelAttrs, defaultLabelAttrs, builtinDefaultLabelAttrs) { + + if (labelAttrs === null) return null; + if (labelAttrs === undefined) { + + if (defaultLabelAttrs === null) return null; + if (defaultLabelAttrs === undefined) { + + if (hasCustomMarkup) return undefined; + return merge({}, builtinDefaultLabelAttrs); + } + + if (hasCustomMarkup) return merge({}, defaultLabelAttrs); + return merge({}, builtinDefaultLabelAttrs, defaultLabelAttrs); + } + + if (hasCustomMarkup) return merge({}, defaultLabelAttrs, labelAttrs); + return merge({}, builtinDefaultLabelAttrs, defaultLabelAttrs, labelAttrs); +} + +// merge label size with default label size and built-in default label size +// the result is always a size object (`null` or `undefined` falls back to the defaults) +function mergeLabelSize(labelSize, defaultLabelSize, builtinDefaultLabelSize) { + + return merge({}, builtinDefaultLabelSize, defaultLabelSize, labelSize); +} + +// merge label position with default label position and built-in default label position +// keep `null` (an invalid position, set on purpose), only `undefined` falls back to the defaults +function mergeLabelPosition(labelPosition, defaultLabelPosition, builtinDefaultLabelPosition) { + + if (labelPosition === null) return null; + return merge( + {}, + builtinDefaultLabelPosition, + normalizeLabelPosition(defaultLabelPosition), + normalizeLabelPosition(labelPosition) + ); +} + +// if label position is a number, normalize it to a position object +// this makes sure that label positions can be merged properly +function normalizeLabelPosition(labelPosition) { + + if (typeof labelPosition === 'number') { + return { + distance: labelPosition, + offset: 0, + angle: 0, + args: null + }; + } + return labelPosition; +} diff --git a/packages/joint-core/src/linkTools/RotateLabel.mjs b/packages/joint-core/src/linkTools/RotateLabel.mjs index d8c2f23bd0..89884c8a3c 100644 --- a/packages/joint-core/src/linkTools/RotateLabel.mjs +++ b/packages/joint-core/src/linkTools/RotateLabel.mjs @@ -52,9 +52,10 @@ export const RotateLabel = Control.extend({ const label = this.getLabel(); const labelPosition = this.getLabelPosition(label); const coords = view.getLabelCoordinates(labelPosition); - let { angle = 0 } = labelPosition; - const { args = {}} = labelPosition; - const keepGradient = args.keepGradient; + // `args` is `null` for a label with a number position + const { args } = labelPosition; + const keepGradient = args && args.keepGradient; + let { angle } = labelPosition; if (keepGradient) { const tangent = view.getTangentAtRatio( view.getClosestPointRatio(coords) @@ -99,13 +100,11 @@ export const RotateLabel = Control.extend({ }, getLabel() { - return this.relatedView.model.label(this.getLabelIndex()) || null; + return this.relatedView.model.getComputedLabel(this.getLabelIndex()); }, getLabelPosition(label) { - const view = this.relatedView; - const labelPosition = view._normalizeLabelPosition(label.position); - return view._mergeLabelPositionProperty(labelPosition, view._getDefaultLabelPositionProperty()); + return label.position; }, }); diff --git a/packages/joint-core/test/jointjs/linkView.js b/packages/joint-core/test/jointjs/linkView.js index aedca785ae..f37aa4f724 100644 --- a/packages/joint-core/test/jointjs/linkView.js +++ b/packages/joint-core/test/jointjs/linkView.js @@ -547,6 +547,24 @@ QUnit.module('linkView', function(hooks) { QUnit.module('getLabelCoordinates', function(hooks) { + QUnit.test('invalid position', function(assert) { + + assert.throws(function() { + linkView.getLabelCoordinates(null); + }, /invalid label position/); + + assert.throws(function() { + linkView.getLabelCoordinates({ distance: null }); + }, /invalid label position distance/); + + // A label's own `null` position is kept by `getComputedLabel()` - it isn't + // replaced by the default position. + assert.throws(function() { + link.appendLabel({ position: null }); + }, /invalid label position/); + link.removeLabel(-1); + }); + QUnit.test('default', function(assert) { var labelCoordinates; diff --git a/packages/joint-core/test/jointjs/links.js b/packages/joint-core/test/jointjs/links.js index 8d5a7bcbbc..a0afd1f3d5 100644 --- a/packages/joint-core/test/jointjs/links.js +++ b/packages/joint-core/test/jointjs/links.js @@ -1931,6 +1931,107 @@ QUnit.module('links', function(hooks) { }); }); + QUnit.module('getComputedLabel/getComputedLabels', function() { + + QUnit.test('resolved against `defaultLabel`/the built-in default - unlike `label`/`labels`', function(assert) { + var link = new joint.shapes.standard.Link({ labels: [{ position: { distance: 10, offset: 10 }}, { position: { distance: 20, offset: 20 }}] }); + // A label's own `position` wins over the built-in default, `markup`/`attrs`/`size` + // fall back to the built-in default. + assert.deepEqual(link.getComputedLabel(0).position, { distance: 10, offset: 10, angle: 0 }); + assert.deepEqual(link.getComputedLabel(1).position, { distance: 20, offset: 20, angle: 0 }); + assert.ok(link.getComputedLabel(0).markup); + assert.ok(link.getComputedLabel(0).attrs); + assert.deepEqual(link.getComputedLabel(0).size, { width: 0, height: 0 }); + assert.strictEqual(link.getComputedLabel(2), null); + + assert.deepEqual(link.getComputedLabels()[0].position, { distance: 10, offset: 10, angle: 0 }); + assert.ok(link.getComputedLabels()[0].markup); + }); + + QUnit.test('`position` and `size` are resolved objects (unless `position` is `null`)', function(assert) { + var link = new joint.shapes.standard.Link({ + labels: [ + {}, + { position: null, size: null }, + { position: 0.3 }, + { position: { offset: { x: 5, y: 5 }}, size: { width: 10 }} + ] + }); + + var labels = link.getComputedLabels(); + assert.deepEqual(labels[0].position, { distance: 0.5, offset: 0, angle: 0 }); + assert.deepEqual(labels[0].size, { width: 0, height: 0 }); + // `null` is kept - only `undefined` falls back to the defaults. + assert.strictEqual(labels[1].position, null); + assert.deepEqual(labels[1].size, { width: 0, height: 0 }); + assert.deepEqual(labels[2].position, { distance: 0.3, offset: 0, angle: 0, args: null }); + assert.deepEqual(labels[3].position, { distance: 0.5, offset: { x: 5, y: 5 }, angle: 0 }); + assert.deepEqual(labels[3].size, { width: 10, height: 0 }); + + link.set('defaultLabel', { position: { distance: 0.2, angle: 45 }, size: { width: 20, height: 20 }}); + labels = link.getComputedLabels(); + assert.deepEqual(labels[0].position, { distance: 0.2, offset: 0, angle: 45 }); + assert.deepEqual(labels[0].size, { width: 20, height: 20 }); + // A number position resets `defaultLabel`'s `offset`/`angle`/`args`. + assert.deepEqual(labels[2].position, { distance: 0.3, offset: 0, angle: 0, args: null }); + assert.deepEqual(labels[3].size, { width: 10, height: 20 }); + }); + + QUnit.test('returns new `markup`/`attrs`/`size`/`position` objects', function(assert) { + var builtinDefaultLabel = joint.util.cloneDeep(joint.dia.Link.prototype._builtins.defaultLabel); + var ownMarkup = [{ tagName: 'text', selector: 'text' }]; + var defaultMarkup = [{ tagName: 'rect', selector: 'body' }]; + var defaultAttrs = { body: { fill: 'red' }}; + + function mutate(label) { + label.markup.push({ tagName: 'circle' }); + label.attrs.mutated = { fill: 'blue' }; + label.size.width = 100; + label.position.distance = 100; + } + + // Built-in default (shared by all links). + var link = new joint.shapes.standard.Link({ labels: [{}] }); + mutate(link.getComputedLabel(0)); + assert.deepEqual(link._builtins.defaultLabel, builtinDefaultLabel); + assert.deepEqual(link.get('labels'), [{}]); + + // `defaultLabel` with a custom markup (attrs are not merged with the built-in ones). + link.set('defaultLabel', { markup: defaultMarkup, attrs: defaultAttrs }); + mutate(link.getComputedLabel(0)); + assert.deepEqual(defaultMarkup, [{ tagName: 'rect', selector: 'body' }]); + assert.deepEqual(defaultAttrs, { body: { fill: 'red' }}); + + // The label's own markup. + link.labels([{ markup: ownMarkup }]); + mutate(link.getComputedLabel(0)); + assert.deepEqual(ownMarkup, [{ tagName: 'text', selector: 'text' }]); + }); + + QUnit.test('pass through custom properties, own value winning over `defaultLabel`\'s', function(assert) { + var link = new joint.shapes.standard.Link({ + defaultLabel: { custom: 'default', onlyOnDefault: 'd' }, + labels: [ + { custom: 'own', position: 0 }, + { position: 0 } + ] + }); + + assert.equal(link.getComputedLabel(0).custom, 'own'); + assert.equal(link.getComputedLabel(0).onlyOnDefault, 'd'); + assert.equal(link.getComputedLabel(1).custom, 'default'); + + assert.equal(link.getComputedLabels()[0].custom, 'own'); + assert.equal(link.getComputedLabels()[1].custom, 'default'); + + // Raw storage (and `label`/`labels`) are unaffected - `defaultLabel`'s value + // isn't baked into either. + assert.equal(link.get('labels')[1].custom, undefined); + assert.equal(link.label(1).custom, undefined); + assert.equal(link.labels()[1].custom, undefined); + }); + }); + QUnit.module('insertLabel', function() { QUnit.test('sanity', function(assert) { @@ -1947,7 +2048,9 @@ QUnit.module('links', function(hooks) { link.insertLabel(-1, { position: { distance: 20, offset: 20 }}); link.insertLabel(0, { position: { distance: 10, offset: 10 }}); link.insertLabel(100, { position: { distance: 30, offset: 30 }}); - assert.deepEqual(link.labels(), [{ position: { distance: 10, offset: 10 }}, { position: { distance: 20, offset: 20 }}, { position: { distance: 30, offset: 30 }}]); + // Raw, as stored (not resolved - see `labels > getter` above) - confirms the + // insert didn't bake any resolved defaults into the other, untouched labels. + assert.deepEqual(link.get('labels'), [{ position: { distance: 10, offset: 10 }}, { position: { distance: 20, offset: 20 }}, { position: { distance: 30, offset: 30 }}]); }); }); @@ -1965,7 +2068,8 @@ QUnit.module('links', function(hooks) { assert.equal(!!error, true); link.appendLabel({ position: { distance: 10, offset: 10 }}); - assert.deepEqual(link.labels(), [{ position: { distance: 10, offset: 10 }}]); + // Raw, as stored - see `insertLabel > sanity` above. + assert.deepEqual(link.get('labels'), [{ position: { distance: 10, offset: 10 }}]); }); }); @@ -1973,12 +2077,13 @@ QUnit.module('links', function(hooks) { QUnit.test('sanity', function(assert) { var link = new joint.shapes.standard.Link({ labels: [{ position: { distance: 10, offset: 10 }}, { position: { distance: 20, offset: 20 }}, { position: { distance: 30, offset: 30 }}, { position: { distance: 40, offset: 40 }}] }); + // Raw, as stored - see `insertLabel > sanity` above. link.removeLabel(100); - assert.deepEqual(link.labels(), [{ position: { distance: 10, offset: 10 }}, { position: { distance: 20, offset: 20 }}, { position: { distance: 30, offset: 30 }}, { position: { distance: 40, offset: 40 }}]); + assert.deepEqual(link.get('labels'), [{ position: { distance: 10, offset: 10 }}, { position: { distance: 20, offset: 20 }}, { position: { distance: 30, offset: 30 }}, { position: { distance: 40, offset: 40 }}]); link.removeLabel(-1); - assert.deepEqual(link.labels(), [{ position: { distance: 10, offset: 10 }}, { position: { distance: 20, offset: 20 }}, { position: { distance: 30, offset: 30 }}]); + assert.deepEqual(link.get('labels'), [{ position: { distance: 10, offset: 10 }}, { position: { distance: 20, offset: 20 }}, { position: { distance: 30, offset: 30 }}]); link.removeLabel(0); - assert.deepEqual(link.labels(), [{ position: { distance: 20, offset: 20 }}, { position: { distance: 30, offset: 30 }}]); + assert.deepEqual(link.get('labels'), [{ position: { distance: 20, offset: 20 }}, { position: { distance: 30, offset: 30 }}]); }); }); }); diff --git a/packages/joint-core/test/ts/index.test.ts b/packages/joint-core/test/ts/index.test.ts index 84e867bb8b..dbff7c3c02 100644 --- a/packages/joint-core/test/ts/index.test.ts +++ b/packages/joint-core/test/ts/index.test.ts @@ -72,6 +72,22 @@ const rectangle = new joint.shapes.standard.Rectangle({ } }); +// `portProp()` - whole port getter, path getter/setter, object setter +const port: joint.dia.Element.Port = rectangle.portProp('port1'); +const portGroup = rectangle.portProp('port1', 'group'); +rectangle.portProp('port1', ['position', 'args'], { x: 10, y: 20 }, { silent: true }); +const portPropObjectResult = rectangle.portProp('port1', { + position: { args: { x: 10, y: 20 }}, + label: { position: { args: { x: 5, y: -5 }}} +}, { rewrite: true }); +const isElementAfterObjectSet: AssertExtends = true; + +// A port label's position `args` are the label layout's own options. +const portLabelPosition: joint.dia.Element.PortLabelPositionType = { + name: 'manual', + args: { x: 5, y: -5, attrs: { labelText: { textAnchor: 'start' }}} +}; + const link = new joint.shapes.standard.Link({ attrs: { line: { diff --git a/packages/joint-core/types/dia.d.ts b/packages/joint-core/types/dia.d.ts index 356555820a..c0c55236d4 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; @@ -1034,6 +1038,27 @@ export namespace Link { position?: LabelPosition | number; // optional for default labels attrs?: Cell.Selectors; size?: Size; + // Any other custom property - passed + // through as-is by `Link#labels`/`Link#label`, whether set on the label itself or + // on `defaultLabel` (the label's own value wins). + [key: string]: any; + } + + interface ComputedLabelPosition { + distance: number; + offset: number | { x: number, y: number }; + angle: number; + args?: LinkView.LabelOptions | null; + } + + interface ComputedLabel { + markup: string | MarkupJSON; + position: ComputedLabelPosition | null; + attrs?: Cell.Selectors | null; + size: Size; + // Any other custom property - passed + // through as-is from the label itself or from `defaultLabel`. + [key: string]: any; } interface Vertex extends Point { @@ -1074,9 +1099,13 @@ export class Link extends CellViewGeneric { getLabelPosition(x: number, y: number, opt?: LinkView.LabelOptions): Link.LabelPosition; getLabelPosition(x: number, y: number, angle: number, opt?: LinkView.LabelOptions): Link.LabelPosition; - getLabelCoordinates(labelPosition: Link.LabelPosition): g.Point; + getLabelCoordinates(labelPosition: Link.LabelPosition | Link.ComputedLabelPosition): g.Point; getVertexIndex(x: number, y: number): number; getVertexIndex(point: Point): number; 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..f261f2c570 --- /dev/null +++ b/packages/joint-layout-elk/README.md @@ -0,0 +1,194 @@ +# JointJS ELK Layout + +A module for automatic layout of *[JointJS](https://www.jointjs.com)* graphs using the [Eclipse Layout Kernel (ELK)](https://www.eclipse.org/elk/), via its JavaScript port [elkjs](https://github.com/kieler/elkjs). + +This library depends on [JointJS](https://github.com/clientio/joint) (*>=4.0*), so please read its `README.md` before using this library. + +`layout()` is a one-off, asynchronous transform: it builds an ELK graph from a JointJS graph (embedded elements become nested containers at any depth, ports and link labels are included), runs ELK's layout algorithm, and writes the result (positions, sizes, link vertices/anchors, label positions) back onto the graph. It does not keep the graph laid out afterwards - call it again after further changes. + +## 🚀 Quick Start + +### Installation + +```bash +npm install @joint/layout-elk +``` + +### Basic Usage + +```ts +import { dia, shapes } from '@joint/core'; +import { layout } from '@joint/layout-elk'; + +const graph = new dia.Graph({}, { cellNamespace: shapes }); +const paper = new dia.Paper({ + model: graph, + cellViewNamespace: shapes, + el: document.getElementById('paper'), +}); + +const rect1 = new shapes.standard.Rectangle({ id: 'a', size: { width: 80, height: 40 }, attrs: { label: { text: 'A' }}}); +const rect2 = new shapes.standard.Rectangle({ id: 'b', size: { width: 80, height: 40 }, attrs: { label: { text: 'B' }}}); +const link = new shapes.standard.Link({ source: { id: 'a' }, target: { id: 'b' }}); + +graph.addCells([rect1, rect2, link]); + +const { bbox } = await layout({ graph }, { + elkLayoutOptions: { + 'elk.algorithm': 'layered', + 'elk.direction': 'RIGHT', + 'elk.edgeRouting': 'ORTHOGONAL' + } +}); +``` + +## 📖 API Reference + +### `layout({ graph, elements?, links? }, options?): Promise` + +- `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 + elkGraph: ElkNode; // The raw ELK layout result (e.g. for junction points) +} +``` + +### `LayoutOptions` + +```ts +interface LayoutOptions { + // The ELK instance to lay out with, e.g. one running in a Web Worker (see `createWorkerElk()` below). + elk?: WorkerElk | ELK; // Default: a shared instance running on the main thread + // ELK layout options, passed through to ELK unmodified. + elkLayoutOptions?: ElkLayoutOptions; // Default: { 'elk.algorithm': 'layered', 'elk.hierarchyHandling': 'INCLUDE_CHILDREN', 'elk.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; + exportPort?: ExportPortCallback; + exportPortLabel?: ExportPortLabelCallback; + exportLink?: ExportLinkCallback; + exportLinkLabel?: ExportLinkLabelCallback; + + // Import callbacks (ELK layout result -> JointJS graph) - see below. + setElementAttributes?: SetElementAttributesCallback; + setPortAttributes?: SetPortAttributesCallback; + setLinkAttributes?: SetLinkAttributesCallback; +} +``` + +### Export callbacks + +Each receives an ELK draft already populated with what this package computed for that element/port/link/label, to mutate in place (e.g. to set `elk.*` `layoutOptions`). Returning `false` instead drops it from the ELK graph entirely - for `exportElement`, its whole subtree (embeds, ports, any connected edge) goes with it. + +```ts +type ExportElementCallback = (params: { element: dia.Element; elkNode: ElkNodeDraft }) => void | false; +type ExportPortCallback = (params: { portId: string; element: dia.Element; elkPort: ElkPortDraft }) => void | false; +type ExportPortLabelCallback = (params: { portId: string; element: dia.Element; elkPortLabel: ElkLabelDraft }) => void | false; +type ExportLinkCallback = (params: { link: dia.Link; elkEdge: ElkEdgeDraft }) => void | false; +type ExportLinkLabelCallback = (params: { link: dia.Link; labelIndex: number; elkEdgeLabel: ElkLabelDraft }) => void | false; +``` + +### Import callbacks + +Each applies the ELK-computed attributes itself, in place of the package's own default (`element.set(...)`/`element.portProp(...)`/`link.set(...)`) - use one to redirect where the result goes, e.g. into a `transition()`. + +```ts +type SetElementAttributesCallback = (params: { element: dia.Element; attributes: { position: dia.Point; size?: dia.Size }; elkNode: ElkNode }) => void; +type SetPortAttributesCallback = (params: { element: dia.Element; portId: 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; +``` + +### 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`: + +```ts +import { layout, createWorkerElk } from '@joint/layout-elk'; + +const elk = createWorkerElk(() => new Worker(new URL('@joint/layout-elk/worker', import.meta.url), { type: 'module' })); + +await layout({ graph }, { elk }); + +// Once no longer needed. +elk.terminate(); +``` + +#### `createWorkerElk(createWorker: () => Worker): WorkerElk` + +`createWorker` starts the worker - running `@joint/layout-elk/worker`, the package's worker script (ELK's own, `elkjs/lib/elk-worker.min.js`). Starting it is up to you, since only your bundler knows where the script ends up: + +- **webpack 5, Vite** (dev server and builds) - `new Worker(new URL('@joint/layout-elk/worker', import.meta.url), { type: 'module' })`, as above. Both bundle the worker script as a file of its own. With webpack, keep `output.publicPath` at `'auto'` (the default), so the file is found wherever the app is served from. +- **Vite** - also `import ElkWorker from '@joint/layout-elk/worker?worker'`, then `createWorkerElk(() => new ElkWorker())`. +- **No bundler** (e.g. the UMD build) - serve a copy of `elkjs/lib/elk-worker.min.js`, then `createWorkerElk(() => new Worker('/path/to/elk-worker.min.js'))`. + +The returned `WorkerElk`: + +- **Starts the worker on its first layout**, then lays out every graph in it, one at a time - share one instance between layouts. +- **Stops an aborted layout** (see `signal` below) by terminating the worker, if it is busy with it - a new worker takes over the layouts still waiting. +- **Rejects the layout the worker crashes during** (e.g. out of memory) - a new worker takes over the layouts still waiting. +- **Rejects every layout when the worker fails to load** (e.g. its script isn't served where `createWorker` starts it from) or can't be started (e.g. a CSP `worker-src` that blocks it) - with an error saying so, rather than running ELK on the main thread. The next layout tries again with a new worker. +- **`terminate()`** terminates the worker - layouts not settled yet are rejected, and a later layout starts a new worker. + +Any other ELK instance works as `elk` too, e.g. `elkjs`'s own `new ELK({ workerUrl })` (`elkjs/lib/elk-api.js`) - without stopping an aborted layout, though (see below). + +### Aborting a layout + +`layout()` is asynchronous, so the graph may change while ELK is still computing - pass an `AbortSignal` to drop a layout that is no longer wanted (or takes too long). An aborted `layout()` rejects with the signal's reason (an `AbortError` `DOMException` by default) and applies nothing to the graph. + +```ts +let controller: AbortController | undefined; + +async function runLayout() { + // Only the latest layout is applied. + controller?.abort(); + controller = new AbortController(); + try { + await layout({ graph }, { signal: controller.signal }); + } catch (error) { + if ((error as Error).name !== 'AbortError') throw error; + } +} + +// Give up after 5 seconds. +await layout({ graph }, { signal: AbortSignal.timeout(5000) }); +``` + +ELK can't stop a layout in progress, so a layout a `createWorkerElk()` worker is busy with is stopped by terminating the worker - a new one takes over the layouts still waiting. A layout on the main thread, or in any other `elk` instance, keeps running - only its result is ignored. + +## ⚠️ Caveats & Known Limitations + +- **Edge coordinates are graph-absolute** - `layout()` sets `elk.json.edgeCoords: 'ROOT'`, so ELK returns every edge's route points and labels relative to the root, whichever container the edge is in, and the default import applies them as they are. Overriding it (e.g. `'CONTAINER'`) is allowed, but the default import then misplaces vertices, end anchors and labels of edges inside containers - convert them yourself in `setLinkAttributes` (from `elkEdge`). The same applies to the raw `elkGraph` in `layout()`'s result. +- **Node labels are not supported** - ELK's node-label placement assumes labels are layout participants, whereas JointJS labels are attrs inside the shape. Link labels are supported. +- **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. + +## 📄 License + +[Mozilla Public License 2.0](https://www.mozilla.org/en-US/MPL/2.0/) + +The code in this package is licensed under the Mozilla Public License 2.0, same as the rest of JointJS. It contains no ELK code: it only calls ELK through its API, and its TypeScript option types link to [ELK's option reference](https://eclipse.dev/elk/reference/options.html) instead of reproducing it. + +It depends on [`elkjs`](https://github.com/kieler/elkjs), which is dual-licensed under the [Eclipse Public License 2.0](https://github.com/kieler/elkjs/blob/master/LICENSE.md) or GPL-3.0-or-later (`EPL-2.0 OR GPL-3.0-or-later`) - you can use it under the EPL-2.0. `elkjs` is installed as a regular dependency and kept external to this package's own builds (ESM and UMD) - it is never copied or inlined into them. An application that bundles this package does ship `elkjs` code though (the main-thread chunk, and the Web Worker file if it uses `@joint/layout-elk/worker`), under that license: keep `elkjs`'s license notice with it (e.g. with your bundler's license extraction), and note where its source is available (it is published on [GitHub](https://github.com/kieler/elkjs) and [npm](https://www.npmjs.com/package/elkjs)). + +Copyright © 2013-2026 client IO diff --git a/packages/joint-layout-elk/SECURITY.md b/packages/joint-layout-elk/SECURITY.md new file mode 100644 index 0000000000..88228bbc7e --- /dev/null +++ b/packages/joint-layout-elk/SECURITY.md @@ -0,0 +1,12 @@ +# Security Policy + +## Supported Versions + +The [latest released version](https://github.com/clientIO/joint/releases) of JointJS is supported. + +## Reporting a Vulnerability + +Please email security@client.io, and we will respond as quickly as possible. + +If the vulnerability is considered valid and accepted, a patch will be made for the latest JointJS version. +If the vulnerability is deemed invalid, no further action is required. diff --git a/packages/joint-layout-elk/coverage.json b/packages/joint-layout-elk/coverage.json new file mode 100644 index 0000000000..4f9779702c --- /dev/null +++ b/packages/joint-layout-elk/coverage.json @@ -0,0 +1,8 @@ +{ + "global": { + "statements": 87, + "branches": 60, + "functions": 100, + "lines": 95 + } +} diff --git a/packages/joint-layout-elk/eslint.config.mjs b/packages/joint-layout-elk/eslint.config.mjs new file mode 100644 index 0000000000..c47ad9e5e6 --- /dev/null +++ b/packages/joint-layout-elk/eslint.config.mjs @@ -0,0 +1,22 @@ +import { tsConfig, jsConfig, rollupConfig } from '@joint/eslint-config'; +import { defineConfig } from 'eslint/config'; + +export default defineConfig([ + ...jsConfig, + ...tsConfig, + ...rollupConfig, + { + // Autogenerated folder + ignores: ['coverage/'], + }, + { + // Tests + files: ['**/test/**'], + languageOptions: { + globals: { + joint: 'readonly', + QUnit: 'readonly', + }, + }, + }, +]); diff --git a/packages/joint-layout-elk/karma.conf.js b/packages/joint-layout-elk/karma.conf.js new file mode 100644 index 0000000000..943296fe15 --- /dev/null +++ b/packages/joint-layout-elk/karma.conf.js @@ -0,0 +1,66 @@ +const puppeteer = require('puppeteer'); +const coverageThresholds = require('./coverage.json'); + +// Used for starting Chrome if using ChromeHeadless in Jenkins +process.env.CHROME_BIN = puppeteer.executablePath(); + +// Coverage is collected for this and source-mapped to `src/*.mts` +const TEST_BUNDLE = './build/test/index.js'; + +// Which path should .lcov files record as root they are relative to? +// - SonarQube (`.github/workflows/sonar.yml`) needs this to be the repo root +const REPOSITORY_ROOT = '../..'; + +module.exports = function(config) { + config.set({ + basePath: '.', + files: [ + './node_modules/@joint/core/build/joint.js', + './node_modules/elkjs/lib/elk.bundled.js', + // Served (not loaded) for the tests starting ELK's default Web Worker. + { pattern: './node_modules/elkjs/lib/elk-worker.min.js', included: false }, + TEST_BUNDLE, + + './test/index.js' + ], + singleRun: true, + frameworks: ['qunit'], + plugins: [ + 'karma-qunit', + 'karma-coverage', + 'karma-sourcemap-loader', + 'karma-chrome-launcher' + ], + reporters: ['progress', 'coverage'], + proxies: {}, + browsers: ['ChromeHeadless_custom'], + customLaunchers: { + ChromeHeadless_custom: { + base: 'ChromeHeadless', + flags: [ + // --no-sandbox needed for Jenkins build + '--no-sandbox', + '--headless', + '--disable-gpu', + '--disable-dev-shm-usage' + ] + } + }, + exclude: [], + preprocessors: { + [TEST_BUNDLE]: ['sourcemap', 'coverage'] + }, + coverageReporter: { + // specify a common output directory + dir: 'coverage/', + // coverage baseline - falling below any of these fails the test + check: coverageThresholds, + reporters: ((process.env.COVERAGE_REPORTER === 'lcov') + ? [{ type: 'lcovonly', subdir: '.', file: 'lcov.info', projectRoot: REPOSITORY_ROOT }] + : [ + { type: 'html', subdir: '.' }, + { type: 'text-summary' } + ]) + } + }); +}; diff --git a/packages/joint-layout-elk/package.json b/packages/joint-layout-elk/package.json new file mode 100644 index 0000000000..4c95c5bab6 --- /dev/null +++ b/packages/joint-layout-elk/package.json @@ -0,0 +1,93 @@ +{ + "name": "@joint/layout-elk", + "title": "JointJS ELK Layout", + "version": "4.3.0", + "description": "ELK Layout module for JointJS", + "sideEffects": false, + "main": "./dist/esm/index.mjs", + "module": "./dist/esm/index.mjs", + "types": "./dist/esm/index.d.mts", + "homepage": "https://jointjs.com", + "author": { + "name": "client IO", + "url": "https://client.io" + }, + "repository": { + "type": "git", + "url": "https://github.com/clientIO/joint.git", + "directory": "packages/joint-layout-elk" + }, + "bugs": { + "url": "https://github.com/clientIO/joint/issues" + }, + "license": "MPL-2.0", + "installConfig": { + "hoistingLimits": "workspaces" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "prepublishOnly": "echo \"Publishing via NPM is not allowed!\" && exit 1", + "prepack": "yarn build", + "dist": "yarn build", + "build": "yarn clean && yarn build:esm && yarn build:umd", + "clean": "rm -rf dist", + "build:esm": "tsc --project tsconfig.esm.json", + "build:umd": "rollup --config", + "watch": "yarn build && concurrently \"tsc --project tsconfig.esm.json --watch\" \"rollup --config --watch\"", + "test": "yarn build && karma start karma.conf.js", + "test-coverage": "karma start karma.conf.js", + "test-coverage-lcov": "COVERAGE_REPORTER=lcov yarn run test-coverage", + "lint": "eslint .", + "lint-fix": "yarn run lint --fix" + }, + "exports": { + ".": { + "types": "./dist/esm/index.d.mts", + "import": "./dist/esm/index.mjs", + "default": "./dist/esm/index.mjs" + }, + "./worker": "./dist/esm/elk.worker.mjs" + }, + "files": [ + "dist", + "SECURITY.md", + "README.md", + "LICENSE", + "CHANGELOG.md" + ], + "dependencies": { + "@joint/core": "workspace:~", + "elkjs": "0.12.0" + }, + "devDependencies": { + "@joint/eslint-config": "workspace:*", + "@rollup/plugin-node-resolve": "^16.0.1", + "@rollup/plugin-terser": "^0.4.4", + "@rollup/plugin-typescript": "^12.1.1", + "concurrently": "^9.2.0", + "eslint": "9.39.2", + "karma": "^6.4.2", + "karma-chrome-launcher": "^3.2.0", + "karma-coverage": "^2.2.1", + "karma-qunit": "^4.1.2", + "karma-sourcemap-loader": "^0.4.0", + "puppeteer": "24.22.0", + "qunit": "^2.24.1", + "rollup": "4.36.0", + "rollup-plugin-banner2": "^1.2.2", + "typescript": "^5.7.3" + }, + "volta": { + "node": "22.14.0", + "npm": "11.2.0", + "yarn": "4.18.0" + }, + "keywords": [ + "jointjs", + "layout", + "elk", + "elkjs" + ] +} diff --git a/packages/joint-layout-elk/rollup.config.mjs b/packages/joint-layout-elk/rollup.config.mjs new file mode 100644 index 0000000000..f5f3f4ee43 --- /dev/null +++ b/packages/joint-layout-elk/rollup.config.mjs @@ -0,0 +1,128 @@ +import packageJson from './package.json' with { type: 'json' }; +import banner from 'rollup-plugin-banner2'; +import terser from '@rollup/plugin-terser'; +import { nodeResolve } from '@rollup/plugin-node-resolve'; +import typescript from '@rollup/plugin-typescript'; + +// JointJS banner. +// - see `joint-core/grunt/resources/banner.js` +const today = new Date(); +const formattedDate = `${today.toLocaleDateString('en-US', { year: 'numeric' })}-${today.toLocaleDateString('en-US', { month: '2-digit' })}-${today.toLocaleDateString('en-US', { day: '2-digit' })}`; +const bannerText = `/*! ${packageJson.title} v${packageJson.version} (${formattedDate}) - ${packageJson.description}\n\nThis Source Code Form is subject to the terms of the Mozilla Public\nLicense, v. 2.0. If a copy of the MPL was not distributed with this\nfile, You can obtain one at http://mozilla.org/MPL/2.0/.\n*/\n\n`; + +const input = ['./dist/esm/index.mjs']; + +// Replaces the module imported as `.mjs` with `code`. +const replaceModule = (name, code) => { + const id = `\0${name}`; + const pattern = new RegExp(`(^|/)${name}\\.mjs$`); + return { + name: `replace-${name}`, + resolveId(source) { + return pattern.test(source) ? id : null; + }, + load(loadId) { + return (loadId === id) ? code : null; + } + }; +}; + +// A UMD bundle can't load a chunk of its own - replace the module that imports +// main-thread ELK dynamically (see `src/mainThreadElk.mts`) with one importing it +// statically, i.e. the `ELK` global. +const staticMainThreadElk = replaceModule( + 'mainThreadElk', + 'import ElkConstructor from \'elkjs/lib/elk.bundled.js\'; export function loadMainThreadElk() { return Promise.resolve(ElkConstructor); }' +); + +// The unit test bundle loads main-thread ELK the same way, unless a test hands it a loader +// of its own (`window.__loadMainThreadElk`), so failing to load it can be tested too. +const testMainThreadElk = replaceModule( + 'mainThreadElk', + 'import ElkConstructor from \'elkjs/lib/elk.bundled.js\'; export function loadMainThreadElk() { return window.__loadMainThreadElk ? window.__loadMainThreadElk() : Promise.resolve(ElkConstructor); }' +); + +export default [ + { + input, + external: [ + '@joint/core', + 'elkjs/lib/elk.bundled.js' + ], + output: [ + { + file: 'dist/umd/index.js', + format: 'umd', + name: 'joint.layout.ELK', + extend: true, + globals: { + '@joint/core': 'joint', + 'elkjs/lib/elk.bundled.js': 'ELK' + }, + plugins: [ + banner(() => bannerText) + ] + }, + { + file: 'dist/umd/index.min.js', + format: 'umd', + name: 'joint.layout.ELK', + extend: true, + globals: { + '@joint/core': 'joint', + 'elkjs/lib/elk.bundled.js': 'ELK' + }, + plugins: [ + terser({ format: { ascii_only: true }}), + banner(() => bannerText) + ] + }, + ], + plugins: [ + staticMainThreadElk, + nodeResolve({ + preferBuiltins: false + }) + ] + }, + // Source-mapped bundle for unit tests (see `karma.conf.js`) + // - Compiles TypeScript directly instead of reusing from `dist`/`esm` + // - (Because Rollup cannot follow inline maps left behind by `tsc`) + { + input: ['./src/index.mts'], + external: [ + '@joint/core', + 'elkjs/lib/elk.bundled.js' + ], + output: [ + { + file: 'build/test/index.js', + format: 'umd', + name: 'joint.layout.ELK', + extend: true, + globals: { + '@joint/core': 'joint', + 'elkjs/lib/elk.bundled.js': 'ELK' + }, + sourcemap: true + } + ], + plugins: [ + testMainThreadElk, + nodeResolve({ + preferBuiltins: false + }), + typescript({ + tsconfig: './tsconfig.json', + compilerOptions: { + // Rollup writes its own source map + inlineSourceMap: false, + inlineSources: false, + sourceMap: true, + declaration: false, + declarationMap: false, + } + }) + ] + } +]; diff --git a/packages/joint-layout-elk/src/abort.mts b/packages/joint-layout-elk/src/abort.mts new file mode 100644 index 0000000000..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 new file mode 100644 index 0000000000..8fe10cfaa5 --- /dev/null +++ b/packages/joint-layout-elk/src/defaultElk.mts @@ -0,0 +1,36 @@ +import { loadMainThreadElk } from './mainThreadElk.mjs'; +import { abortable, throwIfAborted } from './abort.mjs'; + +import type { ELK, ElkNode } from 'elkjs'; + +// Loaded on the first layout without an `elk` option, then shared by every later one. +let mainThreadElk: Promise | undefined; + +function getMainThreadElk(): Promise { + if (!mainThreadElk) { + 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; +} + +/** + * Lays out `elkGraph` with the default ELK instance, on the main thread. + * + * Aborting `signal` rejects with its reason straight away. ELK on the main thread can't be + * stopped once it started - its result is only ignored. + */ +export function layoutWithDefaultElk(elkGraph: ElkNode, signal?: AbortSignal): Promise { + const layout = getMainThreadElk().then((elk) => { + // Aborted while ELK was loading - no need to start it. + throwIfAborted(signal); + return elk.layout(elkGraph); + }); + return abortable(layout, signal); +} diff --git a/packages/joint-layout-elk/src/elk.worker.mts b/packages/joint-layout-elk/src/elk.worker.mts new file mode 100644 index 0000000000..844a8d2bc4 --- /dev/null +++ b/packages/joint-layout-elk/src/elk.worker.mts @@ -0,0 +1,5 @@ +// The script of a Web Worker running ELK, for `createWorkerElk()` - published as +// `@joint/layout-elk/worker`, so `elkjs` resolves from this package rather than from the +// app (which may not depend on it, or on another version of it). ELK's own worker script +// sets up the worker's message handling as soon as it's loaded. +import 'elkjs/lib/elk-worker.min.js'; diff --git a/packages/joint-layout-elk/src/export.mts b/packages/joint-layout-elk/src/export.mts new file mode 100644 index 0000000000..9db1d24f4d --- /dev/null +++ b/packages/joint-layout-elk/src/export.mts @@ -0,0 +1,455 @@ +import { type dia } from '@joint/core'; +import { getLinkLabelId } from './labelIds.mjs'; + +import type { + ElkNode, + ElkPort, + ElkExtendedEdge, + ElkLabel, + ElkLayoutOptions, + NodeElkLayoutOptions, + PortElkLayoutOptions, + EdgeElkLayoutOptions, + LabelElkLayoutOptions +} from './types/index.mjs'; + +// ELK ignores labels with no text. +const ELK_LABEL_TEXT = '-'; + +/** + * An ELK label draft + */ +export interface ElkLabelDraft { + x?: number; + y?: number; + width: number; + height: number; + layoutOptions: LabelElkLayoutOptions; +} + +/** An ELK node draft, one per JointJS element. */ +export interface ElkNodeDraft { + 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; + /** `{ 'elk.portConstraints': 'FIXED_POS' }` for an element with ports, empty otherwise. */ + layoutOptions: NodeElkLayoutOptions; + /** + * Empty. JointJS elements carry no labels, so add them only if ELK should + * size around them, e.g. under `elk.nodeSize.constraints: 'NODE_LABELS'`. + */ + labels?: ElkLabelDraft[]; +} + +/** + * Mutate `elkNode` to customize what this package computed for an element, or + * return `false` to drop the element - and its whole subtree (embeds, ports, + * any edge connected to any of it) - from the ELK graph entirely. + */ +export type ExportElementCallback = (params: ExportElementCallbackParameters) => void | false; +export type ExportElementCallbackParameters = { + element: dia.Element; + elkNode: ElkNodeDraft; +}; + +/** An ELK port draft, one per JointJS port. */ +export interface ElkPortDraft { + readonly id: string; + x: number; + y: number; + width: number; + height: number; + layoutOptions: PortElkLayoutOptions; +} + +/** + * Mutate `elkPort` to customize what this package computed for a port, or return + * `false` to 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 = { + portId: string; + element: dia.Element; + elkPort: ElkPortDraft; +}; + +/** An ELK edge draft, one per JointJS link. */ +export interface ElkEdgeDraft { + readonly id: string; + layoutOptions: EdgeElkLayoutOptions; +} + +/** + * Mutate `elkEdge` to customize what this package computed for a link, or return + * `false` to drop the edge from the ELK graph - it is simply not routed/laid out. + */ +export type ExportLinkCallback = (params: ExportLinkCallbackParameters) => void | false; +export type ExportLinkCallbackParameters = { + link: dia.Link; + elkEdge: ElkEdgeDraft; +}; + +export type ExportPortLabelCallback = (params: ExportPortLabelCallbackParameters) => void | false; +export type ExportPortLabelCallbackParameters = { + portId: string; + element: dia.Element; + elkPortLabel: ElkLabelDraft; +}; + +export type ExportLinkLabelCallback = (params: ExportLinkLabelCallbackParameters) => void | false; +export type ExportLinkLabelCallbackParameters = { + link: dia.Link; + labelIndex: number; + elkEdgeLabel: ElkLabelDraft; +}; + +export interface ElkGraphPort { + element: dia.Element; + portId: string; +} + +export interface ElkGraphData { + elkGraph: ElkNode; + elementsById: Map; + linksById: Map; + portsById: Map; +} + +export interface ExportGraphOptions { + exportElement?: ExportElementCallback; + exportPort?: ExportPortCallback; + exportPortLabel?: ExportPortLabelCallback; + exportLink?: ExportLinkCallback; + exportLinkLabel?: ExportLinkLabelCallback; +} + +let exportGraphOptions: ExportGraphOptions; + +let elementsById: Map; +let linksById: Map; +let portsById: Map; +// 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; +// 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, elements: dia.Element[]): void { + exportGraphOptions = options; + + elementsById = new Map(); + linksById = new Map(); + 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 { + 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. `exportPort` (if given) may mutate a port's draft, or return `false` to drop + * it from the ELK graph (see `ExportPortCallback`). + */ +function buildPorts(element: dia.Element): ElkPort[] | undefined { + if (!element.hasPorts()) return undefined; + + const ports: ElkPort[] = []; + + element.getPorts().forEach((port) => { + const portId = `${port.id}`; + const elkPortId = `${element.id}:${portId}`; + + // ELK takes a port's top-left corner, not its center. No `elk.port.borderOffset`: + // ELK places the port just outside the border, and `importLayout` moves its center + // onto the border (a negative offset would also shift the port along the side). + const { x, y, width, height } = element.getPortRelativeRect(portId); + + const elkPort: ElkPortDraft = { + id: elkPortId, + x, + y, + width, + height, + layoutOptions: {} + }; + + if (exportGraphOptions.exportPort?.({ portId, element, elkPort }) === false) { + getExcludedPortIds(element).add(portId); + return; + } + + const portLabel: ElkLabelDraft = { + width: 0, + height: 0, + layoutOptions: {} + }; + + exportGraphOptions.exportPortLabel?.({ portId, 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 + }); + }); + + return ports; +} + +/** + * ELK positions a node's children/edges relative to its own origin - `containerPosition` + * converts an element's graph-absolute position into that frame as we recurse down. + * Returns `null` if `exportElement` dropped the element - its whole subtree goes with it, + * so nothing is registered and nothing downstream (a child, a port, a connected edge) + * can end up referencing it. + */ +function buildElkNode(element: dia.Element, parentId?: string): ElkNode | null { + const id = `${element.id}`; + + 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). + let width = 0; + let height = 0; + if (embeds.length === 0) { + ({ width, height } = element.size()); + } + + const elkNode: ElkNodeDraft = { + id, + width, + height, + // Ports stay where JointJS already places them - without it, ELK is free to move + // them to another side or reorder them. `exportElement` may override it. + layoutOptions: element.hasPorts() ? { 'elk.portConstraints': 'FIXED_POS' } : {} + }; + + if (exportGraphOptions.exportElement?.({ element, elkNode }) === false) + return null; + + elementsById.set(id, element); + elkParentIdsById.set(id, parentId); + + const ports = buildPorts(element); + + let children: ElkNode[] | undefined; + let edges: ElkExtendedEdge[] | undefined; + if (embeds.length > 0) { + children = embeds + .map((embed) => buildElkNode(embed, id)) + .filter((node): node is ElkNode => node !== null); + // 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); + } + + return { + ...elkNode, + children, + ports, + edges + }; +} + +// The lowest common ancestor of an element and itself/an ancestor is the element's parent chain - +// this returns that chain in the ELK graph, ordered from the outermost ancestor to the +// immediate parent. +function getAncestorPath(element: dia.Element): string[] { + const path: string[] = []; + let parentId = elkParentIdsById.get(`${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 +// they don't share a root, i.e. one of them is the top-level root itself). +function getLowestCommonAncestorId(sourcePath: string[], targetPath: string[]): string | undefined { + let commonId: string | undefined; + const length = Math.min(sourcePath.length, targetPath.length); + for (let i = 0; i < length; i++) { + if (sourcePath[i] !== targetPath[i]) break; + commonId = sourcePath[i]; + } + return commonId; +} + +/** + * 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(); + 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}`; + + const sourcePort = link.source().port; + const targetPort = link.target().port; + + // 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}`]; + + const elkEdge: ElkEdgeDraft = { + id, + layoutOptions: {} + }; + + if (exportGraphOptions.exportLink?.({ link, elkEdge }) === false) + return; + + 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, 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) { + labels = resolvedLabels.reduce((result: ElkLabel[], label, labelIndex) => { + const { width, height } = label.size!; + const labelDraft: ElkLabelDraft = { + width, + height, + layoutOptions: { + 'elk.edgeLabels.inline': 'true' + } + }; + + if (exportGraphOptions.exportLinkLabel?.({ link, labelIndex, elkEdgeLabel: labelDraft }) === false) + return result; + + result.push({ + ...labelDraft, + id: getLinkLabelId(id, labelIndex), + text: ELK_LABEL_TEXT + }); + return result; + }, []); + } + + const edge: ElkExtendedEdge = { + ...elkEdge, + sources, + targets, + labels + }; + + const lcaId = getLowestCommonAncestorId(getAncestorPath(sourceElement), getAncestorPath(targetElement)); + const edges = edgeContainersById.get(lcaId); + // `edges` is always defined - `lcaId` is either `undefined` (the root) or the id of + // one of `sourceElement`/`targetElement`'s ancestors, and every ancestor still part + // of the ELK graph has already been registered in `edgeContainersById` by the time + // links are processed (an excluded ancestor would have failed the guard clause above). + (edges as ElkExtendedEdge[]).push(edge); +} + +/** + * Converts JointJS elements (with their embedded elements and ports) and the links + * between them to an ELK graph structure. + * + * `elements`/`links` are what takes part - a link only if both its ends do too - and + * set its order: the root's children and edges follow them, and so do each container's + * own children. An element whose parent isn't in `elements` becomes a top-level node. + */ +export function exportGraph( + elements: dia.Element[], + links: dia.Link[], + options: ExportGraphOptions, + elkLayoutOptions: ElkLayoutOptions +): ElkGraphData { + + init(options, elements); + + const topLevelElements = elements.filter((element) => !hasLaidOutParent(element)); + + const children: ElkNode[] = topLevelElements + .map((element) => buildElkNode(element)) + .filter((node): node is ElkNode => node !== null); + + const elkGraph: ElkNode = { + id: 'root', + layoutOptions: elkLayoutOptions, + children, + edges: [] + }; + // Shared with `edgeContainersById` (see there) - edges filed under the root by + // `buildEdge` need to end up on `elkGraph` itself. + edgeContainersById.set(undefined, elkGraph.edges as ElkExtendedEdge[]); + + links.forEach(buildEdge); + + return { elkGraph, elementsById, linksById, portsById }; +} diff --git a/packages/joint-layout-elk/src/import.mts b/packages/joint-layout-elk/src/import.mts new file mode 100644 index 0000000000..d4f3ce5bbc --- /dev/null +++ b/packages/joint-layout-elk/src/import.mts @@ -0,0 +1,284 @@ +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; +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; + }; + 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; + portId: string; + // Shaped for `element.portProp(portId, attributes)` - only the port's own + // `position.args`/`label.position.args` (a group's `position`/`label.position` + // decides which layout function reads them - handled separately, see `importNode`). + attributes: { + position: { args: dia.Point }; + // Present only when the port has a label. + label?: { position: { args: dia.Point } }; + }; + elkPort: ElkPort +}; + +/** 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; + // Shaped for `link.set(attributes)`. + attributes: { + vertices: dia.Point[]; + // 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 the link has labels - the whole current `labels` array, + // with each routed label's `position` replaced. + labels?: dia.Link.Label[]; + }; + elkEdge: ElkExtendedEdge +}; + +export interface ImportLayoutOptions { + setElementAttributes?: SetElementAttributesCallback; + setLinkAttributes?: SetLinkAttributesCallback; + setPortAttributes?: SetPortAttributesCallback; +} + +// The anchor for a link end 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 { + name: 'topLeft', + args: { + dx: delta.x, + dy: delta.y, + useModelGeometry: true + } + }; +} + +const defaultSetElementAttributes: SetElementAttributesCallback = ({ element, attributes }) => { + element.set(attributes); +}; + +const defaultSetPortAttributes: SetPortAttributesCallback = ({ element, portId, attributes }) => { + element.portProp(portId, attributes); +}; + +const defaultSetLinkAttributes: SetLinkAttributesCallback = ({ link, attributes }) => { + link.set(attributes); +}; + +let importLayoutOptions: ImportLayoutOptions; + +let elementsById: Map; +let linksById: Map; +let portsById: Map; + +/** + * (Re)initializes all the module-level state above for a single `importLayout` call, so + * that no lookup table or option can leak from one call into the next. + */ +function init( + elements: Map, + links: Map, + ports: Map, + options: ImportLayoutOptions +): void { + importLayoutOptions = options; + + elementsById = elements; + linksById = links; + portsById = ports; +} + +// ELK positions a node's children relative to that node's own origin - `containerPosition` +// accumulates the offset needed to turn those relative coordinates into graph-absolute +// ones as we walk down the hierarchy. Edges need no such conversion: `layout()` defaults +// `elk.json.edgeCoords` to 'ROOT', so ELK already returns their coordinates graph-absolute. +function toAbsolute(point: ElkPoint, containerPosition: dia.Point): dia.Point { + return { + x: containerPosition.x + point.x, + y: containerPosition.y + point.y + }; +} + +/** + * Applies a container's (or the root's) own ELK edges back onto their JointJS links, + * via `setLinkAttributes` (or the default). Their coordinates are used as they are - see + * `toAbsolute` above. + */ +function importEdges(edges: ElkExtendedEdge[] | undefined): void { + const setLinkAttributes = importLayoutOptions.setLinkAttributes ?? defaultSetLinkAttributes; + + (edges || []).forEach((edge) => { + const link = linksById.get(edge.id); + if (!link) return; + + const [section] = edge.sections || []; + if (!section) return; + + const { startPoint, endPoint, bendPoints = [] } = section; + + const vertices = bendPoints.map(({ x, y }) => ({ x, y })); + + // 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, startPoint) + }; + const currentTarget = link.target(); + const target = (currentTarget.port) ? undefined : { + ...currentTarget, + anchor: getPortlessEndAnchor(link.getTargetElement() as dia.Element, endPoint) + }; + + let labels: dia.Link.Label[] | undefined; + if (edge.labels && edge.labels.length > 0) { + const polyline = new g.Polyline([startPoint, ...bendPoints, endPoint]); + // `link.getComputedLabels()` (`@joint/core`) returns each label 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) => { + // 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); + // 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, + ...(source ? { source } : {}), + ...(target ? { target } : {}), + ...(labels ? { labels } : {}) + }, + elkEdge: edge + }); + }); +} + +/** + * Applies one ELK node's result (position, size, ports) back onto its JointJS element, + * then recurses into its children and its own edges. + */ +function importNode(node: ElkNode, containerPosition: dia.Point = { x: 0, y: 0 }): void { + const position = toAbsolute({ x: node.x || 0, y: node.y || 0 }, containerPosition); + + 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, + // 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 + }); + } + + if (node.ports) { + const setPortAttributes = importLayoutOptions.setPortAttributes ?? defaultSetPortAttributes; + + node.ports.forEach((port) => { + const found = portsById.get(port.id); + 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 - the + // label keeps its place next to the port as the port moves onto the border. + labelPosition = { + x: (label.x || 0) - (port.width || 0) / 2, + y: (label.y || 0) - (port.height || 0) / 2 + }; + } + + setPortAttributes({ + element, + portId, + attributes: { + position: { + args: center + }, + ...(labelPosition ? { + label: { + position: { + args: labelPosition + } + } + } : {}) + }, + elkPort: port + }); + }); + } + + (node.children || []).forEach((child) => importNode(child, position)); + importEdges(node.edges); +} + +/** + * Applies an ELK layout result back onto the JointJS graph. + */ +export function importLayout( + elkGraph: ElkNode, + elementsById: Map, + linksById: Map, + portsById: Map, + options: ImportLayoutOptions +): void { + + init(elementsById, linksById, portsById, options); + + (elkGraph.children || []).forEach((node) => importNode(node)); + importEdges(elkGraph.edges); +} diff --git a/packages/joint-layout-elk/src/index.mts b/packages/joint-layout-elk/src/index.mts new file mode 100644 index 0000000000..826d32c16a --- /dev/null +++ b/packages/joint-layout-elk/src/index.mts @@ -0,0 +1,9 @@ +// Public API barrel: the `layout()` entry point, its export/import callback types, and +// the ELK-facing graph/option types. +export * from './layout.mjs'; +export * from './import.mjs'; +export * from './export.mjs'; +export { createWorkerElk } from './workerElk.mjs'; +export type { WorkerElk } from './workerElk.mjs'; + +export type * from './types/index.mjs'; diff --git a/packages/joint-layout-elk/src/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/src/layout.mts b/packages/joint-layout-elk/src/layout.mts new file mode 100644 index 0000000000..5bfff40773 --- /dev/null +++ b/packages/joint-layout-elk/src/layout.mts @@ -0,0 +1,171 @@ +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 { ElkWorkerClient } from './workerElk.mjs'; + +import type { ExportGraphOptions } from './export.mjs'; +import type { ImportLayoutOptions } from './import.mjs'; +import type { WorkerElk } from './workerElk.mjs'; +import type { ElkLayoutOptions, ElkNode } from './types/index.mjs'; +import type { dia } from '@joint/core'; +import type { ELK, ElkNode as RawElkNode } from 'elkjs'; + +const LAYOUT_BATCH_NAME = 'layout'; + +const DEFAULT_LAYOUT_OPTIONS: ElkLayoutOptions = { + 'elk.algorithm': 'layered', + // Lay out embedded elements (containers) as part of the same pass as their + // parent, so that edges crossing a container's boundary are routed and + // accounted for correctly, instead of only being considered afterwards. + 'elk.hierarchyHandling': 'INCLUDE_CHILDREN', + // Keep the order of ports on a node consistent with the order of their + // `ports.items` array, instead of reordering them to reduce edge crossings. + 'elk.layered.considerModelOrder.portModelOrder': 'true', + // Return every edge's route points and labels graph-absolute, whichever container + // the edge belongs to - `importLayout` applies them as they are. Overriding this + // means handling the coordinates yourself (e.g. in `setLinkAttributes`). + 'elk.json.edgeCoords': 'ROOT' +}; + +const DEFAULT_OPTIONS: LayoutOptions = { + batchName: LAYOUT_BATCH_NAME, +}; + +/** + * Layout configuration options. + */ +export interface LayoutOptions extends ImportLayoutOptions, ExportGraphOptions { + + /** + * The ELK instance to lay out with - e.g. one running in a Web Worker, so the layout + * doesn't block the page (see `createWorkerElk()`), or any `elkjs` instance of your + * own. It is never terminated by the package. + * @defaultValue a shared instance running on the main thread (`elkjs/lib/elk.bundled.js`, + * loaded on the first layout that needs it) + * @example + * const elk = createWorkerElk(() => new Worker(new URL('elkjs/lib/elk-worker.min.js', import.meta.url))); + * layout({ graph }, { elk }); + */ + elk?: WorkerElk | ELK; + /** + * ELK layout options, passed through to ELK unmodified. + * @see https://eclipse.dev/elk/reference/options.html + * @defaultValue `{ 'elk.algorithm': 'layered', 'elk.hierarchyHandling': 'INCLUDE_CHILDREN', 'elk.json.edgeCoords': 'ROOT' }` + */ + elkLayoutOptions?: ElkLayoutOptions; + /** + * A name for the layout batch, which can be used to group multiple layout operations together. + * @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 a `createWorkerElk()` worker is busy with is stopped by terminating the + * worker (a new one takes over the layouts still waiting). ELK on the main thread, or + * any other `elk` instance, can't be stopped - its result is only ignored. + * @example + * const controller = new AbortController(); + * layout({ graph }, { signal: controller.signal }); + * graph.once('change', () => controller.abort()); + */ + signal?: AbortSignal; +} + +export interface LayoutResult { + /** Tight bounding box of the laid out graph. */ + bbox: g.Rect; + /** The raw ELK layout result, for anything not mapped back onto the graph (e.g. junction points). */ + elkGraph: ElkNode; +} + +/** + * 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); +} + +/** + * 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. + * 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 (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 (none without `graph`) + */ + 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( + {}, + opt?.elkLayoutOptions || {}, + 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() ?? [], + links ?? graph?.getLinks() ?? [], + options as ExportGraphOptions, + elkLayoutOptions + ); + + const rawElkGraph = elkGraph as unknown as RawElkNode; + 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); + + // Wraps the import in a single batch, so it emits one combined change instead of + // one per element/port/link. Closed even if a `set*Attributes` callback throws - + // a batch left open would e.g. keep a command manager from ever closing its undo step. + graph?.startBatch(batchName); + try { + importLayout(result, elementsById, linksById, portsById, options); + } finally { + graph?.stopBatch(batchName); + } + + return { + bbox: getBBox(result), + elkGraph: result + }; +} diff --git a/packages/joint-layout-elk/src/mainThreadElk.mts b/packages/joint-layout-elk/src/mainThreadElk.mts new file mode 100644 index 0000000000..a3ee735d79 --- /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 an app that always passes an `elk` of its + * own (e.g. one running in a Web Worker, see `createWorkerElk()`). In its own module so the UMD build, which can't + * load a chunk, can replace it with one importing it statically (see `rollup.config.mjs`). + */ +export async function loadMainThreadElk(): Promise ELK> { + const { default: ElkConstructor } = await import('elkjs/lib/elk.bundled.js'); + return ElkConstructor; +} diff --git a/packages/joint-layout-elk/src/types/elkEdgeOptions.mts b/packages/joint-layout-elk/src/types/elkEdgeOptions.mts new file mode 100644 index 0000000000..67855fc619 --- /dev/null +++ b/packages/joint-layout-elk/src/types/elkEdgeOptions.mts @@ -0,0 +1,68 @@ +import type { EdgeType } from './elkEnums.mjs'; + +export interface EdgeElkLayoutOptions { + // Falls back to a plain string for any ELK option beyond this package's Core/Layered coverage. + [key: string]: string | undefined; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-bendPoints.html + */ + 'elk.bendPoints'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-individual.html + */ + 'elk.spacing.individual'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-priority.html + */ + 'elk.priority'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-junctionPoints.html + * @defaultValue `new KVectorChain()` + */ + 'elk.junctionPoints'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-noLayout.html + * @defaultValue 'false' + */ + 'elk.noLayout'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-insideSelfLoops-yo.html + * @defaultValue 'false' + */ + 'elk.insideSelfLoops.yo'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-edge-thickness.html + * @defaultValue '1' + */ + 'elk.edge.thickness'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-edge-type.html + * @defaultValue 'NONE' + */ + 'elk.edge.type'?: EdgeType; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-priority-direction.html + * @defaultValue '0' + */ + 'elk.layered.priority.direction'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-priority-shortness.html + * @defaultValue '0' + */ + 'elk.layered.priority.shortness'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-priority-straightness.html + * @defaultValue '0' + */ + 'elk.layered.priority.straightness'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-groupModelOrder-crossingMinimizationId.html + * @defaultValue '0' + */ + 'elk.layered.considerModelOrder.groupModelOrder.crossingMinimizationId'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-groupModelOrder-componentGroupId.html + * @defaultValue '0' + */ + 'elk.layered.considerModelOrder.groupModelOrder.componentGroupId'?: `${number}`; +} diff --git a/packages/joint-layout-elk/src/types/elkEnums.mts b/packages/joint-layout-elk/src/types/elkEnums.mts new file mode 100644 index 0000000000..0225c2ef9c --- /dev/null +++ b/packages/joint-layout-elk/src/types/elkEnums.mts @@ -0,0 +1,47 @@ +// ELK enum-typed option values. +export type ElkAlgorithm = 'layered' | 'stress' | 'mrtree' | 'radial' | 'force' | 'disco' | 'box' | 'fixed' | 'random' | (string & {}); +export type Alignment = 'AUTOMATIC' | 'LEFT' | 'RIGHT' | 'TOP' | 'BOTTOM' | 'CENTER'; +export type ContentAlignment = 'V_TOP' | 'V_CENTER' | 'V_BOTTOM' | 'H_LEFT' | 'H_CENTER' | 'H_RIGHT'; +export type Direction = 'UNDEFINED' | 'RIGHT' | 'LEFT' | 'DOWN' | 'UP'; +export type EdgeRouting = 'UNDEFINED' | 'POLYLINE' | 'ORTHOGONAL' | 'SPLINES'; +export type HierarchyHandling = 'INHERIT' | 'INCLUDE_CHILDREN' | 'SEPARATE_CHILDREN'; +export type ShapeCoords = 'INHERIT' | 'PARENT' | 'ROOT'; +export type EdgeCoords = 'INHERIT' | 'CONTAINER' | 'PARENT' | 'ROOT'; +export type NodeLabelPlacement = 'H_LEFT' | 'H_CENTER' | 'H_RIGHT' | 'V_TOP' | 'V_CENTER' | 'V_BOTTOM' | 'INSIDE' | 'OUTSIDE' | 'H_PRIORITY'; +export type PortAlignment = 'DISTRIBUTED' | 'JUSTIFIED' | 'BEGIN' | 'CENTER' | 'END'; +export type PortConstraints = 'UNDEFINED' | 'FREE' | 'FIXED_SIDE' | 'FIXED_ORDER' | 'FIXED_RATIO' | 'FIXED_POS'; +export type SizeConstraint = 'PORTS' | 'PORT_LABELS' | 'NODE_LABELS' | 'MINIMUM_SIZE'; +export type SizeOptions = 'DEFAULT_MINIMUM_SIZE' | 'MINIMUM_SIZE_ACCOUNTS_FOR_PADDING' | 'COMPUTE_PADDING' | 'OUTSIDE_NODE_LABELS_OVERHANG' | 'PORTS_OVERHANG' | 'UNIFORM_PORT_SPACING' | 'SPACE_EFFICIENT_PORT_LABELS' | 'FORCE_TABULAR_NODE_LABELS' | 'ASYMMETRICAL'; +export type EdgeLabelPlacement = 'CENTER' | 'HEAD' | 'TAIL'; +export type PortSide = 'UNDEFINED' | 'NORTH' | 'EAST' | 'SOUTH' | 'WEST'; +export type PortLabelPlacement = 'OUTSIDE' | 'INSIDE' | 'NEXT_TO_PORT_IF_POSSIBLE' | 'ALWAYS_SAME_SIDE' | 'ALWAYS_OTHER_SAME_SIDE' | 'SPACE_EFFICIENT'; +export type TopdownNodeTypes = 'PARALLEL_NODE' | 'HIERARCHICAL_NODE' | 'ROOT_NODE'; +export type EdgeType = 'NONE' | 'DIRECTED' | 'UNDIRECTED' | 'ASSOCIATION' | 'GENERALIZATION' | 'DEPENDENCY'; +export type CycleBreakingStrategy = 'GREEDY' | 'DEPTH_FIRST' | 'INTERACTIVE' | 'MODEL_ORDER' | 'GREEDY_MODEL_ORDER' | 'SCC_CONNECTIVITY' | 'SCC_NODE_TYPE' | 'DFS_NODE_ORDER' | 'BFS_NODE_ORDER'; +export type LayeringStrategy = 'NETWORK_SIMPLEX' | 'LONGEST_PATH' | 'LONGEST_PATH_SOURCE' | 'COFFMAN_GRAHAM' | 'INTERACTIVE' | 'STRETCH_WIDTH' | 'MIN_WIDTH' | 'BF_MODEL_ORDER' | 'DF_MODEL_ORDER'; +export type LayerConstraint = 'NONE' | 'FIRST' | 'FIRST_SEPARATE' | 'LAST' | 'LAST_SEPARATE'; +export type NodePromotionStrategy = 'NONE' | 'NIKOLOV' | 'NIKOLOV_PIXEL' | 'NIKOLOV_IMPROVED' | 'NIKOLOV_IMPROVED_PIXEL' | 'DUMMYNODE_PERCENTAGE' | 'NODECOUNT_PERCENTAGE' | 'NO_BOUNDARY' | 'MODEL_ORDER_LEFT_TO_RIGHT' | 'MODEL_ORDER_RIGHT_TO_LEFT'; +export type CrossingMinimizationStrategy = 'LAYER_SWEEP' | 'MEDIAN_LAYER_SWEEP' | 'INTERACTIVE' | 'NONE'; +export type GreedySwitchType = 'ONE_SIDED' | 'TWO_SIDED' | 'OFF'; +export type NodePlacementStrategy = 'SIMPLE' | 'INTERACTIVE' | 'LINEAR_SEGMENTS' | 'BRANDES_KOEPF' | 'NETWORK_SIMPLEX'; +export type EdgeStraighteningStrategy = 'NONE' | 'IMPROVE_STRAIGHTNESS'; +export type FixedAlignment = 'NONE' | 'LEFTUP' | 'RIGHTUP' | 'LEFTDOWN' | 'RIGHTDOWN' | 'BALANCED'; +export type NodeFlexibility = 'NONE' | 'PORT_POSITION' | 'NODE_SIZE_WHERE_SPACE_PERMITS' | 'NODE_SIZE'; +export type SplineRoutingMode = 'CONSERVATIVE' | 'CONSERVATIVE_SOFT' | 'SLOPPY'; +export type SelfLoopDistributionStrategy = 'EQUALLY' | 'NORTH' | 'NORTH_SOUTH'; +export type SelfLoopOrderingStrategy = 'STACKED' | 'REVERSE_STACKED' | 'SEQUENCED'; +export type GraphCompactionStrategy = 'NONE' | 'LEFT' | 'RIGHT' | 'LEFT_RIGHT_CONSTRAINT_LOCKING' | 'LEFT_RIGHT_CONNECTION_LOCKING' | 'EDGE_LENGTH'; +export type ConstraintCalculationStrategy = 'QUADRATIC' | 'SCANLINE'; +export type WrappingStrategy = 'OFF' | 'SINGLE_EDGE' | 'MULTI_EDGE'; +export type CuttingStrategy = 'ARD' | 'MSD' | 'MANUAL'; +export type ValidifyStrategy = 'NO' | 'GREEDY' | 'LOOK_BACK'; +export type LayerUnzippingStrategy = 'NONE' | 'ALTERNATING'; +export type EdgeLabelSideSelection = 'ALWAYS_UP' | 'ALWAYS_DOWN' | 'DIRECTION_UP' | 'DIRECTION_DOWN' | 'SMART_UP' | 'SMART_DOWN'; +export type CenterEdgeLabelPlacementStrategy = 'MEDIAN_LAYER' | 'TAIL_LAYER' | 'HEAD_LAYER' | 'SPACE_EFFICIENT_LAYER' | 'WIDEST_LAYER' | 'CENTER_LAYER'; +export type OrderingStrategy = 'NONE' | 'NODES_AND_EDGES' | 'PREFER_EDGES' | 'PREFER_NODES'; +export type ComponentOrderingStrategy = 'NONE' | 'INSIDE_PORT_SIDE_GROUPS' | 'GROUP_MODEL_ORDER' | 'MODEL_ORDER'; +export type LongEdgeOrderingStrategy = 'DUMMY_NODE_OVER' | 'DUMMY_NODE_UNDER' | 'EQUAL'; +export type GroupOrderStrategy = 'ONLY_WITHIN_GROUP' | 'MODEL_ORDER' | 'ENFORCED'; +export type DirectionCongruency = 'READING_DIRECTION' | 'ROTATION'; +export type InteractiveReferencePoint = 'CENTER' | 'TOP_LEFT'; +export type PortSortingStrategy = 'INPUT_ORDER' | 'PORT_DEGREE'; diff --git a/packages/joint-layout-elk/src/types/elkGraph.mts b/packages/joint-layout-elk/src/types/elkGraph.mts new file mode 100644 index 0000000000..f2837d2c91 --- /dev/null +++ b/packages/joint-layout-elk/src/types/elkGraph.mts @@ -0,0 +1,35 @@ +import type { + ElkNode as RawElkNode, + ElkPort as RawElkPort, + ElkExtendedEdge as RawElkExtendedEdge, + ElkLabel as RawElkLabel +} from 'elkjs'; + +import type { NodeElkLayoutOptions } from './elkNodeOptions.mjs'; +import type { PortElkLayoutOptions } from './elkPortOptions.mjs'; +import type { EdgeElkLayoutOptions } from './elkEdgeOptions.mjs'; +import type { LabelElkLayoutOptions } from './elkLabelOptions.mjs'; + +// ELK graph element shapes, narrowing `layoutOptions` (and, on `ElkNode`, the element +// arrays it nests) from elkjs's own loosely-typed versions to the option types above. +export interface ElkLabel extends Omit { + layoutOptions?: LabelElkLayoutOptions; +} + +export interface ElkPort extends Omit { + layoutOptions?: PortElkLayoutOptions; + labels?: ElkLabel[]; +} + +export interface ElkExtendedEdge extends Omit { + layoutOptions?: EdgeElkLayoutOptions; + labels?: ElkLabel[]; +} + +export interface ElkNode extends Omit { + layoutOptions?: NodeElkLayoutOptions; + children?: ElkNode[]; + ports?: ElkPort[]; + edges?: ElkExtendedEdge[]; + labels?: ElkLabel[]; +} diff --git a/packages/joint-layout-elk/src/types/elkLabelOptions.mts b/packages/joint-layout-elk/src/types/elkLabelOptions.mts new file mode 100644 index 0000000000..446bfceba9 --- /dev/null +++ b/packages/joint-layout-elk/src/types/elkLabelOptions.mts @@ -0,0 +1,52 @@ +import type { EdgeLabelPlacement, CenterEdgeLabelPlacementStrategy } from './elkEnums.mjs'; + +export interface LabelElkLayoutOptions { + // Falls back to a plain string for any ELK option beyond this package's Core/Layered coverage. + [key: string]: string | undefined; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-individual.html + */ + 'elk.spacing.individual'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-nodeLabels-placement.html + * @defaultValue `NodeLabelPlacement.fixed` + */ + 'elk.nodeLabels.placement'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-position.html + */ + 'elk.position'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-edgeLabels-placement.html + * @defaultValue 'CENTER' + */ + 'elk.edgeLabels.placement'?: EdgeLabelPlacement; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-edgeLabels-inline.html + * @defaultValue 'false' + */ + 'elk.edgeLabels.inline'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-font-name.html + */ + 'elk.font.name'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-font-size.html + */ + 'elk.font.size'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-softwrappingFuzziness.html + * @defaultValue '0.0' + */ + 'elk.softwrappingFuzziness'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-noLayout.html + * @defaultValue 'false' + */ + 'elk.noLayout'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-edgeLabels-centerLabelPlacementStrategy.html + * @defaultValue 'MEDIAN_LAYER' + */ + 'elk.layered.edgeLabels.centerLabelPlacementStrategy'?: CenterEdgeLabelPlacementStrategy; +} diff --git a/packages/joint-layout-elk/src/types/elkLayoutOptions.mts b/packages/joint-layout-elk/src/types/elkLayoutOptions.mts new file mode 100644 index 0000000000..8904ddc780 --- /dev/null +++ b/packages/joint-layout-elk/src/types/elkLayoutOptions.mts @@ -0,0 +1,926 @@ +import type { + ElkAlgorithm, + Alignment, + Direction, + EdgeRouting, + HierarchyHandling, + ShapeCoords, + EdgeCoords, + PortAlignment, + PortConstraints, + EdgeLabelPlacement, + TopdownNodeTypes, + CycleBreakingStrategy, + LayeringStrategy, + LayerConstraint, + NodePromotionStrategy, + CrossingMinimizationStrategy, + GreedySwitchType, + NodePlacementStrategy, + EdgeStraighteningStrategy, + FixedAlignment, + NodeFlexibility, + SplineRoutingMode, + SelfLoopDistributionStrategy, + SelfLoopOrderingStrategy, + GraphCompactionStrategy, + ConstraintCalculationStrategy, + WrappingStrategy, + CuttingStrategy, + ValidifyStrategy, + LayerUnzippingStrategy, + EdgeLabelSideSelection, + CenterEdgeLabelPlacementStrategy, + OrderingStrategy, + ComponentOrderingStrategy, + LongEdgeOrderingStrategy, + GroupOrderStrategy, + DirectionCongruency, + InteractiveReferencePoint, + PortSortingStrategy, + EdgeType, + PortSide +} from './elkEnums.mjs'; + +export interface ElkLayoutOptions { + // Falls back to a plain string for any ELK option beyond this package's Core/Layered coverage. + [key: string]: string | undefined; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-box-packingMode.html + * @defaultValue `BoxLayoutProvider.PackingMode.SIMPLE` + */ + 'elk.box.packingMode'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-algorithm.html + */ + 'elk.algorithm'?: ElkAlgorithm; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-alignment.html + * @defaultValue 'AUTOMATIC' + */ + 'elk.alignment'?: Alignment; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-aspectRatio.html + */ + 'elk.aspectRatio'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-bendPoints.html + */ + 'elk.bendPoints'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-contentAlignment.html + * @defaultValue `ContentAlignment.topLeft()` + */ + 'elk.contentAlignment'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-debugMode.html + * @defaultValue 'false' + */ + 'elk.debugMode'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-direction.html + * @defaultValue 'UNDEFINED' + */ + 'elk.direction'?: Direction; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-edgeRouting.html + * @defaultValue 'UNDEFINED' + */ + 'elk.edgeRouting'?: EdgeRouting; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-expandNodes.html + * @defaultValue 'false' + */ + 'elk.expandNodes'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-hierarchyHandling.html + * @defaultValue 'INHERIT' + */ + 'elk.hierarchyHandling'?: HierarchyHandling; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-padding.html + * @defaultValue `new ElkPadding(12)` + */ + 'elk.padding'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-interactive.html + * @defaultValue 'false' + */ + 'elk.interactive'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-interactiveLayout.html + * @defaultValue 'false' + */ + 'elk.interactiveLayout'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-omitNodeMicroLayout.html + * @defaultValue 'false' + */ + 'elk.omitNodeMicroLayout'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-json-shapeCoords.html + * @defaultValue 'INHERIT' + */ + 'elk.json.shapeCoords'?: ShapeCoords; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-json-edgeCoords.html + * @defaultValue 'INHERIT' + */ + 'elk.json.edgeCoords'?: EdgeCoords; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-commentComment.html + * @defaultValue '10' + */ + 'elk.spacing.commentComment'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-commentNode.html + * @defaultValue '10' + */ + 'elk.spacing.commentNode'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-componentComponent.html + * @defaultValue '20' + */ + 'elk.spacing.componentComponent'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-edgeEdge.html + * @defaultValue '10' + */ + 'elk.spacing.edgeEdge'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-edgeLabel.html + * @defaultValue '2' + */ + 'elk.spacing.edgeLabel'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-edgeNode.html + * @defaultValue '10' + */ + 'elk.spacing.edgeNode'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-labelLabel.html + * @defaultValue '0' + */ + 'elk.spacing.labelLabel'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-labelNode.html + * @defaultValue '5' + */ + 'elk.spacing.labelNode'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-labelPortHorizontal.html + * @defaultValue '1' + */ + 'elk.spacing.labelPortHorizontal'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-labelPortVertical.html + * @defaultValue '1' + */ + 'elk.spacing.labelPortVertical'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-nodeNode.html + * @defaultValue '20' + */ + 'elk.spacing.nodeNode'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-nodeSelfLoop.html + * @defaultValue '10' + */ + 'elk.spacing.nodeSelfLoop'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-portPort.html + * @defaultValue '10' + */ + 'elk.spacing.portPort'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-individual.html + */ + 'elk.spacing.individual'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-portsSurrounding.html + * @defaultValue `new ElkMargin(0)` + */ + 'elk.spacing.portsSurrounding'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-partitioning-partition.html + */ + 'elk.partitioning.partition'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-partitioning-activate.html + * @defaultValue 'false' + */ + 'elk.partitioning.activate'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-nodeLabels-padding.html + * @defaultValue `new ElkPadding(5)` + */ + 'elk.nodeLabels.padding'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-nodeLabels-placement.html + * @defaultValue `NodeLabelPlacement.fixed` + */ + 'elk.nodeLabels.placement'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portAlignment-default.html + * @defaultValue 'DISTRIBUTED' + */ + 'elk.portAlignment.default'?: PortAlignment; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portAlignment-north.html + */ + 'elk.portAlignment.north'?: PortAlignment; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portAlignment-south.html + */ + 'elk.portAlignment.south'?: PortAlignment; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portAlignment-west.html + */ + 'elk.portAlignment.west'?: PortAlignment; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portAlignment-east.html + */ + 'elk.portAlignment.east'?: PortAlignment; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portConstraints.html + * @defaultValue 'UNDEFINED' + */ + 'elk.portConstraints'?: PortConstraints; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-position.html + */ + 'elk.position'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-priority.html + */ + 'elk.priority'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-randomSeed.html + */ + 'elk.randomSeed'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-separateConnectedComponents.html + */ + 'elk.separateConnectedComponents'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-nodeSize-constraints.html + * @defaultValue `EnumSet.noneOf(SizeConstraint)` + */ + 'elk.nodeSize.constraints'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-nodeSize-options.html + * @defaultValue `EnumSet.of(SizeOptions.DEFAULT_MINIMUM_SIZE)` + */ + 'elk.nodeSize.options'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-nodeSize-minimum.html + * @defaultValue `new KVector(0, 0)` + */ + 'elk.nodeSize.minimum'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-nodeSize-fixedGraphSize.html + * @defaultValue 'false' + */ + 'elk.nodeSize.fixedGraphSize'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-junctionPoints.html + * @defaultValue `new KVectorChain()` + */ + 'elk.junctionPoints'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-commentBox.html + * @defaultValue 'false' + */ + 'elk.commentBox'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-edgeLabels-placement.html + * @defaultValue 'CENTER' + */ + 'elk.edgeLabels.placement'?: EdgeLabelPlacement; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-edgeLabels-inline.html + * @defaultValue 'false' + */ + 'elk.edgeLabels.inline'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-font-name.html + */ + 'elk.font.name'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-font-size.html + */ + 'elk.font.size'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-hypernode.html + * @defaultValue 'false' + */ + 'elk.hypernode'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-softwrappingFuzziness.html + * @defaultValue '0.0' + */ + 'elk.softwrappingFuzziness'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-margins.html + * @defaultValue `new ElkMargin()` + */ + 'elk.margins'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-noLayout.html + * @defaultValue 'false' + */ + 'elk.noLayout'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-port-anchor.html + */ + 'elk.port.anchor'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-port-index.html + */ + 'elk.port.index'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-port-side.html + * @defaultValue 'UNDEFINED' + */ + 'elk.port.side'?: PortSide; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-port-borderOffset.html + */ + 'elk.port.borderOffset'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portLabels-placement.html + * @defaultValue `PortLabelPlacement.outside` + */ + 'elk.portLabels.placement'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portLabels-nextToPortIfPossible.html + * @defaultValue 'false' + */ + 'elk.portLabels.nextToPortIfPossible'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portLabels-treatAsGroup.html + * @defaultValue 'true' + */ + 'elk.portLabels.treatAsGroup'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-scaleFactor.html + * @defaultValue '1' + */ + 'elk.scaleFactor'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-childAreaWidth.html + */ + 'elk.childAreaWidth'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-childAreaHeight.html + */ + 'elk.childAreaHeight'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-topdownLayout.html + * @defaultValue 'false' + */ + 'elk.topdownLayout'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-topdown-sizeCategories.html + * @defaultValue '3' + */ + 'elk.topdown.sizeCategories'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-topdown-sizeCategoriesHierarchicalNodeWeight.html + * @defaultValue '4' + */ + 'elk.topdown.sizeCategoriesHierarchicalNodeWeight'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-topdown-scaleFactor.html + * @defaultValue '1' + */ + 'elk.topdown.scaleFactor'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-topdown-sizeApproximator.html + * @defaultValue `null` + */ + 'elk.topdown.sizeApproximator'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-topdown-hierarchicalNodeWidth.html + * @defaultValue '150' + */ + 'elk.topdown.hierarchicalNodeWidth'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-topdown-hierarchicalNodeAspectRatio.html + * @defaultValue '1.414' + */ + 'elk.topdown.hierarchicalNodeAspectRatio'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-topdown-nodeType.html + * @defaultValue `null` + */ + 'elk.topdown.nodeType'?: TopdownNodeTypes; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-topdown-scaleCap.html + * @defaultValue '1' + */ + 'elk.topdown.scaleCap'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-insideSelfLoops-activate.html + * @defaultValue 'false' + */ + 'elk.insideSelfLoops.activate'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-insideSelfLoops-yo.html + * @defaultValue 'false' + */ + 'elk.insideSelfLoops.yo'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-edge-thickness.html + * @defaultValue '1' + */ + 'elk.edge.thickness'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-edge-type.html + * @defaultValue 'NONE' + */ + 'elk.edge.type'?: EdgeType; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-animate.html + * @defaultValue 'true' + */ + 'elk.animate'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-animTimeFactor.html + * @defaultValue '100' + */ + 'elk.animTimeFactor'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layoutAncestors.html + * @defaultValue 'false' + */ + 'elk.layoutAncestors'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-maxAnimTime.html + * @defaultValue '4000' + */ + 'elk.maxAnimTime'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-minAnimTime.html + * @defaultValue '400' + */ + 'elk.minAnimTime'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-progressBar.html + * @defaultValue 'false' + */ + 'elk.progressBar'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-validateGraph.html + * @defaultValue 'false' + */ + 'elk.validateGraph'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-validateOptions.html + * @defaultValue 'true' + */ + 'elk.validateOptions'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-zoomToFit.html + * @defaultValue 'false' + */ + 'elk.zoomToFit'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-cycleBreaking-strategy.html + * @defaultValue 'GREEDY' + */ + 'elk.layered.cycleBreaking.strategy'?: CycleBreakingStrategy; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layering-strategy.html + * @defaultValue 'NETWORK_SIMPLEX' + */ + 'elk.layered.layering.strategy'?: LayeringStrategy; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layering-layerConstraint.html + * @defaultValue 'NONE' + */ + 'elk.layered.layering.layerConstraint'?: LayerConstraint; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layering-layerChoiceConstraint.html + * @defaultValue `null` + */ + 'elk.layered.layering.layerChoiceConstraint'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layering-layerId.html + * @defaultValue '-1' + */ + 'elk.layered.layering.layerId'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layering-minWidth-upperBoundOnWidth.html + * @defaultValue '4' + */ + 'elk.layered.layering.minWidth.upperBoundOnWidth'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layering-minWidth-upperLayerEstimationScalingFactor.html + * @defaultValue '2' + */ + 'elk.layered.layering.minWidth.upperLayerEstimationScalingFactor'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layering-nodePromotion-strategy.html + * @defaultValue 'NONE' + */ + 'elk.layered.layering.nodePromotion.strategy'?: NodePromotionStrategy; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layering-nodePromotion-maxIterations.html + * @defaultValue '0' + */ + 'elk.layered.layering.nodePromotion.maxIterations'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layering-coffmanGraham-layerBound.html + * @defaultValue 'MAX_VALUE' + */ + 'elk.layered.layering.coffmanGraham.layerBound'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-crossingMinimization-strategy.html + * @defaultValue 'LAYER_SWEEP' + */ + 'elk.layered.crossingMinimization.strategy'?: CrossingMinimizationStrategy; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-crossingMinimization-forceNodeModelOrder.html + * @defaultValue 'false' + */ + 'elk.layered.crossingMinimization.forceNodeModelOrder'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-crossingMinimization-hierarchicalSweepiness.html + * @defaultValue '0.1' + */ + 'elk.layered.crossingMinimization.hierarchicalSweepiness'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-crossingMinimization-greedySwitch-activationThreshold.html + * @defaultValue '40' + */ + 'elk.layered.crossingMinimization.greedySwitch.activationThreshold'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-crossingMinimization-greedySwitch-type.html + * @defaultValue 'TWO_SIDED' + */ + 'elk.layered.crossingMinimization.greedySwitch.type'?: GreedySwitchType; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-crossingMinimization-greedySwitchHierarchical-type.html + * @defaultValue 'OFF' + */ + 'elk.layered.crossingMinimization.greedySwitchHierarchical.type'?: GreedySwitchType; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-crossingMinimization-semiInteractive.html + * @defaultValue 'false' + */ + 'elk.layered.crossingMinimization.semiInteractive'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-crossingMinimization-inLayerPredOf.html + * @defaultValue `null` + */ + 'elk.layered.crossingMinimization.inLayerPredOf'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-crossingMinimization-inLayerSuccOf.html + * @defaultValue `null` + */ + 'elk.layered.crossingMinimization.inLayerSuccOf'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-crossingMinimization-positionChoiceConstraint.html + * @defaultValue `null` + */ + 'elk.layered.crossingMinimization.positionChoiceConstraint'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-crossingMinimization-positionId.html + * @defaultValue '-1' + */ + 'elk.layered.crossingMinimization.positionId'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-nodePlacement-strategy.html + * @defaultValue 'BRANDES_KOEPF' + */ + 'elk.layered.nodePlacement.strategy'?: NodePlacementStrategy; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-nodePlacement-favorStraightEdges.html + */ + 'elk.layered.nodePlacement.favorStraightEdges'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-nodePlacement-bk-edgeStraightening.html + * @defaultValue 'IMPROVE_STRAIGHTNESS' + */ + 'elk.layered.nodePlacement.bk.edgeStraightening'?: EdgeStraighteningStrategy; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-nodePlacement-bk-fixedAlignment.html + * @defaultValue 'NONE' + */ + 'elk.layered.nodePlacement.bk.fixedAlignment'?: FixedAlignment; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-nodePlacement-linearSegments-deflectionDampening.html + * @defaultValue '0.3' + */ + 'elk.layered.nodePlacement.linearSegments.deflectionDampening'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-nodePlacement-networkSimplex-nodeFlexibility.html + */ + 'elk.layered.nodePlacement.networkSimplex.nodeFlexibility'?: NodeFlexibility; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-nodePlacement-networkSimplex-nodeFlexibility-default.html + * @defaultValue 'NONE' + */ + 'elk.layered.nodePlacement.networkSimplex.nodeFlexibility.default'?: NodeFlexibility; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-nodePlacement-networkSimplex-nodeFlexibility-recomputeNodePlacement.html + * @defaultValue `null` + */ + 'elk.layered.nodePlacement.networkSimplex.nodeFlexibility.recomputeNodePlacement'?: NodePlacementStrategy; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-edgeRouting-splines-mode.html + * @defaultValue 'SLOPPY' + */ + 'elk.layered.edgeRouting.splines.mode'?: SplineRoutingMode; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-edgeRouting-splines-sloppy-layerSpacingFactor.html + * @defaultValue '0.2' + */ + 'elk.layered.edgeRouting.splines.sloppy.layerSpacingFactor'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-edgeRouting-polyline-slopedEdgeZoneWidth.html + * @defaultValue '2.0' + */ + 'elk.layered.edgeRouting.polyline.slopedEdgeZoneWidth'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-edgeRouting-selfLoopDistribution.html + * @defaultValue 'NORTH' + */ + 'elk.layered.edgeRouting.selfLoopDistribution'?: SelfLoopDistributionStrategy; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-edgeRouting-selfLoopOrdering.html + * @defaultValue 'STACKED' + */ + 'elk.layered.edgeRouting.selfLoopOrdering'?: SelfLoopOrderingStrategy; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-spacing-baseValue.html + */ + 'elk.layered.spacing.baseValue'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-spacing-edgeNodeBetweenLayers.html + * @defaultValue '10' + */ + 'elk.layered.spacing.edgeNodeBetweenLayers'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-spacing-edgeEdgeBetweenLayers.html + * @defaultValue '10' + */ + 'elk.layered.spacing.edgeEdgeBetweenLayers'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-spacing-nodeNodeBetweenLayers.html + * @defaultValue '20' + */ + 'elk.layered.spacing.nodeNodeBetweenLayers'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-priority-direction.html + * @defaultValue '0' + */ + 'elk.layered.priority.direction'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-priority-shortness.html + * @defaultValue '0' + */ + 'elk.layered.priority.shortness'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-priority-straightness.html + * @defaultValue '0' + */ + 'elk.layered.priority.straightness'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-compaction-postCompaction-strategy.html + * @defaultValue 'NONE' + */ + 'elk.layered.compaction.postCompaction.strategy'?: GraphCompactionStrategy; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-compaction-postCompaction-constraints.html + * @defaultValue 'SCANLINE' + */ + 'elk.layered.compaction.postCompaction.constraints'?: ConstraintCalculationStrategy; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-compaction-connectedComponents.html + * @defaultValue 'false' + */ + 'elk.layered.compaction.connectedComponents'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-highDegreeNodes-treatment.html + * @defaultValue 'false' + */ + 'elk.layered.highDegreeNodes.treatment'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-highDegreeNodes-threshold.html + * @defaultValue '16' + */ + 'elk.layered.highDegreeNodes.threshold'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-highDegreeNodes-treeHeight.html + * @defaultValue '5' + */ + 'elk.layered.highDegreeNodes.treeHeight'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-wrapping-strategy.html + * @defaultValue 'OFF' + */ + 'elk.layered.wrapping.strategy'?: WrappingStrategy; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-wrapping-additionalEdgeSpacing.html + * @defaultValue '10' + */ + 'elk.layered.wrapping.additionalEdgeSpacing'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-wrapping-correctionFactor.html + * @defaultValue '1.0' + */ + 'elk.layered.wrapping.correctionFactor'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-wrapping-cutting-strategy.html + * @defaultValue 'MSD' + */ + 'elk.layered.wrapping.cutting.strategy'?: CuttingStrategy; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-wrapping-cutting-cuts.html + */ + 'elk.layered.wrapping.cutting.cuts'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-wrapping-cutting-msd-freedom.html + * @defaultValue '1' + */ + 'elk.layered.wrapping.cutting.msd.freedom'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-wrapping-validify-strategy.html + * @defaultValue 'GREEDY' + */ + 'elk.layered.wrapping.validify.strategy'?: ValidifyStrategy; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-wrapping-validify-forbiddenIndices.html + */ + 'elk.layered.wrapping.validify.forbiddenIndices'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-wrapping-multiEdge-improveCuts.html + * @defaultValue 'true' + */ + 'elk.layered.wrapping.multiEdge.improveCuts'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-wrapping-multiEdge-distancePenalty.html + * @defaultValue '2.0' + */ + 'elk.layered.wrapping.multiEdge.distancePenalty'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-wrapping-multiEdge-improveWrappedEdges.html + * @defaultValue 'true' + */ + 'elk.layered.wrapping.multiEdge.improveWrappedEdges'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layerUnzipping-strategy.html + * @defaultValue 'NONE' + */ + 'elk.layered.layerUnzipping.strategy'?: LayerUnzippingStrategy; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layerUnzipping-minimizeEdgeLength.html + * @defaultValue 'false' + */ + 'elk.layered.layerUnzipping.minimizeEdgeLength'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layerUnzipping-layerSplit.html + * @defaultValue '2' + */ + 'elk.layered.layerUnzipping.layerSplit'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layerUnzipping-resetOnLongEdges.html + * @defaultValue 'true' + */ + 'elk.layered.layerUnzipping.resetOnLongEdges'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-edgeLabels-sideSelection.html + * @defaultValue 'SMART_DOWN' + */ + 'elk.layered.edgeLabels.sideSelection'?: EdgeLabelSideSelection; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-edgeLabels-centerLabelPlacementStrategy.html + * @defaultValue 'MEDIAN_LAYER' + */ + 'elk.layered.edgeLabels.centerLabelPlacementStrategy'?: CenterEdgeLabelPlacementStrategy; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-strategy.html + * @defaultValue 'NONE' + */ + 'elk.layered.considerModelOrder.strategy'?: OrderingStrategy; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-portModelOrder.html + * @defaultValue 'false' + */ + 'elk.layered.considerModelOrder.portModelOrder'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-noModelOrder.html + * @defaultValue 'false' + */ + 'elk.layered.considerModelOrder.noModelOrder'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-components.html + * @defaultValue 'NONE' + */ + 'elk.layered.considerModelOrder.components'?: ComponentOrderingStrategy; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-longEdgeStrategy.html + * @defaultValue 'DUMMY_NODE_OVER' + */ + 'elk.layered.considerModelOrder.longEdgeStrategy'?: LongEdgeOrderingStrategy; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-crossingCounterNodeInfluence.html + * @defaultValue '0' + */ + 'elk.layered.considerModelOrder.crossingCounterNodeInfluence'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-crossingCounterPortInfluence.html + * @defaultValue '0' + */ + 'elk.layered.considerModelOrder.crossingCounterPortInfluence'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-groupModelOrder-cycleBreakingId.html + * @defaultValue '0' + */ + 'elk.layered.considerModelOrder.groupModelOrder.cycleBreakingId'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-groupModelOrder-crossingMinimizationId.html + * @defaultValue '0' + */ + 'elk.layered.considerModelOrder.groupModelOrder.crossingMinimizationId'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-groupModelOrder-componentGroupId.html + * @defaultValue '0' + */ + 'elk.layered.considerModelOrder.groupModelOrder.componentGroupId'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-groupModelOrder-cbGroupOrderStrategy.html + * @defaultValue 'ONLY_WITHIN_GROUP' + */ + 'elk.layered.considerModelOrder.groupModelOrder.cbGroupOrderStrategy'?: GroupOrderStrategy; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-groupModelOrder-cbPreferredSourceId.html + */ + 'elk.layered.considerModelOrder.groupModelOrder.cbPreferredSourceId'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-groupModelOrder-cbPreferredTargetId.html + */ + 'elk.layered.considerModelOrder.groupModelOrder.cbPreferredTargetId'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-groupModelOrder-cmGroupOrderStrategy.html + * @defaultValue 'ONLY_WITHIN_GROUP' + */ + 'elk.layered.considerModelOrder.groupModelOrder.cmGroupOrderStrategy'?: GroupOrderStrategy; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-groupModelOrder-cmEnforcedGroupOrders.html + * @defaultValue `#[1, 2, 6, 7, 10, 11]` + */ + 'elk.layered.considerModelOrder.groupModelOrder.cmEnforcedGroupOrders'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-directionCongruency.html + * @defaultValue 'READING_DIRECTION' + */ + 'elk.layered.directionCongruency'?: DirectionCongruency; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-feedbackEdges.html + * @defaultValue 'false' + */ + 'elk.layered.feedbackEdges'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-interactiveReferencePoint.html + * @defaultValue 'CENTER' + */ + 'elk.layered.interactiveReferencePoint'?: InteractiveReferencePoint; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-mergeEdges.html + * @defaultValue 'false' + */ + 'elk.layered.mergeEdges'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-mergeHierarchyEdges.html + * @defaultValue 'true' + */ + 'elk.layered.mergeHierarchyEdges'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-allowNonFlowPortsToSwitchSides.html + * @defaultValue 'false' + */ + 'elk.layered.allowNonFlowPortsToSwitchSides'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-portSortingStrategy.html + * @defaultValue 'INPUT_ORDER' + */ + 'elk.layered.portSortingStrategy'?: PortSortingStrategy; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-thoroughness.html + * @defaultValue '7' + */ + 'elk.layered.thoroughness'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-unnecessaryBendpoints.html + * @defaultValue 'false' + */ + 'elk.layered.unnecessaryBendpoints'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-generatePositionAndLayerIds.html + * @defaultValue 'false' + */ + 'elk.layered.generatePositionAndLayerIds'?: 'true' | 'false'; +} diff --git a/packages/joint-layout-elk/src/types/elkNodeOptions.mts b/packages/joint-layout-elk/src/types/elkNodeOptions.mts new file mode 100644 index 0000000000..a246f33ce4 --- /dev/null +++ b/packages/joint-layout-elk/src/types/elkNodeOptions.mts @@ -0,0 +1,247 @@ +import type { + Alignment, + HierarchyHandling, + PortAlignment, + PortConstraints, + LayerConstraint, + NodeFlexibility, + SelfLoopDistributionStrategy, + SelfLoopOrderingStrategy, + TopdownNodeTypes +} from './elkEnums.mjs'; + +export interface NodeElkLayoutOptions { + // Falls back to a plain string for any ELK option beyond this package's Core/Layered coverage. + [key: string]: string | undefined; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-alignment.html + * @defaultValue 'AUTOMATIC' + */ + 'elk.alignment'?: Alignment; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-hierarchyHandling.html + * @defaultValue 'INHERIT' + */ + 'elk.hierarchyHandling'?: HierarchyHandling; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-padding.html + * @defaultValue `new ElkPadding(12)` + */ + 'elk.padding'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-portPort.html + * @defaultValue '10' + */ + 'elk.spacing.portPort'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-individual.html + */ + 'elk.spacing.individual'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-partitioning-partition.html + */ + 'elk.partitioning.partition'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-nodeLabels-placement.html + * @defaultValue `NodeLabelPlacement.fixed` + */ + 'elk.nodeLabels.placement'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portAlignment-default.html + * @defaultValue 'DISTRIBUTED' + */ + 'elk.portAlignment.default'?: PortAlignment; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portAlignment-north.html + */ + 'elk.portAlignment.north'?: PortAlignment; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portAlignment-south.html + */ + 'elk.portAlignment.south'?: PortAlignment; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portAlignment-west.html + */ + 'elk.portAlignment.west'?: PortAlignment; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portAlignment-east.html + */ + 'elk.portAlignment.east'?: PortAlignment; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portConstraints.html + * @defaultValue 'UNDEFINED' + */ + 'elk.portConstraints'?: PortConstraints; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-position.html + */ + 'elk.position'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-priority.html + */ + 'elk.priority'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-nodeSize-constraints.html + * @defaultValue `EnumSet.noneOf(SizeConstraint)` + */ + 'elk.nodeSize.constraints'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-nodeSize-options.html + * @defaultValue `EnumSet.of(SizeOptions.DEFAULT_MINIMUM_SIZE)` + */ + 'elk.nodeSize.options'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-nodeSize-minimum.html + * @defaultValue `new KVector(0, 0)` + */ + 'elk.nodeSize.minimum'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-commentBox.html + * @defaultValue 'false' + */ + 'elk.commentBox'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-hypernode.html + * @defaultValue 'false' + */ + 'elk.hypernode'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-margins.html + * @defaultValue `new ElkMargin()` + */ + 'elk.margins'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-noLayout.html + * @defaultValue 'false' + */ + 'elk.noLayout'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portLabels-placement.html + * @defaultValue `PortLabelPlacement.outside` + */ + 'elk.portLabels.placement'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portLabels-nextToPortIfPossible.html + * @defaultValue 'false' + */ + 'elk.portLabels.nextToPortIfPossible'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-portLabels-treatAsGroup.html + * @defaultValue 'true' + */ + 'elk.portLabels.treatAsGroup'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-scaleFactor.html + * @defaultValue '1' + */ + 'elk.scaleFactor'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-topdown-sizeApproximator.html + * @defaultValue `null` + */ + 'elk.topdown.sizeApproximator'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-topdown-hierarchicalNodeWidth.html + * @defaultValue '150' + */ + 'elk.topdown.hierarchicalNodeWidth'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-topdown-hierarchicalNodeAspectRatio.html + * @defaultValue '1.414' + */ + 'elk.topdown.hierarchicalNodeAspectRatio'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-topdown-nodeType.html + * @defaultValue `null` + */ + 'elk.topdown.nodeType'?: TopdownNodeTypes; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-insideSelfLoops-activate.html + * @defaultValue 'false' + */ + 'elk.insideSelfLoops.activate'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layering-layerConstraint.html + * @defaultValue 'NONE' + */ + 'elk.layered.layering.layerConstraint'?: LayerConstraint; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layering-layerChoiceConstraint.html + * @defaultValue `null` + */ + 'elk.layered.layering.layerChoiceConstraint'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layering-layerId.html + * @defaultValue '-1' + */ + 'elk.layered.layering.layerId'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-crossingMinimization-inLayerPredOf.html + * @defaultValue `null` + */ + 'elk.layered.crossingMinimization.inLayerPredOf'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-crossingMinimization-inLayerSuccOf.html + * @defaultValue `null` + */ + 'elk.layered.crossingMinimization.inLayerSuccOf'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-crossingMinimization-positionChoiceConstraint.html + * @defaultValue `null` + */ + 'elk.layered.crossingMinimization.positionChoiceConstraint'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-crossingMinimization-positionId.html + * @defaultValue '-1' + */ + 'elk.layered.crossingMinimization.positionId'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-nodePlacement-networkSimplex-nodeFlexibility.html + */ + 'elk.layered.nodePlacement.networkSimplex.nodeFlexibility'?: NodeFlexibility; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-edgeRouting-selfLoopDistribution.html + * @defaultValue 'NORTH' + */ + 'elk.layered.edgeRouting.selfLoopDistribution'?: SelfLoopDistributionStrategy; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-edgeRouting-selfLoopOrdering.html + * @defaultValue 'STACKED' + */ + 'elk.layered.edgeRouting.selfLoopOrdering'?: SelfLoopOrderingStrategy; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layerUnzipping-minimizeEdgeLength.html + * @defaultValue 'false' + */ + 'elk.layered.layerUnzipping.minimizeEdgeLength'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layerUnzipping-layerSplit.html + * @defaultValue '2' + */ + 'elk.layered.layerUnzipping.layerSplit'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-layerUnzipping-resetOnLongEdges.html + * @defaultValue 'true' + */ + 'elk.layered.layerUnzipping.resetOnLongEdges'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-noModelOrder.html + * @defaultValue 'false' + */ + 'elk.layered.considerModelOrder.noModelOrder'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-groupModelOrder-cycleBreakingId.html + * @defaultValue '0' + */ + 'elk.layered.considerModelOrder.groupModelOrder.cycleBreakingId'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-groupModelOrder-crossingMinimizationId.html + * @defaultValue '0' + */ + 'elk.layered.considerModelOrder.groupModelOrder.crossingMinimizationId'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-groupModelOrder-componentGroupId.html + * @defaultValue '0' + */ + 'elk.layered.considerModelOrder.groupModelOrder.componentGroupId'?: `${number}`; +} diff --git a/packages/joint-layout-elk/src/types/elkPortOptions.mts b/packages/joint-layout-elk/src/types/elkPortOptions.mts new file mode 100644 index 0000000000..048f83a8c9 --- /dev/null +++ b/packages/joint-layout-elk/src/types/elkPortOptions.mts @@ -0,0 +1,51 @@ +import type { PortSide } from './elkEnums.mjs'; + +export interface PortElkLayoutOptions { + // Falls back to a plain string for any ELK option beyond this package's Core/Layered coverage. + [key: string]: string | undefined; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-spacing-individual.html + */ + 'elk.spacing.individual'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-position.html + */ + 'elk.position'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-noLayout.html + * @defaultValue 'false' + */ + 'elk.noLayout'?: 'true' | 'false'; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-port-anchor.html + */ + 'elk.port.anchor'?: string; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-port-index.html + */ + 'elk.port.index'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-port-side.html + * @defaultValue 'UNDEFINED' + */ + 'elk.port.side'?: PortSide; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-port-borderOffset.html + */ + 'elk.port.borderOffset'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-groupModelOrder-crossingMinimizationId.html + * @defaultValue '0' + */ + 'elk.layered.considerModelOrder.groupModelOrder.crossingMinimizationId'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-considerModelOrder-groupModelOrder-componentGroupId.html + * @defaultValue '0' + */ + 'elk.layered.considerModelOrder.groupModelOrder.componentGroupId'?: `${number}`; + /** + * @see https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-allowNonFlowPortsToSwitchSides.html + * @defaultValue 'false' + */ + 'elk.layered.allowNonFlowPortsToSwitchSides'?: 'true' | 'false'; +} diff --git a/packages/joint-layout-elk/src/types/index.mts b/packages/joint-layout-elk/src/types/index.mts new file mode 100644 index 0000000000..a83c809f66 --- /dev/null +++ b/packages/joint-layout-elk/src/types/index.mts @@ -0,0 +1,7 @@ +export type * from './elkEnums.mjs'; +export type * from './elkNodeOptions.mjs'; +export type * from './elkEdgeOptions.mjs'; +export type * from './elkPortOptions.mjs'; +export type * from './elkLabelOptions.mjs'; +export type * from './elkLayoutOptions.mjs'; +export type * from './elkGraph.mjs'; diff --git a/packages/joint-layout-elk/src/workerElk.mts b/packages/joint-layout-elk/src/workerElk.mts new file mode 100644 index 0000000000..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/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..b0f4a2dd09 --- /dev/null +++ b/packages/joint-layout-elk/test/index.js @@ -0,0 +1,1244 @@ +QUnit.module('sanity check', () => { + QUnit.test('should load', assert => { + assert.ok(typeof joint.layout.ELK !== 'undefined'); + assert.ok(typeof joint.layout.ELK.layout === 'function'); + }); +}); + +// First: main-thread ELK is loaded once, then shared by every later layout. +QUnit.module('loading main-thread ELK', (hooks) => { + + hooks.after(() => { + delete window.__loadMainThreadElk; + }); + + QUnit.test('should load it again on the next layout after it failed to load', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ id: 'a', size: { width: 100, height: 100 }}); + const el2 = new joint.shapes.standard.Rectangle({ id: 'b', size: { width: 100, height: 100 }}); + graph.resetCells([el1, el2]); + + // E.g. a chunk that failed to load. + const error = new Error('chunk failed to load'); + window.__loadMainThreadElk = () => Promise.reject(error); + await assert.rejects(joint.layout.ELK.layout({ graph }), error); + assert.ok(joint.g.intersection.exists(el1.getBBox(), el2.getBBox())); + + delete window.__loadMainThreadElk; + await joint.layout.ELK.layout({ graph }); + assert.notOk(joint.g.intersection.exists(el1.getBBox(), el2.getBBox())); + }); +}); + +QUnit.module('layout()', () => { + + function createGraph() { + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ id: 'a', size: { width: 100, height: 100 }}); + const el2 = new joint.shapes.standard.Rectangle({ id: 'b', size: { width: 100, height: 100 }}); + const el3 = new joint.shapes.standard.Rectangle({ id: 'c', size: { width: 100, height: 100 }}); + const el4 = new joint.shapes.standard.Rectangle({ id: 'd', size: { width: 100, height: 100 }}); + + const link1 = new joint.shapes.standard.Link({ source: { id: el1.id }, target: { id: el2.id }}); + const link2 = new joint.shapes.standard.Link({ source: { id: el1.id }, target: { id: el3.id }}); + const link3 = new joint.shapes.standard.Link({ source: { id: el2.id }, target: { id: el4.id }}); + const link4 = new joint.shapes.standard.Link({ source: { id: el3.id }, target: { id: el4.id }}); + + graph.resetCells([el1, el2, el3, el4, link1, link2, link3, link4]); + + return { graph, el1, el2, el3, el4 }; + } + + QUnit.test('should position elements without overlapping them', async(assert) => { + + const { graph, el1, el2, el3, el4 } = createGraph(); + + const initialBBox = graph.getBBox(); + assert.equal(initialBBox.x, 0); + assert.equal(initialBBox.y, 0); + + const { bbox } = await joint.layout.ELK.layout({ graph }); + + assert.ok(bbox.width > 0); + assert.ok(bbox.height > 0); + + const boundaries = [ + el1.getBBox(), + el2.getBBox(), + el3.getBBox(), + el4.getBBox() + ]; + + const overlaps = boundaries.some((box, i) => + boundaries.slice(i + 1).some(other => joint.g.intersection.exists(box, other)) + ); + + assert.ok(!overlaps); + }); + + QUnit.test('should route links and set vertices/anchors', async(assert) => { + + const { graph } = createGraph(); + + await joint.layout.ELK.layout({ graph }, { + elkLayoutOptions: { + 'elk.algorithm': 'layered', + 'elk.direction': 'RIGHT', + 'elk.edgeRouting': 'ORTHOGONAL' + } + }); + + graph.getLinks().forEach((link) => { + assert.ok(Array.isArray(link.vertices())); + assert.equal(link.prop('source/anchor/name'), 'topLeft'); + assert.equal(link.prop('target/anchor/name'), 'topLeft'); + }); + }); + + QUnit.test('should position labelled links', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ id: 'a', size: { width: 100, height: 100 }}); + const el2 = new joint.shapes.standard.Rectangle({ id: 'b', size: { width: 100, height: 100 }}); + const link = new joint.shapes.standard.Link({ + source: { id: el1.id }, + target: { id: el2.id }, + labels: [{ size: { width: 40, height: 20 }}] + }); + + graph.resetCells([el1, el2, link]); + + await joint.layout.ELK.layout({ graph }); + + const label = link.label(0); + assert.ok(label.position && typeof label.position.distance === 'number'); + }); + + QUnit.test('should size a link label from `defaultLabel` when the label\'s own `size` is not set', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ id: 'a', size: { width: 100, height: 100 }}); + const el2 = new joint.shapes.standard.Rectangle({ id: 'b', size: { width: 100, height: 100 }}); + const link = new joint.shapes.standard.Link({ + source: { id: el1.id }, + target: { id: el2.id }, + defaultLabel: { size: { width: 80, height: 20 }}, + // No own `size` - resolved only through `defaultLabel` (see `Link#labels`). + labels: [{}] + }); + + graph.resetCells([el1, el2, link]); + + const { elkGraph } = await joint.layout.ELK.layout({ graph }); + + const [elkEdge] = elkGraph.edges; + assert.equal(elkEdge.labels[0].width, 80); + assert.equal(elkEdge.labels[0].height, 20); + }); + + QUnit.test('should lay out embedded elements (containers) and resize their parent to fit them', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const parent = new joint.shapes.standard.Rectangle({ id: 'parent', size: { width: 10, height: 10 }, position: { x: 0, y: 0 }}); + const child1 = new joint.shapes.standard.Rectangle({ id: 'child1', size: { width: 50, height: 50 }}); + const child2 = new joint.shapes.standard.Rectangle({ id: 'child2', size: { width: 50, height: 50 }}); + const childLink = new joint.shapes.standard.Link({ source: { id: 'child1' }, target: { id: 'child2' }}); + parent.embed(child1); + parent.embed(child2); + + graph.resetCells([parent, child1, child2, childLink]); + + await joint.layout.ELK.layout({ graph }); + + const parentBBox = parent.getBBox(); + const child1BBox = child1.getBBox(); + const child2BBox = child2.getBBox(); + + // The parent is resized (and positioned) by ELK to fit its content. + assert.ok(parentBBox.width >= child1BBox.width + child2BBox.width); + assert.ok(parentBBox.containsPoint(child1BBox.center())); + assert.ok(parentBBox.containsPoint(child2BBox.center())); + assert.ok(!joint.g.intersection.exists(child1BBox, child2BBox)); + }); + + QUnit.test('should route a link crossing a container boundary', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const parent = new joint.shapes.standard.Rectangle({ id: 'parent', size: { width: 10, height: 10 }}); + const child = new joint.shapes.standard.Rectangle({ id: 'child', size: { width: 50, height: 50 }}); + const outside = new joint.shapes.standard.Rectangle({ id: 'outside', size: { width: 50, height: 50 }}); + const link = new joint.shapes.standard.Link({ source: { id: 'child' }, target: { id: 'outside' }}); + parent.embed(child); + + graph.resetCells([parent, child, outside, link]); + + await joint.layout.ELK.layout({ graph }); + + assert.ok(Array.isArray(link.vertices())); + assert.ok(!joint.g.intersection.exists(parent.getBBox(), outside.getBBox())); + }); + + QUnit.test('should place a link inside nested containers using ELK\'s graph-absolute edge coordinates', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const outer = new joint.shapes.standard.Rectangle({ id: 'outer', size: { width: 10, height: 10 }}); + const inner = new joint.shapes.standard.Rectangle({ id: 'inner', size: { width: 10, height: 10 }}); + const a = new joint.shapes.standard.Rectangle({ id: 'a', size: { width: 60, height: 40 }}); + const b = new joint.shapes.standard.Rectangle({ id: 'b', size: { width: 60, height: 80 }}); + const link = new joint.shapes.standard.Link({ + source: { id: 'a' }, + target: { id: 'b' }, + labels: [{ size: { width: 30, height: 12 }}] + }); + outer.embed(inner); + inner.embed(a); + inner.embed(b); + + graph.resetCells([outer, inner, a, b, link]); + + const { elkGraph } = await joint.layout.ELK.layout({ graph }, { + elkLayoutOptions: { + 'elk.padding': '[top=40,left=20,bottom=20,right=20]' + } + }); + + assert.equal(elkGraph.layoutOptions['elk.json.edgeCoords'], 'ROOT'); + + const elkInner = elkGraph.children[0].children[0]; + const [elkEdge] = elkInner.edges; + const { startPoint, endPoint } = elkEdge.sections[0]; + + // ELK's own (graph-absolute) end points land on the laid out elements' borders - + // relative to `inner`, they'd be off by both containers' offsets. + assert.ok(a.getBBox().inflate(1).containsPoint(startPoint)); + assert.ok(b.getBBox().inflate(1).containsPoint(endPoint)); + + // ...and are applied as they are: each end's anchor resolves to that same point. + const { dx, dy } = link.source().anchor.args; + assert.deepEqual(a.position().offset(dx, dy).toJSON(), { x: startPoint.x, y: startPoint.y }); + + // The label stays on the link's path. + const [label] = link.labels(); + assert.ok(Math.abs(label.position.offset) < 30); + }); + + QUnit.test('should let `elkLayoutOptions` override `elk.json.edgeCoords`', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ id: 'a', size: { width: 50, height: 50 }}); + const el2 = new joint.shapes.standard.Rectangle({ id: 'b', size: { width: 50, height: 50 }}); + const link = new joint.shapes.standard.Link({ source: { id: 'a' }, target: { id: 'b' }}); + + graph.resetCells([el1, el2, link]); + + const { elkGraph } = await joint.layout.ELK.layout({ graph }, { + elkLayoutOptions: { 'elk.json.edgeCoords': 'CONTAINER' } + }); + + assert.equal(elkGraph.layoutOptions['elk.json.edgeCoords'], 'CONTAINER'); + }); + + QUnit.test('should route links to/from ports without overriding their anchor', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ + id: 'a', + size: { width: 100, height: 100 }, + ports: { + groups: { + out: { position: 'right' } + }, + items: [{ id: 'out1', group: 'out' }] + } + }); + const el2 = new joint.shapes.standard.Rectangle({ + id: 'b', + size: { width: 100, height: 100 }, + ports: { + groups: { + in: { position: 'left' } + }, + items: [{ id: 'in1', group: 'in' }] + } + }); + const link = new joint.shapes.standard.Link({ + source: { id: 'a', port: 'out1' }, + target: { id: 'b', port: 'in1' } + }); + + graph.resetCells([el1, el2, link]); + + await joint.layout.ELK.layout({ graph }); + + assert.notOk(link.prop('source/anchor')); + assert.notOk(link.prop('target/anchor')); + assert.ok(Array.isArray(link.vertices())); + }); + + QUnit.test('should call exportPort for each port', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ + id: 'a', + size: { width: 100, height: 100 }, + ports: { + groups: { + out: { position: 'right' } + }, + items: [{ id: 'out1', group: 'out' }] + } + }); + const el2 = new joint.shapes.standard.Rectangle({ id: 'b', size: { width: 100, height: 100 }}); + const link = new joint.shapes.standard.Link({ source: { id: 'a', port: 'out1' }, target: { id: 'b' }}); + + graph.resetCells([el1, el2, link]); + + const seen = []; + await joint.layout.ELK.layout({ graph }, { + exportPort: ({ portId, element }) => { + seen.push([portId, element.id]); + } + }); + + assert.deepEqual(seen, [['out1', 'a']]); + }); + + QUnit.test('should let exportElement/exportPort/exportLink add to the computed layoutOptions without losing it', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ + id: 'a', + size: { width: 100, height: 100 }, + ports: { + groups: { + out: { position: 'right' } + }, + items: [{ id: 'out1', group: 'out' }] + } + }); + const el2 = new joint.shapes.standard.Rectangle({ id: 'b', size: { width: 100, height: 100 }}); + const link = new joint.shapes.standard.Link({ source: { id: 'a', port: 'out1' }, target: { id: 'b' }}); + + graph.resetCells([el1, el2, link]); + + // Each callback mutates the draft it's given in place, rather than returning a + // value to merge - so what this package itself already put on that same draft + // (e.g. an `elkNode`/`elkPort`'s own `width`) survives + // alongside whatever the callback itself adds. + const { elkGraph } = await joint.layout.ELK.layout({ graph }, { + exportElement: ({ elkNode }) => { + elkNode.layoutOptions['elk.portConstraints'] = 'FIXED_SIDE'; + elkNode.layoutOptions['elk.custom'] = 'node'; + }, + exportPort: ({ elkPort }) => { + elkPort.layoutOptions['elk.custom'] = 'port'; + }, + exportLink: ({ elkEdge }) => { + elkEdge.layoutOptions['elk.custom'] = 'edge'; + } + }); + + const elkNode = elkGraph.children.find((node) => node.id === 'a'); + assert.equal(elkNode.layoutOptions['elk.custom'], 'node'); + assert.equal(elkNode.layoutOptions['elk.portConstraints'], 'FIXED_SIDE'); + assert.equal(typeof elkNode.width, 'number'); + + const elkPort = elkNode.ports.find((port) => port.id === 'a:out1'); + assert.equal(elkPort.layoutOptions['elk.custom'], 'port'); + assert.equal(typeof elkPort.width, 'number'); + + const [elkEdge] = elkGraph.edges; + assert.equal(elkEdge.layoutOptions['elk.custom'], 'edge'); + }); + + QUnit.test('should keep ports at their JointJS-computed position by default', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ + id: 'a', + size: { width: 100, height: 100 }, + ports: { + groups: { + out: { position: 'right' } + }, + items: [{ id: 'out1', group: 'out' }] + } + }); + + // An incoming edge to a port on the right - ELK would move the port to the left + // side if it were free to. + const el2 = new joint.shapes.standard.Rectangle({ id: 'b', size: { width: 100, height: 100 }}); + const link = new joint.shapes.standard.Link({ source: { id: 'b' }, target: { id: 'a', port: 'out1' }}); + + graph.resetCells([el1, el2, link]); + + const portPosition = el1.getPortsPositions('out').out1; + + const { elkGraph } = await joint.layout.ELK.layout({ graph }); + + assert.equal(elkGraph.children.find((node) => node.id === 'a').layoutOptions['elk.portConstraints'], 'FIXED_POS'); + assert.notOk(elkGraph.children.find((node) => node.id === 'b').layoutOptions['elk.portConstraints']); + // Untouched - still the original group config, not switched to 'absolute'. + assert.equal(el1.prop(['ports', 'groups', 'out', 'position']), 'right'); + // Still on the right side, where JointJS placed it. + const { x, y } = el1.getPortsPositions('out').out1; + assert.deepEqual({ x, y }, { x: portPosition.x, y: portPosition.y }); + }); + + QUnit.test('should let ELK position ports when `exportElement` opts a node into `FIXED_SIDE`', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ + id: 'a', + size: { width: 100, height: 100 }, + ports: { + groups: { + out: { position: 'right' } + }, + items: [{ id: 'out1', group: 'out' }] + } + }); + const el2 = new joint.shapes.standard.Rectangle({ + id: 'b', + size: { width: 100, height: 100 }, + ports: { + groups: { + in: { position: 'left' } + }, + items: [{ id: 'in1', group: 'in' }] + } + }); + const link = new joint.shapes.standard.Link({ + source: { id: 'a', port: 'out1' }, + target: { id: 'b', port: 'in1' } + }); + + graph.resetCells([el1, el2, link]); + + await joint.layout.ELK.layout({ graph }, { + exportElement: ({ elkNode }) => { + elkNode.layoutOptions['elk.portConstraints'] = 'FIXED_SIDE'; + } + }); + + // The group config itself is untouched - a built-in position function + // ('right'/'left'/'top'/'bottom') already prioritizes a port's own + // `position.args`, set below, over its own even-spacing fallback, so there's no + // need to switch it to 'absolute' for the ELK-computed position to apply. + assert.equal(el1.prop(['ports', 'groups', 'out', 'position']), 'right'); + assert.equal(el2.prop(['ports', 'groups', 'in', 'position']), 'left'); + + const position = el1.portProp('out1', ['position', 'args']); + assert.equal(typeof position.x, 'number'); + assert.equal(typeof position.y, 'number'); + + // The port's rendered position reflects the position ELK computed for it. + const relativePosition = el1.getPortRelativePosition('out1'); + assert.equal(relativePosition.x, position.x); + assert.equal(relativePosition.y, position.y); + }); + + QUnit.test('should keep a port where it is under FIXED_POS port constraints', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ + id: 'a', + size: { width: 100, height: 60 }, + ports: { + groups: { + right: { position: 'right', size: { width: 20, height: 10 }} + }, + items: [{ id: 'right1', group: 'right' }] + } + }); + const el2 = new joint.shapes.standard.Rectangle({ id: 'b', size: { width: 100, height: 60 }}); + const link = new joint.shapes.standard.Link({ source: { id: 'a', port: 'right1' }, target: { id: 'b' }}); + + graph.resetCells([el1, el2, link]); + + const before = el1.getPortRelativePosition('right1'); + + await joint.layout.ELK.layout({ graph }, { + exportElement: ({ elkNode }) => { + elkNode.layoutOptions['elk.portConstraints'] = 'FIXED_POS'; + } + }); + + const after = el1.getPortRelativePosition('right1'); + assert.deepEqual({ x: after.x, y: after.y }, { x: before.x, y: before.y }); + }); + + QUnit.test('should center a non-square port on whichever side ELK puts it', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + // 'absolute' with no args - every port starts at the element's corner, so only + // `elk.port.side` (not the port's own position) says which side it belongs to. + const group = { position: { name: 'absolute' }, size: { width: 14, height: 8 }}; + const el1 = new joint.shapes.standard.Rectangle({ + id: 'a', + size: { width: 130, height: 50 }, + ports: { + groups: { in: group, out: group }, + items: [{ id: 'in1', group: 'in' }, { id: 'out1', group: 'out' }] + } + }); + const el2 = new joint.shapes.standard.Rectangle({ id: 'b', size: { width: 100, height: 40 }}); + const el3 = new joint.shapes.standard.Rectangle({ id: 'c', size: { width: 100, height: 40 }}); + const link1 = new joint.shapes.standard.Link({ source: { id: 'b' }, target: { id: 'a', port: 'in1' }}); + const link2 = new joint.shapes.standard.Link({ source: { id: 'a', port: 'out1' }, target: { id: 'c' }}); + + graph.resetCells([el1, el2, el3, link1, link2]); + + await joint.layout.ELK.layout({ graph }, { + exportElement: ({ element, elkNode }) => { + if (element.hasPorts()) elkNode.layoutOptions['elk.portConstraints'] = 'FIXED_SIDE'; + }, + exportPort: ({ portId, elkPort }) => { + elkPort.layoutOptions['elk.port.side'] = (portId === 'in1') ? 'WEST' : 'SOUTH'; + } + }); + + // Each port's center sits on its border, in the middle of that side. + assert.deepEqual(el1.portProp('in1', ['position', 'args']), { x: 0, y: 25 }); + assert.deepEqual(el1.portProp('out1', ['position', 'args']), { x: 65, y: 50 }); + }); + + QUnit.test('should keep a port\'s rendered position stable across repeated `layout()` calls', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + // A non-zero port `size` is essential here - it's what the center/top-left mismatch + // this test guards against gets applied to (a zero-sized port can't reveal it). + const el1 = new joint.shapes.standard.Rectangle({ + id: 'a', + size: { width: 100, height: 100 }, + ports: { + groups: { + out: { position: 'right', size: { width: 12, height: 12 }} + }, + items: [{ id: 'out1', group: 'out' }] + } + }); + const el2 = new joint.shapes.standard.Rectangle({ + id: 'b', + size: { width: 100, height: 100 }, + ports: { + groups: { + in: { position: 'left', size: { width: 12, height: 12 }} + }, + items: [{ id: 'in1', group: 'in' }] + } + }); + const link = new joint.shapes.standard.Link({ + source: { id: 'a', port: 'out1' }, + target: { id: 'b', port: 'in1' } + }); + + graph.resetCells([el1, el2, link]); + + await joint.layout.ELK.layout({ graph }); + const firstPosition = el1.getPortRelativePosition('out1'); + + // Laying out the same, already laid out graph again should not move the + // port any further - each call is independent, not cumulative. + await joint.layout.ELK.layout({ graph }); + const secondPosition = el1.getPortRelativePosition('out1'); + + assert.equal(secondPosition.x, firstPosition.x); + assert.equal(secondPosition.y, firstPosition.y); + }); + + QUnit.test('should let exportElement read a node\'s own custom property into its computed layoutOptions', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const parent = new joint.shapes.standard.Rectangle({ + id: 'parent', + size: { width: 10, height: 10 }, + // A plain custom property - this package has no built-in name/convention for + // one, `exportElement` (below) reads it explicitly. + elkLayoutOptions: { 'elk.padding': '[top=40,left=20,bottom=20,right=20]' } + }); + const child = new joint.shapes.standard.Rectangle({ id: 'child', size: { width: 50, height: 50 }}); + parent.embed(child); + + graph.resetCells([parent, child]); + + const { elkGraph } = await joint.layout.ELK.layout({ graph }, { + exportElement: ({ element, elkNode }) => { + Object.assign(elkNode.layoutOptions, element.get('elkLayoutOptions')); + } + }); + + const parentNode = elkGraph.children.find((node) => node.id === 'parent'); + assert.equal(parentNode.layoutOptions['elk.padding'], '[top=40,left=20,bottom=20,right=20]'); + }); + + QUnit.test('should let exportLinkLabel read a link label\'s own custom property into its computed layoutOptions', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ id: 'a', size: { width: 100, height: 100 }}); + const el2 = new joint.shapes.standard.Rectangle({ id: 'b', size: { width: 100, height: 100 }}); + const link = new joint.shapes.standard.Link({ + source: { id: 'a' }, + target: { id: 'b' }, + labels: [{ size: { width: 40, height: 20 }, elkLayoutOptions: { 'elk.edgeLabels.inline': 'false' }}] + }); + + graph.resetCells([el1, el2, link]); + + const { elkGraph } = await joint.layout.ELK.layout({ graph }, { + exportLinkLabel: ({ link, labelIndex, elkEdgeLabel }) => { + Object.assign(elkEdgeLabel.layoutOptions, link.label(labelIndex).elkLayoutOptions); + } + }); + + const [elkEdge] = elkGraph.edges; + // The label's own value wins over the package's inline default. + assert.equal(elkEdge.labels[0].layoutOptions['elk.edgeLabels.inline'], 'false'); + + // The label's own raw JSON is unaffected - reading it in `exportLinkLabel` doesn't + // write anything back. + assert.deepEqual(link.get('labels')[0].elkLayoutOptions, { 'elk.edgeLabels.inline': 'false' }); + }); + + QUnit.test('should place a link label inline by default - `exportLinkLabel` can opt one out', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ id: 'a', size: { width: 100, height: 100 }}); + const el2 = new joint.shapes.standard.Rectangle({ id: 'b', size: { width: 100, height: 100 }}); + const link = new joint.shapes.standard.Link({ + source: { id: 'a' }, + target: { id: 'b' }, + labels: [{ size: { width: 40, height: 20 }}] + }); + + graph.resetCells([el1, el2, link]); + + const { elkGraph } = await joint.layout.ELK.layout({ graph }); + assert.equal(elkGraph.edges[0].labels[0].layoutOptions['elk.edgeLabels.inline'], 'true'); + + const { elkGraph: optedOutElkGraph } = await joint.layout.ELK.layout({ graph }, { + exportLinkLabel: ({ elkEdgeLabel }) => { + elkEdgeLabel.layoutOptions['elk.edgeLabels.inline'] = 'false'; + } + }); + assert.equal(optedOutElkGraph.edges[0].labels[0].layoutOptions['elk.edgeLabels.inline'], 'false'); + }); + + QUnit.test('should let exportLinkLabel read a link\'s `defaultLabel`-inherited custom property for every label', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ id: 'a', size: { width: 100, height: 100 }}); + const el2 = new joint.shapes.standard.Rectangle({ id: 'b', size: { width: 100, height: 100 }}); + const link = new joint.shapes.standard.Link({ + source: { id: 'a' }, + target: { id: 'b' }, + defaultLabel: { + size: { width: 40, height: 20 }, + elkLayoutOptions: { 'elk.edgeLabels.inline': 'false' } + }, + // Neither label sets its own `elkLayoutOptions` - both fall back to + // `defaultLabel`'s, merged in by `Link#getComputedLabels()` (`@joint/core`) + // which `exportLinkLabel` indexes into with `labelIndex` below. + labels: [{}, {}] + }); + + graph.resetCells([el1, el2, link]); + + const { elkGraph } = await joint.layout.ELK.layout({ graph }, { + exportLinkLabel: ({ link, labelIndex, elkEdgeLabel }) => { + Object.assign(elkEdgeLabel.layoutOptions, link.getComputedLabels()[labelIndex].elkLayoutOptions); + } + }); + + const [elkEdge] = elkGraph.edges; + assert.equal(elkEdge.labels[0].layoutOptions['elk.edgeLabels.inline'], 'false'); + assert.equal(elkEdge.labels[1].layoutOptions['elk.edgeLabels.inline'], 'false'); + }); + + QUnit.test('should let exportPort read a port group\'s own custom property into its computed layoutOptions', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ + id: 'a', + size: { width: 100, height: 100 }, + ports: { + groups: { + out: { position: 'right', elkLayoutOptions: { 'elk.port.side': 'WEST' }} + }, + items: [{ id: 'out1', group: 'out' }] + } + }); + + graph.resetCells([el1]); + + const { elkGraph } = await joint.layout.ELK.layout({ graph }, { + exportElement: ({ elkNode }) => { + elkNode.layoutOptions['elk.portConstraints'] = 'FIXED_SIDE'; + }, + exportPort: ({ element, portId, elkPort }) => { + const { group } = element.getPort(portId); + const groupOptions = element.prop(['ports', 'groups', group, 'elkLayoutOptions']); + Object.assign(elkPort.layoutOptions, groupOptions); + } + }); + + const elkNode = elkGraph.children.find((node) => node.id === 'a'); + const elkPort = elkNode.ports.find((port) => port.id === 'a:out1'); + // The group's own `elk.port.side` wins over what `right` would otherwise compute. + assert.equal(elkPort.layoutOptions['elk.port.side'], 'WEST'); + }); + + QUnit.test('should size a port label via exportPortLabel, from the port\'s (or its group\'s) `label.size`', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ + id: 'a', + size: { width: 100, height: 100 }, + ports: { + groups: { + out: { position: 'right', label: { size: { width: 99, height: 22 }}} + }, + items: [ + { id: 'out1', group: 'out' }, + { id: 'out2', group: 'out', label: { size: { width: 55, height: 11 }}} + ] + } + }); + + graph.resetCells([el1]); + + const { elkGraph } = await joint.layout.ELK.layout({ graph }, { + // `portProp(id, 'label/size')` only reads the port's own item data, with no + // group fallback - `getPortMetrics` resolves it the same way `dia.Element` + // itself does for rendering (group first, item overriding it). + exportPortLabel: ({ element, portId, elkPortLabel }) => { + const { width, height } = element.getPortMetrics(portId).labelSize; + elkPortLabel.width = width; + elkPortLabel.height = height; + } + }); + + const elkNode = elkGraph.children.find((node) => node.id === 'a'); + const [out1Label] = elkNode.ports.find((port) => port.id === 'a:out1').labels; + const [out2Label] = elkNode.ports.find((port) => port.id === 'a:out2').labels; + + // `out1` has no `label.size` of its own - falls back to its group's. + assert.equal(out1Label.width, 99); + assert.equal(out1Label.height, 22); + // `out2`'s own `label.size` overrides its group's. + assert.equal(out2Label.width, 55); + assert.equal(out2Label.height, 11); + }); + + QUnit.test('should return a zero-size bbox for an empty graph', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + + const { bbox, elkGraph } = await joint.layout.ELK.layout({ graph }); + + assert.equal(bbox.width, 0); + assert.equal(bbox.height, 0); + assert.deepEqual(elkGraph.children, []); + }); + + QUnit.test('should apply the layout in a single `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 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 }}); + 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 }); + 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, []); + }); + + QUnit.test('should apply each laid out label to its own link label when exportLinkLabel drops another', async(assert) => { + + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ id: 'a', size: { width: 100, height: 100 }}); + const el2 = new joint.shapes.standard.Rectangle({ id: 'b', size: { width: 100, height: 100 }}); + const link = new joint.shapes.standard.Link({ + source: { id: 'a' }, + target: { id: 'b' }, + labels: [ + { position: 0.25, size: { width: 40, height: 20 }}, + { position: 0.75, size: { width: 40, height: 20 }} + ] + }); + + graph.resetCells([el1, el2, link]); + + const { elkGraph } = await joint.layout.ELK.layout({ graph }, { + exportLinkLabel: ({ labelIndex }) => (labelIndex === 0 ? false : undefined) + }); + + const [elkEdge] = elkGraph.edges; + assert.equal(elkEdge.labels.length, 1); + assert.equal(elkEdge.labels[0].id, `${link.id}:labels:1`); + // The dropped label is left untouched - the laid out one goes to the second label. + assert.equal(link.label(0).position, 0.25); + assert.equal(typeof link.label(1).position, 'object'); + assert.equal(typeof link.label(1).position.distance, 'number'); + }); +}); + +QUnit.module('createWorkerElk()', (hooks) => { + + const WORKER_URL = '/base/node_modules/elkjs/lib/elk-worker.min.js'; + // A script that doesn't exist - e.g. one a bundler didn't emit - fails to load. + const MISSING_WORKER_URL = '/base/missing-elk-worker.js'; + + // Every worker started by `createWorker()` below, and how many messages they've sent back. + let startedWorkers; + let workerMessageCount; + // Instances to terminate after each test. + let elks; + + hooks.beforeEach(() => { + startedWorkers = []; + workerMessageCount = 0; + elks = []; + }); + + hooks.afterEach(() => { + elks.forEach((elk) => elk.terminate()); + }); + + const createElk = (getUrl = () => WORKER_URL) => { + const elk = joint.layout.ELK.createWorkerElk(() => { + const worker = new Worker(getUrl()); + worker.addEventListener('message', () => workerMessageCount++); + startedWorkers.push(worker); + return worker; + }); + elks.push(elk); + return elk; + }; + + const createGraph = () => { + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const el1 = new joint.shapes.standard.Rectangle({ id: 'a', size: { width: 100, height: 100 }}); + const el2 = new joint.shapes.standard.Rectangle({ id: 'b', size: { width: 100, height: 100 }}); + const link = new joint.shapes.standard.Link({ source: { id: 'a' }, target: { id: 'b' }}); + graph.resetCells([el1, el2, link]); + return { graph, el1, el2 }; + }; + + const isLaidOut = ({ el1, el2 }) => !joint.g.intersection.exists(el1.getBBox(), el2.getBBox()); + + QUnit.test('should lay out in a worker - started on the first layout, then shared', async(assert) => { + + const elk = createElk(); + assert.equal(startedWorkers.length, 0); + + const first = createGraph(); + await joint.layout.ELK.layout({ graph: first.graph }, { elk }); + + assert.equal(startedWorkers.length, 1); + // The layout came from the worker. + assert.ok(workerMessageCount > 0); + assert.ok(isLaidOut(first)); + + const messageCount = workerMessageCount; + const second = createGraph(); + await joint.layout.ELK.layout({ 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 controller = new AbortController(); + + 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, 2); + assert.notOk(isLaidOut(aborted)); + assert.ok(isLaidOut(waiting)); + }); + + QUnit.test('should keep the worker when a layout still waiting its turn is aborted', async(assert) => { + + const elk = createElk(); + const busy = createGraph(); + const aborted = createGraph(); + const controller = new AbortController(); + + const busyResult = joint.layout.ELK.layout({ 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, 1); + assert.ok(isLaidOut(busy)); + assert.notOk(isLaidOut(aborted)); + }); + + QUnit.test('should reject a layout the worker crashes during - a new worker takes over the layouts still waiting', async(assert) => { + + const elk = createElk(); + const crashed = createGraph(); + const waiting = createGraph(); + + const crashedResult = joint.layout.ELK.layout({ 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, 2); + assert.notOk(isLaidOut(crashed)); + assert.ok(isLaidOut(waiting)); + }); + + QUnit.test('should reject every layout when the worker fails to load - the next one starts a new worker', async(assert) => { + + let url = MISSING_WORKER_URL; + const elk = createElk(() => url); + const first = createGraph(); + const second = createGraph(); + + // Handled right away - both reject while the other is still pending. + const rejected = Promise.all([ + assert.rejects(joint.layout.ELK.layout({ 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)); + }); + + QUnit.test('should reject a layout when no worker can be started', async(assert) => { + + const error = new Error('blocked by CSP'); + const elk = joint.layout.ELK.createWorkerElk(() => { throw error; }); + const { graph, el1, el2 } = createGraph(); + + await assert.rejects(joint.layout.ELK.layout({ graph }, { elk }), error); + assert.notOk(isLaidOut({ el1, el2 })); + }); + + QUnit.test('should reject the layouts not settled yet when terminated - a later layout starts a new worker', async(assert) => { + + const elk = createElk(); + const terminated = createGraph(); + + const terminatedResult = joint.layout.ELK.layout({ 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)); + }); +}); diff --git a/packages/joint-layout-elk/tsconfig.cjs.json b/packages/joint-layout-elk/tsconfig.cjs.json new file mode 100644 index 0000000000..e6c9d3bc2e --- /dev/null +++ b/packages/joint-layout-elk/tsconfig.cjs.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "CommonJS", + "moduleResolution": "node", + "outDir": "dist/cjs", + "declarationDir": "dist/cjs", + "allowImportingTsExtensions": false, + "noEmit": false + } +} diff --git a/packages/joint-layout-elk/tsconfig.esm.json b/packages/joint-layout-elk/tsconfig.esm.json new file mode 100644 index 0000000000..ad8c30b3c2 --- /dev/null +++ b/packages/joint-layout-elk/tsconfig.esm.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "node", + "outDir": "dist/esm", + "declarationDir": "dist/esm" + } +} diff --git a/packages/joint-layout-elk/tsconfig.json b/packages/joint-layout-elk/tsconfig.json new file mode 100644 index 0000000000..86a8d24b48 --- /dev/null +++ b/packages/joint-layout-elk/tsconfig.json @@ -0,0 +1,33 @@ +{ + "compilerOptions": { + "target": "ES2019", + "moduleResolution": "bundler", + "module": "ESNext", + "declaration": true, + "declarationMap": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "importHelpers": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "inlineSources": true, + "inlineSourceMap": true, + "lib": [ + "ES2019", + "dom" + ] + }, + "exclude": [ + "node_modules", + "**/dist" + ], + "include": [ + "./src/**/*.mts" + ] +} diff --git a/yarn.lock b/yarn.lock index 18c86d3567..8a2c186393 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6353,13 +6353,81 @@ __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-flowchart-ts@workspace:examples/layout-elk-flowchart-ts": + version: 0.0.0-use.local + resolution: "@joint/demo-layout-elk-flowchart-ts@workspace:examples/layout-elk-flowchart-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" @@ -6715,6 +6783,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" @@ -16275,10 +16368,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