@yalehwang/plantuml-js 0.3.0 → 0.4.0

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/dist/core.d.ts CHANGED
@@ -7,12 +7,12 @@
7
7
  * engine + `renderDot`.
8
8
  */
9
9
  import { PlantUmlModel } from './foundation/model.js';
10
- import { parsePlantUml } from './parser.js';
10
+ import { parsePlantUml } from './parse/parser.js';
11
11
  import { LayoutEngine, LayoutResult, DetailLevel } from './foundation/layout.js';
12
12
  import { ThemeOverrides } from './render/theme.js';
13
13
  import { ElkLayoutEngine, type ElkLayoutOptions } from './layout/elk.js';
14
14
  import { renderSvg, RenderSvgOptions, RenderEngine, RenderFrameOptions, SvgRenderer, SvgRendererOptions } from './render/svg.js';
15
- import { ViewSpec, TransformOptions } from './transform.js';
15
+ import { ViewSpec, TransformOptions } from './transform/transform.js';
16
16
  export type { PlantUmlModel, PlantUmlEntity, PlantUmlMember, PlantUmlRelation, PlantUmlPackage, EntityKind, RelationType, } from './foundation/model.js';
17
17
  export { defaultTheme, resolveTheme } from './render/theme.js';
18
18
  export type { Theme, ThemeOverrides } from './render/theme.js';
@@ -191,11 +191,11 @@ export interface LineExplanation {
191
191
  */
192
192
  export declare function explain(puml: string): LineExplanation[];
193
193
  export { parsePlantUml, ElkLayoutEngine, renderSvg, SvgRenderer };
194
- export { toPlantUml } from './serialize.js';
194
+ export { toPlantUml } from './parse/serialize.js';
195
195
  export type { RenderSvgOptions, RenderEngine, RenderFrameOptions, SvgRendererOptions };
196
- export { computeLayoutMetrics } from './layout/layout-metrics.js';
197
- export type { LayoutMetrics } from './layout/layout-metrics.js';
198
- export { applyView, transformModel, aggregateRelations, transitiveReduce, summarizeHubs, markCycles, groupByPathPrefix, } from './transform.js';
199
- export type { ViewSpec, TransformOptions } from './transform.js';
196
+ export { computeLayoutMetrics } from './metrics/layout-metrics.js';
197
+ export type { LayoutMetrics } from './metrics/layout-metrics.js';
198
+ export { applyView, transformModel, aggregateRelations, transitiveReduce, summarizeHubs, markCycles, groupByPathPrefix, } from './transform/transform.js';
199
+ export type { ViewSpec, TransformOptions } from './transform/transform.js';
200
200
  export { archguardToPlantUml, archguardAliasMap, pathName, displayName, ARCHGUARD_RELATION_ARROWS, } from './archguard.js';
201
201
  export type { ArchguardNode, ArchguardEdge, ArchguardGraph, ArchguardBridgeOptions, ArchguardPlantUml, } from './archguard.js';
package/dist/core.js CHANGED
@@ -6,11 +6,11 @@
6
6
  * `src/index.ts` is the full build: it re-exports this core and adds the viz
7
7
  * engine + `renderDot`.
8
8
  */
9
- import { parsePlantUml, parseWithDiagnostics, ENTITY_LINE, ENTITY_KEYWORD, extractDeclaredName, stripComment } from './parser.js';
9
+ import { parsePlantUml, parseWithDiagnostics, ENTITY_LINE, ENTITY_KEYWORD, extractDeclaredName, stripComment } from './parse/parser.js';
10
10
  import { applyDetail } from './foundation/layout.js';
11
11
  import { ElkLayoutEngine } from './layout/elk.js';
12
12
  import { renderSvg, SvgRenderer, } from './render/svg.js';
13
- import { applyView, transformModel, transformModelDetailed, shouldAutoReduce, INTER_PACKAGE_AGGREGATE_ENTITY_THRESHOLD } from './transform.js';
13
+ import { applyView, transformModel, transformModelDetailed, shouldAutoReduce, INTER_PACKAGE_AGGREGATE_ENTITY_THRESHOLD } from './transform/transform.js';
14
14
  export { defaultTheme, resolveTheme } from './render/theme.js';
15
15
  /** Error thrown by {@link createRenderer}'s `render` on structurally invalid puml. */
16
16
  export class RenderError extends Error {
@@ -294,7 +294,7 @@ function classifyLine(raw) {
294
294
  return { kind: 'unknown', explanation: 'unrecognised line (ignored)' };
295
295
  }
296
296
  export { parsePlantUml, ElkLayoutEngine, renderSvg, SvgRenderer };
297
- export { toPlantUml } from './serialize.js';
298
- export { computeLayoutMetrics } from './layout/layout-metrics.js';
299
- export { applyView, transformModel, aggregateRelations, transitiveReduce, summarizeHubs, markCycles, groupByPathPrefix, } from './transform.js';
297
+ export { toPlantUml } from './parse/serialize.js';
298
+ export { computeLayoutMetrics } from './metrics/layout-metrics.js';
299
+ export { applyView, transformModel, aggregateRelations, transitiveReduce, summarizeHubs, markCycles, groupByPathPrefix, } from './transform/transform.js';
300
300
  export { archguardToPlantUml, archguardAliasMap, pathName, displayName, ARCHGUARD_RELATION_ARROWS, } from './archguard.js';
@@ -7,9 +7,9 @@
7
7
  * candidate that collides with no node and no previously placed label wins, so
8
8
  * labels of neighbouring edges do not pile onto each other.
9
9
  */
10
- import { PlantUmlModel, PlantUmlEntity } from '../foundation/model.js';
11
- import { LayoutResult, NodeLayout, Point } from '../foundation/layout.js';
12
- import { Rect } from '../foundation/edge-router.js';
10
+ import { PlantUmlModel, PlantUmlEntity } from './model.js';
11
+ import { LayoutResult, NodeLayout, Point } from './layout.js';
12
+ import { Rect } from './edge-router.js';
13
13
  export declare const EDGE_LABEL_FONT_SIZE = 11;
14
14
  export declare const HUB_BADGE_RADIUS = 9;
15
15
  export interface EdgeLabelPlacement {
@@ -7,9 +7,9 @@
7
7
  * candidate that collides with no node and no previously placed label wins, so
8
8
  * labels of neighbouring edges do not pile onto each other.
9
9
  */
10
- import { METRICS, displayName } from '../foundation/layout.js';
11
- import { nodeRect } from '../foundation/edge-router.js';
12
- import { measureTextWidth } from '../foundation/text-measure.js';
10
+ import { METRICS, displayName } from './layout.js';
11
+ import { nodeRect } from './edge-router.js';
12
+ import { measureTextWidth } from './text-measure.js';
13
13
  export const EDGE_LABEL_FONT_SIZE = 11;
14
14
  const EDGE_LABEL_PAD_X = 3;
15
15
  const EDGE_LABEL_HEIGHT = 14;
@@ -2,7 +2,7 @@
2
2
  * LayoutEngine — the pluggable layout contract. A LayoutEngine is a pure
3
3
  * function from PlantUmlModel to concrete positions; the parser never talks
4
4
  * to a layout engine and the renderer never talks to the parser, so swapping
5
- * engines (elkjs, @viz-js/viz, a custom tuner) is an injection, not a rewrite.
5
+ * engines (ELK, Graphviz, a custom tuner) is an injection, not a rewrite.
6
6
  */
7
7
  import { PlantUmlModel, PlantUmlEntity } from './model.js';
8
8
  export interface Point {
@@ -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
- * Approximate the on-canvas size of an entity box. The title plus every member
72
- * line contributes one row; the width is the longest rendered line measured
73
- * through the text table plus padding, never below MIN_WIDTH.
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;
@@ -2,7 +2,7 @@
2
2
  * LayoutEngine — the pluggable layout contract. A LayoutEngine is a pure
3
3
  * function from PlantUmlModel to concrete positions; the parser never talks
4
4
  * to a layout engine and the renderer never talks to the parser, so swapping
5
- * engines (elkjs, @viz-js/viz, a custom tuner) is an injection, not a rewrite.
5
+ * engines (ELK, Graphviz, a custom tuner) is an injection, not a rewrite.
6
6
  */
7
7
  import { measureTextWidth, MONOSPACE_FONT_SIZE } from './text-measure.js';
8
8
  /**
@@ -40,9 +40,36 @@ export function displayName(entity) {
40
40
  return `${entity.name}<${generics.join(', ')}>`;
41
41
  }
42
42
  /**
43
- * Approximate the on-canvas size of an entity box. The title plus every member
44
- * line contributes one row; the width is the longest rendered line measured
45
- * through the text table plus padding, never below MIN_WIDTH.
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 height = snapUp(lines.length * METRICS.lineHeight + METRICS.paddingY * 2);
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/index.d.ts CHANGED
@@ -6,10 +6,10 @@
6
6
  * bundle stays free of the Graphviz WASM.
7
7
  */
8
8
  import { VizLayoutEngine } from './layout/viz.js';
9
- import { renderDot } from './foundation/render-dot.js';
9
+ import { renderDot } from './layout/render-dot.js';
10
10
  import { Renderer, RendererOptions } from './core.js';
11
11
  export * from './core.js';
12
- export type { GraphvizEngine } from './foundation/render-dot.js';
12
+ export type { GraphvizEngine } from './layout/render-dot.js';
13
13
  export { renderDot, VizLayoutEngine };
14
14
  /**
15
15
  * Build a renderer. `layout` is `'elk'` (default), `'viz'` (experimental), or a
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * bundle stays free of the Graphviz WASM.
7
7
  */
8
8
  import { VizLayoutEngine } from './layout/viz.js';
9
- import { renderDot } from './foundation/render-dot.js';
9
+ import { renderDot } from './layout/render-dot.js';
10
10
  import { createRenderer as createCoreRenderer, } from './core.js';
11
11
  export * from './core.js';
12
12
  export { renderDot, VizLayoutEngine };
@@ -15,10 +15,25 @@ import type { PlantUmlModel } from '../foundation/model.js';
15
15
  import { type LayoutResult } from '../foundation/layout.js';
16
16
  import { type BoxPlacementContext, type PlacementStrategy, type PlacementStrategyOptions, type StubOrbit } from './placement-strategy.js';
17
17
  export type { StubOrbit } from './placement-strategy.js';
18
+ /**
19
+ * Entity count above which the compound layout switches to its cheaper
20
+ * large-graph mode (see [[CompoundOptions.largeGraphThreshold]]).
21
+ *
22
+ * The cross-block router's A* cost grows with the lattice area, i.e. with the
23
+ * canvas the compound pack produced — 0.3.0 measured a 2000-node nested graph
24
+ * at ~282 s against 1.2 s at 300 nodes. The 800-node point is the knee of that
25
+ * curve in the reported scale ladder (500 ≈ 1.4 s, 800 ≈ 4-7 s, 2000 ≈ 282 s).
26
+ */
27
+ export declare const DEFAULT_LARGE_GRAPH_THRESHOLD = 800;
18
28
  export interface CompoundOptions extends PlacementStrategyOptions {
19
29
  edgeRouting?: string;
20
30
  /** Rip-up passes of the cross-block router; its default when omitted. */
21
31
  ripupPasses?: number;
32
+ /**
33
+ * Entity count above which the cross-block router degrades to a coarser
34
+ * lattice with no rip-up passes. Default [[DEFAULT_LARGE_GRAPH_THRESHOLD]].
35
+ */
36
+ largeGraphThreshold?: number;
22
37
  /**
23
38
  * Extra block-placement strategies tried, in order, before the built-in
24
39
  * chain; the first to return a placement wins (see [[./placement-strategy]]).
@@ -75,6 +75,27 @@ const HUB_RAIL_MIN_HEIGHT = 400;
75
75
  const CANVAS_MARGIN = 40;
76
76
  /** Node gap inside packed blocks of a dense (very large) graph; leaves one routing lane. */
77
77
  const DENSE_NODE_SPACING = 36;
78
+ /**
79
+ * Entity count above which the compound layout switches to its cheaper
80
+ * large-graph mode (see [[CompoundOptions.largeGraphThreshold]]).
81
+ *
82
+ * The cross-block router's A* cost grows with the lattice area, i.e. with the
83
+ * canvas the compound pack produced — 0.3.0 measured a 2000-node nested graph
84
+ * at ~282 s against 1.2 s at 300 nodes. The 800-node point is the knee of that
85
+ * curve in the reported scale ladder (500 ≈ 1.4 s, 800 ≈ 4-7 s, 2000 ≈ 282 s).
86
+ */
87
+ export const DEFAULT_LARGE_GRAPH_THRESHOLD = 800;
88
+ /**
89
+ * Lattice pitch of the large-graph router. The default pitch is tied to the
90
+ * canvas area and becomes very fine on a big canvas (one lattice vertex every
91
+ * ~9-20 px), which makes every A* search explore hundreds of thousands of
92
+ * states. A coarser lattice cuts that by roughly the square of the pitch ratio
93
+ * while still leaving the 64 px gutters between blocks routable. 22 px is the
94
+ * coarsest value that still finds a terminal vertex outside every node in the
95
+ * dense nested fixtures; at 24 px a small package can sit entirely between
96
+ * lattice rows and the router falls back to a node-crossing dog-leg.
97
+ */
98
+ const LARGE_GRAPH_ROUTER_PITCH = 22;
78
99
  /** Tightest gap between neighbouring stubs on the rail, px. */
79
100
  const RAIL_MIN_GAP = 24;
80
101
  /** Lattice cost of crossing a wire in a view with stub rails (their fans are crossing-prone), and elsewhere. */
@@ -198,12 +219,17 @@ export async function compoundLayout(elk, model, options) {
198
219
  target: model.relations[index].target,
199
220
  points,
200
221
  }));
222
+ // Large graphs route on a coarser lattice and skip the rip-up refinement:
223
+ // the A* search is the superlinear term (see DEFAULT_LARGE_GRAPH_THRESHOLD),
224
+ // and both knobs trade routing polish for a bounded layout time.
225
+ const large = model.entities.length > (options.largeGraphThreshold ?? DEFAULT_LARGE_GRAPH_THRESHOLD);
201
226
  const routed = routeOrthogonal(requests, nodes, {
202
227
  width: placed.width,
203
228
  height: placed.height,
204
229
  frames: packagesOut,
205
230
  existing: drawn,
206
- ripupPasses: options.ripupPasses,
231
+ ripupPasses: large ? 0 : options.ripupPasses,
232
+ pitch: large ? LARGE_GRAPH_ROUTER_PITCH : undefined,
207
233
  crossingCost: model.entities.some((e) => e.stub) ? STUB_CROSSING_COST : CROSSING_COST,
208
234
  });
209
235
  for (const [id, points] of routed)
@@ -61,6 +61,21 @@ export interface ElkLayoutOptions {
61
61
  * desktop panel or 9/16 for a portrait phone. Default 1.6.
62
62
  */
63
63
  aspectRatio?: number;
64
+ /**
65
+ * Entity count above which the compound layout degrades to its cheaper
66
+ * large-graph mode: the cross-block router uses a coarser lattice with no
67
+ * rip-up passes, the dependency-ordered block packing is not retried with
68
+ * rect-packing, and the cosmetic edge-polishing passes (fan-out bundling,
69
+ * trunk straightening, un-coiling) are skipped. Those passes run A* and
70
+ * O(edges) crossing scans per candidate edge, which grows superlinearly with
71
+ * the canvas area — the 0.3.0 nested 2000-node graph measured ~282 s at the
72
+ * default and is bounded above this threshold.
73
+ *
74
+ * Default 800, the knee between the reported 800-node (~4-7 s) and
75
+ * 2000-node (~282 s) measurements. Set to `Infinity` to keep the full
76
+ * polishing pipeline at every scale.
77
+ */
78
+ largeGraphThreshold?: number;
64
79
  /**
65
80
  * Extra layout strategies tried, in order, before the built-in decision
66
81
  * chain; the first to return a result wins (see [[./strategy]]).
@@ -111,6 +126,8 @@ export declare class ElkLayoutEngine implements LayoutEngine {
111
126
  */
112
127
  private layoutToward;
113
128
  private compound;
129
+ /** Entity count above which the cheap large-graph pipeline is used. */
130
+ private isLargeGraph;
114
131
  /** One ELK run over the whole nested graph (`INCLUDE_CHILDREN`). */
115
132
  private hierarchicalLayout;
116
133
  }
@@ -36,8 +36,8 @@
36
36
  */
37
37
  import ELKDefault from 'elkjs';
38
38
  import { alignRows, alignIsolatedFrames } from '../foundation/layout.js';
39
- import { compoundLayout } from './compound.js';
40
- import { computeLayoutMetrics, computeMaxEmptyBlockRatio } from './layout-metrics.js';
39
+ import { compoundLayout, DEFAULT_LARGE_GRAPH_THRESHOLD } from './compound.js';
40
+ import { computeLayoutMetrics, computeMaxEmptyBlockRatio } from '../metrics/layout-metrics.js';
41
41
  import { resolveLayerOrder, assignPartitions } from '../foundation/layers.js';
42
42
  import { buildElkGraph } from './elk-graph.js';
43
43
  import { clipPackageEndpoints } from './package-endpoints.js';
@@ -148,9 +148,16 @@ export class ElkLayoutEngine {
148
148
  this.options = options;
149
149
  }
150
150
  async layout(model) {
151
- const finish = (raw) => fitCanvas(liftFlushSegments(
152
- // stub views price crossings high on purpose: their rail edges are not un-coiled
153
- (model.entities.some((e) => e.stub) ? (l) => l : uncoilWraps)(straightenTrunkDetours(mergeFanoutTrunks(model, spreadParallelLanes(clipPackageEndpoints(model, alignIsolatedFrames(model, alignRows(model, raw)))))))));
151
+ const large = this.isLargeGraph(model);
152
+ const spread = (raw) => spreadParallelLanes(clipPackageEndpoints(model, alignIsolatedFrames(model, alignRows(model, raw))));
153
+ // The edge-polishing passes are quadratic-ish in the canvas size (per-edge
154
+ // A* plus all-pairs crossing scans) and cannot be bounded by any budget on
155
+ // a 2000-node nested canvas, so a large graph keeps the raw compound
156
+ // geometry and only gets the cheap passes.
157
+ const finish = (raw) => fitCanvas(liftFlushSegments(large
158
+ ? spread(raw)
159
+ : // stub views price crossings high on purpose: their rail edges are not un-coiled
160
+ (model.entities.some((e) => e.stub) ? (l) => l : uncoilWraps)(straightenTrunkDetours(mergeFanoutTrunks(model, spread(raw))))));
154
161
  if (!model.entities.some((e) => e.stub))
155
162
  return finish((await this.layoutRaw(model)).layout);
156
163
  // Where the orbiting stubs sit decides how the router weaves their edges
@@ -239,6 +246,7 @@ export class ElkLayoutEngine {
239
246
  }
240
247
  async compound(model, layers, partitions, { orbit, quick }) {
241
248
  const dense = model.entities.length > COMPOUND_ENTITY_THRESHOLD;
249
+ const large = this.isLargeGraph(model);
242
250
  const run = (rectPackDense) => compoundLayout(this.elk, model, {
243
251
  edgeRouting: this.options.edgeRouting,
244
252
  aspectRatio: this.options.aspectRatio,
@@ -248,15 +256,22 @@ export class ElkLayoutEngine {
248
256
  partitions: layers.length > 0 ? partitions : new Map(),
249
257
  stubOrbit: orbit,
250
258
  ripupPasses: quick ? 0 : undefined,
259
+ largeGraphThreshold: this.options.largeGraphThreshold,
251
260
  });
252
261
  const ordered = await run(false);
253
262
  // Dependency-ordered rows cost canvas on very large graphs; past the budget
254
- // fall back to the tighter (order-blind) rect packing.
255
- if (dense && ordered.width * ordered.height > COMPOUND_AREA_BUDGET) {
263
+ // fall back to the tighter (order-blind) rect packing. A large graph skips
264
+ // the retry: laying every nested block out twice doubles the dominant cost
265
+ // for a canvas-area gain that the large-graph router no longer needs.
266
+ if (!large && dense && ordered.width * ordered.height > COMPOUND_AREA_BUDGET) {
256
267
  return { layout: await run(true), orbited: true };
257
268
  }
258
269
  return { layout: ordered, orbited: true };
259
270
  }
271
+ /** Entity count above which the cheap large-graph pipeline is used. */
272
+ isLargeGraph(model) {
273
+ return model.entities.length > (this.options.largeGraphThreshold ?? DEFAULT_LARGE_GRAPH_THRESHOLD);
274
+ }
260
275
  /** One ELK run over the whole nested graph (`INCLUDE_CHILDREN`). */
261
276
  async hierarchicalLayout(model, layers, partitions) {
262
277
  const graph = buildElkGraph(model, layers, partitions, {
@@ -11,7 +11,7 @@
11
11
  * same direction, the second across) take part — the shape both ELK and the
12
12
  * grid router give a plain fan-out. Anything else in the group is left alone.
13
13
  */
14
- import { findParallelBundles } from './layout-metrics.js';
14
+ import { findParallelBundles } from '../metrics/layout-metrics.js';
15
15
  import { routeOrthogonal } from './grid-router.js';
16
16
  /** Edges leaving (or entering) one side that make a group worth merging. */
17
17
  export const TRUNK_MIN_EDGES = 5;
@@ -9,7 +9,7 @@
9
9
  * no unrelated node or package frame, and adds at most [[EXTRA_CROSSINGS]] crossings.
10
10
  */
11
11
  import { routeOrthogonal } from './grid-router.js';
12
- import { findParallelBundles } from './layout-metrics.js';
12
+ import { findParallelBundles } from '../metrics/layout-metrics.js';
13
13
  /** Path length ÷ Manhattan distance above which an edge is considered for a Z. */
14
14
  const DETOUR_FACTOR = 2;
15
15
  /** Clearance between a Z segment and an unrelated node box. */
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import { PlantUmlModel } from '../foundation/model.js';
15
15
  import { LayoutEngine, LayoutResult } from '../foundation/layout.js';
16
- import { GraphvizEngine } from '../foundation/render-dot.js';
16
+ import { GraphvizEngine } from './render-dot.js';
17
17
  export declare class VizLayoutEngine implements LayoutEngine {
18
18
  private readonly graphvizEngine;
19
19
  constructor(graphvizEngine?: GraphvizEngine);
@@ -12,7 +12,7 @@
12
12
  * engine and the SVG renderer already speak.
13
13
  */
14
14
  import { PACKAGE_LABEL, displayName, estimateEntitySize, } from '../foundation/layout.js';
15
- import { getViz } from '../foundation/render-dot.js';
15
+ import { getViz } from './render-dot.js';
16
16
  import { measureTextWidth } from '../foundation/text-measure.js';
17
17
  import { relationSemantics } from '../foundation/relation-semantics.js';
18
18
  const POINTS_PER_INCH = 72;
@@ -11,7 +11,7 @@
11
11
  import { METRICS, PACKAGE_LABEL, displayName } from '../foundation/layout.js';
12
12
  import { countEdgeBoxIntersections, countNonOrthogonalSegments, nodeRect } from '../foundation/edge-router.js';
13
13
  import { measureTextWidth } from '../foundation/text-measure.js';
14
- import { entityTextRects, hubBadgeRect, placeEdgeLabels, rectHitsOtherEdge, rectsIntersect, } from '../render/labels.js';
14
+ import { entityTextRects, hubBadgeRect, placeEdgeLabels, rectHitsOtherEdge, rectsIntersect, } from '../foundation/labels.js';
15
15
  const registry = [];
16
16
  /** Register a metric; the next [[computeLayoutMetrics]] call reports it. */
17
17
  export function registerMetric(metric) {
@@ -7,7 +7,7 @@
7
7
  * generics (`List~Item~`, `BaseEntity<T>`), package/namespace grouping,
8
8
  * aliases, and tolerant skipping of skinparam/hide/note directives.
9
9
  */
10
- import { PlantUmlModel } from './foundation/model.js';
10
+ import { PlantUmlModel } from '../foundation/model.js';
11
11
  export declare const ENTITY_LINE: RegExp;
12
12
  export declare const ENTITY_KEYWORD: RegExp;
13
13
  /**
@@ -7,7 +7,7 @@
7
7
  * generics (`List~Item~`, `BaseEntity<T>`), package/namespace grouping,
8
8
  * aliases, and tolerant skipping of skinparam/hide/note directives.
9
9
  */
10
- import { emptyModel, } from './foundation/model.js';
10
+ import { emptyModel, } from '../foundation/model.js';
11
11
  export const ENTITY_LINE = /^(abstract\s+)?(class|interface|enum)\s+(.+)$/i;
12
12
  export const ENTITY_KEYWORD = /^(?:abstract\s+)?(?:class|interface|enum)\b/i;
13
13
  const ARROWS = {
@@ -19,7 +19,7 @@
19
19
  * as n0`), so names carrying a stereotype prefix or `/` path separators survive
20
20
  * the round trip as authored.
21
21
  */
22
- import type { PlantUmlModel } from './foundation/model.js';
22
+ import type { PlantUmlModel } from '../foundation/model.js';
23
23
  /**
24
24
  * Serialize a model to `@startuml … @enduml` class-diagram source.
25
25
  *
@@ -3,9 +3,9 @@
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
- import { hubBadgeCircle, placeEdgeLabels, EDGE_LABEL_FONT_SIZE } from './labels.js';
8
+ import { hubBadgeCircle, placeEdgeLabels, EDGE_LABEL_FONT_SIZE } from '../foundation/labels.js';
9
9
  import { renderLegend, LEGEND_DEFAULT_NODE_THRESHOLD } from './legend.js';
10
10
  import { resolveTheme } from './theme.js';
11
11
  import { packageLayer } from '../foundation/layers.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 headerBottom = node.y + METRICS.paddingY + METRICS.lineHeight;
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
- entity.members.forEach((member, i) => {
157
- const baseline = node.y + METRICS.paddingY + METRICS.lineHeight * (i + 1) + METRICS.fontSize + 1;
158
- parts.push(textEl(member.raw, node.x + METRICS.paddingX, baseline, 'start', font.size.member, font.weight.member, color.textMember, theme));
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} class="uml-node${entity.external ? ' uml-external' : ''}">` +
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
- parts.push(`<line class="uml-package-tab" x1="${fmt(box.x)}" y1="${fmt(box.y + PACKAGE_LABEL.tabHeight)}"` +
197
- ` x2="${fmt(box.x + tabWidth)}" y2="${fmt(box.y + PACKAGE_LABEL.tabHeight)}"` +
198
- ` stroke="${color.packageStroke}" stroke-width="${theme.stroke.package}"/>`);
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
  }
@@ -9,7 +9,7 @@
9
9
  * input and produces the same output for the same input, so a transformed
10
10
  * render stays byte-for-byte reproducible.
11
11
  */
12
- import { PlantUmlModel } from './foundation/model.js';
12
+ import { PlantUmlModel } from '../foundation/model.js';
13
13
  /** Options driving the model-transform pipeline. */
14
14
  export interface TransformOptions {
15
15
  /**
@@ -9,7 +9,7 @@
9
9
  * input and produces the same output for the same input, so a transformed
10
10
  * render stays byte-for-byte reproducible.
11
11
  */
12
- import { relationSemantics } from './foundation/relation-semantics.js';
12
+ import { relationSemantics } from '../foundation/relation-semantics.js';
13
13
  /** Entity count above which inter-package edges aggregate into corridors by default. */
14
14
  export const INTER_PACKAGE_AGGREGATE_ENTITY_THRESHOLD = 150;
15
15
  /** Stub count above which a single-package view groups stubs by name prefix. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yalehwang/plantuml-js",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Pure-TypeScript PlantUML class diagram renderer (MIT)",
5
5
  "type": "module",
6
6
  "license": "MIT",
File without changes
File without changes
File without changes
File without changes