diff --git a/changes/unreleased/vscode-auto-layout.changed.md b/changes/unreleased/vscode-auto-layout.changed.md new file mode 100644 index 000000000..1e6c8fd29 --- /dev/null +++ b/changes/unreleased/vscode-auto-layout.changed.md @@ -0,0 +1 @@ +- **The diagram panel lays unplaced nodes out in layers and routes edges around boxes.** Nodes the model does not place took slots in a square grid and every edge ran straight between centres, across whatever lay between; they are now laid out by the ELK layered algorithm, edges orthogonal and routed around the boxes. A node the model places, or one dragged, is drawn where stated as before. Renderings of more than 600 nodes keep the grid. diff --git a/docs/guide/08-editors.md b/docs/guide/08-editors.md index b00d96b08..4c7533aaa 100644 --- a/docs/guide/08-editors.md +++ b/docs/guide/08-editors.md @@ -91,7 +91,9 @@ redraws from what the file now says. Where a diagram's boxes go is the model's decision when it states one: a view whose body places its elements with the bundled `DiagramLayout` library (`metadata Layout about engine { x = 120; y = 80; }`, and `Route` for an edge's waypoints) is drawn exactly so, and a node the model does -not place takes a slot in a grid under its owner. Dragging a node writes that annotation — into +not place is laid out in layers under its owner by the ELK layered algorithm, its edges +running orthogonally around the boxes (a rendering of more than 600 nodes falls back to a +square grid). Dragging a node writes that annotation — into the view's body when a view is drawn, into the element's own when the document is drawn directly — as one edit when the pointer is released; dragging the handle on an edge bends it through a `Route` waypoint. The geometry is on every node and edge the server sends (`x`, `y`, diff --git a/editors/vscode/README.md b/editors/vscode/README.md index a2ce9f927..a387baade 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -96,7 +96,7 @@ own just waits for the server. | --- | --- | | **What it draws** | The view the document declares. A document declaring several drawable views opens on the one whose declaration holds the editor's cursor, else the one last chosen for that document in this workspace, else the one picked from a list — the drawable views by name and kind, **All views** to open each in its own panel, and the pseudo-views last; views the server cannot draw are left out of that list (the panel's own picker still shows them, disabled, with the reason), and cancelling opens nothing. A document declaring none is drawn directly, as a model tree, interconnection diagram, state diagram, action flow, sequence diagram or element table — a table is written as Markdown rather than drawn, and is shown as that. A view whose rendering is not supported (`geometry`, `textual`) is listed but not drawable, and the reason is written under the diagram. | | **Several panels** | A document may have one panel per view open at once; they are titled `Diagram: — ` while there are several, each redraws when the model changes, and each highlights the cursor's node. Open Diagram reveals the panel already showing the chosen view, or opens another beside the source for a different one. Picking a view in a panel's picker retargets that panel — unless another panel already draws it, which is revealed instead. Panels come back with their views when the window reloads. | -| **Where things go** | A node the model places — a `DiagramLayout::Layout` annotation in the view's body or the element's own — is drawn exactly there, at the size it states; every other node takes a slot in a grid under its owner, in the order rendered, so the same model draws the same way every time. An edge follows the waypoints its `DiagramLayout::Route` gives it, else runs straight. | +| **Where things go** | A node the model places — a `DiagramLayout::Layout` annotation in the view's body or the element's own — is drawn exactly there, at the size it states; every other node is laid out in layers under its owner by the ELK layered algorithm, and an edge without waypoints of its own runs orthogonally around the boxes between two nodes neither the model nor a drag placed, else straight. An edge follows the waypoints its `DiagramLayout::Route` gives it. A rendering of more than 600 nodes keeps the earlier square grid, so a migrated model does not hang the panel. | | **Style** | The panel's **Style** list, or the `opensysml.diagram.style` setting, picks the look of every diagram. `theme` (the default) follows the VS Code colour theme. `pilot` is the pilot visualizer's Standard B&W, the look the DOT and PlantUML forms are written in: white canvas, black sans-serif text, thin dark borders, square definitions and rounded usages, a heavier border on a package and a dashed one on a region, bold names over a small italic `«kind»`, thick arrowless connections and dashed flows, filled black pseudo-states. The eight palettes (`okabe-ito`, `tol-bright`, `tol-muted`, `tol-light`, `brewer-set2`, `brewer-dark2`, `viridis`, `cividis`, [described here](../../docs/project/view-rendering-forms.md#palettes)) are that look filled by keyword family — parts one colour, ports another, a usage a lighter tint of its definition's — in the very colours a DOT or PlantUML export of the view takes, since the server names them; text stays black. Changing the list keeps the choice in your settings and redraws every open diagram. A `sysml-lsp` too old to name colours draws a palette as `pilot` and says so under the diagram. | | **Navigation** | Click a node to open the declaration it was built from; moving the cursor in the editor highlights the node whose declaration contains it. A node built from a standard library declaration opens the bundled library file, read-only. | | **While typing** | A rendering that fails mid-keystroke leaves the last good diagram on screen, dimmed, with the error in the status line: the panel never blanks. What a rendering could not represent is listed under it. | diff --git a/editors/vscode/package-lock.json b/editors/vscode/package-lock.json index c9d0f42ae..167b3bde2 100644 --- a/editors/vscode/package-lock.json +++ b/editors/vscode/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "license": "Apache-2.0", "dependencies": { + "elkjs": "0.12.0", "vscode-languageclient": "9.0.1" }, "devDependencies": { @@ -1577,6 +1578,12 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/elkjs": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.12.0.tgz", + "integrity": "sha512-YZcKynxVxYoKIOEpywEPwCFdg+BTbxQRNf3pbwdDCvc8O3kQD8bmIwSxKU1eOTVc4Xo+VG9Te+575mlfvOrhEQ==", + "license": "EPL-2.0 OR GPL-3.0-or-later" + }, "node_modules/encoding-sniffer": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", diff --git a/editors/vscode/package.json b/editors/vscode/package.json index c186de97c..659b9ba59 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -26,7 +26,10 @@ "untrustedWorkspaces": { "supported": "limited", "description": "In Restricted Mode the language server is only started from `opensysml.server.path` or `PATH`, never from the workspace's own `bin/` directory, and the server settings are read from user settings only.", - "restrictedConfigurations": ["opensysml.server.path", "opensysml.server.args"] + "restrictedConfigurations": [ + "opensysml.server.path", + "opensysml.server.args" + ] } }, "contributes": { @@ -213,6 +216,7 @@ "package": "npm run typecheck && npm test && npm run build -- --production && vsce package --no-dependencies --out opensysml-sysml.vsix" }, "dependencies": { + "elkjs": "0.12.0", "vscode-languageclient": "9.0.1" }, "devDependencies": { diff --git a/editors/vscode/src/webview/autolayout.test.ts b/editors/vscode/src/webview/autolayout.test.ts new file mode 100644 index 000000000..c0f5d6fb5 --- /dev/null +++ b/editors/vscode/src/webview/autolayout.test.ts @@ -0,0 +1,172 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import type { RenderEdge, RenderNode, RenderPoint, RenderResult } from "../protocol"; +import { AUTO_LAYOUT_LIMIT, autoLayout, type AutoLayout } from "./autolayout"; +import { GAP, type Box } from "./layout"; + +const origin = { uri: "file:///m.sysml", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 4 } }, digest: "d0" }; + +function node(id: string, name: string, extra: Partial = {}): RenderNode { + return { id, kind: "part", name, type: "", detail: "", fqn: `M::${name}`, origin, ...extra }; +} + +function edge(from: string, to: string, extra: Partial = {}): RenderEdge { + return { from, to, label: "", kind: "connection", fqn: `M::${from}${to}`, ...extra }; +} + +function rendering(nodes: RenderNode[], edges: RenderEdge[] = [], extra: Partial = {}): RenderResult { + return { + view: "M::V", + kind: "tree", + stated: "", + form: "mermaid", + artifact: "", + nodes, + edges, + notices: [], + version: 7, + ...extra, + }; +} + +// boxOf is a node's placed geometry as a box; ELK always states a size. +function boxOf(laid: AutoLayout, id: string): Box { + const geometry = laid.nodes.get(id); + assert.ok(geometry?.width !== undefined && geometry.height !== undefined); + return { x: geometry.x, y: geometry.y, width: geometry.width, height: geometry.height }; +} + +function disjoint(a: Box, b: Box): boolean { + return a.x + a.width <= b.x || b.x + b.width <= a.x || a.y + a.height <= b.y || b.y + b.height <= a.y; +} + +// onBorder is the point lying on the box's edge, give or take a pixel of snapping. +function onBorder(point: RenderPoint, box: Box): boolean { + const withinX = point.x >= box.x - 1 && point.x <= box.x + box.width + 1; + const withinY = point.y >= box.y - 1 && point.y <= box.y + box.height + 1; + const onX = Math.abs(point.x - box.x) <= 1 || Math.abs(point.x - box.x - box.width) <= 1; + const onY = Math.abs(point.y - box.y) <= 1 || Math.abs(point.y - box.y - box.height) <= 1; + return (withinX && onY) || (withinY && onX); +} + +function orthogonal(points: RenderPoint[]): boolean { + return points.every((point, i) => i === 0 || point.x === points[i - 1].x || point.y === points[i - 1].y); +} + +test("autoLayout lays a chain out in layers downward and routes its edges orthogonally", async () => { + const result = rendering([node("a", "a"), node("b", "b"), node("c", "c")], [edge("a", "b"), edge("b", "c")]); + const laid = await autoLayout(result); + assert.ok(laid); + const a = boxOf(laid, "a"); + const b = boxOf(laid, "b"); + const c = boxOf(laid, "c"); + assert.ok(a.y < b.y && b.y < c.y); + for (const box of [a, b, c]) { + assert.ok(box.width > 0 && box.height > 0); + } + assert.ok(disjoint(a, b) && disjoint(b, c) && disjoint(a, c)); + const routes = [laid.routes.get(0)!, laid.routes.get(1)!]; + assert.ok(routes.every((route) => route.length >= 2)); + assert.ok(routes.every(orthogonal)); + assert.ok(onBorder(routes[0][0], a) && onBorder(routes[0].at(-1)!, b)); + assert.ok(onBorder(routes[1][0], b) && onBorder(routes[1].at(-1)!, c)); +}); + +test("autoLayout holds a container's children inside it and reports absolute edge points", async () => { + const result = rendering( + [node("p", "p"), node("a", "a", { parent: "p" }), node("b", "b", { parent: "p" }), node("c", "c")], + [edge("a", "c"), edge("b", "c")], + ); + const laid = await autoLayout(result); + assert.ok(laid); + const p = boxOf(laid, "p"); + const a = boxOf(laid, "a"); + const b = boxOf(laid, "b"); + const c = boxOf(laid, "c"); + for (const child of [a, b]) { + assert.ok(child.x >= p.x && child.x + child.width <= p.x + p.width); + assert.ok(child.y >= p.y && child.y + child.height <= p.y + p.height); + } + const horizontalGap = Math.max(b.x - (a.x + a.width), a.x - (b.x + b.width)); + const verticalGap = Math.max(b.y - (a.y + a.height), a.y - (b.y + b.height)); + assert.ok(horizontalGap >= GAP || verticalGap >= GAP); + // The routes are absolute canvas coordinates: they end on c's border directly. + for (const route of [laid.routes.get(0)!, laid.routes.get(1)!]) { + assert.ok(onBorder(route.at(-1)!, c)); + } +}); + +test("autoLayout leaves a routed edge and a self-loop alone", async () => { + const route = [{ x: 40, y: 40 }]; + const result = rendering( + [node("a", "a"), node("b", "b"), node("c", "c")], + [edge("a", "b", { route }), edge("b", "c"), edge("c", "c")], + ); + const laid = await autoLayout(result); + assert.ok(laid); + assert.equal(laid.routes.has(0), false); + assert.ok(laid.routes.has(1)); + assert.equal(laid.routes.has(2), false); +}); + +test("autoLayout declines a kind with no canvas and a rendering over the limit", async () => { + assert.equal(await autoLayout(rendering([node("a", "a")], [], { kind: "sequence" })), undefined); + const many = Array.from({ length: AUTO_LAYOUT_LIMIT + 1 }, (_, i) => node(`n${i}`, `n${i}`)); + assert.equal(await autoLayout(rendering(many)), undefined); +}); + +test("autoLayout lays an interconnection out left to right", async () => { + const result = rendering([node("a", "a"), node("b", "b")], [edge("a", "b")], { kind: "interconnection" }); + const laid = await autoLayout(result); + assert.ok(laid); + assert.ok(boxOf(laid, "a").x < boxOf(laid, "b").x); +}); + +test("autoLayout moves an unplaced subtree with the container the model places", async () => { + const result = rendering( + [node("p", "p", { x: 500, y: 400, width: 300, height: 200 }), node("a", "a", { parent: "p" }), node("b", "b", { parent: "p" })], + [edge("a", "b")], + { kind: "interconnection" }, + ); + const laid = await autoLayout(result); + assert.ok(laid); + const p = boxOf(laid, "p"); + assert.deepEqual(p, { x: 500, y: 400, width: 300, height: 200 }); + for (const child of [boxOf(laid, "a"), boxOf(laid, "b")]) { + assert.ok(child.x >= p.x && child.x + child.width <= p.x + p.width); + assert.ok(child.y >= p.y && child.y + child.height <= p.y + p.height); + } + for (const point of laid.routes.get(0)!) { + assert.ok(point.x >= p.x && point.x <= p.x + p.width && point.y >= p.y && point.y <= p.y + p.height); + } +}); + +test("autoLayout grows an unplaced container around a child the model places elsewhere", async () => { + const result = rendering( + [node("q", "q"), node("a", "a", { parent: "q", x: 700, y: 50 }), node("b", "b"), node("c", "c")], + [edge("q", "b"), edge("a", "b"), edge("c", "b")], + ); + const laid = await autoLayout(result); + assert.ok(laid); + const q = boxOf(laid, "q"); + const a = boxOf(laid, "a"); + assert.deepEqual([a.x, a.y], [700, 50]); + assert.ok(a.x >= q.x && a.x + a.width <= q.x + q.width); + assert.ok(a.y >= q.y && a.y + a.height <= q.y + q.height); + // The grown box's own route is dropped so the edge anchors on its new border. + assert.equal(laid.routes.has(0), false); + // So is a route at the placed child, which the model positions. + assert.equal(laid.routes.has(1), false); + assert.ok(laid.routes.has(2)); +}); + +test("autoLayout drops the route of an edge crossing a placed container's border", async () => { + const result = rendering( + [node("p", "p", { x: 500, y: 400, width: 300, height: 200 }), node("a", "a", { parent: "p" }), node("c", "c")], + [edge("a", "c")], + ); + const laid = await autoLayout(result); + assert.ok(laid); + assert.equal(laid.routes.has(0), false); +}); diff --git a/editors/vscode/src/webview/autolayout.ts b/editors/vscode/src/webview/autolayout.ts new file mode 100644 index 000000000..6a13b6ca2 --- /dev/null +++ b/editors/vscode/src/webview/autolayout.ts @@ -0,0 +1,266 @@ +// Places the nodes a model does not place, and routes the edges between them, +// with the ELK layered algorithm: boxes land in layers, edges run orthogonally +// around them, and containers grow to hold their children. +import ELK from "elkjs/lib/elk.bundled.js"; +import type { ElkExtendedEdge, ElkNode } from "elkjs/lib/elk-api"; + +import type { LayoutGeometry, RenderNode, RenderPoint, RenderResult } from "../protocol"; +import { + CONTAINER_PAD, + GAP, + labelLines, + labelSize, + MARGIN, + PLACEABLE_KINDS, + shapeOf, + snap, + symbolSize, +} from "./layout"; + +export interface AutoLayout { + /** Absolute canvas geometry (x, y, width, height) for every node ELK placed. */ + nodes: Map; + /** By edge index: ELK's orthogonal polyline in absolute canvas coordinates, source anchor first, target anchor last. */ + routes: Map; +} + +/** Above this many nodes the grid stays: laying out a migrated model must not hang the panel. */ +export const AUTO_LAYOUT_LIMIT = 600; + +const elk = new ELK(); + +/** + * autoLayout lays the rendering out with ELK, or returns undefined when the kind + * has no editable canvas, the rendering is too large, or ELK fails. + */ +export async function autoLayout(result: RenderResult): Promise { + if (!PLACEABLE_KINDS.has(result.kind) || (result.nodes?.length ?? 0) === 0 || result.nodes!.length > AUTO_LAYOUT_LIMIT) { + return undefined; + } + try { + return await layOut(result); + } catch (err) { + console.warn(`auto layout failed: ${err instanceof Error ? err.message : String(err)}`); + return undefined; + } +} + +// layOut hands ELK the nodes — nested as the model owns them — and the edges, and +// reads back absolute canvas geometry: node coordinates are relative to their +// parent in ELK's JSON, and edge section coordinates relative to the root. +async function layOut(result: RenderResult): Promise { + const nodes = result.nodes ?? []; + const byId = new Map(nodes.map((node) => [node.id, node])); + const children = new Map(); + for (const node of nodes) { + const parent = node.parent !== undefined && node.parent !== node.id && byId.has(node.parent) ? node.parent : undefined; + const siblings = children.get(parent) ?? []; + siblings.push(node); + children.set(parent, siblings); + } + // A node whose owner is collapsed is not in the graph, so no edge routes to it. + const inGraph = (id: string): boolean => { + let node = byId.get(id); + while (node) { + const parent = node.parent; + if (parent === undefined || parent === node.id || !byId.has(parent)) { + return true; + } + node = byId.get(parent); + if (node?.collapsed) { + return false; + } + } + return false; + }; + const options = spacingOptions(result.kind); + const elkNode = (node: RenderNode): ElkNode => { + const size = symbolSize(shapeOf(node.kind)) ?? labelSize(labelLines(node)); + const kids = node.collapsed ? [] : (children.get(node.id) ?? []); + const out: ElkNode = { id: node.id, width: size.width, height: size.height }; + if (kids.length > 0) { + out.children = kids.map(elkNode); + // A container's label sits above its children, so the top padding is its height. + out.layoutOptions = { + ...options, + "elk.padding": `[top=${size.height},left=${CONTAINER_PAD},bottom=${CONTAINER_PAD},right=${CONTAINER_PAD}]`, + "elk.nodeSize.constraints": "MINIMUM_SIZE", + "elk.nodeSize.minimum": `(${size.width},${size.height})`, + }; + } + return out; + }; + const edges: ElkExtendedEdge[] = []; + (result.edges ?? []).forEach((edge, index) => { + if (edge.from === edge.to || !inGraph(edge.from) || !inGraph(edge.to)) { + return; + } + edges.push({ id: `e${index}`, sources: [edge.from], targets: [edge.to] }); + }); + const laid = await elk.layout({ + id: "__root__", + layoutOptions: { + ...options, + "elk.algorithm": "layered", + "elk.hierarchyHandling": "INCLUDE_CHILDREN", + // Edge section coordinates come back relative to the root, like a node's + // geometry relative to its parent, so they read as canvas coordinates. + "org.eclipse.elk.json.edgeCoords": "ROOT", + }, + children: (children.get(undefined) ?? []).map(elkNode), + edges, + }); + const placed = new Map(); + const walk = (node: ElkNode, ox: number, oy: number): void => { + for (const child of node.children ?? []) { + const x = ox + (child.x ?? 0); + const y = oy + (child.y ?? 0); + const geometry: LayoutGeometry = { + x: snap(x + MARGIN), + y: snap(y + MARGIN), + width: snap(child.width ?? 0), + height: snap(child.height ?? 0), + }; + if (byId.get(child.id)?.collapsed) { + geometry.collapsed = true; + } + placed.set(child.id, geometry); + walk(child, x, y); + } + }; + walk(laid, 0, 0); + const routes = new Map(); + for (const edge of laid.edges ?? []) { + const index = Number(edge.id.slice(1)); + if ((result.edges?.[index]?.route?.length ?? 0) > 0) { + continue; + } + const points: RenderPoint[] = []; + for (const section of edge.sections ?? []) { + points.push(section.startPoint, ...(section.bendPoints ?? []), section.endPoint); + } + if (points.length >= 2) { + routes.set(index, points.map((point) => ({ x: snap(point.x + MARGIN), y: snap(point.y + MARGIN) }))); + } + } + reconcile(result, children, placed, routes); + return { nodes: placed, routes }; +} + +// reconcile moves each placed node's subtree to the model's place (outermost first), keeps routes +// inside it and drops those crossing its border; then unplaced containers grow to cover their +// children, their own routes dropped. +function reconcile( + result: RenderResult, + children: Map, + placed: Map, + routes: Map, +): void { + const nodes = result.nodes ?? []; + const byId = new Map(nodes.map((node) => [node.id, node])); + const depth = (node: RenderNode): number => { + let d = 0; + let at = node; + while (at.parent !== undefined && at.parent !== at.id && byId.has(at.parent)) { + d++; + at = byId.get(at.parent)!; + } + return d; + }; + const subtree = (id: string): Set => { + const inside = new Set([id]); + const stack = [id]; + while (stack.length > 0) { + for (const child of children.get(stack.pop()!) ?? []) { + if (placed.has(child.id) && !inside.has(child.id)) { + inside.add(child.id); + stack.push(child.id); + } + } + } + return inside; + }; + for (const node of nodes + .filter((n) => n.x !== undefined && n.y !== undefined && placed.has(n.id)) + .sort((a, b) => depth(a) - depth(b))) { + const geometry = placed.get(node.id)!; + const dx = node.x! - geometry.x; + const dy = node.y! - geometry.y; + const inside = subtree(node.id); + for (const id of inside) { + if (id === node.id) { + continue; + } + const moved = placed.get(id)!; + moved.x += dx; + moved.y += dy; + } + geometry.x = node.x!; + geometry.y = node.y!; + if (node.width !== undefined) { + geometry.width = node.width; + } + if (node.height !== undefined) { + geometry.height = node.height; + } + for (const [index, points] of [...routes]) { + const edge = result.edges![index]; + const from = inside.has(edge.from); + const to = inside.has(edge.to); + if (from && to) { + routes.set(index, points.map((point) => ({ x: snap(point.x + dx), y: snap(point.y + dy) }))); + } else if (from || to) { + routes.delete(index); + } + } + } + for (const node of nodes + .filter((n) => placed.has(n.id) && (n.x === undefined || n.y === undefined) && !n.collapsed) + .sort((a, b) => depth(b) - depth(a))) { + const kids = (children.get(node.id) ?? []).filter((child) => placed.has(child.id)); + if (kids.length === 0) { + continue; + } + const geometry = placed.get(node.id)!; + const header = symbolSize(shapeOf(node.kind)) ?? labelSize(labelLines(node)); + let left = geometry.x; + let top = geometry.y; + let right = geometry.x + (geometry.width ?? 0); + let bottom = geometry.y + (geometry.height ?? 0); + for (const child of kids) { + const box = placed.get(child.id)!; + left = Math.min(left, box.x - CONTAINER_PAD); + top = Math.min(top, box.y - header.height); + right = Math.max(right, box.x + (box.width ?? 0) + CONTAINER_PAD); + bottom = Math.max(bottom, box.y + (box.height ?? 0) + CONTAINER_PAD); + } + if (left === geometry.x && top === geometry.y && right - left === geometry.width && bottom - top === geometry.height) { + continue; + } + geometry.x = left; + geometry.y = top; + geometry.width = right - left; + geometry.height = bottom - top; + // The box moved, so routes anchored on its old border are dropped; they are drawn straight. + for (const [index] of [...routes]) { + const edge = result.edges![index]; + if (edge.from === node.id || edge.to === node.id) { + routes.delete(index); + } + } + } +} + +// spacingOptions is shared by the root and every compound node so nested +// layers have the same direction, edge routing, and label-friendly gaps. +function spacingOptions(kind: string): Record { + return { + "elk.edgeRouting": "ORTHOGONAL", + "elk.direction": kind === "tree" || kind === "action" ? "DOWN" : "RIGHT", + "elk.spacing.nodeNode": `${GAP}`, + "elk.layered.spacing.nodeNodeBetweenLayers": `${GAP * 2}`, + "elk.spacing.edgeNode": `${GAP / 2}`, + "elk.layered.spacing.edgeNodeBetweenLayers": `${GAP / 2}`, + "elk.spacing.componentComponent": `${GAP}`, + }; +} diff --git a/editors/vscode/src/webview/diagram.ts b/editors/vscode/src/webview/diagram.ts index 6bd22e643..bf448d638 100644 --- a/editors/vscode/src/webview/diagram.ts +++ b/editors/vscode/src/webview/diagram.ts @@ -12,6 +12,7 @@ import { } from "../protocol"; import { type DiagramStyle, pilotLook, STYLE_LABELS, STYLES, styleOf } from "../style"; import { MenuCommand, MenuItem, nodeMenu, paletteItems } from "./actions"; +import { autoLayout, type AutoLayout } from "./autolayout"; import { cssEscape, drawCanvas, liftNode } from "./canvas"; import { dragHint, Drop, dropOn } from "./drop"; import { @@ -59,6 +60,8 @@ let style: DiagramStyle = styleOf(saved.style); let selectedNode: string | undefined; /** The layout on screen, which gestures act on; undefined while a table or nothing is shown. */ let layout: CanvasLayout | undefined; +/** What ELK placed for the rendering on screen; undefined until it answers, and for kinds it does not lay out. */ +let auto: AutoLayout | undefined; let gesture: Gesture | undefined; /** How far the pointer moves before a press becomes a drag rather than a click. */ const DRAG_THRESHOLD = 3; @@ -227,6 +230,7 @@ function draw(result: RenderResult): boolean { if (result.form === "mermaid") { // Mermaid is the machine form a diagram is exported in; the panel draws // the same nodes and edges itself, so their geometry is its own to edit. + auto = undefined; layout = layoutCanvas(result); show(layout); } else { @@ -240,6 +244,22 @@ function draw(result: RenderResult): boolean { diagram.classList.remove("stale"); showStatus(""); last = result; + if (result.form === "mermaid") { + // The grid answers at once; ELK's layered layout replaces it when it resolves. + showStatus("Laying out…"); + void autoLayout(result).then((laid) => { + if (last !== result) { + return; + } + showStatus(""); + if (!laid) { + return; + } + auto = laid; + layout = layoutCanvas(result, {}, auto); + show(layout); + }); + } remember(); showNotices(result); // An open menu names nodes of the drawing just replaced. @@ -357,7 +377,7 @@ function moveGesture(event: PointerEvent): void { drawDrag(event.shiftKey); return; } - showDragged(layoutCanvas(result, overridesOf(gesture.placements))); + showDragged(layoutCanvas(result, overridesOf(gesture.placements), auto)); } // showDragged puts the canvas a gesture has changed on screen. The pointer is captured by the @@ -381,7 +401,7 @@ function drawDrag(shift: boolean): void { const svg = showDragged(layout); liftNode(svg, layout, gesture.id, gesture.at.x - gesture.start.x, gesture.at.y - gesture.start.y); } else { - showDragged(layoutCanvas(last, overridesOf(gesture.placements))); + showDragged(layoutCanvas(last, overridesOf(gesture.placements), auto)); } previewDrop(shift); } diff --git a/editors/vscode/src/webview/layout.test.ts b/editors/vscode/src/webview/layout.test.ts index 0515661d4..f3dfecba6 100644 --- a/editors/vscode/src/webview/layout.test.ts +++ b/editors/vscode/src/webview/layout.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import type { RenderEdge, RenderNode, RenderResult } from "../protocol"; +import type { AutoLayout } from "./autolayout"; import { anchor, GAP, @@ -447,3 +448,41 @@ test("overridesOf previews a gesture: the moved node and route show where the dr assert.deepEqual(preview.edges[0].route, []); assert.equal(preview.edges[0].points.length, 2); }); + +test("layoutCanvas takes an auto layout's geometry for nodes the model does not place", () => { + const auto: AutoLayout = { + nodes: new Map([ + ["a", { x: 100, y: 50, width: 140, height: 60 }], + ["b", { x: 300, y: 200, width: 140, height: 60 }], + ["c", { x: 500, y: 50, width: 140, height: 60 }], + ]), + routes: new Map([[0, [{ x: 240, y: 80 }, { x: 300, y: 80 }, { x: 300, y: 200 }, { x: 300, y: 230 }]]]), + }; + const result = rendering( + [node("a", "a"), node("b", "b"), node("c", "c", { x: 50, y: 400, width: 90, height: 50 })], + [{ from: "a", to: "b", label: "", kind: "connection", fqn: "M::ab" }, { from: "a", to: "c", label: "", kind: "connection", fqn: "M::ac" }], + ); + const layout = layoutCanvas(result, {}, auto); + const a = layout.nodes.get("a")!; + const b = layout.nodes.get("b")!; + const c = layout.nodes.get("c")!; + // The auto geometry is honored exactly, but does not pin the node. + assert.deepEqual(a.box, { x: 100, y: 50, width: 140, height: 60 }); + assert.equal(a.pinned, false); + assert.equal(b.pinned, false); + // The model's geometry wins over the auto layout and stays pinned. + assert.deepEqual(c.box, { x: 50, y: 400, width: 90, height: 50 }); + assert.equal(c.pinned, true); + // An edge between two auto-placed nodes follows the auto route verbatim: its + // anchors first and last, its inner points the edge's route. + assert.deepEqual(layout.edges[0].points, auto.routes.get(0)); + assert.deepEqual(layout.edges[0].route, auto.routes.get(0)!.slice(1, -1)); + // An edge at a node the model places is straight, the auto route void at it. + assert.equal(layout.edges[1].points.length, 2); + assert.deepEqual(layout.edges[1].route, []); + // A gesture wins over both, and the pinned end straightens the edge to it. + const preview = layoutCanvas(result, overridesOf({ nodes: [{ id: "a", layout: { x: 10, y: 10 } }], edges: [] }), auto); + assert.deepEqual([preview.nodes.get("a")!.box.x, preview.nodes.get("a")!.box.y], [10, 10]); + assert.equal(preview.nodes.get("a")!.pinned, true); + assert.equal(preview.edges[0].points.length, 2); +}); diff --git a/editors/vscode/src/webview/layout.ts b/editors/vscode/src/webview/layout.ts index f434753f8..6ef4eaabb 100644 --- a/editors/vscode/src/webview/layout.ts +++ b/editors/vscode/src/webview/layout.ts @@ -12,6 +12,7 @@ import { type RenderPoint, type RenderResult, } from "../protocol"; +import type { AutoLayout } from "./autolayout"; export interface Box { x: number; @@ -69,7 +70,7 @@ export interface CanvasLayout { } /** The rendering kinds whose renderers position nodes and route edges from DiagramLayout. */ -const PLACEABLE_KINDS = new Set(["tree", "interconnection", "state", "action"]); +export const PLACEABLE_KINDS = new Set(["tree", "interconnection", "state", "action"]); /** Geometry a gesture in progress shows in place of the model's, before the model says so. */ export interface Overrides { @@ -95,7 +96,7 @@ const MIN_WIDTH = 96; const MIN_HEIGHT = 40; /** Between slots of one grid, and between a container's border and its slots. */ export const GAP = 32; -const CONTAINER_PAD = 16; +export const CONTAINER_PAD = 16; /** The canvas's margin around the outermost boxes. */ export const MARGIN = 24; const POINT_SIZE = 12; @@ -114,8 +115,12 @@ export function snap(value: number): number { return Math.round(value); } -/** layoutCanvas places every node and routes every edge of the rendering. */ -export function layoutCanvas(result: RenderResult, overrides: Overrides = {}): CanvasLayout { +/** + * layoutCanvas places every node and routes every edge of the rendering. `auto` + * is what ELK laid out for the same rendering: it places a node the model does + * not, without pinning it, and routes an edge whose ends are not placed. + */ +export function layoutCanvas(result: RenderResult, overrides: Overrides = {}, auto?: AutoLayout): CanvasLayout { const placed = new Map(); const roots: PlacedNode[] = []; for (const node of result.nodes ?? []) { @@ -143,13 +148,19 @@ export function layoutCanvas(result: RenderResult, overrides: Overrides = {}): C if (result.kind === "sequence") { return layoutSequence(result, roots, placed); } - const geometry = (entry: PlacedNode): LayoutGeometry | undefined => { + const geometry = (entry: PlacedNode): NodeGeometry => { const override = overrides.nodes?.get(entry.node.id); if (override) { - return override; + return { stated: override, pinned: true }; } const { x, y, width, height, collapsed } = entry.node; - return x !== undefined && y !== undefined ? { x, y, width, height, collapsed } : undefined; + if (x !== undefined && y !== undefined) { + return { stated: { x, y, width, height, collapsed }, pinned: true }; + } + // The auto layout's geometry is stated but not pinned: it is a guess, so + // the node keeps a grid slot for a layout that runs without it. + const laid = auto?.nodes.get(entry.node.id); + return laid !== undefined ? { stated: { ...laid, collapsed }, pinned: false } : { pinned: false }; }; placeGrid(roots, { x: MARGIN, y: MARGIN }, geometry); @@ -169,7 +180,7 @@ export function layoutCanvas(result: RenderResult, overrides: Overrides = {}): C reach(entry.box.x, entry.box.y); reach(entry.box.x + entry.box.width, entry.box.y + entry.box.height); } - const edges = (result.edges ?? []).map((edge, index) => routeEdge(edge, index, placed, overrides.routes)); + const edges = (result.edges ?? []).map((edge, index) => routeEdge(edge, index, placed, overrides.routes, auto)); for (const edge of edges) { if (edge.hidden) { continue; @@ -233,7 +244,13 @@ function headHeight(roots: PlacedNode[]): number { return roots.reduce((max, root) => Math.max(max, labelSize(root.lines).height), 0); } -type Geometry = (entry: PlacedNode) => LayoutGeometry | undefined; +/** Where a node goes and whether the model or a gesture, rather than a guess, states it. */ +interface NodeGeometry { + stated?: LayoutGeometry; + pinned: boolean; +} + +type Geometry = (entry: PlacedNode) => NodeGeometry; // placeGrid puts entries in a near-square grid from origin, in order, each column // as wide and each row as tall as its widest and tallest entry. An unsized box's @@ -275,8 +292,8 @@ function spread(lanes: PlacedNode[][], start: number, placeAt: (entry: PlacedNod // model's width, else its label's, widened to hold every child shown. The columns // of its children are settled first, from inside its padding. function placeAcross(entry: PlacedNode, slot: number, geometry: Geometry): number { - const stated = geometry(entry); - entry.pinned = stated !== undefined; + const { stated, pinned } = geometry(entry); + entry.pinned = pinned; entry.collapsed = stated?.collapsed === true; if (entry.collapsed) { hide(entry.children); @@ -300,7 +317,7 @@ function placeAcross(entry: PlacedNode, slot: number, geometry: Geometry): numbe // placeDown settles a node's y and height as placeAcross does its x and width; // the rows of its children start below its label. function placeDown(entry: PlacedNode, slot: number, geometry: Geometry): number { - const stated = geometry(entry); + const { stated } = geometry(entry); entry.box.y = stated?.y ?? slot; const shown = shownChildren(entry, stated); const columns = Math.max(1, Math.ceil(Math.sqrt(shown.length))); @@ -356,7 +373,7 @@ function labelHead(node: RenderNode): string { } // labelSize is the box a label needs, its head in bold glyphs. -function labelSize(lines: string[]): { width: number; height: number } { +export function labelSize(lines: string[]): { width: number; height: number } { let width = 0; lines.forEach((line, i) => { width = Math.max(width, [...line].length * (i === 0 ? BOLD_GLYPH_WIDTH : GLYPH_WIDTH)); @@ -393,7 +410,7 @@ export function shapeOf(kind: string): Shape { } // symbolSize is a symbol's fixed size; undefined for a label box. -function symbolSize(shape: Shape): { width: number; height: number } | undefined { +export function symbolSize(shape: Shape): { width: number; height: number } | undefined { switch (shape) { case "point": return { width: POINT_SIZE, height: POINT_SIZE }; @@ -411,15 +428,33 @@ function symbolSize(shape: Shape): { width: number; height: number } | undefined // routeEdge is an edge's polyline: from the border of its source, through the // route's waypoints, to the border of its target; a self-loop swings out to the right. +// The auto layout's route applies only where neither end is placed, since an end +// the model or a gesture moved is where the route was computed around it. function routeEdge( edge: RenderEdge, index: number, placed: Map, routes: Map | undefined, + auto?: AutoLayout, ): PlacedEdge { - const route = (routes?.has(index) ? routes.get(index) : edge.route) ?? []; + const stated = routes?.has(index) ? routes.get(index) : edge.route; const source = placed.get(edge.from); const target = placed.get(edge.to); + const routed = + stated === undefined && source?.pinned === false && target?.pinned === false ? auto?.routes.get(index) : undefined; + if (routed !== undefined && routed.length >= 2) { + // ELK's anchors already lie on the boxes' borders, so its polyline is drawn + // verbatim and a drag edits only the inner waypoints. + return { + edge, + index, + points: routed, + route: routed.slice(1, -1), + label: midpoint(routed), + hidden: source?.hidden === true || target?.hidden === true, + }; + } + const route = stated ?? []; const from = source?.box ?? { x: 0, y: 0, width: 0, height: 0 }; const to = target?.box ?? { x: 0, y: 0, width: 0, height: 0 }; let inner = route; diff --git a/editors/vscode/src/webview/tsconfig.json b/editors/vscode/src/webview/tsconfig.json index f52193284..f3c9debf6 100644 --- a/editors/vscode/src/webview/tsconfig.json +++ b/editors/vscode/src/webview/tsconfig.json @@ -4,6 +4,7 @@ "module": "ESNext", "moduleResolution": "Bundler", "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, "outDir": "../../out/webview" }, // The extension project excludes this directory; that exclusion is inherited diff --git a/editors/vscode/tsconfig.json b/editors/vscode/tsconfig.json index 310017623..e0700b10a 100644 --- a/editors/vscode/tsconfig.json +++ b/editors/vscode/tsconfig.json @@ -11,6 +11,7 @@ "noUnusedLocals": true, "noUnusedParameters": true, "noImplicitReturns": true, + "skipLibCheck": true, "sourceMap": true }, "include": ["src"],