@yalehwang/plantuml-js 0.3.1 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -1
- package/dist/foundation/layout.d.ts +20 -3
- package/dist/foundation/layout.js +32 -4
- package/dist/render/svg.js +49 -15
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -6,9 +6,28 @@ default, `@viz-js/viz` (WASM Graphviz) as an option.
|
|
|
6
6
|
|
|
7
7
|
> **Engine status** — `'viz'` is **experimental**. It reaches only a fraction
|
|
8
8
|
> of `'elk'`'s area utilization and still routes a few edges through unrelated
|
|
9
|
-
> nodes on large package diagrams. See
|
|
9
|
+
> nodes on large package diagrams. See
|
|
10
|
+
> [`docs/viz-decision.md`](https://github.com/yaleh/plantuml-js/blob/author/docs/viz-decision.md)
|
|
10
11
|
> for the measured comparison. Use `'elk'` (the default) for production diagrams.
|
|
11
12
|
|
|
13
|
+
## What it renders
|
|
14
|
+
|
|
15
|
+
`@yalehwang/plantuml-js` turns a PlantUML class diagram into a styled SVG. Here
|
|
16
|
+
is a minimal diagram through the default `'elk'` engine — three-compartment
|
|
17
|
+
class/interface boxes, a package, and implementation / composition / dependency
|
|
18
|
+
edges:
|
|
19
|
+
|
|
20
|
+

|
|
21
|
+
|
|
22
|
+
The same pipeline on a real medium-size architecture graph (package grouping,
|
|
23
|
+
orthogonal edge routing, bundled arrowheads):
|
|
24
|
+
|
|
25
|
+

|
|
26
|
+
|
|
27
|
+
See the [gallery](https://github.com/yaleh/plantuml-js/blob/author/docs/gallery.md)
|
|
28
|
+
for every figure's source `.puml`, its SVG output, and the `elk` vs `viz`
|
|
29
|
+
comparison.
|
|
30
|
+
|
|
12
31
|
## Install
|
|
13
32
|
|
|
14
33
|
```bash
|
|
@@ -68,9 +68,26 @@ export declare function snapUp(n: number, grid?: number): number;
|
|
|
68
68
|
/** Reconstruct the name drawn in the title compartment, generics expanded. */
|
|
69
69
|
export declare function displayName(entity: PlantUmlEntity): string;
|
|
70
70
|
/**
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
71
|
+
* Heights of the three UML compartments inside an entity box: the title band,
|
|
72
|
+
* the attribute (field) band, and the operations (method) band. Each band is a
|
|
73
|
+
* whole number of text rows; the compartment separator rules are zero-thickness
|
|
74
|
+
* boundaries drawn *between* bands and so consume no additional height.
|
|
75
|
+
*
|
|
76
|
+
* The renderer and {@link estimateEntitySize} both read this, so the drawn
|
|
77
|
+
* boxes and the emitted `data-title-h` / `data-attrs-h` / `data-ops-h`
|
|
78
|
+
* metadata can never drift apart.
|
|
79
|
+
*/
|
|
80
|
+
export declare function compartmentHeights(entity: PlantUmlEntity): {
|
|
81
|
+
title: number;
|
|
82
|
+
attrs: number;
|
|
83
|
+
ops: number;
|
|
84
|
+
};
|
|
85
|
+
/**
|
|
86
|
+
* Approximate the on-canvas size of an entity box. Height is the sum of the
|
|
87
|
+
* three compartments (title rows + attribute rows + operation rows) plus the
|
|
88
|
+
* top/bottom padding, snapped up to the spacing grid; width is the longest
|
|
89
|
+
* rendered line measured through the text table plus padding, never below
|
|
90
|
+
* MIN_WIDTH.
|
|
74
91
|
*/
|
|
75
92
|
export declare function estimateEntitySize(entity: PlantUmlEntity): {
|
|
76
93
|
width: number;
|
|
@@ -40,9 +40,36 @@ export function displayName(entity) {
|
|
|
40
40
|
return `${entity.name}<${generics.join(', ')}>`;
|
|
41
41
|
}
|
|
42
42
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
43
|
+
* Heights of the three UML compartments inside an entity box: the title band,
|
|
44
|
+
* the attribute (field) band, and the operations (method) band. Each band is a
|
|
45
|
+
* whole number of text rows; the compartment separator rules are zero-thickness
|
|
46
|
+
* boundaries drawn *between* bands and so consume no additional height.
|
|
47
|
+
*
|
|
48
|
+
* The renderer and {@link estimateEntitySize} both read this, so the drawn
|
|
49
|
+
* boxes and the emitted `data-title-h` / `data-attrs-h` / `data-ops-h`
|
|
50
|
+
* metadata can never drift apart.
|
|
51
|
+
*/
|
|
52
|
+
export function compartmentHeights(entity) {
|
|
53
|
+
let attrs = 0;
|
|
54
|
+
let ops = 0;
|
|
55
|
+
for (const member of entity.members) {
|
|
56
|
+
if (member.kind === 'method')
|
|
57
|
+
ops++;
|
|
58
|
+
else
|
|
59
|
+
attrs++;
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
title: METRICS.lineHeight,
|
|
63
|
+
attrs: attrs * METRICS.lineHeight,
|
|
64
|
+
ops: ops * METRICS.lineHeight,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Approximate the on-canvas size of an entity box. Height is the sum of the
|
|
69
|
+
* three compartments (title rows + attribute rows + operation rows) plus the
|
|
70
|
+
* top/bottom padding, snapped up to the spacing grid; width is the longest
|
|
71
|
+
* rendered line measured through the text table plus padding, never below
|
|
72
|
+
* MIN_WIDTH.
|
|
46
73
|
*/
|
|
47
74
|
export function estimateEntitySize(entity) {
|
|
48
75
|
const lines = [
|
|
@@ -51,7 +78,8 @@ export function estimateEntitySize(entity) {
|
|
|
51
78
|
];
|
|
52
79
|
const longest = lines.reduce((max, line) => Math.max(max, measureTextWidth(line, METRICS.fontSize)), 0);
|
|
53
80
|
const width = snapUp(Math.max(METRICS.minWidth, longest + METRICS.paddingX * 2));
|
|
54
|
-
const
|
|
81
|
+
const bands = compartmentHeights(entity);
|
|
82
|
+
const height = snapUp(bands.title + bands.attrs + bands.ops + METRICS.paddingY * 2);
|
|
55
83
|
return { width, height };
|
|
56
84
|
}
|
|
57
85
|
/** Entity count up to which `full` is chosen automatically. */
|
package/dist/render/svg.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* The renderer consumes only the LayoutResult (positions/sizes) plus the
|
|
4
4
|
* model's display fields; it never reaches back into the parser.
|
|
5
5
|
*/
|
|
6
|
-
import { METRICS, PACKAGE_LABEL, displayName, } from '../foundation/layout.js';
|
|
6
|
+
import { METRICS, PACKAGE_LABEL, displayName, compartmentHeights, } from '../foundation/layout.js';
|
|
7
7
|
import { measureTextWidth } from '../foundation/text-measure.js';
|
|
8
8
|
import { hubBadgeCircle, placeEdgeLabels, EDGE_LABEL_FONT_SIZE } from '../foundation/labels.js';
|
|
9
9
|
import { renderLegend, LEGEND_DEFAULT_NODE_THRESHOLD } from './legend.js';
|
|
@@ -134,6 +134,14 @@ function renderEntity(entity, node, theme) {
|
|
|
134
134
|
const fontStyle = italic ? ' font-style="italic"' : '';
|
|
135
135
|
const stub = entity.stub === true;
|
|
136
136
|
const { color, font } = theme;
|
|
137
|
+
// UML compartments, top to bottom: title / attributes (fields) / operations
|
|
138
|
+
// (methods). Members are regrouped by kind, so an authored interleaving of
|
|
139
|
+
// fields and methods still lands in the two canonical member bands.
|
|
140
|
+
const attrs = entity.members.filter((m) => m.kind !== 'method');
|
|
141
|
+
const ops = entity.members.filter((m) => m.kind === 'method');
|
|
142
|
+
const bands = compartmentHeights(entity);
|
|
143
|
+
const titleBottom = node.y + METRICS.paddingY + bands.title;
|
|
144
|
+
const opsTop = titleBottom + bands.attrs;
|
|
137
145
|
const parts = [];
|
|
138
146
|
if (entity.groupMembers)
|
|
139
147
|
parts.push(`<title>${entity.groupMembers.map(escapeXml).join('\n')}</title>`);
|
|
@@ -145,17 +153,25 @@ function renderEntity(entity, node, theme) {
|
|
|
145
153
|
` height="${fmt(node.height)}" rx="${theme.shape.nodeRadius}" ry="${theme.shape.nodeRadius}"` +
|
|
146
154
|
` fill="${weak ? color.stubFill : color.nodeFill}"` +
|
|
147
155
|
`${stubAttrs} stroke="${weak ? color.strokeStub : color.stroke}" stroke-width="${theme.stroke.node}"/>`);
|
|
148
|
-
const
|
|
156
|
+
const rule = (y, kind) => `<line class="uml-compartment-rule" data-rule="${kind}"` +
|
|
157
|
+
` x1="${fmt(node.x)}" y1="${fmt(y)}"` +
|
|
158
|
+
` x2="${fmt(node.x + node.width)}" y2="${fmt(y)}"` +
|
|
159
|
+
` stroke="${color.stroke}" stroke-width="${theme.stroke.node}"/>`;
|
|
160
|
+
// Title/attrs boundary: present whenever any member row is drawn.
|
|
161
|
+
if (attrs.length + ops.length > 0)
|
|
162
|
+
parts.push(rule(titleBottom, 'title'));
|
|
163
|
+
// Attrs/ops boundary: only when both member compartments carry rows. An
|
|
164
|
+
// interface (methods only) therefore has exactly one rule, `data-rule="title"`.
|
|
165
|
+
if (attrs.length > 0 && ops.length > 0)
|
|
166
|
+
parts.push(rule(opsTop, 'ops'));
|
|
149
167
|
const titleBaseline = node.y + METRICS.paddingY + METRICS.fontSize + 1;
|
|
150
|
-
if (entity.members.length > 0) {
|
|
151
|
-
parts.push(`<line x1="${fmt(node.x)}" y1="${fmt(headerBottom)}"` +
|
|
152
|
-
` x2="${fmt(node.x + node.width)}" y2="${fmt(headerBottom)}"` +
|
|
153
|
-
` stroke="${color.stroke}" stroke-width="${theme.stroke.node}"/>`);
|
|
154
|
-
}
|
|
155
168
|
parts.push(textEl(displayName(entity), node.x + node.width / 2, titleBaseline, 'middle', font.size.title, font.weight.title, color.textTitle, theme, fontStyle));
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
parts.push(
|
|
169
|
+
const memberText = (member, idx, top, cls) => textEl(member.raw, node.x + METRICS.paddingX, top + METRICS.lineHeight * idx + METRICS.fontSize + 1, 'start', font.size.member, font.weight.member, color.textMember, theme, ` class="${cls}"`);
|
|
170
|
+
attrs.forEach((member, i) => {
|
|
171
|
+
parts.push(memberText(member, i, titleBottom, 'uml-attr'));
|
|
172
|
+
});
|
|
173
|
+
ops.forEach((member, i) => {
|
|
174
|
+
parts.push(memberText(member, i, opsTop, 'uml-op'));
|
|
159
175
|
});
|
|
160
176
|
if (entity.usageCount && entity.usageCount > 0) {
|
|
161
177
|
parts.push(renderHubBadge(node, entity.usageCount, theme));
|
|
@@ -163,10 +179,17 @@ function renderEntity(entity, node, theme) {
|
|
|
163
179
|
// Each entity is a single DOM group carrying its stable id and its innermost
|
|
164
180
|
// package, so a browser host can bind hover/click handlers, cross-panel
|
|
165
181
|
// linkage, and package-containment queries without geometric hit-testing.
|
|
182
|
+
// The data-* geometry lets a level-of-detail host map zoom to compartments
|
|
183
|
+
// from metadata instead of re-measuring the drawn rect/lines.
|
|
166
184
|
const packageAttr = entity.packageId !== undefined
|
|
167
185
|
? ` data-package-id="${escapeXml(entity.packageId)}"`
|
|
168
186
|
: '';
|
|
169
|
-
return (`<g data-entity-id="${escapeXml(entity.id)}"${packageAttr}
|
|
187
|
+
return (`<g data-entity-id="${escapeXml(entity.id)}"${packageAttr}` +
|
|
188
|
+
` data-x="${fmt(node.x)}" data-y="${fmt(node.y)}"` +
|
|
189
|
+
` data-w="${fmt(node.width)}" data-h="${fmt(node.height)}"` +
|
|
190
|
+
` data-title-h="${fmt(bands.title)}" data-attrs-h="${fmt(bands.attrs)}"` +
|
|
191
|
+
` data-ops-h="${fmt(bands.ops)}"` +
|
|
192
|
+
` class="uml-node${entity.external ? ' uml-external' : ''}">` +
|
|
170
193
|
parts.join('') +
|
|
171
194
|
`</g>`);
|
|
172
195
|
}
|
|
@@ -193,11 +216,22 @@ function renderPackage(pkg, box, theme, tone) {
|
|
|
193
216
|
parts.push(`<rect x="${fmt(box.x)}" y="${fmt(box.y)}" width="${fmt(box.width)}"` +
|
|
194
217
|
` height="${fmt(box.height)}" rx="${theme.shape.packageRadius}" ry="${theme.shape.packageRadius}"` +
|
|
195
218
|
` fill="${fill}" stroke="${color.packageStroke}" stroke-width="${theme.stroke.package}"/>`);
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
219
|
+
// Closed UML label tab: top edge → right vertical down → bottom edge → close
|
|
220
|
+
// back up the left side. The opaque fill hides the package frame underneath,
|
|
221
|
+
// so the tab reads as a solid tab with a sealed right side and bottom edge.
|
|
222
|
+
const tabHeight = PACKAGE_LABEL.tabHeight;
|
|
223
|
+
const tabRight = box.x + tabWidth;
|
|
224
|
+
const tabBottom = box.y + tabHeight;
|
|
225
|
+
parts.push(`<path class="uml-package-tab"` +
|
|
226
|
+
` d="M ${fmt(box.x)},${fmt(box.y)} H ${fmt(tabRight)} V ${fmt(tabBottom)}` +
|
|
227
|
+
` H ${fmt(box.x)} Z"` +
|
|
228
|
+
` fill="${fill}" stroke="${color.packageStroke}" stroke-width="${theme.stroke.package}"` +
|
|
229
|
+
` stroke-linejoin="round"/>`);
|
|
199
230
|
parts.push(textEl(pkg.name, box.x + 8, box.y + PACKAGE_LABEL.tabHeight - 8, 'start', theme.font.size.packageLabel, theme.font.weight.packageLabel, color.textPackage, theme));
|
|
200
|
-
return (`<g data-package-id="${escapeXml(pkg.id)}" class="uml-package"
|
|
231
|
+
return (`<g data-package-id="${escapeXml(pkg.id)}" class="uml-package"` +
|
|
232
|
+
` data-x="${fmt(box.x)}" data-y="${fmt(box.y)}"` +
|
|
233
|
+
` data-w="${fmt(box.width)}" data-h="${fmt(box.height)}"` +
|
|
234
|
+
` data-tab-w="${fmt(tabWidth)}" data-tab-h="${fmt(tabHeight)}">` +
|
|
201
235
|
parts.join('') +
|
|
202
236
|
`</g>`);
|
|
203
237
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yalehwang/plantuml-js",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Pure-TypeScript PlantUML class diagram renderer (MIT)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -39,6 +39,8 @@
|
|
|
39
39
|
"typecheck": "tsc -p tsconfig.test.json",
|
|
40
40
|
"test": "vitest run",
|
|
41
41
|
"demo:build": "node scripts/build-demo.mjs",
|
|
42
|
+
"docs": "node scripts/render-docs.mjs",
|
|
43
|
+
"docs:check": "node scripts/render-docs.mjs --check",
|
|
42
44
|
"release": "node scripts/release.mjs",
|
|
43
45
|
"prepublishOnly": "npm run typecheck && npm run build && node scripts/check-dist-clean.mjs"
|
|
44
46
|
},
|