@rse/gradia 1.2.4 → 1.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.
@@ -5,8 +5,8 @@
5
5
  */
6
6
  /* external dependencies */
7
7
  import UUID from "pure-uuid";
8
- import { resolveFont, cssValueOf } from "./gradia-api-config.js";
9
- import { FS_NAME, FS_TYPE, FS_ATTR, FS_EDGE, FS_ARITY, FS_GROUP, ARITY_OFF, textWidth, escapeXML } from "./gradia-api-render-base.js";
8
+ import { resolveFont, cssValueOf, cssSizeOf } from "./gradia-api-config.js";
9
+ import { FS_GROUP, ARITY_OFF, ARITY_PAD, arityHeight, textWidth, escapeXML } from "./gradia-api-render-base.js";
10
10
  import { linesOfNode, urlOf, typeOf, defaultStyleOf, containerHead, MIN_H, NAME_H, ATTR_H, ATTR_P, TYPE_H, TYPE_D, HEAD_H } from "./gradia-api-render-node.js";
11
11
  import { computeHops, pathOf, pointAt } from "./gradia-api-render-edge.js";
12
12
  /* escape a string for use inside a CSS string literal (the XML escaping
@@ -20,23 +20,32 @@ 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 */
31
33
  /* the half width of the box an edge line segment occupies */
32
34
  const LINE_PAD = 2;
35
+ /* the clearance a label keeps from a foreign edge line, as a label
36
+ merely not overlapping such a line still reads as its annotation */
37
+ const LINE_GAP = 12;
38
+ /* the gap between two arity labels set back one behind the other along
39
+ one and the same edge line, and the number of such setbacks tried */
40
+ const ARITY_STEP = 8;
41
+ const ARITY_BACK = 3;
33
42
  /* track occupied areas (node boxes and already placed labels) to
34
43
  let subsequent labels dodge into a collision-free position, and the
35
44
  edge lines (their segments and crossing hops) to let the labels at
36
45
  least prefer the position covering the fewest lines (the returned
37
46
  "occupied" array grows with every claimed label box and hence also
38
47
  serves as the box input of the overall bounding box) */
39
- const labelPlacer = (layout, hops, hopRadius) => {
48
+ const labelPlacer = (layout, hops, hopRadius, config) => {
40
49
  const { nodes, cx, cy, boxW, boxH } = layout;
41
50
  const occupied = nodes.map((node) => [
42
51
  cx(node.id) - boxW.get(node.id) / 2, cy(node.id) - boxH.get(node.id) / 2,
@@ -48,27 +57,41 @@ const labelPlacer = (layout, hops, hopRadius) => {
48
57
  group.x + TAG_DX + textWidth(group.name, FS_GROUP), group.y + TAG_DY + FS_GROUP * 1.2]);
49
58
  for (const c of layout.containers ?? [])
50
59
  occupied.push([c.x + TAG_DX, c.y + TAG_DY,
51
- c.x + TAG_DX + textWidth(c.node.name, FS_GROUP), c.y + containerHead(c.node)]);
52
- /* collect the edge line segments and the hops bulging above them */
53
- const lines = [];
54
- layout.polys.forEach((poly, i) => {
60
+ c.x + TAG_DX + textWidth(c.node.name, FS_GROUP), c.y + containerHead(c.node, config)]);
61
+ /* collect the edge line segments and the hops bulging above them,
62
+ kept per edge, so a label can tell its own route from the foreign ones */
63
+ const lines = layout.polys.map((poly, i) => {
64
+ const boxes = [];
55
65
  for (let k = 0; k < poly.length - 1; k++) {
56
66
  const [a, b] = [poly[k], poly[k + 1]];
57
- lines.push([Math.min(a[0], b[0]) - LINE_PAD, Math.min(a[1], b[1]) - LINE_PAD,
67
+ boxes.push([Math.min(a[0], b[0]) - LINE_PAD, Math.min(a[1], b[1]) - LINE_PAD,
58
68
  Math.max(a[0], b[0]) + LINE_PAD, Math.max(a[1], b[1]) + LINE_PAD]);
59
69
  for (const hx of hops[i].get(k) ?? [])
60
- lines.push([hx - hopRadius, a[1] - hopRadius, hx + hopRadius, a[1]]);
70
+ boxes.push([hx - hopRadius, a[1] - hopRadius, hx + hopRadius, a[1]]);
61
71
  }
72
+ return boxes;
62
73
  });
74
+ const lineCount = lines.reduce((n, boxes) => n + boxes.length, 0);
63
75
  /* claim the candidate box colliding with the fewest occupied
64
76
  areas and, among those, with the fewest edge lines (earlier
65
- candidates win ties, so the first collision-free one is taken) */
77
+ candidates win ties, so the first collision-free one is taken),
78
+ where the own edge lines count on touch only, as the label sits
79
+ beside its own route by construction, while the foreign ones
80
+ count already within a clearance, so a label hugging a foreign
81
+ line loses against one staying with its own edge. A claim with
82
+ "dodge" unset weighs the lines not at all, as its candidates are
83
+ ordered by a proximity which matters more than a crossed line,
84
+ which the halo of the label keeps readable anyway */
66
85
  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
- const claim = (candidates) => {
86
+ const crossings = (box, edge) => {
87
+ const near = [box[0] - LINE_GAP, box[1] - LINE_GAP, box[2] + LINE_GAP, box[3] + LINE_GAP];
88
+ return lines.reduce((n, boxes, i) => n + collisions(boxes, i === edge ? box : near), 0);
89
+ };
90
+ const claim = (candidates, edge, dodge = true) => {
68
91
  let box = candidates[0];
69
92
  let lowest = Infinity;
70
93
  for (const c of candidates) {
71
- const score = collisions(occupied, c) * (lines.length + 1) + collisions(lines, c);
94
+ const score = collisions(occupied, c) * (lineCount + 1) + (dodge ? crossings(c, edge) : 0);
72
95
  if (score < lowest) {
73
96
  box = c;
74
97
  lowest = score;
@@ -83,7 +106,7 @@ const labelPlacer = (layout, hops, hopRadius) => {
83
106
  };
84
107
  /* generate the SVG fragments for a single node box (a node with a
85
108
  "url" attribute becomes a hyperlink covering the whole box) */
86
- const renderNode = (node, layout, style, font, color, config) => {
109
+ const renderNode = (node, layout, style, styler, config) => {
87
110
  const { cx, cy, boxW, boxH, contentH } = layout;
88
111
  const w = boxW.get(node.id);
89
112
  const h = boxH.get(node.id);
@@ -93,32 +116,31 @@ const renderNode = (node, layout, style, font, color, config) => {
93
116
  const url = urlOf(node);
94
117
  const parts = [];
95
118
  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"` +
119
+ `class="${styler.box(style.fill, style.stroke, 4)}"` +
97
120
  `${style.dash !== undefined ? ` stroke-dasharray="${escapeXML(style.dash)}"` : ""}/>`);
98
121
  /* vertically center the textual content block within the box, with
99
122
  the optional type lines shifting the name and attributes down and
100
123
  the additional wrapped name lines shifting the attributes down */
101
124
  const ty = cy(node.id) - contentH.get(node.id) / 2;
102
- const th = lines.type.length * TYPE_H;
103
- const nh = (lines.name.length - 1) * NAME_H;
125
+ const th = lines.type.length * TYPE_H(config);
126
+ const nh = (lines.name.length - 1) * NAME_H(config);
127
+ const asc = config["size-font-node"] * 0.36;
104
128
  const nameY = lines.attrs.length > 0 ?
105
- ty + th + MIN_H / 2 + FS_NAME * 0.36 : cy(node.id) + th / 2 + FS_NAME * 0.36 - nh / 2;
129
+ ty + th + MIN_H(config) / 2 + asc : cy(node.id) + th / 2 + asc - nh / 2;
106
130
  lines.type.forEach((line, k) => {
107
131
  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>`);
132
+ `y="${nameY - TYPE_D(config) - (lines.type.length - 1 - k) * TYPE_H(config)}" ` +
133
+ `class="${styler.text(style.text, "size-font-type", { middle: true })}">${escapeXML(line)}</text>`);
111
134
  });
112
135
  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>`);
136
+ parts.push(`<text x="${cx(node.id)}" y="${nameY + k * NAME_H(config)}" ` +
137
+ `class="${styler.text(style.text, "size-font-node", { bold: true, middle: true })}">` +
138
+ `${escapeXML(line)}</text>`);
116
139
  });
117
140
  lines.attrs.forEach((line, k) => {
118
141
  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>`);
142
+ `y="${ty + th + nh + MIN_H(config) + ATTR_P + k * ATTR_H(config)}" ` +
143
+ `class="${styler.text(style.text, "size-font-prop", { middle: true })}">${escapeXML(line)}</text>`);
122
144
  });
123
145
  if (url === undefined)
124
146
  return parts;
@@ -157,97 +179,93 @@ const viewBoxOf = (layout, boxes, margin) => {
157
179
  optional name, placed near the middle of the route, and its optional
158
180
  arity, placed near the arrow head (both dodging into a collision-free
159
181
  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\"";
182
+ const renderEdgeLabels = (edge, poly, claim, styler, config) => {
165
183
  const parts = [];
166
184
  if (edge.name !== undefined) {
167
- const w = textWidth(edge.name, FS_EDGE);
185
+ const w = textWidth(edge.name, config["size-font-edge"]);
186
+ const h = config["size-font-edge"] + 4;
168
187
  const candidates = [];
169
188
  for (const f of [0.50, 0.40, 0.60, 0.30, 0.70, 0.20, 0.80]) {
170
189
  const p = pointAt(poly, f);
171
190
  if (p.horizontal) {
172
- candidates.push([p.x - w / 2, p.y - 23, p.x + w / 2, p.y - 3]);
173
- candidates.push([p.x - w / 2, p.y + 3, p.x + w / 2, p.y + 23]);
191
+ candidates.push([p.x - w / 2, p.y - 3 - h, p.x + w / 2, p.y - 3]);
192
+ candidates.push([p.x - w / 2, p.y + 3, p.x + w / 2, p.y + 3 + h]);
174
193
  }
175
194
  else {
176
- candidates.push([p.x + 5, p.y - 10, p.x + 5 + w, p.y + 10]);
177
- candidates.push([p.x - 5 - w, p.y - 10, p.x - 5, p.y + 10]);
195
+ candidates.push([p.x + 5, p.y - h / 2, p.x + 5 + w, p.y + h / 2]);
196
+ candidates.push([p.x - 5 - w, p.y - h / 2, p.x - 5, p.y + h / 2]);
178
197
  }
179
198
  }
180
199
  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>`);
200
+ parts.push(`<text x="${(box[0] + box[2]) / 2}" y="${box[3] - 3}" ` +
201
+ `class="${styler.text("color-edge-name", "size-font-edge", { middle: true, halo: true })}">` +
202
+ `${escapeXML(edge.name)}</text>`);
186
203
  }
187
204
  if (edge.arity !== undefined) {
188
- const w = textWidth(edge.arity, FS_ARITY);
205
+ const w = textWidth(edge.arity, config["size-font-arity"]);
206
+ const h = arityHeight(config);
189
207
  const p = pointAt(poly, 1.0);
190
208
  const prev = pointAt(poly, 0.999);
191
209
  /* set the arity back from the arrow head along the final
192
210
  segment and place it beside the line, so an edge approaching
193
- vertically keeps its arity next to its own arrow */
194
- let candidates;
195
- if (p.horizontal) {
196
- const dx = Math.sign(p.x - prev.x) || 1;
197
- const ax = p.x - dx * (ARITY_OFF + w / 2);
198
- candidates = [
199
- [ax - w / 2, p.y - 17, ax + w / 2, p.y - 4],
200
- [ax - w / 2, p.y + 4, ax + w / 2, p.y + 17],
201
- [ax - w / 2 - dx * 14, p.y - 17, ax + w / 2 - dx * 14, p.y - 4]
202
- ];
203
- }
204
- else {
205
- const dy = Math.sign(p.y - prev.y) || 1;
206
- const ay = p.y - dy * ARITY_OFF;
207
- candidates = [
208
- [p.x + 6, ay - 7, p.x + 6 + w, ay + 6],
209
- [p.x - 6 - w, ay - 7, p.x - 6, ay + 6],
210
- [p.x + 6, ay - 7 - dy * 14, p.x + 6 + w, ay + 6 - dy * 14]
211
- ];
211
+ vertically keeps its arity next to its own arrow, offering
212
+ both sides at every further setback, which steps by the label
213
+ extent along the segment, as a shorter step would leave the
214
+ label on top of the very label it dodges */
215
+ const candidates = [];
216
+ for (let k = 0; k < ARITY_BACK; k++) {
217
+ const back = k * ((p.horizontal ? w : h) + ARITY_STEP);
218
+ if (p.horizontal) {
219
+ const dx = Math.sign(p.x - prev.x) || 1;
220
+ const ax = p.x - dx * (ARITY_OFF + w / 2 + back);
221
+ candidates.push([ax - w / 2, p.y - ARITY_PAD - h, ax + w / 2, p.y - ARITY_PAD]);
222
+ candidates.push([ax - w / 2, p.y + ARITY_PAD, ax + w / 2, p.y + ARITY_PAD + h]);
223
+ }
224
+ else {
225
+ const dy = Math.sign(p.y - prev.y) || 1;
226
+ const ay = p.y - dy * (ARITY_OFF + back);
227
+ candidates.push([p.x + 6, ay + 6 - h, p.x + 6 + w, ay + 6]);
228
+ candidates.push([p.x - 6 - w, ay + 6 - h, p.x - 6, ay + 6]);
229
+ }
212
230
  }
213
- 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>`);
231
+ /* an arity claims its position without dodging the edge lines,
232
+ as the neighboring ports of a node run closer than any
233
+ clearance anyway and it is the very setback from its own
234
+ arrow head which attaches it to its edge, so a line crossed
235
+ beneath its halo weighs less than a drift away from that arrow */
236
+ const box = claim(candidates, false);
237
+ parts.push(`<text x="${(box[0] + box[2]) / 2}" y="${box[3] - 3}" ` +
238
+ `class="${styler.text("color-edge-arity", "size-font-arity", { middle: true, halo: true })}">` +
239
+ `${escapeXML(edge.arity)}</text>`);
219
240
  }
220
241
  return parts;
221
242
  };
222
243
  /* generate the SVG fragments for a single group box and its tag
223
244
  in the top-left corner */
224
- const renderGroup = (group, font, color) => [
245
+ const renderGroup = (group, styler) => [
225
246
  `<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"/>`,
247
+ `class="${styler.box("color-group-box", "color-group-border", 3)}"/>`,
228
248
  `<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>`
249
+ `class="${styler.text("color-group-name", FS_GROUP, { bold: true })}">${escapeXML(group.name)}</text>`
231
250
  ];
232
251
  /* generate the SVG fragments for a single container box and its tag
233
252
  in the top-left corner, with the optional type line above the name
234
253
  (a container with a "url" attribute becomes a hyperlink covering
235
254
  the whole box) */
236
- const renderContainer = (c, font, color) => {
255
+ const renderContainer = (c, styler, config) => {
237
256
  const type = typeOf(c.node);
238
257
  const url = urlOf(c.node);
239
258
  const parts = [
240
259
  `<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"/>`,
260
+ `class="${styler.box("color-container-box", "color-container-border", 3)}" ` +
261
+ "stroke-dasharray=\"10 6\"/>",
243
262
  ...(type !== undefined ? [
244
- `<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>`
263
+ `<text x="${c.x + TAG_DX}" y="${c.y + TAG_DY + config["size-font-type"]}" ` +
264
+ `class="${styler.text("color-container-name", "size-font-type")}">${escapeXML(type)}</text>`
247
265
  ] : []),
248
- `<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>`
266
+ `<text x="${c.x + TAG_DX}" y="${c.y + containerHead(c.node, config) - HEAD_H + TAG_DY + FS_GROUP}" ` +
267
+ `class="${styler.text("color-container-name", FS_GROUP, { bold: true })}">` +
268
+ `${escapeXML(c.node.name)}</text>`
251
269
  ];
252
270
  if (url === undefined)
253
271
  return parts;
@@ -264,7 +282,7 @@ export const renderSVG = (layout, config, explicit, seed) => {
264
282
  values: explicitly configured values are hard-coded, while all
265
283
  others are fetched at display time from the "--gradia-<option>"
266
284
  CSS custom properties, falling back to the built-in defaults */
267
- const color = (key) => escapeXML(cssValueOf(explicit, key));
285
+ const color = (key) => cssValueOf(explicit, key);
268
286
  /* derive the collision-free identifiers of this SVG document */
269
287
  const prefix = idPrefix(seed);
270
288
  const idArrow = `${prefix}-arrow`;
@@ -273,47 +291,71 @@ export const renderSVG = (layout, config, explicit, seed) => {
273
291
  const stack = Object.hasOwn(explicit, "font-family") ?
274
292
  `'${escapeCSS(family)}'` :
275
293
  `var(--gradia-font-family, '${escapeCSS(family)}')`;
276
- const font = escapeXML(`${stack}, ui-sans-serif, -apple-system, Helvetica, Arial, sans-serif`);
294
+ const font = `${stack}, ui-sans-serif, -apple-system, Helvetica, Arial, sans-serif`;
295
+ /* collect the CSS classes the rendered elements reference (the
296
+ halo behind an edge label keeps it readable on top of a line) */
297
+ const rules = new Map();
298
+ const classOf = (decls) => {
299
+ let name = rules.get(decls);
300
+ if (name === undefined) {
301
+ name = `gradia-${new UUID(5, NS_GRADIA, decls).format("b16").toLowerCase().slice(0, 8)}`;
302
+ rules.set(decls, name);
303
+ }
304
+ return name;
305
+ };
306
+ const styler = {
307
+ text: (fill, size, options = {}) => classOf(`font-family: ${font}; ` +
308
+ `font-size: ${typeof size === "number" ? `${size}px` : cssSizeOf(config, size)}; ` +
309
+ (options.bold ? "font-weight: 600; " : "") +
310
+ (options.middle ? "text-anchor: middle; " : "") +
311
+ `fill: ${color(fill)}` +
312
+ (options.halo ? `; stroke: ${color("color-edge-halo")}; stroke-width: 4.5; ` +
313
+ "paint-order: stroke; stroke-linejoin: round" : "")),
314
+ box: (fill, stroke, width) => classOf(`fill: ${color(fill)}; stroke: ${color(stroke)}; stroke-width: ${width}`)
315
+ };
316
+ const classEdge = classOf(`fill: none; stroke: ${color("color-edge-line")}; stroke-width: 3`);
317
+ const classArrow = classOf(`fill: ${color("color-edge-line")}`);
277
318
  /* detect the edge crossings requiring rendered hops */
278
319
  const hops = computeHops(polys);
279
320
  /* prepare the collision-free placement of the edge labels */
280
- const { claim, occupied } = labelPlacer(layout, hops, config["size-edge-hop-radius"]);
321
+ const { claim, occupied } = labelPlacer(layout, hops, config["size-edge-hop-radius"], config);
281
322
  /* generate the SVG fragments for the edges (paths below, labels above) */
282
323
  const svgEdges = [];
283
324
  const svgLabels = [];
284
325
  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));
326
+ svgEdges.push(`<path d="${pathOf(polys[i], hops[i], config["size-edge-corner-radius"], config["size-edge-hop-radius"])}" ` +
327
+ `class="${classEdge}" marker-end="url(#${idArrow})"/>`);
328
+ svgLabels.push(...renderEdgeLabels(edge, polys[i], (c, dodge) => claim(c, i, dodge), styler, config));
288
329
  });
289
330
  /* generate the SVG fragments for the node boxes */
290
- const svgNodes = nodes.flatMap((node) => renderNode(node, layout, styleOf(node), font, color, config));
331
+ const svgNodes = nodes.flatMap((node) => renderNode(node, layout, styleOf(node), styler, config));
291
332
  /* generate the SVG fragments for the group boxes (drawn below
292
333
  everything else) and the container boxes (drawn below the edges
293
334
  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));
335
+ const svgGroups = groups.flatMap((group) => renderGroup(group, styler));
336
+ const svgContainers = containers.flatMap((c) => renderContainer(c, styler, config));
296
337
  /* determine the overall bounding box of all rendered elements */
297
338
  const groupBoxes = groups.map((group) => [group.x, group.y, group.x + group.w, group.y + group.h]);
298
339
  const containerBoxes = containers.map((c) => [c.x, c.y, c.x + c.w, c.y + c.h]);
299
340
  const vb = viewBoxOf(layout, [...occupied, ...groupBoxes, ...containerBoxes], config["size-canvas-margin"]);
300
- /* assemble the final SVG document */
341
+ /* assemble the final SVG document (with one CSS rule per line) */
301
342
  return [
302
343
  "<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
303
344
  "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" " +
304
345
  `viewBox="${vb.x} ${vb.y} ${vb.w} ${vb.h}" width="${vb.w}" height="${vb.h}">`,
305
346
  "<defs>",
347
+ "<style>",
306
348
  ...(embed !== undefined ? [
307
- "<style>",
308
349
  `@font-face { font-family: "${escapeXML(escapeCSS(family))}"; ` +
309
350
  (weight !== undefined ? `font-weight: ${escapeXML(escapeCSS(weight))}; ` : "") +
310
- `src: url(data:font/woff2;base64,${embed}) format("woff2"); }`,
311
- "</style>"
351
+ `src: url(data:font/woff2;base64,${embed}) format("woff2"); }`
312
352
  ] : []),
353
+ ...Array.from(rules, ([decls, name]) => `.${name} { ${escapeXML(decls)} }`),
354
+ "</style>",
313
355
  `<marker id="${idArrow}" viewBox="0 0 10 10" refX="9" refY="5" ` +
314
356
  "markerWidth=\"21\" markerHeight=\"21\" markerUnits=\"userSpaceOnUse\" " +
315
357
  "orient=\"auto-start-reverse\">",
316
- `<path d="M 0 1 L 9 5 L 0 9 z" style="fill: ${color("color-edge-line")}"/>`,
358
+ `<path d="M 0 1 L 9 5 L 0 9 z" class="${classArrow}"/>`,
317
359
  "</marker>",
318
360
  "</defs>",
319
361
  ...svgGroups,
@@ -5,9 +5,9 @@
5
5
  */
6
6
  /* external dependencies */
7
7
  import { DagreLayout } from "@antv/layout";
8
- import { FS_EDGE, FS_ARITY, ARITY_OFF, textWidth } from "./gradia-api-render-base.js";
8
+ import { ARITY_OFF, textWidth } from "./gradia-api-render-base.js";
9
9
  import { measureNodes, orderOf } from "./gradia-api-render-node.js";
10
- import { simplifyPoly, assignPorts, assignTracks, computeHops } from "./gradia-api-render-edge.js";
10
+ import { simplifyPoly, assignPorts, assignTracks, computeHops, portGapOf, PORT_PAD } from "./gradia-api-render-edge.js";
11
11
  /* rendering geometry constants */
12
12
  const CLUSTX = 26; /* max X distance within a grid column */
13
13
  const CLUSTY = 72; /* max Y distance within a grid row */
@@ -255,7 +255,7 @@ const trackChans = (plan) => plan.gate === "t" ? [plan.chans[0]] : plan.gate ===
255
255
  its one, and, if those differ, the gutter next to the target row
256
256
  connects them (an edge whose gate end lies beyond that gutter ends
257
257
  in the gutter itself, as the gate merely marks the border passage) */
258
- const planRoutes = (edges, col, row, sides, gates) => {
258
+ const planRoutes = (edges, col, row, sides, gates, config) => {
259
259
  const flag = (from, to) => ({ mirror: from === "r", hairpin: from === to });
260
260
  const plans = edges.map((edge, i) => {
261
261
  /* a self-loop detours as a hairpin through the channel beside
@@ -303,11 +303,11 @@ const planRoutes = (edges, col, row, sides, gates) => {
303
303
  if (source === target)
304
304
  return;
305
305
  if (name !== undefined && plan.chans.length === 1)
306
- chanNeed(plan.chans[0], textWidth(name, FS_EDGE) + 2 * CHAN_LBL);
306
+ chanNeed(plan.chans[0], textWidth(name, config["size-font-edge"]) + 2 * CHAN_LBL);
307
307
  else if (name !== undefined && plan.guts.length > 0)
308
308
  gutLbl.set(plan.guts[0], (gutLbl.get(plan.guts[0]) ?? 0) + 1);
309
309
  if (arity !== undefined)
310
- chanNeed(chans[chans.length - 1], ARITY_OFF + textWidth(arity, FS_ARITY) + CHAN_LBL);
310
+ chanNeed(chans[chans.length - 1], ARITY_OFF + textWidth(arity, config["size-font-arity"]) + CHAN_LBL);
311
311
  });
312
312
  /* count the self-loops of every node, as their detours stack up
313
313
  above its box and have to fit into the gutter above its row
@@ -481,9 +481,10 @@ const routePolys = (edges, plans, portPos, boxH, cy, chanX, gutY) => {
481
481
  orthogonal polyline through the channels between the columns and
482
482
  the gutters between the rows */
483
483
  const routeEdges = (edges, plans, sides, boxW, boxH, cx, cy, grid, config, level) => {
484
- const portPre = assignPorts(edges, sides, cx, cy, boxW, boxH, config["size-edge-port-gap"], undefined, level.fixedPort);
484
+ const portGap = portGapOf(edges, config);
485
+ const portPre = assignPorts(edges, sides, cx, cy, boxW, boxH, portGap, undefined, level.fixedPort);
485
486
  const trackPre = assignTrackCoords(edges, plans, portPre, grid, config);
486
- const portPos = assignPorts(edges, sides, cx, cy, boxW, boxH, config["size-edge-port-gap"], (edge) => plans[edge].guts.length > 0 ?
487
+ const portPos = assignPorts(edges, sides, cx, cy, boxW, boxH, portGap, (edge) => plans[edge].guts.length > 0 ?
487
488
  trackPre.gutY(plans[edge].guts[0], edge) : undefined, level.fixedPort);
488
489
  const { chanX, gutY } = assignTrackCoords(edges, plans, portPos, grid, config);
489
490
  const polys = routePolys(edges, plans, portPos, boxH, cy, chanX, gutY);
@@ -586,12 +587,14 @@ const attachSides = (edges, col, level) => edges.map((edge, i) => {
586
587
  const t = level.fixedPort?.(i, "t") !== undefined ? "w" : sc < tc ? "w" : "e";
587
588
  return { s, t };
588
589
  });
589
- /* grow every box whose edge attachments exceed the configured
590
- per-side maximum, step-wise by one port separation per
591
- additional edge, so the edges keep enough attachment room
592
- without a fixed height increase (the fixed-size boxes of a
593
- containment level are exempt) */
590
+ /* grow every box up to the height hosting all of its edge
591
+ attachments at the full port separation, and the one whose
592
+ attachments exceed the configured per-side maximum additionally
593
+ step-wise by one separation per additional edge, so the ports of a
594
+ node never pack tighter than configured, however small its content
595
+ height is (the fixed-size boxes of a containment level are exempt) */
594
596
  const growBoxes = (nodes, edges, sides, boxH, config, level) => {
597
+ const portGap = portGapOf(edges, config);
595
598
  const portCnt = new Map();
596
599
  edges.forEach((edge, i) => {
597
600
  portCnt.set(`${sides[i].s}:${edge.source}`, (portCnt.get(`${sides[i].s}:${edge.source}`) ?? 0) + 1);
@@ -601,9 +604,8 @@ const growBoxes = (nodes, edges, sides, boxH, config, level) => {
601
604
  if (level.fixedSize?.has(node.id))
602
605
  continue;
603
606
  const cnt = Math.max(portCnt.get(`w:${node.id}`) ?? 0, portCnt.get(`e:${node.id}`) ?? 0);
604
- if (cnt > config["graph-node-degree-max"])
605
- boxH.set(node.id, boxH.get(node.id) +
606
- (cnt - config["graph-node-degree-max"]) * config["size-edge-port-gap"]);
607
+ const extra = Math.max(0, cnt - config["graph-node-degree-max"]);
608
+ boxH.set(node.id, Math.max(boxH.get(node.id) + extra * portGap, cnt * portGap + PORT_PAD));
607
609
  }
608
610
  };
609
611
  /* lay out a directed graph model (for a containment level: with the
@@ -699,12 +701,12 @@ export const render = async (graph, config, level = {}) => {
699
701
  /* plan the coarse channel/gutter route of every edge (a west side
700
702
  attachment in the first column needs a channel left of it, so
701
703
  the whole grid is shifted one column to the right then) */
702
- let routes = planRoutes(edges, col, row, sides, gates);
704
+ let routes = planRoutes(edges, col, row, sides, gates, config);
703
705
  if (routes.plans.some((plan) => plan.chans.some((c) => c < 0))) {
704
706
  for (const node of nodes)
705
707
  col.set(node.id, col.get(node.id) + 1);
706
708
  ncols++;
707
- routes = planRoutes(edges, col, row, sides, gates);
709
+ routes = planRoutes(edges, col, row, sides, gates, config);
708
710
  }
709
711
  const { plans, chanCnt, gutCnt, chanLbl, gutLbl, gutLoop } = routes;
710
712
  /* grow the boxes by their edge attachment needs */