Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/unreleased/vscode-auto-layout.changed.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 3 additions & 1 deletion docs/guide/08-editors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
2 changes: 1 addition & 1 deletion editors/vscode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <file> — <view>` 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. |
Expand Down
7 changes: 7 additions & 0 deletions editors/vscode/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion editors/vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down
172 changes: 172 additions & 0 deletions editors/vscode/src/webview/autolayout.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): RenderNode {
return { id, kind: "part", name, type: "", detail: "", fqn: `M::${name}`, origin, ...extra };
}

function edge(from: string, to: string, extra: Partial<RenderEdge> = {}): RenderEdge {
return { from, to, label: "", kind: "connection", fqn: `M::${from}${to}`, ...extra };
}

function rendering(nodes: RenderNode[], edges: RenderEdge[] = [], extra: Partial<RenderResult> = {}): 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);
});
Loading
Loading