diff --git a/.changeset/defaults-undefined-sources.md b/.changeset/defaults-undefined-sources.md
new file mode 100644
index 0000000000..225dd3af84
--- /dev/null
+++ b/.changeset/defaults-undefined-sources.md
@@ -0,0 +1,5 @@
+---
+"@joint/core": patch
+---
+
+util - accept `undefined` source objects in the `defaults()`, `defaultsDeep()`, `supplement()` and `deepSupplement()` typings, which the implementations already skip
diff --git a/.changeset/layout-elk-new-package.md b/.changeset/layout-elk-new-package.md
new file mode 100644
index 0000000000..05f10cfef9
--- /dev/null
+++ b/.changeset/layout-elk-new-package.md
@@ -0,0 +1,5 @@
+---
+"@joint/layout-elk": minor
+---
+
+new package - automatic layout for JointJS graphs using the Eclipse Layout Kernel (ELK), including layout of embedded elements as containers and of element ports
diff --git a/.changeset/port-label-position-type.md b/.changeset/port-label-position-type.md
new file mode 100644
index 0000000000..59e97a88bc
--- /dev/null
+++ b/.changeset/port-label-position-type.md
@@ -0,0 +1,5 @@
+---
+"@joint/core": patch
+---
+
+dia.Element - fix `PortLabelPositionType` type to use `PortLabelPositionJSON` for a port label's position
diff --git a/.changeset/port-prop-overloads.md b/.changeset/port-prop-overloads.md
new file mode 100644
index 0000000000..c69ec19c08
--- /dev/null
+++ b/.changeset/port-prop-overloads.md
@@ -0,0 +1,5 @@
+---
+"@joint/core": patch
+---
+
+dia.Element - fix types to allow `portProp(portId)` and `portProp(portId, object, opt)`
diff --git a/examples/layout-elk-ts/.gitignore b/examples/layout-elk-containers-and-ports-ts/.gitignore
similarity index 74%
rename from examples/layout-elk-ts/.gitignore
rename to examples/layout-elk-containers-and-ports-ts/.gitignore
index 69c575d17f..1eae0cf670 100644
--- a/examples/layout-elk-ts/.gitignore
+++ b/examples/layout-elk-containers-and-ports-ts/.gitignore
@@ -1,3 +1,2 @@
-build/
dist/
node_modules/
diff --git a/examples/layout-elk-containers-and-ports-ts/README.md b/examples/layout-elk-containers-and-ports-ts/README.md
new file mode 100644
index 0000000000..28b865d6c9
--- /dev/null
+++ b/examples/layout-elk-containers-and-ports-ts/README.md
@@ -0,0 +1,27 @@
+# JointJS ELK Containers & Ports Demo
+
+A fixed (non-random) web platform reference architecture laid out automatically with `@joint/layout-elk`: nested containers ("Client Layer" > "Edge", "Core Services" > "Data Layer", "Observability") grouping services that connect through ports, plus a couple of links connecting containers directly - including one that crosses from one container into another. Styled with Material Design, via JointJS's theme mechanism and CSS.
+
+## Setup
+
+Use Yarn to run this demo.
+
+You need to build *JointJS* first. Navigate to the root folder and run:
+```bash
+yarn install
+yarn run build
+```
+
+Navigate to this directory, then run:
+```bash
+yarn dev
+```
+
+Then open the URL printed in the terminal (usually `http://localhost:5173`). `yarn build`
+makes a production build in `dist/`, `yarn preview` serves it.
+
+## License
+
+The *JointJS* library is licensed under the [Mozilla Public License 2.0](https://github.com/clientIO/joint/blob/master/LICENSE).
+
+Copyright © 2013-2026 client IO
diff --git a/examples/layout-elk-containers-and-ports-ts/index.html b/examples/layout-elk-containers-and-ports-ts/index.html
new file mode 100644
index 0000000000..62920c7f13
--- /dev/null
+++ b/examples/layout-elk-containers-and-ports-ts/index.html
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+ ELK Containers & Ports Layout | JointJS
+
+
+
+
+
+
+
+ Zoom Out
+ Zoom In
+
+
+
+
+
+
+
diff --git a/examples/layout-elk-ts/package.json b/examples/layout-elk-containers-and-ports-ts/package.json
similarity index 53%
rename from examples/layout-elk-ts/package.json
rename to examples/layout-elk-containers-and-ports-ts/package.json
index 1706be066c..18f38b5e3e 100644
--- a/examples/layout-elk-ts/package.json
+++ b/examples/layout-elk-containers-and-ports-ts/package.json
@@ -1,8 +1,8 @@
{
- "name": "@joint/demo-layout-elk-ts",
+ "name": "@joint/demo-layout-elk-containers-and-ports-ts",
"version": "4.3.1",
- "description": "JointJS - ELK Layout Demo",
- "main": "dist/bundle.js",
+ "type": "module",
+ "description": "JointJS - ELK Layout Containers & Ports Demo",
"homepage": "https://jointjs.com",
"author": {
"name": "client IO",
@@ -14,22 +14,18 @@
"hoistingLimits": "workspaces"
},
"scripts": {
- "start": "webpack-dev-server",
- "build": "webpack"
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "preview": "vite preview"
},
"dependencies": {
"@joint/core": "workspace:^",
- "elkjs": "^0.11.0"
+ "@joint/layout-elk": "workspace:^"
},
"devDependencies": {
- "css-loader": "3.5.3",
- "sass-loader": "8.0.2",
- "style-loader": "1.2.1",
- "ts-loader": "^9.2.5",
+ "sass": "^1.93.2",
"typescript": "5.8.2",
- "webpack": "5.98.0",
- "webpack-cli": "6.0.1",
- "webpack-dev-server": "5.2.0"
+ "vite": "^7.3.1"
},
"volta": {
"node": "22.14.0",
diff --git a/examples/layout-elk-containers-and-ports-ts/src/example.ts b/examples/layout-elk-containers-and-ports-ts/src/example.ts
new file mode 100644
index 0000000000..f2245ecf9f
--- /dev/null
+++ b/examples/layout-elk-containers-and-ports-ts/src/example.ts
@@ -0,0 +1,208 @@
+import { dia } from '@joint/core';
+
+// A fixed (non-random) system diagram: three top-level containers, two of
+// them with a nested container of their own, grouping eight services that
+// communicate over ports - plus a couple of links that connect two
+// containers directly, rather than a pair of ports, including one that
+// crosses container boundaries. A recognizable, if simplified, web platform
+// reference architecture: a client layer talking through an edge (load
+// balancer + API gateway) to core services (auth, guarding a data layer of
+// cache + database), with logs/metrics/analytics flowing to observability.
+// Every plain `example.Service` has exactly one 'in' and one 'out' port; the
+// four "hub" services (Load Balancer, API Gateway, Auth Service, Monitoring)
+// are `example.HubService` instead, with a custom number of ports and ELK's
+// `FIXED_SIDE` port constraint, so it can reorder them to minimize crossings
+// (see `index.ts`).
+export const graphJSON: dia.Graph.JSON = {
+ cells: [
+ // Containers
+ {
+ id: 'frontend',
+ type: 'example.Container',
+ attrs: { label: { text: 'Client Layer' } }
+ },
+ {
+ id: 'edge',
+ type: 'example.Container',
+ parent: 'frontend',
+ attrs: { label: { text: 'Edge' } }
+ },
+ {
+ id: 'backend',
+ type: 'example.Container',
+ attrs: { label: { text: 'Core Services' } }
+ },
+ {
+ id: 'storage',
+ type: 'example.Container',
+ parent: 'backend',
+ attrs: { label: { text: 'Data Layer' } }
+ },
+ {
+ id: 'observability',
+ type: 'example.Container',
+ attrs: { label: { text: 'Observability' } }
+ },
+
+ // Client Layer
+ {
+ id: 'webui',
+ type: 'example.Service',
+ parent: 'frontend',
+ attrs: { label: { text: 'Web App' } }
+ },
+ {
+ id: 'mobileui',
+ type: 'example.Service',
+ parent: 'frontend',
+ attrs: { label: { text: 'Mobile App' } }
+ },
+ {
+ id: 'lb',
+ type: 'example.HubService',
+ parent: 'edge',
+ size: { width: 130, height: 80 },
+ attrs: { label: { text: 'Load Balancer' } },
+ ports: {
+ items: [
+ { id: 'in1', group: 'in', attrs: { text: { text: 'in1' } } },
+ { id: 'in2', group: 'in', attrs: { text: { text: 'in2' } } },
+ { id: 'out', group: 'out', attrs: { text: { text: 'out' } } }
+ ]
+ }
+ },
+ {
+ id: 'gateway',
+ type: 'example.HubService',
+ parent: 'edge',
+ size: { width: 130, height: 80 },
+ attrs: { label: { text: 'API Gateway' } },
+ ports: {
+ items: [
+ { id: 'in', group: 'in', attrs: { text: { text: 'in' } } },
+ { id: 'out1', group: 'out', attrs: { text: { text: 'out1' } } },
+ { id: 'out2', group: 'out', attrs: { text: { text: 'out2' } } }
+ ]
+ }
+ },
+
+ // Core Services
+ {
+ id: 'auth',
+ type: 'example.HubService',
+ parent: 'backend',
+ size: { width: 130, height: 80 },
+ attrs: { label: { text: 'Auth Service' } },
+ ports: {
+ items: [
+ { id: 'in', group: 'in', attrs: { text: { text: 'in' } } },
+ { id: 'out1', group: 'out', attrs: { text: { text: 'out1' } } },
+ { id: 'out2', group: 'out', attrs: { text: { text: 'out2' } } }
+ ]
+ }
+ },
+ {
+ id: 'cache',
+ type: 'example.Service',
+ parent: 'storage',
+ attrs: { label: { text: 'Redis Cache' } }
+ },
+ {
+ id: 'db',
+ type: 'example.Service',
+ parent: 'storage',
+ attrs: { label: { text: 'PostgreSQL' } }
+ },
+
+ // Observability
+ {
+ id: 'monitoring',
+ type: 'example.HubService',
+ parent: 'observability',
+ size: { width: 130, height: 80 },
+ attrs: { label: { text: 'Monitoring' } },
+ ports: {
+ items: [
+ { id: 'in1', group: 'in', attrs: { text: { text: 'in1' } } },
+ { id: 'in2', group: 'in', attrs: { text: { text: 'in2' } } }
+ ]
+ }
+ },
+
+ // Links
+ {
+ id: 'l1',
+ type: 'example.InteractionLink',
+ source: { id: 'webui', port: 'out' },
+ target: { id: 'lb', port: 'in1' },
+ labels: [{ attrs: { text: { text: 'request' } } }]
+ },
+ {
+ id: 'l2',
+ type: 'example.InteractionLink',
+ source: { id: 'mobileui', port: 'out' },
+ target: { id: 'lb', port: 'in2' },
+ labels: [{ attrs: { text: { text: 'request' } } }]
+ },
+ {
+ id: 'l3',
+ type: 'example.InteractionLink',
+ source: { id: 'lb', port: 'out' },
+ target: { id: 'gateway', port: 'in' },
+ labels: [{ attrs: { text: { text: 'route' } } }]
+ },
+ {
+ id: 'l4',
+ type: 'example.InteractionLink',
+ source: { id: 'gateway', port: 'out1' },
+ target: { id: 'auth', port: 'in' },
+ labels: [{ attrs: { text: { text: 'authenticate' } } }]
+ },
+ {
+ id: 'l5',
+ type: 'example.InteractionLink',
+ source: { id: 'gateway', port: 'out2' },
+ target: { id: 'monitoring', port: 'in1' },
+ labels: [{ attrs: { text: { text: 'metrics' } } }]
+ },
+ {
+ id: 'l6',
+ type: 'example.InteractionLink',
+ source: { id: 'auth', port: 'out1' },
+ target: { id: 'cache', port: 'in' },
+ labels: [{ attrs: { text: { text: 'lookup' } } }]
+ },
+ {
+ id: 'l7',
+ type: 'example.InteractionLink',
+ source: { id: 'auth', port: 'out2' },
+ target: { id: 'monitoring', port: 'in2' },
+ labels: [{ attrs: { text: { text: 'logs' } } }]
+ },
+ {
+ id: 'l8',
+ type: 'example.InteractionLink',
+ source: { id: 'cache', port: 'out' },
+ target: { id: 'db', port: 'in' },
+ labels: [{ attrs: { text: { text: 'query' } } }]
+ },
+
+ // Container-to-container links - connected to a `example.Container` cell
+ // itself rather than to one of its ports, aggregating what the individual
+ // service-to-service links above already carry.
+ {
+ id: 'l9',
+ type: 'example.InteractionLink',
+ source: { id: 'backend' },
+ target: { id: 'observability' },
+ labels: [{ attrs: { text: { text: 'metrics' } } }]
+ },
+ {
+ id: 'l10',
+ type: 'example.InteractionLink',
+ source: { id: 'frontend' },
+ target: { id: 'observability' },
+ labels: [{ attrs: { text: { text: 'analytics' } } }]
+ }
+ ]
+};
diff --git a/examples/layout-elk-containers-and-ports-ts/src/index.ts b/examples/layout-elk-containers-and-ports-ts/src/index.ts
new file mode 100644
index 0000000000..cda174f1fb
--- /dev/null
+++ b/examples/layout-elk-containers-and-ports-ts/src/index.ts
@@ -0,0 +1,205 @@
+import { dia, shapes, setTheme } from '@joint/core';
+import {
+ ElkLayoutOptions,
+ ExportElementCallback,
+ ExportPortCallback,
+ ExportPortLabelCallback,
+ createWorkerElk,
+ layout
+} from '@joint/layout-elk';
+import { graphJSON } from './example';
+import { Container, HubService, InteractionLink, Service } from './shapes';
+import './styles.scss';
+
+const ELK_DIRECTION = 'RIGHT';
+
+const cellNamespace = {
+ ...shapes,
+ example: {
+ Container,
+ Service,
+ HubService,
+ InteractionLink
+ }
+};
+
+// ELK runs in a Web Worker, so a layout doesn't block the page - the worker is started on
+// the first layout, then shared by every later one.
+const elk = createWorkerElk(() => new Worker(new URL('@joint/layout-elk/worker', import.meta.url), { type: 'module' }));
+
+const init = () => {
+
+ // Every view (paper and cells alike) picks up a `joint-theme-material` class -
+ // this example's own CSS gives that class its actual meaning (see `styles.scss`).
+ setTheme('material');
+
+ // Create JointJS graph and paper
+ const graph = new dia.Graph({}, { cellNamespace });
+ const paper = new dia.Paper({
+ model: graph,
+ cellViewNamespace: cellNamespace,
+ width: 1200,
+ height: 700,
+ gridSize: 1,
+ interactive: false,
+ async: true,
+ frozen: true,
+ defaultConnector: {
+ name: 'straight',
+ args: {
+ cornerType: 'cubic',
+ cornerRadius: 5
+ }
+ }
+ });
+ document.getElementById('canvas')!.appendChild(paper.el);
+ addZoomAndPanListeners(paper);
+
+ // Load the fixed example data - `mergeArrays` merges each cell's array
+ // attributes (e.g. a link's `labels`) into its class defaults index by
+ // index, instead of the default behavior of replacing them outright.
+ graph.fromJSON(graphJSON, { mergeArrays: true });
+
+ const elkLayoutOptions: ElkLayoutOptions = {
+ /**
+ * Overall direction of the layout.
+ * 'UP' | 'DOWN' | 'LEFT' | 'RIGHT'
+ */
+ 'elk.direction': ELK_DIRECTION,
+
+ /**
+ * Spacing between nodes (siblings).
+ * A number value as a string.
+ */
+ 'elk.spacing.nodeNode': '30',
+
+ /**
+ * Spacing between layers (for layered algorithm).
+ * A number value as a string.
+ */
+ 'elk.layered.spacing.nodeNodeBetweenLayers': '40',
+
+ /**
+ * Edge routing style.
+ * 'ORTHOGONAL' | 'SPLINES' | 'POLYLINE'
+ */
+ 'elk.edgeRouting': 'ORTHOGONAL',
+
+ /**
+ * Desired width-to-height ratio of the drawing - ELK's wrapping
+ * strategy (below) targets this to decide how many rows to wrap
+ * onto. Tuned, together with the spacing above, for this
+ * particular graph.
+ */
+ 'elk.aspectRatio': '1.2',
+ /**
+ * Wraps layers onto additional rows, connected by dedicated
+ * "wrap" edges, instead of growing a single row indefinitely.
+ * 'NONE' | 'SINGLE_EDGE' | 'MULTI_EDGE'
+ */
+ 'elk.layered.wrapping.strategy': 'MULTI_EDGE'
+ };
+
+ // `FIXED_SIDE` (not the package's default `FIXED_POS`) lets ELK move each port
+ // along the side `exportPort` puts it on, to reduce crossings.
+ const exportElement: ExportElementCallback = ({ element, elkNode }) => {
+ if (element.hasPorts()) {
+ elkNode.layoutOptions['elk.portConstraints'] = 'FIXED_SIDE';
+ }
+
+ const padding = element.get('padding');
+ if (padding) {
+ elkNode.layoutOptions['elk.padding'] = padding;
+ }
+ };
+
+ const exportPort: ExportPortCallback = ({ portId, element, elkPort }) => {
+ // @ts-expect-error a port id may be a number - clientIO/joint#3544
+ switch (element.getPort(portId).group) {
+ case 'in':
+ elkPort.layoutOptions['elk.port.side'] = 'WEST';
+ break;
+ case 'out':
+ elkPort.layoutOptions['elk.port.side'] = 'SOUTH';
+ break;
+ }
+ };
+
+ const exportPortLabel: ExportPortLabelCallback = ({ portId, element, elkPortLabel }) => {
+ // @ts-expect-error a port id may be a number - clientIO/joint#3544
+ const size = element.portProp(portId, 'label/size');
+ // A port with no label text is never sized (see `Service` in `shapes.ts`) - it
+ // goes to ELK without a label rather than with an unsized one.
+ if (!size) return false;
+ elkPortLabel.width = size.width;
+ elkPortLabel.height = size.height;
+ };
+
+ // Freezes the paper for the (async) duration of the layout, so nothing renders
+ // mid-layout.
+ const runLayout = (): Promise => {
+ paper.freeze();
+ return layout(graph, {
+ elk,
+ exportElement,
+ exportPort,
+ exportPortLabel,
+ elkLayoutOptions
+ }).then(() => {
+ paper.unfreeze();
+ }).catch((error) => {
+ paper.unfreeze();
+ console.error('ELK layout error:', error.message);
+ });
+ };
+
+ // Initial layout of the fixed example data, fit to the paper's viewport.
+ runLayout().then(() => {
+ zoom(paper, 1);
+ });
+};
+
+function zoom(paper: dia.Paper, zoomLevel: number): void {
+ paper.scale(zoomLevel);
+ paper.fitToContent({
+ useModelGeometry: true,
+ padding: 40 * zoomLevel,
+ allowNewOrigin: 'any'
+ });
+}
+
+/**
+ * Add toolbar zoom in/out listeners to the paper and setup panning.
+ */
+function addZoomAndPanListeners(paper: dia.Paper): void {
+
+ let zoomLevel = paper.scale().sx;
+
+ document.getElementById('zoom-in')!.addEventListener('click', () => {
+ zoomLevel = Math.min(3, zoomLevel + 0.2);
+ zoom(paper, zoomLevel);
+ });
+
+ document.getElementById('zoom-out')!.addEventListener('click', () => {
+ zoomLevel = Math.max(0.2, zoomLevel - 0.2);
+ zoom(paper, zoomLevel);
+ });
+
+ paper.on('blank:pointerdown', (evt) => {
+ evt.data = {
+ scrollX: window.scrollX,
+ clientX: evt.clientX,
+ scrollY: window.scrollY,
+ clientY: evt.clientY
+ };
+ });
+
+ paper.on('blank:pointermove', (evt) => {
+ window.scroll(
+ evt.data.scrollX + (evt.data.clientX - evt.clientX!),
+ evt.data.scrollY + (evt.data.clientY - evt.clientY!)
+ );
+ });
+}
+
+init();
diff --git a/examples/layout-elk-containers-and-ports-ts/src/shapes.ts b/examples/layout-elk-containers-and-ports-ts/src/shapes.ts
new file mode 100644
index 0000000000..831d1c665d
--- /dev/null
+++ b/examples/layout-elk-containers-and-ports-ts/src/shapes.ts
@@ -0,0 +1,227 @@
+import { dia, shapes, util } from '@joint/core';
+
+const PORT_SIZE = { width: 12, height: 12 };
+const PORT_ATTRS = {
+ circle: {
+ r: 6,
+ class: 'md-port'
+ },
+ text: {
+ class: 'md-port-label'
+ }
+};
+
+// `@joint/layout-elk` reads a port label's `size` directly rather than measuring the
+// rendered text itself, so it has to be estimated from the label text up front.
+// `Service` (below) computes and assigns it for every port as soon as the port is
+// added, from that port's own label text length. The estimate only needs to roughly
+// match `.md-port-label`'s CSS font size/weight, since it never has to be exact.
+const PORT_LABEL_FONT_SIZE = 11;
+const PORT_LABEL_AVERAGE_CHAR_WIDTH = PORT_LABEL_FONT_SIZE * 0.6;
+const PORT_LABEL_HEIGHT = PORT_LABEL_FONT_SIZE + 5;
+
+function estimatePortLabelSize(text: string): dia.Size {
+ return {
+ width: Math.ceil(text.length * PORT_LABEL_AVERAGE_CHAR_WIDTH),
+ height: PORT_LABEL_HEIGHT
+ };
+}
+
+// Square ports (rather than `PORT_ATTRS`' circles) set `HubService` apart as
+// a hub with several ports fanning in/out on the same side.
+const HUB_PORT_SIZE = { width: 14, height: 8 };
+const HUB_PORT_MARKUP = [{
+ tagName: 'rect',
+ selector: 'rect'
+}];
+const HUB_PORT_ATTRS = {
+ rect: {
+ x: -HUB_PORT_SIZE.width / 2,
+ y: -HUB_PORT_SIZE.height / 2,
+ width: HUB_PORT_SIZE.width,
+ height: HUB_PORT_SIZE.height,
+ class: 'md-port'
+ },
+ text: {
+ class: 'md-port-label'
+ }
+};
+
+const CONTAINER_PADDING = '[top=40,left=20,bottom=20,right=20]';
+
+/**
+ * A tonal container surface - its final size and position are computed by ELK to fit
+ * whatever gets embedded into it. Its label sits in the top-left corner, out of the
+ * way of embedded elements.
+ */
+export class Container extends shapes.standard.Rectangle {
+ defaults() {
+ return util.defaultsDeep({
+ type: 'example.Container',
+ size: { width: 100, height: 100 },
+ padding: CONTAINER_PADDING,
+ attrs: {
+ body: {
+ class: 'md-container'
+ },
+ label: {
+ x: 12,
+ y: 10,
+ textAnchor: 'start',
+ textVerticalAnchor: 'top',
+ class: 'md-container-label'
+ }
+ }
+ }, super.defaults);
+ }
+}
+
+/**
+ * A service node with exactly one 'in' (left) and one 'out' (right) port,
+ * always - only the label `text` is left for each instance to fill in. See
+ * `HubService` for a service with a custom number of ports.
+ */
+export class Service extends shapes.standard.Rectangle {
+ defaults() {
+ return util.defaultsDeep({
+ type: 'example.Service',
+ size: { width: 130, height: 50 },
+ attrs: {
+ body: {
+ class: 'md-card'
+ },
+ label: {
+ class: 'md-card-label'
+ }
+ },
+ ports: {
+ groups: {
+ in: {
+ position: {
+ name: 'absolute'
+ },
+ label: {
+ position: {
+ name: 'manual'
+ }
+ },
+ size: PORT_SIZE,
+ attrs: PORT_ATTRS,
+ },
+ out: {
+ position: {
+ name: 'absolute'
+ },
+ label: {
+ position: {
+ name: 'manual'
+ }
+ },
+ size: PORT_SIZE,
+ attrs: PORT_ATTRS,
+ }
+ },
+ items: [
+ { id: 'in', group: 'in', attrs: { text: { text: 'in' } } },
+ { id: 'out', group: 'out', attrs: { text: { text: 'out' } } }
+ ]
+ }
+ }, super.defaults);
+ }
+
+ initialize(...args: any[]) {
+ super.initialize(...args);
+
+ // Ports present from the start don't go through `ports:add` (it only fires for
+ // ports added after the element already exists), so size them here too.
+ this._sizePortLabels(this.getPorts());
+ this.on('ports:add', (_element: this, addedPorts: dia.Element.Port[]) => {
+ this._sizePortLabels(addedPorts);
+ });
+ }
+
+ private _sizePortLabels(ports: dia.Element.Port[]) {
+ ports.forEach((port) => {
+ const text = port.attrs?.text?.text;
+ if (!port.id || typeof text !== 'string') return;
+ this.portProp(port.id, 'label/size', estimatePortLabelSize(text));
+ });
+ }
+}
+
+/**
+ * A service with a custom (per-instance) number of ports - `ports.items`
+ * always comes from the instance, replacing `Service`'s fixed pair. Its own
+ * `md-card--hub` outline (an emphasis color, not a shape change) sets it apart
+ * as a hub with several ports fanning in/out on the same side.
+ */
+export class HubService extends Service {
+ defaults() {
+ // `super.defaults` (unlike extending a built-in `shapes.standard.*`
+ // class, whose `defaults` is a plain object) is a method here too -
+ // it must be called to get the merged object, not just referenced.
+ return util.defaultsDeep({
+ type: 'example.HubService',
+ attrs: {
+ body: {
+ class: 'md-card md-card--hub'
+ }
+ },
+ ports: {
+ groups: {
+ in: {
+ markup: HUB_PORT_MARKUP,
+ size: HUB_PORT_SIZE,
+ attrs: HUB_PORT_ATTRS
+ },
+ out: {
+ markup: HUB_PORT_MARKUP,
+ size: HUB_PORT_SIZE,
+ attrs: HUB_PORT_ATTRS
+ }
+ }
+ }
+ }, super.defaults());
+ }
+}
+
+/**
+ * A link with a labelled, pill-shaped Material "assist chip" background - only
+ * the label `text` is left for each instance to fill in.
+ */
+export class InteractionLink extends shapes.standard.Link {
+ defaults() {
+ return util.defaultsDeep({
+ type: 'example.InteractionLink',
+ attrs: {
+ // A CSS class alone can't color this: the arrowhead is a separate
+ // `` def (in ``, so it isn't reached by a class on the
+ // line) whose own color JointJS derives from this `stroke` value -
+ // see `attributes/defs.mjs`'s `contextMarker()`.
+ line: {
+ stroke: '#78909C',
+ class: 'md-link'
+ }
+ },
+ defaultLabel: {
+ size: { width: 80, height: 20 },
+ attrs: {
+ text: {
+ class: 'md-chip-text'
+ },
+ rect: {
+ ref: null,
+ x: 'calc(x - calc(w / 2))',
+ y: 'calc(y - calc(h / 2))',
+ width: 'calc(w)',
+ height: 'calc(h)',
+ rx: 'calc(h / 2)',
+ ry: 'calc(h / 2)',
+ class: 'md-chip-bg'
+ }
+ },
+ position: 0.5
+ }
+ }, super.defaults);
+ }
+}
diff --git a/examples/layout-elk-containers-and-ports-ts/src/styles.scss b/examples/layout-elk-containers-and-ports-ts/src/styles.scss
new file mode 100644
index 0000000000..431995d02f
--- /dev/null
+++ b/examples/layout-elk-containers-and-ports-ts/src/styles.scss
@@ -0,0 +1,161 @@
+// A restrained Material Design palette + elevation system, scoped under the
+// `joint-theme-material` class every view gets from this example's own
+// `setTheme('material')` call (`index.ts`) - JointJS's theme mechanism is just that
+// class; giving it meaning is entirely up to this stylesheet (see `mvc.View#setTheme`).
+// Applied almost entirely through the classes below (see `shapes.ts`) - JointJS attrs
+// are only used where CSS alone cannot reach: geometry (`calc(...)`-driven `rx`/
+// `width`/...), text content, and the link's `stroke` (its arrowhead marker is a
+// separate `` def that derives its own color from that attrs value, not
+// from the `line`'s CSS - see `shapes.ts`'s `InteractionLink`).
+:root {
+ --md-primary: #3F51B5;
+ --md-primary-tint: #E8EAF6;
+ --md-on-primary-tint: #283593;
+ --md-surface: #FFFFFF;
+ --md-surface-container: #F3F4F9;
+ --md-outline: #D0D3E3;
+ --md-on-surface: rgba(0, 0, 0, 0.87);
+ --md-on-surface-variant: rgba(0, 0, 0, 0.6);
+}
+
+html, body {
+ margin: 0;
+ padding: 0;
+ font-family: 'Roboto', 'Segoe UI', sans-serif;
+}
+
+#canvas {
+ position: absolute;
+ margin-top: 50px;
+ margin-left: 20px;
+ border: 1px solid var(--md-outline);
+ background-color: #FAFAFA;
+ overflow: hidden;
+}
+
+.toolbar {
+ display: flex;
+ position: fixed;
+ width: 100%;
+ top: 10px;
+ margin-left: 30px;
+ text-align: center;
+ justify-content: left;
+ z-index: 1;
+}
+
+.toolbar-button {
+ outline: none;
+ background: var(--md-surface);
+ border: none;
+ border-radius: 4px;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2), 0 1px 1px rgba(0, 0, 0, 0.14);
+ text-align: center;
+ font-family: inherit;
+ font-size: 13px;
+ font-weight: 500;
+ text-transform: uppercase;
+ padding: 8px 14px;
+ letter-spacing: 0.4px;
+ color: var(--md-primary);
+ cursor: pointer;
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+ margin: 0 4px;
+ transition: box-shadow 0.15s ease, background 0.15s ease;
+
+ &:hover {
+ background: var(--md-primary-tint);
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.24), 0 1px 2px rgba(0, 0, 0, 0.16);
+ }
+}
+
+// Everything from here on only ever applies to a `joint-theme-material` view.
+.joint-theme-material {
+
+ // Containers - a tonal surface (no shadow: Material's own elevated surfaces
+ // don't float above the canvas the way a card does, they just sit at a
+ // slightly different tone). Containment itself already reads as hierarchy, so
+ // nested and top-level containers share one treatment rather than a color per
+ // nesting depth.
+ .md-container {
+ fill: var(--md-surface-container);
+ stroke: var(--md-outline);
+ stroke-width: 1px;
+ stroke-dasharray: 4 3;
+ rx: 8px;
+ ry: 8px;
+ }
+
+ .md-container-label {
+ font-family: inherit;
+ font-weight: 500;
+ font-size: 12px;
+ letter-spacing: 0.5px;
+ text-transform: uppercase;
+ fill: var(--md-on-surface-variant);
+ }
+
+ // Service cards - a plain elevated surface (Material's shadow is two stacked
+ // shadows, a tighter "key" one and a softer, larger "ambient" one; `filter:
+ // drop-shadow(...)`, unlike `box-shadow`, works on SVG shapes, and stacks the
+ // same way across multiple `drop-shadow()`s in one `filter`). A hub gets the
+ // primary color as its own outline (an emphasis Material otherwise expresses
+ // via a "tonal"/"outlined" variant) plus one step more elevation, instead of a
+ // one-off fill color per shape.
+ .md-card {
+ fill: var(--md-surface);
+ stroke: var(--md-outline);
+ stroke-width: 1px;
+ rx: 6px;
+ ry: 6px;
+ filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.3)) drop-shadow(0 1px 3px rgba(0, 0, 0, 0.15));
+ }
+
+ .md-card--hub {
+ stroke: var(--md-primary);
+ stroke-width: 2px;
+ filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.3)) drop-shadow(0 2px 6px rgba(0, 0, 0, 0.18));
+ }
+
+ .md-card-label {
+ font-family: inherit;
+ font-weight: 500;
+ font-size: 13px;
+ fill: var(--md-on-surface);
+ }
+
+ // Ports - one consistent treatment (a small primary-outlined dot) regardless
+ // of direction or shape (`HubService`'s square ports reuse the same classes).
+ .md-port {
+ fill: var(--md-surface);
+ stroke: var(--md-primary);
+ stroke-width: 2px;
+ }
+
+ .md-port-label {
+ font-family: inherit;
+ font-size: 11px;
+ fill: var(--md-on-surface-variant);
+ }
+
+ // Links - `stroke` itself stays an attrs value (see the file-level comment
+ // above); this class only covers what CSS can fully own.
+ .md-link {
+ stroke-linecap: round;
+ }
+
+ // A link label rendered as a Material "assist chip" - a filled pill.
+ .md-chip-bg {
+ fill: var(--md-primary-tint);
+ }
+
+ .md-chip-text {
+ font-family: inherit;
+ font-size: 11px;
+ font-weight: 500;
+ fill: var(--md-on-primary-tint);
+ }
+}
diff --git a/examples/layout-elk-containers-and-ports-ts/tsconfig.json b/examples/layout-elk-containers-and-ports-ts/tsconfig.json
new file mode 100644
index 0000000000..66924fb436
--- /dev/null
+++ b/examples/layout-elk-containers-and-ports-ts/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "module": "ES2020",
+ "moduleResolution": "bundler",
+ "target": "es6",
+ "lib": [
+ "es2022",
+ "dom"
+ ],
+ "noImplicitAny": false,
+ "resolveJsonModule": true,
+ "esModuleInterop": true,
+ "noEmit": true,
+ "types": [
+ "vite/client"
+ ]
+ },
+ "include": [
+ "src"
+ ]
+}
diff --git a/examples/layout-elk-defaults-ts/.gitignore b/examples/layout-elk-defaults-ts/.gitignore
new file mode 100644
index 0000000000..1eae0cf670
--- /dev/null
+++ b/examples/layout-elk-defaults-ts/.gitignore
@@ -0,0 +1,2 @@
+dist/
+node_modules/
diff --git a/examples/layout-elk-defaults-ts/README.md b/examples/layout-elk-defaults-ts/README.md
new file mode 100644
index 0000000000..821c5a5819
--- /dev/null
+++ b/examples/layout-elk-defaults-ts/README.md
@@ -0,0 +1,27 @@
+# JointJS ELK Default Usage Demo
+
+A small, fixed system diagram laid out automatically with `@joint/layout-elk`'s default behavior alone - no `exportElement`/`exportPort`/`setPortAttributes`/... callbacks, just plain ELK layout options (direction, spacing, edge routing). Containers, ports with labels, link labels, and all three link connectivity shapes (port-to-port, port-to-element, element-to-element) are laid out from the package's own defaults. Styled with Material Design, via JointJS's theme mechanism and CSS.
+
+## Setup
+
+Use Yarn to run this demo.
+
+You need to build *JointJS* first. Navigate to the root folder and run:
+```bash
+yarn install
+yarn run build
+```
+
+Navigate to this directory, then run:
+```bash
+yarn dev
+```
+
+Then open the URL printed in the terminal (usually `http://localhost:5173`). `yarn build`
+makes a production build in `dist/`, `yarn preview` serves it.
+
+## License
+
+The *JointJS* library is licensed under the [Mozilla Public License 2.0](https://github.com/clientIO/joint/blob/master/LICENSE).
+
+Copyright © 2013-2026 client IO
diff --git a/examples/layout-elk-defaults-ts/index.html b/examples/layout-elk-defaults-ts/index.html
new file mode 100644
index 0000000000..359e0c363d
--- /dev/null
+++ b/examples/layout-elk-defaults-ts/index.html
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+ ELK Default Usage | JointJS
+
+
+
+
+
+
+
+ Zoom Out
+ Zoom In
+
+
+
+
+
+
+
diff --git a/examples/layout-elk-defaults-ts/package.json b/examples/layout-elk-defaults-ts/package.json
new file mode 100644
index 0000000000..99a6d8f320
--- /dev/null
+++ b/examples/layout-elk-defaults-ts/package.json
@@ -0,0 +1,35 @@
+{
+ "name": "@joint/demo-layout-elk-defaults-ts",
+ "version": "4.3.1",
+ "type": "module",
+ "description": "JointJS - ELK Layout Default Usage Demo",
+ "homepage": "https://jointjs.com",
+ "author": {
+ "name": "client IO",
+ "url": "https://client.io"
+ },
+ "license": "MPL-2.0",
+ "private": true,
+ "installConfig": {
+ "hoistingLimits": "workspaces"
+ },
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@joint/core": "workspace:^",
+ "@joint/layout-elk": "workspace:^"
+ },
+ "devDependencies": {
+ "sass": "^1.93.2",
+ "typescript": "5.8.2",
+ "vite": "^7.3.1"
+ },
+ "volta": {
+ "node": "22.14.0",
+ "npm": "11.2.0",
+ "yarn": "4.18.0"
+ }
+}
diff --git a/examples/layout-elk-defaults-ts/src/example.ts b/examples/layout-elk-defaults-ts/src/example.ts
new file mode 100644
index 0000000000..18c0b74200
--- /dev/null
+++ b/examples/layout-elk-defaults-ts/src/example.ts
@@ -0,0 +1,125 @@
+import { dia } from '@joint/core';
+
+// A small, fixed system diagram exercising every connectivity/labeling shape
+// `@joint/layout-elk` lays out with no custom export/import callbacks at all:
+// two containers grouping services that talk over ports (port <-> port),
+// a port connected straight to a portless element (port <-> element), and a
+// link between the two containers themselves (element <-> element).
+export const graphJSON: dia.Graph.JSON = {
+ cells: [
+ // Containers
+ {
+ id: 'web',
+ type: 'example.Container',
+ attrs: { label: { text: 'Web Tier' } }
+ },
+ {
+ id: 'data',
+ type: 'example.Container',
+ attrs: { label: { text: 'Data Tier' } }
+ },
+
+ // Web Tier
+ {
+ id: 'webapp',
+ type: 'example.Service',
+ parent: 'web',
+ attrs: { label: { text: 'Web App' } },
+ ports: {
+ items: [
+ { id: 'out', group: 'out', attrs: { text: { text: 'out' } } }
+ ]
+ }
+ },
+ {
+ id: 'mobileapp',
+ type: 'example.Service',
+ parent: 'web',
+ attrs: { label: { text: 'Mobile App' } },
+ ports: {
+ items: [
+ { id: 'out', group: 'out', attrs: { text: { text: 'out' } } }
+ ]
+ }
+ },
+
+ // Data Tier
+ {
+ id: 'api',
+ type: 'example.Service',
+ parent: 'data',
+ // Taller than the default - two ports a side need enough vertical room
+ // between them that a port's own label (offset just above it) doesn't
+ // collide with its neighbor.
+ size: { width: 130, height: 90 },
+ attrs: { label: { text: 'API Service' } },
+ ports: {
+ items: [
+ { id: 'in1', group: 'in', attrs: { text: { text: 'in1' } } },
+ { id: 'in2', group: 'in', attrs: { text: { text: 'in2' } } },
+ { id: 'out1', group: 'out', attrs: { text: { text: 'out1' } } },
+ { id: 'out2', group: 'out', attrs: { text: { text: 'out2' } } }
+ ]
+ }
+ },
+ {
+ id: 'db',
+ type: 'example.Service',
+ parent: 'data',
+ attrs: { label: { text: 'Database' } },
+ ports: {
+ items: [
+ { id: 'in', group: 'in', attrs: { text: { text: 'in' } } }
+ ]
+ }
+ },
+
+ // A standalone, portless element - the target of a port-to-element link.
+ {
+ id: 'monitoring',
+ type: 'example.Service',
+ attrs: { label: { text: 'Monitoring' } }
+ },
+
+ // Links between ports
+ {
+ id: 'l1',
+ type: 'example.InteractionLink',
+ source: { id: 'webapp', port: 'out' },
+ target: { id: 'api', port: 'in1' },
+ labels: [{ attrs: { text: { text: 'request' } } }]
+ },
+ {
+ id: 'l2',
+ type: 'example.InteractionLink',
+ source: { id: 'mobileapp', port: 'out' },
+ target: { id: 'api', port: 'in2' },
+ labels: [{ attrs: { text: { text: 'request' } } }]
+ },
+ {
+ id: 'l3',
+ type: 'example.InteractionLink',
+ source: { id: 'api', port: 'out1' },
+ target: { id: 'db', port: 'in' },
+ labels: [{ attrs: { text: { text: 'query' } } }]
+ },
+
+ // A link from a port straight to a portless element.
+ {
+ id: 'l4',
+ type: 'example.InteractionLink',
+ source: { id: 'api', port: 'out2' },
+ target: { id: 'monitoring' },
+ labels: [{ attrs: { text: { text: 'metrics' } } }]
+ },
+
+ // A link between the two containers themselves - neither end is a port.
+ {
+ id: 'l5',
+ type: 'example.InteractionLink',
+ source: { id: 'web' },
+ target: { id: 'data' },
+ labels: [{ attrs: { text: { text: 'traffic' } } }]
+ }
+ ]
+};
diff --git a/examples/layout-elk-defaults-ts/src/index.ts b/examples/layout-elk-defaults-ts/src/index.ts
new file mode 100644
index 0000000000..416260bfa3
--- /dev/null
+++ b/examples/layout-elk-defaults-ts/src/index.ts
@@ -0,0 +1,112 @@
+import { dia, shapes, setTheme } from '@joint/core';
+import { layout } from '@joint/layout-elk';
+import { graphJSON } from './example';
+import { Container, Service, InteractionLink } from './shapes';
+import './styles.scss';
+
+const cellNamespace = {
+ ...shapes,
+ example: {
+ Container,
+ Service,
+ InteractionLink
+ }
+};
+
+const init = () => {
+
+ // Every view (paper and cells alike) picks up a `joint-theme-material` class -
+ // this example's own CSS gives that class its actual meaning (see `styles.scss`).
+ setTheme('material');
+
+ // Create JointJS graph and paper
+ const graph = new dia.Graph({}, { cellNamespace });
+ const paper = new dia.Paper({
+ model: graph,
+ cellViewNamespace: cellNamespace,
+ width: 900,
+ height: 600,
+ gridSize: 1,
+ interactive: false,
+ async: true,
+ frozen: true,
+ defaultConnector: {
+ name: 'straight',
+ args: {
+ cornerType: 'cubic',
+ cornerRadius: 5
+ }
+ }
+ });
+ document.getElementById('canvas')!.appendChild(paper.el);
+ addZoomAndPanListeners(paper);
+
+ // Load the fixed example data.
+ graph.fromJSON(graphJSON);
+
+ // No `exportElement`/`exportPort`/`setPortAttributes`/... callbacks - this is
+ // `layout()` at its simplest, with only plain ELK layout options passed through.
+ // Containers, ports and link labels are all laid out from this package's own
+ // defaults alone - and with no `elk` option, ELK runs on the main thread (the
+ // other ELK examples run it in a Web Worker, see `createWorkerElk()`).
+ layout(graph, {
+ elkLayoutOptions: {
+ // `'elk.algorithm': 'layered'` is the package's default - not repeated here.
+ 'elk.direction': 'RIGHT',
+ 'elk.edgeRouting': 'ORTHOGONAL',
+ 'elk.spacing.nodeNode': '30',
+ 'elk.layered.spacing.nodeNodeBetweenLayers': '50'
+ },
+ }).then(() => {
+ paper.unfreeze();
+ zoom(paper, 1);
+ }).catch((error) => {
+ paper.unfreeze();
+ console.error('ELK layout error:', error.message);
+ });
+};
+
+function zoom(paper: dia.Paper, zoomLevel: number): void {
+ paper.scale(zoomLevel);
+ paper.fitToContent({
+ useModelGeometry: true,
+ padding: 40 * zoomLevel,
+ allowNewOrigin: 'any'
+ });
+}
+
+/**
+ * Add toolbar zoom in/out listeners to the paper and setup panning.
+ */
+function addZoomAndPanListeners(paper: dia.Paper): void {
+
+ let zoomLevel = paper.scale().sx;
+
+ document.getElementById('zoom-in')!.addEventListener('click', () => {
+ zoomLevel = Math.min(3, zoomLevel + 0.2);
+ zoom(paper, zoomLevel);
+ });
+
+ document.getElementById('zoom-out')!.addEventListener('click', () => {
+ zoomLevel = Math.max(0.2, zoomLevel - 0.2);
+ zoom(paper, zoomLevel);
+ });
+
+ paper.on('blank:pointerdown', (evt) => {
+ evt.data = {
+ scrollX: window.scrollX,
+ clientX: evt.clientX,
+ scrollY: window.scrollY,
+ clientY: evt.clientY
+ };
+ });
+
+ paper.on('blank:pointermove', (evt) => {
+ window.scroll(
+ evt.data.scrollX + (evt.data.clientX - evt.clientX!),
+ evt.data.scrollY + (evt.data.clientY - evt.clientY!)
+ );
+ });
+}
+
+init();
diff --git a/examples/layout-elk-defaults-ts/src/shapes.ts b/examples/layout-elk-defaults-ts/src/shapes.ts
new file mode 100644
index 0000000000..323a4f7abd
--- /dev/null
+++ b/examples/layout-elk-defaults-ts/src/shapes.ts
@@ -0,0 +1,135 @@
+import { shapes, util } from '@joint/core';
+
+const PORT_SIZE = { width: 12, height: 12 };
+const PORT_ATTRS = {
+ circle: {
+ r: 6,
+ class: 'md-port'
+ },
+ text: {
+ class: 'md-port-label'
+ }
+};
+
+/**
+ * A tonal container surface - `@joint/layout-elk` sizes and positions it to fit
+ * whatever gets embedded into it, entirely through its own default container
+ * handling. Its label sits just above the box, so it never has to compete
+ * with embedded elements for space inside it.
+ */
+export class Container extends shapes.standard.Rectangle {
+ defaults() {
+ return util.defaultsDeep({
+ type: 'example.Container',
+ size: { width: 100, height: 100 },
+ attrs: {
+ body: {
+ class: 'md-container'
+ },
+ label: {
+ x: 0,
+ y: -8,
+ textAnchor: 'start',
+ textVerticalAnchor: 'bottom',
+ class: 'md-container-label'
+ }
+ }
+ }, super.defaults);
+ }
+}
+
+/**
+ * A service card - each instance declares its own `ports.items` (from none up
+ * to several), using a plain 'left'/'right' port group. Port labels are laid
+ * out by JointJS itself, not ELK - this example passes no `exportPortLabel`
+ * callback to customize that.
+ */
+export class Service extends shapes.standard.Rectangle {
+ defaults() {
+ return util.defaultsDeep({
+ type: 'example.Service',
+ size: { width: 130, height: 50 },
+ attrs: {
+ body: {
+ class: 'md-card'
+ },
+ label: {
+ class: 'md-card-label'
+ }
+ },
+ ports: {
+ groups: {
+ // The label sits above the port (not to its side, via `left`/`right`)
+ // so it never lands directly on the horizontal edge segment ELK's
+ // orthogonal routing always draws right up to a left/right port.
+ in: {
+ position: 'left',
+ label: {
+ position: {
+ name: 'outside',
+ args: {
+ y: 10
+ }
+ }
+ },
+ size: PORT_SIZE,
+ attrs: PORT_ATTRS
+ },
+ out: {
+ position: 'right',
+ label: {
+ position: {
+ name: 'outside',
+ args: {
+ y: 10
+ }
+ }
+ },
+ size: PORT_SIZE,
+ attrs: PORT_ATTRS
+ }
+ }
+ }
+ }, super.defaults);
+ }
+}
+
+/**
+ * A link with a labelled, pill-shaped Material "assist chip" - ELK positions
+ * the label on its own, since this example passes no `exportLinkLabel`
+ * callback to customize it.
+ */
+export class InteractionLink extends shapes.standard.Link {
+ defaults() {
+ return util.defaultsDeep({
+ type: 'example.InteractionLink',
+ attrs: {
+ // A CSS class alone can't color this: the arrowhead is a separate
+ // `` def whose own color JointJS derives from this `stroke`
+ // value - see `attributes/defs.mjs`'s `contextMarker()`.
+ line: {
+ stroke: '#78909C',
+ class: 'md-link'
+ }
+ },
+ defaultLabel: {
+ size: { width: 80, height: 20 },
+ attrs: {
+ text: {
+ class: 'md-chip-text'
+ },
+ rect: {
+ ref: null,
+ x: 'calc(x - calc(w / 2))',
+ y: 'calc(y - calc(h / 2))',
+ width: 'calc(w)',
+ height: 'calc(h)',
+ rx: 'calc(h / 2)',
+ ry: 'calc(h / 2)',
+ class: 'md-chip-bg'
+ }
+ }
+ }
+ }, super.defaults);
+ }
+}
diff --git a/examples/layout-elk-defaults-ts/src/styles.scss b/examples/layout-elk-defaults-ts/src/styles.scss
new file mode 100644
index 0000000000..8df932ad06
--- /dev/null
+++ b/examples/layout-elk-defaults-ts/src/styles.scss
@@ -0,0 +1,149 @@
+// A restrained Material Design palette + elevation system, scoped under the
+// `joint-theme-material` class every view gets from this example's own
+// `setTheme('material')` call (`index.ts`) - JointJS's theme mechanism is just that
+// class; giving it meaning is entirely up to this stylesheet (see `mvc.View#setTheme`).
+// Applied almost entirely through the classes below (see `shapes.ts`) - JointJS attrs
+// are only used where CSS alone cannot reach: geometry (`calc(...)`-driven `rx`/
+// `width`/...), text content, and the link's `stroke` (its arrowhead marker is a
+// separate `` def that derives its own color from that attrs value, not
+// from the `line`'s CSS - see `shapes.ts`'s `InteractionLink`).
+:root {
+ --md-primary: #3F51B5;
+ --md-primary-tint: #E8EAF6;
+ --md-on-primary-tint: #283593;
+ --md-surface: #FFFFFF;
+ --md-surface-container: #F3F4F9;
+ --md-outline: #D0D3E3;
+ --md-on-surface: rgba(0, 0, 0, 0.87);
+ --md-on-surface-variant: rgba(0, 0, 0, 0.6);
+}
+
+html, body {
+ margin: 0;
+ padding: 0;
+ font-family: 'Roboto', 'Segoe UI', sans-serif;
+}
+
+#canvas {
+ position: absolute;
+ margin-top: 50px;
+ margin-left: 20px;
+ border: 1px solid var(--md-outline);
+ background-color: #FAFAFA;
+ overflow: hidden;
+}
+
+.toolbar {
+ display: flex;
+ position: fixed;
+ width: 100%;
+ top: 10px;
+ margin-left: 30px;
+ text-align: center;
+ justify-content: left;
+ z-index: 1;
+}
+
+.toolbar-button {
+ outline: none;
+ background: var(--md-surface);
+ border: none;
+ border-radius: 4px;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2), 0 1px 1px rgba(0, 0, 0, 0.14);
+ text-align: center;
+ font-family: inherit;
+ font-size: 13px;
+ font-weight: 500;
+ text-transform: uppercase;
+ padding: 8px 14px;
+ letter-spacing: 0.4px;
+ color: var(--md-primary);
+ cursor: pointer;
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+ margin: 0 4px;
+ transition: box-shadow 0.15s ease, background 0.15s ease;
+
+ &:hover {
+ background: var(--md-primary-tint);
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.24), 0 1px 2px rgba(0, 0, 0, 0.16);
+ }
+}
+
+// Everything from here on only ever applies to a `joint-theme-material` view.
+.joint-theme-material {
+
+ // Containers - a tonal surface (no shadow: Material's own elevated surfaces
+ // don't float above the canvas the way a card does, they just sit at a
+ // slightly different tone).
+ .md-container {
+ fill: var(--md-surface-container);
+ stroke: var(--md-outline);
+ stroke-width: 1px;
+ stroke-dasharray: 4 3;
+ rx: 8px;
+ ry: 8px;
+ }
+
+ .md-container-label {
+ font-family: inherit;
+ font-weight: 500;
+ font-size: 12px;
+ letter-spacing: 0.5px;
+ text-transform: uppercase;
+ fill: var(--md-on-surface-variant);
+ }
+
+ // Service cards - a plain elevated surface (Material's shadow is two stacked
+ // shadows, a tighter "key" one and a softer, larger "ambient" one; `filter:
+ // drop-shadow(...)`, unlike `box-shadow`, works on SVG shapes, and stacks the
+ // same way across multiple `drop-shadow()`s in one `filter`).
+ .md-card {
+ fill: var(--md-surface);
+ stroke: var(--md-outline);
+ stroke-width: 1px;
+ rx: 6px;
+ ry: 6px;
+ filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.3)) drop-shadow(0 1px 3px rgba(0, 0, 0, 0.15));
+ }
+
+ .md-card-label {
+ font-family: inherit;
+ font-weight: 500;
+ font-size: 13px;
+ fill: var(--md-on-surface);
+ }
+
+ // Ports - one consistent treatment (a small primary-outlined dot).
+ .md-port {
+ fill: var(--md-surface);
+ stroke: var(--md-primary);
+ stroke-width: 2px;
+ }
+
+ .md-port-label {
+ font-family: inherit;
+ font-size: 11px;
+ fill: var(--md-on-surface-variant);
+ }
+
+ // Links - `stroke` itself stays an attrs value (see the file-level comment
+ // above); this class only covers what CSS can fully own.
+ .md-link {
+ stroke-linecap: round;
+ }
+
+ // A link label rendered as a Material "assist chip" - a filled pill.
+ .md-chip-bg {
+ fill: var(--md-primary-tint);
+ }
+
+ .md-chip-text {
+ font-family: inherit;
+ font-size: 11px;
+ font-weight: 500;
+ fill: var(--md-on-primary-tint);
+ }
+}
diff --git a/examples/layout-elk-defaults-ts/tsconfig.json b/examples/layout-elk-defaults-ts/tsconfig.json
new file mode 100644
index 0000000000..cc77a5e2f0
--- /dev/null
+++ b/examples/layout-elk-defaults-ts/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "module": "ES6",
+ "moduleResolution": "bundler",
+ "target": "es6",
+ "lib": [
+ "es2022",
+ "dom"
+ ],
+ "noImplicitAny": false,
+ "resolveJsonModule": true,
+ "esModuleInterop": true,
+ "noEmit": true,
+ "types": [
+ "vite/client"
+ ]
+ },
+ "include": [
+ "src"
+ ]
+}
diff --git a/examples/layout-elk-dependency-graph-ts/.gitignore b/examples/layout-elk-dependency-graph-ts/.gitignore
new file mode 100644
index 0000000000..1eae0cf670
--- /dev/null
+++ b/examples/layout-elk-dependency-graph-ts/.gitignore
@@ -0,0 +1,2 @@
+dist/
+node_modules/
diff --git a/examples/layout-elk-ts/README.md b/examples/layout-elk-dependency-graph-ts/README.md
similarity index 51%
rename from examples/layout-elk-ts/README.md
rename to examples/layout-elk-dependency-graph-ts/README.md
index d9eaedf7e5..50d999caf4 100644
--- a/examples/layout-elk-ts/README.md
+++ b/examples/layout-elk-dependency-graph-ts/README.md
@@ -1,4 +1,6 @@
-# JointJS ELK Demo
+# JointJS ELK Dependency Graph Demo
+
+A package dependency graph laid out with [`@joint/layout-elk`](../../packages/joint-layout-elk) - the Eclipse Layout Kernel (ELK), running in a Web Worker.
## Setup
@@ -12,9 +14,12 @@ yarn run build
Navigate to this directory, then run:
```bash
-yarn start
+yarn dev
```
+Then open the URL printed in the terminal (usually `http://localhost:5173`). `yarn build`
+makes a production build in `dist/`, `yarn preview` serves it.
+
## License
The *JointJS* library is licensed under the [Mozilla Public License 2.0](https://github.com/clientIO/joint/blob/master/LICENSE).
diff --git a/examples/layout-elk-ts/index.html b/examples/layout-elk-dependency-graph-ts/index.html
similarity index 91%
rename from examples/layout-elk-ts/index.html
rename to examples/layout-elk-dependency-graph-ts/index.html
index ee2522d3cf..86e692c00a 100644
--- a/examples/layout-elk-ts/index.html
+++ b/examples/layout-elk-dependency-graph-ts/index.html
@@ -15,7 +15,7 @@
Zoom In
-
+