@rse/gradia 1.2.3 → 1.3.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
@@ -269,8 +269,6 @@ following conditions hold:
269
269
  - Every edge either points to or originates from that primary node. An
270
270
  edge between two non-primary nodes is rejected.
271
271
 
272
- - The primary node carries no self-loop.
273
-
274
272
  - Every non-primary node is an input or an output of the primary node,
275
273
  i.e., no node is free-standing.
276
274
 
@@ -279,6 +277,11 @@ twice, once in the input column and once in the output column. The second
279
277
  placement is rendered as a dashed "ghost" box, colored by the
280
278
  `color-node-ghost-*` options.
281
279
 
280
+ A self-loop on the primary node is unrolled: the primary node is placed a
281
+ second time on top of the output column, as the target of the self-loop.
282
+ This second placement is rendered as a dashed "self" box, colored by the
283
+ (darker grey) `color-node-self-*` options.
284
+
282
285
  The diagram type `grid` rejects the input with an error as soon as the
283
286
  graph contains at least one edge.
284
287
 
@@ -304,31 +307,33 @@ command-line options (`font-embed` being command-line only, see below).
304
307
  The recognized options, and their default values, are:
305
308
 
306
309
  ```
307
- font-family Helvetica size-edge-corner-radius 20
308
- font-embed false size-edge-hop-radius 8
309
- color-node-regular-name #336699 size-edge-track-gap 12
310
- color-node-regular-box #e0f0ff size-edge-port-gap 24
311
- color-node-regular-border #c0d0e0 group-box-padding 30
312
- color-node-primary-name #ffffff group-box-gap 40
313
- color-node-primary-box #336699 container-box-padding 30
314
- color-node-primary-border #003366 graph-columns-max 4
315
- color-node-ghost-name #666666 graph-channel-width-max 140
316
- color-node-ghost-box #f0f0f0 graph-channel-width-min 24
317
- color-node-ghost-border #a0a0a0 graph-gutter-height-max 90
318
- color-group-name #6699cc graph-gutter-height-min 20
319
- color-group-box #f4f8fc graph-node-separation 30
320
- color-group-border #c0d0e0 graph-rank-separation 60
321
- color-container-name #666666 graph-node-degree-max 3
322
- color-container-box #f6f6f6 hub-channel-width-max 340
323
- color-container-border #a0a0a0 hub-channel-width-min 240
324
- color-edge-line #999999 hub-node-gap 20
325
- color-edge-name #333333 hub-node-degree-max 3
326
- color-edge-arity #333333 grid-columns-max 4
327
- color-edge-halo #ffffff grid-columns-min 3
328
- size-canvas-margin 40 grid-gap-horizontal 40
329
- size-node-width-min 220 grid-gap-vertical 20
330
- size-node-width-max 0 grid-node-width-equal true
331
- size-node-height-scale 2.25 grid-node-height-equal true
310
+ font-family Helvetica size-node-height-scale 2.25
311
+ font-embed false size-edge-corner-radius 20
312
+ color-node-regular-name #336699 size-edge-hop-radius 8
313
+ color-node-regular-box #e0f0ff size-edge-track-gap 12
314
+ color-node-regular-border #c0d0e0 size-edge-port-gap 24
315
+ color-node-primary-name #ffffff group-box-padding 30
316
+ color-node-primary-box #336699 group-box-gap 40
317
+ color-node-primary-border #003366 container-box-padding 30
318
+ color-node-ghost-name #666666 graph-columns-max 4
319
+ color-node-ghost-box #f0f0f0 graph-channel-width-max 140
320
+ color-node-ghost-border #a0a0a0 graph-channel-width-min 24
321
+ color-node-self-name #444444 graph-gutter-height-max 90
322
+ color-node-self-box #e0e0e0 graph-gutter-height-min 20
323
+ color-node-self-border #707070 graph-node-separation 30
324
+ color-group-name #6699cc graph-rank-separation 60
325
+ color-group-box #f4f8fc graph-node-degree-max 3
326
+ color-group-border #c0d0e0 hub-channel-width-max 340
327
+ color-container-name #666666 hub-channel-width-min 240
328
+ color-container-box #f6f6f6 hub-node-gap 20
329
+ color-container-border #a0a0a0 hub-node-degree-max 3
330
+ color-edge-line #999999 hub-node-count-max 0
331
+ color-edge-name #333333 grid-columns-max 4
332
+ color-edge-arity #333333 grid-columns-min 3
333
+ color-edge-halo #ffffff grid-gap-horizontal 40
334
+ size-canvas-margin 40 grid-gap-vertical 20
335
+ size-node-width-min 220 grid-node-width-equal true
336
+ size-node-width-max 0 grid-node-height-equal true
332
337
  ```
333
338
 
334
339
  The `size-*`, `group-*`, `container-*`, `graph-*`, `hub-*`, and
@@ -371,6 +376,15 @@ unlabeled diagram can be spaced out, while the `-max` options cap it, so
371
376
  that neither many parallel edges nor a long label can push the nodes
372
377
  apart without bound.
373
378
 
379
+ The `hub-node-count-max` option controls the wrapping of the input and
380
+ output column of a `hub` diagram: given a positive value, a column of
381
+ more nodes than this wraps into two staggered sub-columns, whose nodes
382
+ alternate between the outer sub-column and the inner one, with every
383
+ outer node vertically centered onto a gap between two inner nodes,
384
+ through which its edges reach the primary node. This trades nearly
385
+ half of the height of a large diagram for additional width. The value
386
+ `0` disables the wrapping entirely.
387
+
374
388
  The `grid-columns-min` and `grid-columns-max` options control the column
375
389
  count of a `grid` diagram, which by default is derived from the node
376
390
  count as a roughly square grid: `grid-columns-min` raises the derived
@@ -399,6 +413,15 @@ hard-coded into the SVG instead. The resulting precedence is: first
399
413
  `#config`/`--config`, then the CSS custom property `--gradia-<option>`,
400
414
  and finally the built-in default.
401
415
 
416
+ The styling is not repeated on every element of the generated SVG, but
417
+ declared once as CSS classes in a `<style>` element, which the elements
418
+ reference. A class is named `gradia-<digest>` after the digest of its
419
+ own declarations, so equal declarations always yield the very same
420
+ class. As the class names are global once multiple diagrams are embedded
421
+ into the very same document, the embedding document can hence strip the
422
+ `<style>` elements off the diagrams and declare the union of their
423
+ rules (one per line) a single time on its own.
424
+
402
425
  The `font-family` option is either a built-in font family, a plain font
403
426
  family name, or the path to a WOFF2 file. The only built-in font family
404
427
  is `Source Sans 3`, shipped as a variable WOFF2 file by the NPM
@@ -10,6 +10,9 @@ export declare const configDefaults: {
10
10
  "color-node-ghost-name": string;
11
11
  "color-node-ghost-box": string;
12
12
  "color-node-ghost-border": string;
13
+ "color-node-self-name": string;
14
+ "color-node-self-box": string;
15
+ "color-node-self-border": string;
13
16
  "color-group-name": string;
14
17
  "color-group-box": string;
15
18
  "color-group-border": string;
@@ -43,6 +46,7 @@ export declare const configDefaults: {
43
46
  "hub-channel-width-min": number;
44
47
  "hub-node-gap": number;
45
48
  "hub-node-degree-max": number;
49
+ "hub-node-count-max": number;
46
50
  "grid-columns-max": number;
47
51
  "grid-columns-min": number;
48
52
  "grid-gap-horizontal": number;
@@ -20,6 +20,9 @@ export const configDefaults = {
20
20
  "color-node-ghost-name": "#666666",
21
21
  "color-node-ghost-box": "#f0f0f0",
22
22
  "color-node-ghost-border": "#a0a0a0",
23
+ "color-node-self-name": "#444444",
24
+ "color-node-self-box": "#e0e0e0",
25
+ "color-node-self-border": "#707070",
23
26
  "color-group-name": "#6699cc",
24
27
  "color-group-box": "#f4f8fc",
25
28
  "color-group-border": "#c0d0e0",
@@ -53,6 +56,7 @@ export const configDefaults = {
53
56
  "hub-channel-width-min": 240, /* hub: min width of column channels */
54
57
  "hub-node-gap": 20, /* hub: vertical gap of stacked nodes */
55
58
  "hub-node-degree-max": 3, /* hub: max edges/side before growth */
59
+ "hub-node-count-max": 0, /* hub: max nodes/column before wrapping */
56
60
  "grid-columns-max": 4, /* grid: max side-by-side tiles */
57
61
  "grid-columns-min": 3, /* grid: min side-by-side tiles */
58
62
  "grid-gap-horizontal": 40, /* grid: horizontal gap between tiles */
@@ -160,7 +164,7 @@ export const resolveFont = (config) => {
160
164
  return { family };
161
165
  }
162
166
  else if (config["font-embed"])
163
- throw new Error("option \"font-embed\" requires option \"font-family\" to be a built-in font " +
167
+ throw new Error("configuration option \"font-embed\" requires configuration option \"font-family\" to be a built-in font " +
164
168
  `family (${Object.keys(fontsBuiltIn).map((name) => `"${name}"`).join(", ")}) ` +
165
169
  "or the path to a WOFF2 file");
166
170
  else
@@ -38,9 +38,9 @@ export const textWrap = (text, size, maxWidth) => {
38
38
  lines.push(line);
39
39
  return lines;
40
40
  };
41
- /* escape a string for use in XML/SVG content (control characters are
42
- stripped, as XML forbids them even in their escaped form) */
43
- export const escapeXML = (text) => text.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")
41
+ /* escape a string for use in XML/SVG content (control and non-characters
42
+ are stripped, as XML forbids them even in their escaped form) */
43
+ export const escapeXML = (text) => text.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F\uFFFE\uFFFF\p{Cs}]/gu, "")
44
44
  .replace(/&/g, "&amp;")
45
45
  .replace(/</g, "&lt;")
46
46
  .replace(/>/g, "&gt;")
@@ -12,6 +12,7 @@ import { simplifyPoly } from "./gradia-api-render-edge.js";
12
12
  container with one of its own members, and every "container"
13
13
  annotation has to name a valid diagram type */
14
14
  export const containmentOf = (graph, types) => {
15
+ /* collect the parent/children relations of the nodes */
15
16
  const parentOfId = new Map();
16
17
  const childrenOf = new Map();
17
18
  for (const node of graph.nodes.values()) {
@@ -30,6 +31,7 @@ export const containmentOf = (graph, types) => {
30
31
  else
31
32
  children.push(node);
32
33
  }
34
+ /* reject the containment cycles */
33
35
  for (const id of parentOfId.keys()) {
34
36
  const seen = new Set([id]);
35
37
  for (let p = parentOfId.get(id); p !== undefined; p = parentOfId.get(p)) {
@@ -38,6 +40,7 @@ export const containmentOf = (graph, types) => {
38
40
  seen.add(p);
39
41
  }
40
42
  }
43
+ /* reject the edges connecting a container with one of its own members */
41
44
  const isAncestor = (a, id) => {
42
45
  for (let p = parentOfId.get(id); p !== undefined; p = parentOfId.get(p))
43
46
  if (p === a)
@@ -253,7 +256,7 @@ export const renderContained = async (graph, containment, type, types, config) =
253
256
  const tt = topOf(edge.target);
254
257
  if (ts === undefined && tt === undefined)
255
258
  return;
256
- if (ts !== undefined && tt !== undefined && ts === tt && inner.has(ts) && edge.source !== ts)
259
+ if (ts !== undefined && ts === tt && inner.has(ts) && edge.source !== ts)
257
260
  return;
258
261
  let source;
259
262
  let target;
@@ -18,7 +18,8 @@ export const simplifyPoly = (pts) => {
18
18
  /* drop the remaining duplicate points */
19
19
  const out = [];
20
20
  for (const p of flat) {
21
- if (out.length > 0 && out[out.length - 1][0] === p[0] && out[out.length - 1][1] === p[1])
21
+ const q = out.at(-1);
22
+ if (q !== undefined && q[0] === p[0] && q[1] === p[1])
22
23
  continue;
23
24
  out.push(p);
24
25
  }
@@ -70,10 +71,11 @@ export const assignPorts = (edges, sides, cx, cy, boxW, boxH, portGap, approachY
70
71
  const px = cx(id) + (side === "e" ? +1 : -1) * boxW.get(id) / 2;
71
72
  /* place the fixed ports at their given offsets */
72
73
  const fixedOf = (p) => fixedY?.(p.edge, p.role);
74
+ const place = (p, py) => portPos.set(`${p.edge}:${p.role}`, { x: px, y: py });
73
75
  const taken = [];
74
76
  for (const p of group.filter((p) => fixedOf(p) !== undefined)) {
75
77
  const py = cy(id) + fixedOf(p);
76
- portPos.set(`${p.edge}:${p.role}`, { x: px, y: py });
78
+ place(p, py);
77
79
  taken.push(py);
78
80
  }
79
81
  taken.sort((a, b) => a - b);
@@ -86,7 +88,6 @@ export const assignPorts = (edges, sides, cx, cy, boxW, boxH, portGap, approachY
86
88
  free.sort((a, b) => (keyOf(a) - keyOf(b)) || (a.edge - b.edge));
87
89
  const n = free.length + taken.length;
88
90
  const spacing = Math.min(portGap, (boxH.get(id) - PORT_PAD) / n);
89
- const place = (p, py) => portPos.set(`${p.edge}:${p.role}`, { x: px, y: py });
90
91
  if (taken.length === 0) {
91
92
  free.forEach((p, idx) => place(p, cy(id) + (idx - (n - 1) / 2) * spacing));
92
93
  continue;
@@ -348,15 +349,15 @@ export const pathOf = (poly, hop, rounding, hopRadius) => {
348
349
  their individual hops would be shorter than 14px) */
349
350
  const groups = [];
350
351
  for (const hx of xs) {
351
- const g = groups[groups.length - 1];
352
- if (g !== undefined && Math.abs(hx - g[g.length - 1]) <= hopRadius * 2 + 14)
352
+ const g = groups.at(-1);
353
+ if (g !== undefined && Math.abs(hx - g.at(-1)) <= hopRadius * 2 + 14)
353
354
  g.push(hx);
354
355
  else
355
356
  groups.push([hx]);
356
357
  }
357
358
  for (const g of groups) {
358
359
  let x1 = g[0] - dir * hopRadius;
359
- let x2 = g[g.length - 1] + dir * hopRadius;
360
+ let x2 = g.at(-1) + dir * hopRadius;
360
361
  /* clamp the arc into the drawable segment part, so
361
362
  crossings close to a corner still get their hop */
362
363
  if ((x1 - p0[0]) * dir < 0)
@@ -17,9 +17,11 @@ export const groupOf = (node) => node.attrs.findLast((attr) => attr.key === "gro
17
17
  deviating group of its own. Edges have to stay within a single
18
18
  group, so reject any group-crossing edge */
19
19
  export const partitionGroups = (graph, containment) => {
20
+ /* short-circuit when no node uses a group at all */
20
21
  const nodes = Array.from(graph.nodes.values());
21
22
  if (!nodes.some((node) => groupOf(node) !== undefined))
22
23
  return null;
24
+ /* assign every node to the group of its outermost container */
23
25
  const parts = new Map();
24
26
  const nameOf = new Map();
25
27
  for (const node of nodes) {
@@ -37,6 +39,7 @@ export const partitionGroups = (graph, containment) => {
37
39
  }
38
40
  part.nodes.set(node.id, node);
39
41
  }
42
+ /* assign every edge to the common group of its endpoints */
40
43
  for (const edge of graph.edges) {
41
44
  const sg = nameOf.get(edge.source);
42
45
  const tg = nameOf.get(edge.target);
@@ -45,6 +48,7 @@ export const partitionGroups = (graph, containment) => {
45
48
  `crosses groups "${sg}" and "${tg}"`);
46
49
  parts.get(sg).edges.push(edge);
47
50
  }
51
+ /* provide the partitions in order of first appearance */
48
52
  return Array.from(parts.entries()).map(([name, subgraph]) => ({ name, graph: subgraph }));
49
53
  };
50
54
  /* compose the per-group layouts into a single layout: the groups are
@@ -79,12 +79,11 @@ export const linesOfNode = (node, config) => {
79
79
  attrs: attrsOfNode(node).flatMap((attr) => textWrap(`${attr.key}: ${attr.val}`, FS_ATTR, max))
80
80
  };
81
81
  };
82
- /* determine node box sizes from their textual content
83
- (boxes are scaled up in height by a per-node factor, but never
84
- below the content height, to give the edges more attachment
85
- room), except for the nodes
86
- of a fixed size (the container placeholders and gate nodes
87
- of a containment level), which are taken as given */
82
+ /* determine node box sizes from their textual content (boxes are
83
+ scaled up in height by a per-node factor, but never below the
84
+ content height, to give the edges more attachment room), except
85
+ for the nodes of a fixed size (the container placeholders and gate
86
+ nodes of a containment level), which are taken as given */
88
87
  export const measureNodes = (nodes, config, scaleOf, fixed) => {
89
88
  /* the resulting node box dimensions */
90
89
  const boxW = new Map();
@@ -20,11 +20,13 @@ const escapeCSS = (text) => text.replace(/[\\"']/g, "\\$&").replace(/[\r\n\f]/g,
20
20
  const NS_GRADIA = new UUID(5, "ns:URL", "https://github.com/rse/gradia").format();
21
21
  /* derive a per-document identifier prefix from a seed, as the SVG
22
22
  identifiers are DOM-global and would collide once multiple diagrams
23
- are embedded into the very same document. The prefix is a UUID v5 of
24
- the seed and hence stays stable across regenerations of an unchanged
25
- diagram (the Base16 format keeps it alphanumeric and thus a valid XML
26
- name and CSS/URL fragment) */
27
- const idPrefix = (seed) => `gradia-${new UUID(5, NS_GRADIA, seed).format("b16").toLowerCase()}`;
23
+ are embedded into the very same document. The prefix is derived from
24
+ the UUID v5 of the seed and hence stays stable across regenerations
25
+ of an unchanged diagram (the Base16 format keeps it alphanumeric and
26
+ thus a valid XML name and CSS/URL fragment, and its leading 12 digits
27
+ keep the identifiers short, as every edge references one, while
28
+ still making a collision among the diagrams of a document unlikely) */
29
+ const idPrefix = (seed) => `gradia-${new UUID(5, NS_GRADIA, seed).format("b16").toLowerCase().slice(0, 12)}`;
28
30
  /* the placement of a group tag in the top-left corner of its group box */
29
31
  const TAG_DX = 18; /* left offset of the group tag */
30
32
  const TAG_DY = 12; /* top offset of the group tag */
@@ -66,12 +68,12 @@ const labelPlacer = (layout, hops, hopRadius) => {
66
68
  const collisions = (boxes, box) => boxes.filter((o) => box[0] < o[2] && box[2] > o[0] && box[1] < o[3] && box[3] > o[1]).length;
67
69
  const claim = (candidates) => {
68
70
  let box = candidates[0];
69
- let worst = Infinity;
71
+ let lowest = Infinity;
70
72
  for (const c of candidates) {
71
73
  const score = collisions(occupied, c) * (lines.length + 1) + collisions(lines, c);
72
- if (score < worst) {
74
+ if (score < lowest) {
73
75
  box = c;
74
- worst = score;
76
+ lowest = score;
75
77
  }
76
78
  if (score === 0)
77
79
  break;
@@ -83,7 +85,7 @@ const labelPlacer = (layout, hops, hopRadius) => {
83
85
  };
84
86
  /* generate the SVG fragments for a single node box (a node with a
85
87
  "url" attribute becomes a hyperlink covering the whole box) */
86
- const renderNode = (node, layout, style, font, color, config) => {
88
+ const renderNode = (node, layout, style, styler, config) => {
87
89
  const { cx, cy, boxW, boxH, contentH } = layout;
88
90
  const w = boxW.get(node.id);
89
91
  const h = boxH.get(node.id);
@@ -93,7 +95,7 @@ const renderNode = (node, layout, style, font, color, config) => {
93
95
  const url = urlOf(node);
94
96
  const parts = [];
95
97
  parts.push(`<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="6" ` +
96
- `style="fill: ${color(style.fill)}; stroke: ${color(style.stroke)}" stroke-width="4.0"` +
98
+ `class="${styler.box(style.fill, style.stroke, 4)}"` +
97
99
  `${style.dash !== undefined ? ` stroke-dasharray="${escapeXML(style.dash)}"` : ""}/>`);
98
100
  /* vertically center the textual content block within the box, with
99
101
  the optional type lines shifting the name and attributes down and
@@ -105,20 +107,18 @@ const renderNode = (node, layout, style, font, color, config) => {
105
107
  ty + th + MIN_H / 2 + FS_NAME * 0.36 : cy(node.id) + th / 2 + FS_NAME * 0.36 - nh / 2;
106
108
  lines.type.forEach((line, k) => {
107
109
  parts.push(`<text x="${cx(node.id)}" ` +
108
- `y="${nameY - TYPE_D - (lines.type.length - 1 - k) * TYPE_H}" text-anchor="middle" ` +
109
- `font-size="${FS_TYPE}" ` +
110
- `style="font-family: ${font}; fill: ${color(style.text)}">${escapeXML(line)}</text>`);
110
+ `y="${nameY - TYPE_D - (lines.type.length - 1 - k) * TYPE_H}" ` +
111
+ `class="${styler.text(style.text, FS_TYPE, { middle: true })}">${escapeXML(line)}</text>`);
111
112
  });
112
113
  lines.name.forEach((line, k) => {
113
- parts.push(`<text x="${cx(node.id)}" y="${nameY + k * NAME_H}" text-anchor="middle" ` +
114
- `font-size="${FS_NAME}" font-weight="600" ` +
115
- `style="font-family: ${font}; fill: ${color(style.text)}">${escapeXML(line)}</text>`);
114
+ parts.push(`<text x="${cx(node.id)}" y="${nameY + k * NAME_H}" ` +
115
+ `class="${styler.text(style.text, FS_NAME, { bold: true, middle: true })}">` +
116
+ `${escapeXML(line)}</text>`);
116
117
  });
117
118
  lines.attrs.forEach((line, k) => {
118
119
  parts.push(`<text x="${cx(node.id)}" ` +
119
- `y="${ty + th + nh + MIN_H + ATTR_P + k * ATTR_H}" text-anchor="middle" ` +
120
- `font-size="${FS_ATTR}" ` +
121
- `style="font-family: ${font}; fill: ${color(style.text)}">${escapeXML(line)}</text>`);
120
+ `y="${ty + th + nh + MIN_H + ATTR_P + k * ATTR_H}" ` +
121
+ `class="${styler.text(style.text, FS_ATTR, { middle: true })}">${escapeXML(line)}</text>`);
122
122
  });
123
123
  if (url === undefined)
124
124
  return parts;
@@ -157,11 +157,7 @@ const viewBoxOf = (layout, boxes, margin) => {
157
157
  optional name, placed near the middle of the route, and its optional
158
158
  arity, placed near the arrow head (both dodging into a collision-free
159
159
  position through the "claim" of the label placer) */
160
- const renderEdgeLabels = (edge, poly, claim, font, color) => {
161
- /* the halo rendered behind the edge labels for readability
162
- (its color is emitted into the style attribute below, as only
163
- there the CSS custom property lookup can be resolved) */
164
- const halo = "stroke-width=\"4.5\" paint-order=\"stroke\" stroke-linejoin=\"round\"";
160
+ const renderEdgeLabels = (edge, poly, claim, styler) => {
165
161
  const parts = [];
166
162
  if (edge.name !== undefined) {
167
163
  const w = textWidth(edge.name, FS_EDGE);
@@ -178,11 +174,9 @@ const renderEdgeLabels = (edge, poly, claim, font, color) => {
178
174
  }
179
175
  }
180
176
  const box = claim(candidates);
181
- parts.push(`<text x="${(box[0] + box[2]) / 2}" y="${box[3] - 3}" text-anchor="middle" ` +
182
- `font-size="${FS_EDGE}" ` +
183
- `style="font-family: ${font}; fill: ${color("color-edge-name")}; ` +
184
- `stroke: ${color("color-edge-halo")}" ` +
185
- `${halo}>${escapeXML(edge.name)}</text>`);
177
+ parts.push(`<text x="${(box[0] + box[2]) / 2}" y="${box[3] - 3}" ` +
178
+ `class="${styler.text("color-edge-name", FS_EDGE, { middle: true, halo: true })}">` +
179
+ `${escapeXML(edge.name)}</text>`);
186
180
  }
187
181
  if (edge.arity !== undefined) {
188
182
  const w = textWidth(edge.arity, FS_ARITY);
@@ -211,43 +205,38 @@ const renderEdgeLabels = (edge, poly, claim, font, color) => {
211
205
  ];
212
206
  }
213
207
  const box = claim(candidates);
214
- parts.push(`<text x="${(box[0] + box[2]) / 2}" y="${box[3] - 3}" text-anchor="middle" ` +
215
- `font-size="${FS_ARITY}" ` +
216
- `style="font-family: ${font}; fill: ${color("color-edge-arity")}; ` +
217
- `stroke: ${color("color-edge-halo")}" ` +
218
- `${halo}>${escapeXML(edge.arity)}</text>`);
208
+ parts.push(`<text x="${(box[0] + box[2]) / 2}" y="${box[3] - 3}" ` +
209
+ `class="${styler.text("color-edge-arity", FS_ARITY, { middle: true, halo: true })}">` +
210
+ `${escapeXML(edge.arity)}</text>`);
219
211
  }
220
212
  return parts;
221
213
  };
222
214
  /* generate the SVG fragments for a single group box and its tag
223
215
  in the top-left corner */
224
- const renderGroup = (group, font, color) => [
216
+ const renderGroup = (group, styler) => [
225
217
  `<rect x="${group.x}" y="${group.y}" width="${group.w}" height="${group.h}" rx="12" ` +
226
- `style="fill: ${color("color-group-box")}; ` +
227
- `stroke: ${color("color-group-border")}" stroke-width="3.0"/>`,
218
+ `class="${styler.box("color-group-box", "color-group-border", 3)}"/>`,
228
219
  `<text x="${group.x + TAG_DX}" y="${group.y + TAG_DY + FS_GROUP}" ` +
229
- `font-size="${FS_GROUP}" font-weight="600" ` +
230
- `style="font-family: ${font}; fill: ${color("color-group-name")}">${escapeXML(group.name)}</text>`
220
+ `class="${styler.text("color-group-name", FS_GROUP, { bold: true })}">${escapeXML(group.name)}</text>`
231
221
  ];
232
222
  /* generate the SVG fragments for a single container box and its tag
233
223
  in the top-left corner, with the optional type line above the name
234
224
  (a container with a "url" attribute becomes a hyperlink covering
235
225
  the whole box) */
236
- const renderContainer = (c, font, color) => {
226
+ const renderContainer = (c, styler) => {
237
227
  const type = typeOf(c.node);
238
228
  const url = urlOf(c.node);
239
229
  const parts = [
240
230
  `<rect x="${c.x}" y="${c.y}" width="${c.w}" height="${c.h}" rx="12" ` +
241
- `style="fill: ${color("color-container-box")}; ` +
242
- `stroke: ${color("color-container-border")}" stroke-width="3.0" stroke-dasharray="10 6"/>`,
231
+ `class="${styler.box("color-container-box", "color-container-border", 3)}" ` +
232
+ "stroke-dasharray=\"10 6\"/>",
243
233
  ...(type !== undefined ? [
244
234
  `<text x="${c.x + TAG_DX}" y="${c.y + TAG_DY + FS_TYPE}" ` +
245
- `font-size="${FS_TYPE}" ` +
246
- `style="font-family: ${font}; fill: ${color("color-container-name")}">${escapeXML(type)}</text>`
235
+ `class="${styler.text("color-container-name", FS_TYPE)}">${escapeXML(type)}</text>`
247
236
  ] : []),
248
237
  `<text x="${c.x + TAG_DX}" y="${c.y + containerHead(c.node) - HEAD_H + TAG_DY + FS_GROUP}" ` +
249
- `font-size="${FS_GROUP}" font-weight="600" ` +
250
- `style="font-family: ${font}; fill: ${color("color-container-name")}">${escapeXML(c.node.name)}</text>`
238
+ `class="${styler.text("color-container-name", FS_GROUP, { bold: true })}">` +
239
+ `${escapeXML(c.node.name)}</text>`
251
240
  ];
252
241
  if (url === undefined)
253
242
  return parts;
@@ -264,7 +253,7 @@ export const renderSVG = (layout, config, explicit, seed) => {
264
253
  values: explicitly configured values are hard-coded, while all
265
254
  others are fetched at display time from the "--gradia-<option>"
266
255
  CSS custom properties, falling back to the built-in defaults */
267
- const color = (key) => escapeXML(cssValueOf(explicit, key));
256
+ const color = (key) => cssValueOf(explicit, key);
268
257
  /* derive the collision-free identifiers of this SVG document */
269
258
  const prefix = idPrefix(seed);
270
259
  const idArrow = `${prefix}-arrow`;
@@ -273,7 +262,29 @@ export const renderSVG = (layout, config, explicit, seed) => {
273
262
  const stack = Object.hasOwn(explicit, "font-family") ?
274
263
  `'${escapeCSS(family)}'` :
275
264
  `var(--gradia-font-family, '${escapeCSS(family)}')`;
276
- const font = escapeXML(`${stack}, ui-sans-serif, -apple-system, Helvetica, Arial, sans-serif`);
265
+ const font = `${stack}, ui-sans-serif, -apple-system, Helvetica, Arial, sans-serif`;
266
+ /* collect the CSS classes the rendered elements reference (the
267
+ halo behind an edge label keeps it readable on top of a line) */
268
+ const rules = new Map();
269
+ const classOf = (decls) => {
270
+ let name = rules.get(decls);
271
+ if (name === undefined) {
272
+ name = `gradia-${new UUID(5, NS_GRADIA, decls).format("b16").toLowerCase().slice(0, 8)}`;
273
+ rules.set(decls, name);
274
+ }
275
+ return name;
276
+ };
277
+ const styler = {
278
+ text: (fill, size, options = {}) => classOf(`font-family: ${font}; font-size: ${size}px; ` +
279
+ (options.bold ? "font-weight: 600; " : "") +
280
+ (options.middle ? "text-anchor: middle; " : "") +
281
+ `fill: ${color(fill)}` +
282
+ (options.halo ? `; stroke: ${color("color-edge-halo")}; stroke-width: 4.5; ` +
283
+ "paint-order: stroke; stroke-linejoin: round" : "")),
284
+ box: (fill, stroke, width) => classOf(`fill: ${color(fill)}; stroke: ${color(stroke)}; stroke-width: ${width}`)
285
+ };
286
+ const classEdge = classOf(`fill: none; stroke: ${color("color-edge-line")}; stroke-width: 3`);
287
+ const classArrow = classOf(`fill: ${color("color-edge-line")}`);
277
288
  /* detect the edge crossings requiring rendered hops */
278
289
  const hops = computeHops(polys);
279
290
  /* prepare the collision-free placement of the edge labels */
@@ -282,38 +293,39 @@ export const renderSVG = (layout, config, explicit, seed) => {
282
293
  const svgEdges = [];
283
294
  const svgLabels = [];
284
295
  edges.forEach((edge, i) => {
285
- svgEdges.push(`<path d="${pathOf(polys[i], hops[i], config["size-edge-corner-radius"], config["size-edge-hop-radius"])}" fill="none" ` +
286
- `style="stroke: ${color("color-edge-line")}" stroke-width="3.0" marker-end="url(#${idArrow})"/>`);
287
- svgLabels.push(...renderEdgeLabels(edge, polys[i], claim, font, color));
296
+ svgEdges.push(`<path d="${pathOf(polys[i], hops[i], config["size-edge-corner-radius"], config["size-edge-hop-radius"])}" ` +
297
+ `class="${classEdge}" marker-end="url(#${idArrow})"/>`);
298
+ svgLabels.push(...renderEdgeLabels(edge, polys[i], claim, styler));
288
299
  });
289
300
  /* generate the SVG fragments for the node boxes */
290
- const svgNodes = nodes.flatMap((node) => renderNode(node, layout, styleOf(node), font, color, config));
301
+ const svgNodes = nodes.flatMap((node) => renderNode(node, layout, styleOf(node), styler, config));
291
302
  /* generate the SVG fragments for the group boxes (drawn below
292
303
  everything else) and the container boxes (drawn below the edges
293
304
  and nodes, an outer box before its nested ones) */
294
- const svgGroups = groups.flatMap((group) => renderGroup(group, font, color));
295
- const svgContainers = containers.flatMap((c) => renderContainer(c, font, color));
305
+ const svgGroups = groups.flatMap((group) => renderGroup(group, styler));
306
+ const svgContainers = containers.flatMap((c) => renderContainer(c, styler));
296
307
  /* determine the overall bounding box of all rendered elements */
297
308
  const groupBoxes = groups.map((group) => [group.x, group.y, group.x + group.w, group.y + group.h]);
298
309
  const containerBoxes = containers.map((c) => [c.x, c.y, c.x + c.w, c.y + c.h]);
299
310
  const vb = viewBoxOf(layout, [...occupied, ...groupBoxes, ...containerBoxes], config["size-canvas-margin"]);
300
- /* assemble the final SVG document */
311
+ /* assemble the final SVG document (with one CSS rule per line) */
301
312
  return [
302
313
  "<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
303
314
  "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" " +
304
315
  `viewBox="${vb.x} ${vb.y} ${vb.w} ${vb.h}" width="${vb.w}" height="${vb.h}">`,
305
316
  "<defs>",
317
+ "<style>",
306
318
  ...(embed !== undefined ? [
307
- "<style>",
308
319
  `@font-face { font-family: "${escapeXML(escapeCSS(family))}"; ` +
309
320
  (weight !== undefined ? `font-weight: ${escapeXML(escapeCSS(weight))}; ` : "") +
310
- `src: url(data:font/woff2;base64,${embed}) format("woff2"); }`,
311
- "</style>"
321
+ `src: url(data:font/woff2;base64,${embed}) format("woff2"); }`
312
322
  ] : []),
323
+ ...Array.from(rules, ([decls, name]) => `.${name} { ${escapeXML(decls)} }`),
324
+ "</style>",
313
325
  `<marker id="${idArrow}" viewBox="0 0 10 10" refX="9" refY="5" ` +
314
326
  "markerWidth=\"21\" markerHeight=\"21\" markerUnits=\"userSpaceOnUse\" " +
315
327
  "orient=\"auto-start-reverse\">",
316
- `<path d="M 0 1 L 9 5 L 0 9 z" style="fill: ${color("color-edge-line")}"/>`,
328
+ `<path d="M 0 1 L 9 5 L 0 9 z" class="${classArrow}"/>`,
317
329
  "</marker>",
318
330
  "</defs>",
319
331
  ...svgGroups,