@rse/gradia 1.1.8 → 1.2.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/README.md CHANGED
@@ -41,6 +41,10 @@ Here are examples of the three graph diagram renderings:
41
41
 
42
42
  ![broadcast-grid](smp/broadcast-grid.svg)
43
43
 
44
+ - [container-sample](smp/container-sample.txt) (nested containers):
45
+
46
+ ![container-sample](smp/container-sample.svg)
47
+
44
48
  Playground
45
49
  ----------
46
50
 
@@ -193,7 +197,7 @@ separated by `|`.
193
197
  - The `<edge-name>` is rendered as the label of the edge and the
194
198
  `<edge-arity>` as its cardinality label.
195
199
 
196
- - Four attribute keys are reserved and consumed by the renderer instead
200
+ - Six attribute keys are reserved and consumed by the renderer instead
197
201
  of being displayed as a regular `<key>: <val>` line:
198
202
 
199
203
  - `url: <url>` renders the node box as a hyperlink. Only relative URLs
@@ -204,12 +208,36 @@ separated by `|`.
204
208
  - `primary: true` renders the node in the primary node colors
205
209
  (and, for diagram type `hub`, marks the single central hub node),
206
210
  - `group: <name>` places the node into the surrounding group box
207
- `<name>`.
211
+ `<name>`,
212
+ - `parent: <id>` nests the node into the container node `<id>` (a
213
+ node which is implicitly declared if never referenced otherwise),
214
+ - `container: <type>` marks the node as a container whose members
215
+ are laid out with the diagram type `<type>` (`graph`, `hub`, or
216
+ `grid`), even if it has no members at all.
208
217
 
209
218
  - A node cannot be a member of more than one group. As soon as at least
210
219
  one node carries a `group` attribute, all nodes without one implicitly
211
220
  belong to the group `default`, and every edge has to stay within a
212
- single group.
221
+ single group. A nested node belongs to the group of its outermost
222
+ container.
223
+
224
+ - A node referenced as the `parent` of another node is a container: it
225
+ is rendered as a dashed grey box surrounding its members instead of as
226
+ a node box, with its label, `type`, and `url` in the box head (its
227
+ remaining attributes are not rendered). Containers nest to any depth,
228
+ but a node cannot be a member of more than one container, and cannot
229
+ be nested into itself.
230
+
231
+ - The members of a container are laid out as a diagram of their own,
232
+ with the diagram type of the enclosing level unless overridden by
233
+ `container: <type>`, and the container then takes part in the layout
234
+ of the enclosing level as a single box. Edges may connect any two
235
+ nodes, across container boundaries and to container nodes themselves,
236
+ except a container with one of its own members. An edge crossing the
237
+ boundary of a container laid out as `graph` is routed through the box
238
+ border to the inner node (entering on the west and leaving on the east
239
+ side), while an edge crossing the boundary of a container laid out as
240
+ `hub` or `grid` ends at the box border instead.
213
241
 
214
242
  - All remaining attributes are displayed as `<key>: <val>` lines inside
215
243
  the node box.
@@ -241,10 +269,14 @@ placement is rendered as a dashed "ghost" box, colored by the
241
269
  The diagram type `grid` rejects the input with an error as soon as the
242
270
  graph contains at least one edge.
243
271
 
244
- As the groups of a grouped graph are laid out individually with the
245
- selected diagram type, these constraints apply to every single group. For
246
- the type `hub` this especially means that every group needs its own
247
- primary node.
272
+ As the groups of a grouped graph, and the members of every container,
273
+ are laid out individually with the selected (or, for a container, the
274
+ annotated) diagram type, these constraints apply to every single group
275
+ and container level, with the edges crossing a container boundary
276
+ counting at the level they are lifted to (the boundary-crossing edges of
277
+ a `graph` container additionally count inside it, from the boundary to
278
+ the inner node). For the type `hub` this especially means that every
279
+ group and every `hub` container needs its own primary node.
248
280
 
249
281
  ### Directives
250
282
 
@@ -259,35 +291,38 @@ command-line options (`font-embed` being command-line only, see below).
259
291
  The recognized options, and their default values, are:
260
292
 
261
293
  ```
262
- font-family Helvetica size-edge-hop-radius 8
263
- font-embed false size-edge-track-gap 12
264
- color-node-regular-name #336699 size-edge-port-gap 24
265
- color-node-regular-box #e0f0ff group-box-padding 30
266
- color-node-regular-border #c0d0e0 group-box-gap 40
267
- color-node-primary-name #ffffff graph-columns-max 4
268
- color-node-primary-box #336699 graph-channel-width-max 140
269
- color-node-primary-border #003366 graph-channel-width-min 24
270
- color-node-ghost-name #666666 graph-gutter-height-max 90
271
- color-node-ghost-box #f0f0f0 graph-gutter-height-min 20
272
- color-node-ghost-border #a0a0a0 graph-node-separation 30
273
- color-group-name #6699cc graph-rank-separation 60
274
- color-group-box #f4f8fc graph-node-degree-max 3
275
- color-group-border #c0d0e0 hub-channel-width-max 340
276
- color-edge-line #999999 hub-channel-width-min 240
277
- color-edge-name #333333 hub-node-gap 20
278
- color-edge-arity #333333 hub-node-degree-max 3
279
- color-edge-halo #ffffff grid-columns-max 4
280
- size-canvas-margin 40 grid-columns-min 3
281
- size-node-width-min 220 grid-gap-horizontal 40
282
- size-node-width-max 0 grid-gap-vertical 20
283
- size-node-height-scale 2.25 grid-node-width-equal true
284
- size-edge-corner-radius 20
294
+ font-family Helvetica size-edge-corner-radius 20
295
+ font-embed false size-edge-hop-radius 8
296
+ color-node-regular-name #336699 size-edge-track-gap 12
297
+ color-node-regular-box #e0f0ff size-edge-port-gap 24
298
+ color-node-regular-border #c0d0e0 group-box-padding 30
299
+ color-node-primary-name #ffffff group-box-gap 40
300
+ color-node-primary-box #336699 container-box-padding 30
301
+ color-node-primary-border #003366 graph-columns-max 4
302
+ color-node-ghost-name #666666 graph-channel-width-max 140
303
+ color-node-ghost-box #f0f0f0 graph-channel-width-min 24
304
+ color-node-ghost-border #a0a0a0 graph-gutter-height-max 90
305
+ color-group-name #6699cc graph-gutter-height-min 20
306
+ color-group-box #f4f8fc graph-node-separation 30
307
+ color-group-border #c0d0e0 graph-rank-separation 60
308
+ color-container-name #666666 graph-node-degree-max 3
309
+ color-container-box #f6f6f6 hub-channel-width-max 340
310
+ color-container-border #a0a0a0 hub-channel-width-min 240
311
+ color-edge-line #999999 hub-node-gap 20
312
+ color-edge-name #333333 hub-node-degree-max 3
313
+ color-edge-arity #333333 grid-columns-max 4
314
+ color-edge-halo #ffffff grid-columns-min 3
315
+ size-canvas-margin 40 grid-gap-horizontal 40
316
+ size-node-width-min 220 grid-gap-vertical 20
317
+ size-node-width-max 0 grid-node-width-equal true
318
+ size-node-height-scale 2.25
285
319
  ```
286
320
 
287
- The `size-*`, `group-*`, `graph-*`, `hub-*`, and `grid-*` options
288
- control the rendering geometry (canvas margin, node box sizing, edge
289
- routing, group box spacing, and the per-diagram-type layout) and take
290
- non-negative numbers, except the boolean `grid-node-width-equal`.
321
+ The `size-*`, `group-*`, `container-*`, `graph-*`, `hub-*`, and
322
+ `grid-*` options control the rendering geometry (canvas margin, node
323
+ box sizing, edge routing, group and container box spacing, and the
324
+ per-diagram-type layout) and take non-negative numbers, except the
325
+ boolean `grid-node-width-equal`.
291
326
 
292
327
  The `size-node-width-max` option additionally enables the word-wrapping
293
328
  of the node box texts: given a positive value, the node name, its type,
@@ -297,12 +332,19 @@ value `0` disables the wrapping entirely and hence lets a node box
297
332
  become as wide as its longest text. A single word wider than the
298
333
  maximum is never broken and still widens the box beyond the maximum.
299
334
 
335
+ The `group-box-padding` and `container-box-padding` options control the
336
+ inner padding of the group and container boxes around their content.
337
+ For a `graph` container, the content additionally includes the
338
+ boundary channels along its west and east sides, through which the
339
+ edges crossing the container boundary reach the inner nodes, sized
340
+ like all channels by the edges routed through them.
341
+
300
342
  The `size-edge-port-gap` option controls the distance of the edge
301
343
  attachment ports along a node side, and hence the vertical distance of
302
344
  the parallel edges and their labels. It is a *maximum*: a node box too
303
345
  small to hold all its ports at that distance packs them closer together
304
346
  instead. A node side carrying more edges than `graph-node-degree-max`
305
- respectively `hub-node-degree-max` grows its box height by this very
347
+ or `hub-node-degree-max` grows its box height by this very
306
348
  distance per additional edge.
307
349
 
308
350
  The `graph-channel-width-*` and `graph-gutter-height-*` options control
@@ -351,7 +393,7 @@ $ gradia -c font-family="Source Sans 3" -c font-embed=true -o graph.svg graph.tx
351
393
 
352
394
  The option `font-embed`, and a `font-family` value pointing to a WOFF2
353
395
  file, are intentionally rejected in directives, as the input is treated
354
- as untrusted. Both are available on the command-line only.
396
+ as untrusted. Both are available on the command line only.
355
397
 
356
398
  ### Example
357
399
 
@@ -13,6 +13,9 @@ export declare const configDefaults: {
13
13
  "color-group-name": string;
14
14
  "color-group-box": string;
15
15
  "color-group-border": string;
16
+ "color-container-name": string;
17
+ "color-container-box": string;
18
+ "color-container-border": string;
16
19
  "color-edge-line": string;
17
20
  "color-edge-name": string;
18
21
  "color-edge-arity": string;
@@ -27,6 +30,7 @@ export declare const configDefaults: {
27
30
  "size-edge-port-gap": number;
28
31
  "group-box-padding": number;
29
32
  "group-box-gap": number;
33
+ "container-box-padding": number;
30
34
  "graph-columns-max": number;
31
35
  "graph-channel-width-max": number;
32
36
  "graph-channel-width-min": number;
@@ -50,6 +54,9 @@ export type ConfigEmbedded = {
50
54
  [K in keyof Config]: Config[K] extends string ? K : never;
51
55
  }[keyof Config];
52
56
  export declare const cssValueOf: (explicit: Partial<Config>, key: ConfigEmbedded) => string;
57
+ export declare const validateConfig: (raw: Record<string, unknown>, options: {
58
+ trusted: boolean;
59
+ }) => Partial<Config>;
53
60
  export declare const parseDirectives: (input: string) => Partial<Config>;
54
61
  export declare const resolveFont: (config: Config) => {
55
62
  family: string;
@@ -23,6 +23,9 @@ export const configDefaults = {
23
23
  "color-group-name": "#6699cc",
24
24
  "color-group-box": "#f4f8fc",
25
25
  "color-group-border": "#c0d0e0",
26
+ "color-container-name": "#666666",
27
+ "color-container-box": "#f6f6f6",
28
+ "color-container-border": "#a0a0a0",
26
29
  "color-edge-line": "#999999",
27
30
  "color-edge-name": "#333333",
28
31
  "color-edge-arity": "#333333",
@@ -37,6 +40,7 @@ export const configDefaults = {
37
40
  "size-edge-port-gap": 24, /* max separation of adjacent edge ports */
38
41
  "group-box-padding": 30, /* inner padding of group boxes */
39
42
  "group-box-gap": 40, /* vertical gap between group boxes */
43
+ "container-box-padding": 30, /* inner padding of container boxes */
40
44
  "graph-columns-max": 4, /* graph: max side-by-side nodes */
41
45
  "graph-channel-width-max": 140, /* graph: max width of column channels */
42
46
  "graph-channel-width-min": 24, /* graph: min width of column channels */
@@ -64,37 +68,63 @@ export const configDefaults = {
64
68
  export const cssValueOf = (explicit, key) => Object.hasOwn(explicit, key) ?
65
69
  explicit[key].replace(/[;{}]/g, "") :
66
70
  `var(--gradia-${key}, ${configDefaults[key]})`;
71
+ /* validate and coerce rendering configuration options into their
72
+ types (coercing strings, as given on the command line): an unknown
73
+ option or an invalid value is rejected, an option set to "undefined"
74
+ is dropped, and an untrusted source may neither embed fonts nor name
75
+ a WOFF2 file, as this would let it read arbitrary local files */
76
+ export const validateConfig = (raw, options) => {
77
+ const config = {};
78
+ for (const [key, val] of Object.entries(raw)) {
79
+ if (!Object.hasOwn(configDefaults, key))
80
+ throw new Error(`unknown configuration option "${key}"`);
81
+ const k = key;
82
+ if (val === undefined)
83
+ continue;
84
+ if (k === "font-embed" && !options.trusted)
85
+ throw new Error(`configuration option "${key}" is not available in this context`);
86
+ else if (typeof configDefaults[k] === "boolean") {
87
+ if (typeof val !== "boolean" && val !== "true" && val !== "false")
88
+ throw new Error(`invalid value "${String(val)}" for boolean configuration option "${key}" ` +
89
+ "(expected \"true\" or \"false\")");
90
+ config[k] = typeof val === "boolean" ? val : val === "true";
91
+ }
92
+ else if (typeof configDefaults[k] === "number") {
93
+ const num = Number(val);
94
+ if ((typeof val !== "number" && typeof val !== "string") || String(val).trim() === "" || !Number.isFinite(num) || num < 0)
95
+ throw new Error(`invalid value "${String(val)}" for numeric configuration option "${key}" ` +
96
+ "(expected a non-negative number)");
97
+ config[k] = num;
98
+ }
99
+ else {
100
+ if (typeof val !== "string")
101
+ throw new Error(`invalid value "${String(val)}" for string configuration option "${key}" (expected a string)`);
102
+ if (val === "")
103
+ throw new Error(`invalid empty value for string configuration option "${key}"`);
104
+ if (k === "font-family" && val.endsWith(".woff2") && !options.trusted)
105
+ throw new Error("configuration option \"font-family\" must not be the path to a WOFF2 file in this context");
106
+ config[k] = val;
107
+ }
108
+ }
109
+ return config;
110
+ };
67
111
  /* parse "#config <option> <value>" configuration directives from a graph
68
- description (lines which are otherwise treated as plain comments) */
112
+ description (lines which are otherwise treated as plain comments),
113
+ validating them as untrusted and silently skipping an unknown option
114
+ or an invalid value, as directives never abort the rendering */
69
115
  export const parseDirectives = (input) => {
70
116
  const partial = {};
71
117
  for (const line of input.split(/\r?\n/)) {
72
118
  const m = /^[ \t]*#config[ \t]+([a-z][a-z0-9-]*)[ \t]+(?:"((?:[^"\\]|\\.)*)"|(\S+))[ \t]*$/.exec(line);
73
- if (m === null || !Object.hasOwn(configDefaults, m[1]))
119
+ if (m === null)
74
120
  continue;
75
- const key = m[1];
76
121
  const val = m[2] !== undefined ? m[2].replace(/\\(.)/g, "$1") : m[3];
77
- /* reject font file paths and font embedding from the untrusted graph
78
- description, as they would let it read arbitrary local WOFF2 files */
79
- if (key === "font-embed" || (key === "font-family" && val.endsWith(".woff2")))
80
- continue;
81
- if (typeof configDefaults[key] === "boolean") {
82
- /* silently skip invalid boolean values, as directives are
83
- lines of an untrusted input and never abort the rendering */
84
- if (val !== "true" && val !== "false")
85
- continue;
86
- partial[key] = val === "true";
122
+ try {
123
+ Object.assign(partial, validateConfig({ [m[1]]: val }, { trusted: false }));
87
124
  }
88
- else if (typeof configDefaults[key] === "number") {
89
- /* silently skip invalid numeric values, as directives are
90
- lines of an untrusted input and never abort the rendering */
91
- const num = Number(val);
92
- if (val === "" || !Number.isFinite(num) || num < 0)
93
- continue;
94
- partial[key] = num;
125
+ catch {
126
+ /* skip the invalid directive */
95
127
  }
96
- else
97
- partial[key] = val;
98
128
  }
99
129
  return partial;
100
130
  };
@@ -108,8 +138,8 @@ const fontsBuiltIn = {
108
138
  };
109
139
  /* resolve the configured font family: either a built-in font family, a
110
140
  plain font family name, or a path to a WOFF2 file (the content of
111
- the two former optionally embedded as base64, together with the
112
- weight range of the embedded, potentially variable, font) */
141
+ the built-in family or the WOFF2 file optionally embedded as base64,
142
+ the former together with the weight range of its variable font) */
113
143
  export const resolveFont = (config) => {
114
144
  const spec = config["font-family"];
115
145
  if (Object.hasOwn(fontsBuiltIn, spec)) {
@@ -130,15 +130,21 @@ export const parse = (input) => {
130
130
  if (idx === -1)
131
131
  node.attrs.push(attr);
132
132
  else {
133
- /* a node has to stay in a single group, so reject any
134
- attempt to re-assign it to a different group */
135
- if (attr.key === "group" && node.attrs[idx].val !== attr.val)
136
- throw new Error(`node "${id}" cannot be a member of more than one group ` +
133
+ /* a node has to stay in a single group and container, so
134
+ reject any attempt to re-assign it to a different one */
135
+ if ((attr.key === "group" || attr.key === "parent") && node.attrs[idx].val !== attr.val)
136
+ throw new Error(`node "${id}" cannot be a member of more than one ` +
137
+ `${attr.key === "group" ? "group" : "container"} ` +
137
138
  `("${node.attrs[idx].val}" vs "${attr.val}") ` +
138
139
  `at line ${start.line}, column ${start.col}`);
139
140
  node.attrs[idx] = attr;
140
141
  }
141
142
  }
143
+ /* implicitly declare the container node a "parent" attribute
144
+ refers to, so a container needs no declaration of its own */
145
+ const parent = node.attrs.find((attr) => attr.key === "parent")?.val;
146
+ if (parent !== undefined && !graph.nodes.has(parent))
147
+ graph.nodes.set(parent, { id: parent, name: parent, attrs: [] });
142
148
  graph.nodes.set(id, node);
143
149
  return id;
144
150
  };
@@ -1,3 +1,4 @@
1
+ import { Node, Edge } from "./gradia-api-model.js";
1
2
  import { ConfigEmbedded } from "./gradia-api-config.js";
2
3
  export declare const FS_NAME = 30;
3
4
  export declare const FS_TYPE = 16;
@@ -16,3 +17,30 @@ export interface NodeStyle {
16
17
  text: ConfigEmbedded;
17
18
  dash?: string;
18
19
  }
20
+ export interface GroupBox {
21
+ name: string;
22
+ x: number;
23
+ y: number;
24
+ w: number;
25
+ h: number;
26
+ }
27
+ export interface ContainerBox {
28
+ node: Node;
29
+ x: number;
30
+ y: number;
31
+ w: number;
32
+ h: number;
33
+ }
34
+ export interface Layout {
35
+ nodes: Node[];
36
+ edges: Edge[];
37
+ cx: (id: string) => number;
38
+ cy: (id: string) => number;
39
+ boxW: Map<string, number>;
40
+ boxH: Map<string, number>;
41
+ contentH: Map<string, number>;
42
+ polys: Poly[];
43
+ styleOf?: (node: Node) => NodeStyle;
44
+ groups?: GroupBox[];
45
+ containers?: ContainerBox[];
46
+ }
@@ -22,8 +22,9 @@ export const textWidth = (text, size) => text.length * size * 0.52;
22
22
  inside a word harms the readability more than an overlong box) */
23
23
  export const textWrap = (text, size, maxWidth) => {
24
24
  const words = text.split(/\s+/).filter((word) => word !== "");
25
- if (maxWidth <= 0 || words.length <= 1 || textWidth(text, size) <= maxWidth)
26
- return [text];
25
+ const norm = words.join(" ");
26
+ if (maxWidth <= 0 || words.length <= 1 || textWidth(norm, size) <= maxWidth)
27
+ return [norm];
27
28
  const lines = [];
28
29
  let line = words[0];
29
30
  for (const word of words.slice(1)) {
@@ -0,0 +1,31 @@
1
+ import { Node, Graph } from "./gradia-api-model.js";
2
+ import { Config } from "./gradia-api-config.js";
3
+ import { Poly, Layout } from "./gradia-api-render-base.js";
4
+ export type GateSide = "in" | "out";
5
+ export interface LevelContext {
6
+ fixedSize?: Map<string, {
7
+ w: number;
8
+ h: number;
9
+ }>;
10
+ fixedPort?: (edge: number, role: "s" | "t") => number | undefined;
11
+ gates?: Map<string, GateSide>;
12
+ }
13
+ export type Renderer = (graph: Graph, config: Config, level?: LevelContext) => Promise<Layout>;
14
+ export interface DiagramTypeSpec {
15
+ render: Renderer;
16
+ gated: boolean;
17
+ }
18
+ export interface Containment {
19
+ parentOf: Map<string, string>;
20
+ childrenOf: Map<string, Node[]>;
21
+ }
22
+ export declare const containmentOf: (graph: Graph, types: string[]) => Containment;
23
+ export declare const rootOf: (containment: Containment, id: string) => string;
24
+ export interface Bounds {
25
+ minX: number;
26
+ minY: number;
27
+ maxX: number;
28
+ maxY: number;
29
+ }
30
+ export declare const boundsOf: (layout: Layout, extra?: Poly[]) => Bounds;
31
+ export declare const renderContained: (graph: Graph, containment: Containment, type: string, types: Record<string, DiagramTypeSpec>, config: Config) => Promise<Layout>;