@rse/gradia 0.9.8 → 1.0.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
@@ -24,6 +24,23 @@ graphs:
24
24
  output nodes.
25
25
  - `grid`: a compact grid of tiles for an edge-less graph.
26
26
 
27
+ Examples
28
+ --------
29
+
30
+ Here are examples of the three graph diagram renderings:
31
+
32
+ - [broadcast-graph](smp/broadcast-graph.txt):
33
+
34
+ ![broadcast-graph](smp/broadcast-graph.svg)
35
+
36
+ - [broadcast-hub](smp/broadcast-hub.txt):
37
+
38
+ ![broadcast-hub](smp/broadcast-hub.svg)
39
+
40
+ - [broadcast-grid](smp/broadcast-grid.txt):
41
+
42
+ ![broadcast-grid](smp/broadcast-grid.svg)
43
+
27
44
  Playground
28
45
  ----------
29
46
 
@@ -179,7 +196,9 @@ separated by `|`.
179
196
  - Four attribute keys are reserved and consumed by the renderer instead
180
197
  of being displayed as a regular `<key>: <val>` line:
181
198
 
182
- - `url: <url>` renders the node box as a hyperlink,
199
+ - `url: <url>` renders the node box as a hyperlink. Only relative URLs
200
+ and the schemes `http`, `https`, and `mailto` are honored; any other
201
+ URL is silently dropped and the node box then simply stays unlinked,
183
202
  - `type: <name>` renders `<name>` as a smaller text above the node
184
203
  label inside the node box,
185
204
  - `primary: true` renders the node in the primary node colors
@@ -195,6 +214,38 @@ separated by `|`.
195
214
  - All remaining attributes are displayed as `<key>: <val>` lines inside
196
215
  the node box.
197
216
 
217
+ ### Diagram Type Constraints
218
+
219
+ The diagram type `graph` imposes no constraints on the topology: it
220
+ accepts self-loops and free-standing nodes alike, and hence is the safe
221
+ choice whenever the topology is not known to fit `hub` or `grid`.
222
+
223
+ The diagram type `hub` rejects the input with an error unless all of the
224
+ following conditions hold:
225
+
226
+ - Exactly one node carries the attribute `primary: true`.
227
+
228
+ - Every edge either points to or originates from that primary node. An
229
+ edge between two non-primary nodes is rejected.
230
+
231
+ - The primary node carries no self-loop.
232
+
233
+ - Every non-primary node is an input or an output of the primary node,
234
+ i.e., no node is free-standing.
235
+
236
+ A node which is both an input and an output of the primary node is placed
237
+ twice, once in the input column and once in the output column. The second
238
+ placement is rendered as a dashed "ghost" box, colored by the
239
+ `color-node-ghost-*` options.
240
+
241
+ The diagram type `grid` rejects the input with an error as soon as the
242
+ graph contains at least one edge.
243
+
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.
248
+
198
249
  ### Directives
199
250
 
200
251
  A `#type` directive sets the diagram type (`graph`, `hub`, or `grid`),
@@ -204,28 +255,30 @@ type is silently ignored. Without both, the type defaults to `graph`.
204
255
 
205
256
  A `#config` directive sets one of the rendering options, which otherwise
206
257
  are settable through the corresponding `--config <option>=<value>`
207
- command-line options. The recognized options are:
258
+ command-line options (`font-embed` being command-line only, see below).
259
+ The recognized options are:
208
260
 
209
261
  ```
210
- font-family color-edge-line graph-columns-max
211
- color-node-regular-name color-edge-name graph-channel-width-max
212
- color-node-regular-box color-edge-arity graph-gutter-height-max
213
- color-node-regular-border color-edge-halo graph-node-separation
214
- color-node-primary-name size-canvas-margin graph-rank-separation
215
- color-node-primary-box size-node-width-min graph-node-degree-max
216
- color-node-primary-border size-node-width-max hub-channel-width-max
217
- color-node-ghost-name size-node-height-scale hub-channel-width-min
218
- color-node-ghost-box size-edge-corner-radius hub-node-gap
219
- color-node-ghost-border size-edge-hop-radius hub-node-degree-max
220
- color-group-name size-edge-track-gap grid-columns-max
221
- color-group-box group-box-padding grid-gap-horizontal
222
- color-group-border group-box-gap grid-gap-vertical
262
+ font-family color-edge-line graph-channel-width-max
263
+ font-embed color-edge-name graph-gutter-height-max
264
+ color-node-regular-name color-edge-arity graph-node-separation
265
+ color-node-regular-box color-edge-halo graph-rank-separation
266
+ color-node-regular-border size-canvas-margin graph-node-degree-max
267
+ color-node-primary-name size-node-width-min hub-channel-width-max
268
+ color-node-primary-box size-node-width-max hub-channel-width-min
269
+ color-node-primary-border size-node-height-scale hub-node-gap
270
+ color-node-ghost-name size-edge-corner-radius hub-node-degree-max
271
+ color-node-ghost-box size-edge-hop-radius grid-columns-max
272
+ color-node-ghost-border size-edge-track-gap grid-columns-min
273
+ color-group-name group-box-padding grid-gap-horizontal
274
+ color-group-box group-box-gap grid-gap-vertical
275
+ color-group-border graph-columns-max grid-node-width-equal
223
276
  ```
224
277
 
225
278
  The `size-*`, `group-*`, `graph-*`, `hub-*`, and `grid-*` options
226
279
  control the rendering geometry (canvas margin, node box sizing, edge
227
280
  routing, group box spacing, and the per-diagram-type layout) and take
228
- non-negative numbers.
281
+ non-negative numbers, except the boolean `grid-node-width-equal`.
229
282
 
230
283
  The `size-node-width-max` option additionally enables the word-wrapping
231
284
  of the node box texts: given a positive value, the node name, its type,
@@ -235,6 +288,19 @@ default `0` disables the wrapping entirely and hence lets a node box
235
288
  become as wide as its longest text. A single word wider than the
236
289
  maximum is never broken and still widens the box beyond the maximum.
237
290
 
291
+ The `grid-columns-min` and `grid-columns-max` options control the column
292
+ count of a `grid` diagram, which by default is derived from the node
293
+ count as a roughly square grid: `grid-columns-min` (default `3`) raises
294
+ the derived count, so that a few nodes still share a single row instead
295
+ of being stacked, while `grid-columns-max` (default `4`) caps it, so
296
+ that larger diagrams grow in height only. The column count never
297
+ exceeds the node count and the maximum always wins over the minimum.
298
+
299
+ The `grid-node-width-equal` option controls the tile widths of a `grid`
300
+ diagram: its default `true` forces all node boxes to the width of the
301
+ widest one and hence yields a strictly regular grid, while `false` lets
302
+ each column become only as wide as its own widest node box.
303
+
238
304
  The `font-family` and `color-*` options are embedded directly into the
239
305
  generated SVG. When such an option is *not* explicitly configured, the
240
306
  SVG references the CSS custom property `--gradia-<option>` (definable by
@@ -37,8 +37,10 @@ export declare const configDefaults: {
37
37
  "hub-node-gap": number;
38
38
  "hub-node-degree-max": number;
39
39
  "grid-columns-max": number;
40
+ "grid-columns-min": number;
40
41
  "grid-gap-horizontal": number;
41
42
  "grid-gap-vertical": number;
43
+ "grid-node-width-equal": boolean;
42
44
  };
43
45
  export type Config = typeof configDefaults;
44
46
  export type ConfigEmbedded = {
@@ -36,19 +36,21 @@ export const configDefaults = {
36
36
  "size-edge-track-gap": 12, /* offset between parallel edge routes */
37
37
  "group-box-padding": 30, /* inner padding of group boxes */
38
38
  "group-box-gap": 40, /* vertical gap between group boxes */
39
- "graph-columns-max": 5, /* graph: max side-by-side nodes */
39
+ "graph-columns-max": 4, /* graph: max side-by-side nodes */
40
40
  "graph-channel-width-max": 140, /* graph: max width of column channels */
41
41
  "graph-gutter-height-max": 90, /* graph: max height of row gutters */
42
42
  "graph-node-separation": 30, /* graph: layered layout node separation */
43
43
  "graph-rank-separation": 60, /* graph: layered layout rank separation */
44
- "graph-node-degree-max": 3, /* graph: max edges per side before growth */
44
+ "graph-node-degree-max": 3, /* graph: max edges/side before growth */
45
45
  "hub-channel-width-max": 340, /* hub: max width of column channels */
46
46
  "hub-channel-width-min": 240, /* hub: min width of column channels */
47
47
  "hub-node-gap": 20, /* hub: vertical gap of stacked nodes */
48
- "hub-node-degree-max": 3, /* hub: max edges per side before growth */
48
+ "hub-node-degree-max": 3, /* hub: max edges/side before growth */
49
49
  "grid-columns-max": 4, /* grid: max side-by-side tiles */
50
+ "grid-columns-min": 3, /* grid: min side-by-side tiles */
50
51
  "grid-gap-horizontal": 40, /* grid: horizontal gap between tiles */
51
- "grid-gap-vertical": 20 /* grid: vertical gap between tiles */
52
+ "grid-gap-vertical": 20, /* grid: vertical gap between tiles */
53
+ "grid-node-width-equal": true /* grid: force equal width of node boxes */
52
54
  };
53
55
  /* resolve a directly embedded configuration option into a CSS value:
54
56
  an explicitly configured value is hard-coded (stripped of the
@@ -73,8 +75,13 @@ export const parseDirectives = (input) => {
73
75
  description, as they would let it read arbitrary local WOFF2 files */
74
76
  if (key === "font-embed" || (key === "font-family" && val.endsWith(".woff2")))
75
77
  continue;
76
- if (typeof configDefaults[key] === "boolean")
78
+ if (typeof configDefaults[key] === "boolean") {
79
+ /* silently skip invalid boolean values, as directives are
80
+ lines of an untrusted input and never abort the rendering */
81
+ if (val !== "true" && val !== "false")
82
+ continue;
77
83
  partial[key] = val === "true";
84
+ }
78
85
  else if (typeof configDefaults[key] === "number") {
79
86
  /* silently skip invalid numeric values, as directives are
80
87
  lines of an untrusted input and never abort the rendering */
@@ -126,17 +126,17 @@ export const parse = (input) => {
126
126
  if (name !== null)
127
127
  node.name = name;
128
128
  for (const attr of attrs) {
129
- const k = node.attrs.findIndex((a) => a.key === attr.key);
130
- if (k === -1)
129
+ const idx = node.attrs.findIndex((a) => a.key === attr.key);
130
+ if (idx === -1)
131
131
  node.attrs.push(attr);
132
132
  else {
133
133
  /* a node has to stay in a single group, so reject any
134
134
  attempt to re-assign it to a different group */
135
- if (attr.key === "group" && node.attrs[k].val !== attr.val)
135
+ if (attr.key === "group" && node.attrs[idx].val !== attr.val)
136
136
  throw new Error(`node "${id}" cannot be a member of more than one group ` +
137
- `("${node.attrs[k].val}" vs "${attr.val}") ` +
137
+ `("${node.attrs[idx].val}" vs "${attr.val}") ` +
138
138
  `at line ${start.line}, column ${start.col}`);
139
- node.attrs[k] = attr;
139
+ node.attrs[idx] = attr;
140
140
  }
141
141
  }
142
142
  graph.nodes.set(id, node);
@@ -144,10 +144,12 @@ export const parse = (input) => {
144
144
  };
145
145
  /* parse the statements (one node/edge chain per line) */
146
146
  while (pos < tokens.length) {
147
+ /* skip the empty lines */
147
148
  if (tokens[pos].type === "newline") {
148
149
  pos++;
149
150
  continue;
150
151
  }
152
+ /* parse the chain of node references, connected by edge operators */
151
153
  let source = parseNodeRef();
152
154
  let t = peek();
153
155
  while (t !== null && t.type === "edge") {
@@ -157,6 +159,7 @@ export const parse = (input) => {
157
159
  source = target;
158
160
  t = peek();
159
161
  }
162
+ /* ensure the statement ends at the end of the line */
160
163
  if (t !== null && t.type !== "newline")
161
164
  bail("edge operator or end of line");
162
165
  }
@@ -21,7 +21,7 @@ export const textWrap = (text, size, maxWidth) => {
21
21
  const words = text.split(/\s+/).filter((word) => word !== "");
22
22
  if (maxWidth <= 0 || words.length <= 1 || textWidth(text, size) <= maxWidth)
23
23
  return [text];
24
- const lines = new Array();
24
+ const lines = [];
25
25
  let line = words[0];
26
26
  for (const word of words.slice(1)) {
27
27
  if (textWidth(`${line} ${word}`, size) > maxWidth) {
@@ -34,8 +34,10 @@ export const textWrap = (text, size, maxWidth) => {
34
34
  lines.push(line);
35
35
  return lines;
36
36
  };
37
- /* escape a string for use in XML/SVG content */
38
- export const escapeXML = (text) => text.replace(/&/g, "&amp;")
37
+ /* escape a string for use in XML/SVG content (control characters are
38
+ stripped, as XML forbids them even in their escaped form) */
39
+ export const escapeXML = (text) => text.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")
40
+ .replace(/&/g, "&amp;")
39
41
  .replace(/</g, "&lt;")
40
42
  .replace(/>/g, "&gt;")
41
43
  .replace(/"/g, "&quot;");
@@ -141,10 +141,11 @@ export const computeHops = (polys) => {
141
141
  return result;
142
142
  });
143
143
  };
144
+ /* the Manhattan length of an orthogonal polyline segment */
145
+ const len = (a, b) => Math.abs(b[0] - a[0]) + Math.abs(b[1] - a[1]);
144
146
  /* convert an edge polyline into an SVG path with rounded corners
145
147
  at the bends and semi-circular hops at the crossing points */
146
148
  export const pathOf = (poly, hop, rounding, hopRadius) => {
147
- const len = (a, b) => Math.abs(b[0] - a[0]) + Math.abs(b[1] - a[1]);
148
149
  const radius = (k) => {
149
150
  if (k <= 0 || k >= poly.length - 1)
150
151
  return 0;
@@ -214,7 +215,7 @@ export const pathOf = (poly, hop, rounding, hopRadius) => {
214
215
  };
215
216
  /* determine a point on a polyline at a given fraction of its length */
216
217
  export const pointAt = (poly, fraction) => {
217
- const lens = poly.slice(0, -1).map((p, k) => Math.abs(poly[k + 1][0] - p[0]) + Math.abs(poly[k + 1][1] - p[1]));
218
+ const lens = poly.slice(0, -1).map((p, k) => len(p, poly[k + 1]));
218
219
  const total = lens.reduce((a, b) => a + b, 0);
219
220
  let want = total * fraction;
220
221
  for (let k = 0; k < lens.length; k++) {
@@ -36,7 +36,7 @@ export const partitionGroups = (graph) => {
36
36
  `crosses groups "${sg}" and "${tg}"`);
37
37
  parts.get(sg).edges.push(edge);
38
38
  }
39
- return Array.from(parts.entries()).map(([name, graph]) => ({ name, graph }));
39
+ return Array.from(parts.entries()).map(([name, subgraph]) => ({ name, graph: subgraph }));
40
40
  };
41
41
  /* determine the bounding box of a laid out group (nodes and edges) */
42
42
  const boundsOf = (layout) => {
@@ -18,10 +18,11 @@ export const defaultStyleOf = (node) => isPrimary(node) ? {
18
18
  };
19
19
  /* the special "type" annotation (rendered above the node name) */
20
20
  export const typeOf = (node) => node.attrs.findLast((attr) => attr.key === "type")?.val;
21
- /* split the attributes rendered as text lines from the special
22
- "url", "type", "primary", and "group" attributes consumed by the renderers */
23
- export const attrsOfNode = (node) => node.attrs.filter((attr) => attr.key !== "url" && attr.key !== "type"
24
- && attr.key !== "primary" && attr.key !== "group");
21
+ /* the special attributes consumed by the renderers instead of being
22
+ rendered as node box text lines */
23
+ const ATTRS_SPECIAL = new Set(["url", "type", "primary", "group"]);
24
+ /* split the attributes rendered as text lines from the special ones */
25
+ export const attrsOfNode = (node) => node.attrs.filter((attr) => !ATTRS_SPECIAL.has(attr.key));
25
26
  /* the special "url" attribute (rendered as a hyperlink on the whole node
26
27
  box, restricted to relative URLs and safe schemes, as the generated
27
28
  SVG anchor would otherwise execute an injected "javascript:" URL) */
@@ -20,4 +20,4 @@ export interface Layout {
20
20
  styleOf?: (node: Node) => NodeStyle;
21
21
  groups?: GroupBox[];
22
22
  }
23
- export declare const renderSVG: (layout: Layout, config: Config, explicit: Partial<Config>) => string;
23
+ export declare const renderSVG: (layout: Layout, config: Config, explicit: Partial<Config>, seed: string) => string;
@@ -4,21 +4,27 @@
4
4
  ** Distributed under MIT license <https://spdx.org/licenses/MIT.html>
5
5
  */
6
6
  /* external dependencies */
7
- import { customAlphabet } from "nanoid";
7
+ import UUID from "pure-uuid";
8
8
  import { resolveFont, cssValueOf } from "./gradia-api-config.js";
9
9
  import { FS_NAME, FS_TYPE, FS_ATTR, FS_EDGE, FS_ARITY, FS_GROUP, textWidth, escapeXML } from "./gradia-api-render-base.js";
10
10
  import { linesOfNode, urlOf, defaultStyleOf, MIN_H, NAME_H, ATTR_H, ATTR_P, TYPE_H, TYPE_D } 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
13
13
  is resolved by the parser before the CSS is parsed, so quotes and
14
- backslashes have to be neutralized to prevent a CSS injection) */
15
- const escapeCSS = (text) => text.replace(/[\\"']/g, "\\$&").replace(/[\r\n]/g, " ");
16
- /* generate a per-document identifier prefix, as the SVG identifiers
17
- are DOM-global and would collide once multiple diagrams are embedded
18
- into the very same document (the alphabet is restricted to
19
- alphanumerics to stay a valid XML name and CSS/URL fragment) */
20
- const nanoid = customAlphabet("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", 12);
21
- const idPrefix = () => `gradia-${nanoid()}`;
14
+ backslashes have to be neutralized to prevent a CSS injection, and
15
+ the CSS newline characters LF, CR and FF have to be replaced, as
16
+ they would end the string literal as an invalid "bad string") */
17
+ const escapeCSS = (text) => text.replace(/[\\"']/g, "\\$&").replace(/[\r\n\f]/g, " ");
18
+ /* the UUID namespace of Gradia, under which the per-document
19
+ identifier prefixes are derived */
20
+ const NS_GRADIA = new UUID(5, "ns:URL", "https://github.com/rse/gradia").format();
21
+ /* derive a per-document identifier prefix from a seed, as the SVG
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()}`;
22
28
  /* the placement of a group tag in the top-left corner of its group box */
23
29
  const TAG_DX = 18; /* left offset of the group tag */
24
30
  const TAG_DY = 12; /* top offset of the group tag */
@@ -117,8 +123,59 @@ const viewBoxOf = (layout, boxes, margin) => {
117
123
  h: Math.ceil(maxY - minY + margin)
118
124
  };
119
125
  };
120
- /* render a laid out graph into an SVG document */
121
- export const renderSVG = (layout, config, explicit) => {
126
+ /* generate the SVG fragments for the labels of a single edge: its
127
+ optional name, placed near the middle of the route, and its optional
128
+ arity, placed near the arrow head (both dodging into a collision-free
129
+ position through the "claim" of the label placer) */
130
+ const renderEdgeLabels = (edge, poly, claim, font, color) => {
131
+ /* the halo rendered behind the edge labels for readability
132
+ (its color is emitted into the style attribute below, as only
133
+ there the CSS custom property lookup can be resolved) */
134
+ const halo = "stroke-width=\"4.5\" paint-order=\"stroke\" stroke-linejoin=\"round\"";
135
+ const parts = [];
136
+ if (edge.name !== undefined) {
137
+ const w = textWidth(edge.name, FS_EDGE);
138
+ const candidates = [];
139
+ for (const f of [0.50, 0.40, 0.60, 0.30, 0.70, 0.20, 0.80]) {
140
+ const p = pointAt(poly, f);
141
+ if (p.horizontal) {
142
+ candidates.push([p.x - w / 2, p.y - 23, p.x + w / 2, p.y - 3]);
143
+ candidates.push([p.x - w / 2, p.y + 3, p.x + w / 2, p.y + 23]);
144
+ }
145
+ else {
146
+ candidates.push([p.x + 5, p.y - 10, p.x + 5 + w, p.y + 10]);
147
+ candidates.push([p.x - 5 - w, p.y - 10, p.x - 5, p.y + 10]);
148
+ }
149
+ }
150
+ const box = claim(candidates);
151
+ parts.push(`<text x="${(box[0] + box[2]) / 2}" y="${box[3] - 3}" text-anchor="middle" ` +
152
+ `font-size="${FS_EDGE}" ` +
153
+ `style="font-family: ${font}; fill: ${color("color-edge-name")}; ` +
154
+ `stroke: ${color("color-edge-halo")}" ` +
155
+ `${halo}>${escapeXML(edge.name)}</text>`);
156
+ }
157
+ if (edge.arity !== undefined) {
158
+ const w = textWidth(edge.arity, FS_ARITY);
159
+ const p = pointAt(poly, 1.0);
160
+ const prev = pointAt(poly, 0.999);
161
+ const dx = Math.sign(p.x - prev.x) || 1;
162
+ const ax = p.x - dx * (24 + w / 2);
163
+ const box = claim([
164
+ [ax - w / 2, p.y - 17, ax + w / 2, p.y - 4],
165
+ [ax - w / 2, p.y + 4, ax + w / 2, p.y + 17],
166
+ [ax - w / 2 - dx * 14, p.y - 17, ax + w / 2 - dx * 14, p.y - 4]
167
+ ]);
168
+ parts.push(`<text x="${(box[0] + box[2]) / 2}" y="${box[3] - 3}" text-anchor="middle" ` +
169
+ `font-size="${FS_ARITY}" ` +
170
+ `style="font-family: ${font}; fill: ${color("color-edge-arity")}; ` +
171
+ `stroke: ${color("color-edge-halo")}" ` +
172
+ `${halo}>${escapeXML(edge.arity)}</text>`);
173
+ }
174
+ return parts;
175
+ };
176
+ /* render a laid out graph into an SVG document (the seed determines the
177
+ identifier prefix of the document, see "idPrefix" above) */
178
+ export const renderSVG = (layout, config, explicit, seed) => {
122
179
  const { nodes, edges, polys } = layout;
123
180
  const groups = layout.groups ?? [];
124
181
  const styleOf = layout.styleOf ?? defaultStyleOf;
@@ -128,7 +185,7 @@ export const renderSVG = (layout, config, explicit) => {
128
185
  CSS custom properties, falling back to the built-in defaults */
129
186
  const color = (key) => escapeXML(cssValueOf(explicit, key));
130
187
  /* derive the collision-free identifiers of this SVG document */
131
- const prefix = idPrefix();
188
+ const prefix = idPrefix(seed);
132
189
  const idArrow = `${prefix}-arrow`;
133
190
  /* resolve the configured font into the rendered font family stack */
134
191
  const { family, embed, weight } = resolveFont(config);
@@ -143,51 +200,10 @@ export const renderSVG = (layout, config, explicit) => {
143
200
  /* generate the SVG fragments for the edges (paths below, labels above) */
144
201
  const svgEdges = [];
145
202
  const svgLabels = [];
146
- /* the halo rendered behind the edge labels for readability
147
- (its color is emitted into the style attribute below, as only
148
- there the CSS custom property lookup can be resolved) */
149
- const halo = "stroke-width=\"4.5\" paint-order=\"stroke\" stroke-linejoin=\"round\"";
150
203
  edges.forEach((edge, i) => {
151
204
  svgEdges.push(`<path d="${pathOf(polys[i], hops[i], config["size-edge-corner-radius"], config["size-edge-hop-radius"])}" fill="none" ` +
152
205
  `style="stroke: ${color("color-edge-line")}" stroke-width="3.0" marker-end="url(#${idArrow})"/>`);
153
- if (edge.name !== undefined) {
154
- const w = textWidth(edge.name, FS_EDGE);
155
- const candidates = [];
156
- for (const f of [0.50, 0.40, 0.60, 0.30, 0.70, 0.20, 0.80]) {
157
- const p = pointAt(polys[i], f);
158
- if (p.horizontal) {
159
- candidates.push([p.x - w / 2, p.y - 23, p.x + w / 2, p.y - 3]);
160
- candidates.push([p.x - w / 2, p.y + 3, p.x + w / 2, p.y + 23]);
161
- }
162
- else {
163
- candidates.push([p.x + 5, p.y - 10, p.x + 5 + w, p.y + 10]);
164
- candidates.push([p.x - 5 - w, p.y - 10, p.x - 5, p.y + 10]);
165
- }
166
- }
167
- const box = claim(candidates);
168
- svgLabels.push(`<text x="${(box[0] + box[2]) / 2}" y="${box[3] - 3}" text-anchor="middle" ` +
169
- `font-size="${FS_EDGE}" ` +
170
- `style="font-family: ${font}; fill: ${color("color-edge-name")}; ` +
171
- `stroke: ${color("color-edge-halo")}" ` +
172
- `${halo}>${escapeXML(edge.name)}</text>`);
173
- }
174
- if (edge.arity !== undefined) {
175
- const w = textWidth(edge.arity, FS_ARITY);
176
- const p = pointAt(polys[i], 1.0);
177
- const prev = pointAt(polys[i], 0.999);
178
- const dx = Math.sign(p.x - prev.x) || 1;
179
- const ax = p.x - dx * (24 + w / 2);
180
- const box = claim([
181
- [ax - w / 2, p.y - 17, ax + w / 2, p.y - 4],
182
- [ax - w / 2, p.y + 4, ax + w / 2, p.y + 17],
183
- [ax - w / 2 - dx * 14, p.y - 17, ax + w / 2 - dx * 14, p.y - 4]
184
- ]);
185
- svgLabels.push(`<text x="${(box[0] + box[2]) / 2}" y="${box[3] - 3}" text-anchor="middle" ` +
186
- `font-size="${FS_ARITY}" ` +
187
- `style="font-family: ${font}; fill: ${color("color-edge-arity")}; ` +
188
- `stroke: ${color("color-edge-halo")}" ` +
189
- `${halo}>${escapeXML(edge.arity)}</text>`);
190
- }
206
+ svgLabels.push(...renderEdgeLabels(edge, polys[i], claim, font, color));
191
207
  });
192
208
  /* generate the SVG fragments for the node boxes */
193
209
  const svgNodes = nodes.flatMap((node) => renderNode(node, layout, styleOf(node), font, color, config));
@@ -8,8 +8,15 @@ import { DagreLayout } from "@antv/layout";
8
8
  import { measureNodes } from "./gradia-api-render-node.js";
9
9
  import { PORT_SEP, simplifyPoly, assignPorts, assignTracks } from "./gradia-api-render-edge.js";
10
10
  /* rendering geometry constants */
11
- const CLUSTX = 26; /* max X distance within a grid column */
12
- const CLUSTY = 72; /* max Y distance within a grid row */
11
+ const CLUSTX = 26; /* max X distance within a grid column */
12
+ const CLUSTY = 72; /* max Y distance within a grid row */
13
+ const CHAN_W0 = 24; /* width of an edge-less inter-column channel */
14
+ const CHAN_W1 = 28; /* width of a one-edge inter-column channel */
15
+ const CHAN_PAD = 16; /* cross-axis padding inside a channel */
16
+ const GUT_H0 = 20; /* height of an edge-less inter-row gutter */
17
+ const GUT_H1 = 24; /* height of a one-edge inter-row gutter */
18
+ const GUT_PAD = 12; /* cross-axis padding inside a gutter */
19
+ const LOOP_GAP = 24; /* detour of a self-loop right of its node box */
13
20
  /* snap raw node positions onto a discrete grid by clustering the
14
21
  distinct X coordinates into columns and Y coordinates into rows */
15
22
  const snapToGrid = (nodes, rawX, rawY) => {
@@ -23,18 +30,19 @@ const snapToGrid = (nodes, rawX, rawY) => {
23
30
  };
24
31
  const colReps = cluster(nodes.map((node) => rawX.get(node.id)), CLUSTX);
25
32
  const rowReps = cluster(nodes.map((node) => rawY.get(node.id)), CLUSTY);
26
- const indexOf = (reps, v) => {
33
+ const nearestIndexOf = (reps, v) => {
27
34
  let best = 0;
28
35
  for (let i = 1; i < reps.length; i++)
29
36
  if (Math.abs(reps[i] - v) < Math.abs(reps[best] - v))
30
37
  best = i;
31
38
  return best;
32
39
  };
40
+ /* assign every node to its nearest column and row */
33
41
  const col = new Map();
34
42
  const row = new Map();
35
43
  for (const node of nodes) {
36
- col.set(node.id, indexOf(colReps, rawX.get(node.id)));
37
- row.set(node.id, indexOf(rowReps, rawY.get(node.id)));
44
+ col.set(node.id, nearestIndexOf(colReps, rawX.get(node.id)));
45
+ row.set(node.id, nearestIndexOf(rowReps, rawY.get(node.id)));
38
46
  }
39
47
  return { col, row, ncols: colReps.length, nrows: rowReps.length };
40
48
  };
@@ -120,6 +128,7 @@ const planRoutes = (edges, col, row) => {
120
128
  }
121
129
  return { chans, guts };
122
130
  });
131
+ /* count the edges occupying each channel and gutter */
123
132
  const chanCnt = new Map();
124
133
  const gutCnt = new Map();
125
134
  for (const plan of plans) {
@@ -143,11 +152,11 @@ const computeGrid = (nodes, col, row, boxW, boxH, ncols, nrows, chanCnt, gutCnt,
143
152
  /* size the channels and gutters by their actual edge usage */
144
153
  const chanW = Array.from({ length: ncols }, (_, c) => {
145
154
  const cnt = chanCnt.get(c) ?? 0;
146
- return cnt === 0 ? 24 : Math.min(config["graph-channel-width-max"], 28 + (cnt - 1) * config["size-edge-track-gap"]);
155
+ return cnt === 0 ? CHAN_W0 : Math.min(config["graph-channel-width-max"], CHAN_W1 + (cnt - 1) * config["size-edge-track-gap"]);
147
156
  });
148
157
  const gutH = Array.from({ length: nrows }, (_, g) => {
149
158
  const cnt = gutCnt.get(g) ?? 0;
150
- return cnt === 0 ? 20 : Math.min(config["graph-gutter-height-max"], 24 + (cnt - 1) * config["size-edge-track-gap"]);
159
+ return cnt === 0 ? GUT_H0 : Math.min(config["graph-gutter-height-max"], GUT_H1 + (cnt - 1) * config["size-edge-track-gap"]);
151
160
  });
152
161
  /* derive the column and row center positions */
153
162
  const colCX = [];
@@ -192,7 +201,7 @@ const assignTrackCoords = (edges, plans, col, portPos, grid, config) => {
192
201
  });
193
202
  const chanOff = new Map();
194
203
  for (const [c, users] of chanUsers)
195
- for (const [edge, off] of assignTracks(users, chanW[c], 16, config["size-edge-track-gap"]))
204
+ for (const [edge, off] of assignTracks(users, chanW[c], CHAN_PAD, config["size-edge-track-gap"]))
196
205
  chanOff.set(`${c}:${edge}`, off);
197
206
  const chanX = (c, edge) => colCX[c] + colWidth[c] / 2 + chanW[c] / 2 +
198
207
  chanOff.get(`${c}:${edge}`);
@@ -212,7 +221,7 @@ const assignTrackCoords = (edges, plans, col, portPos, grid, config) => {
212
221
  });
213
222
  const gutOff = new Map();
214
223
  for (const [g, users] of gutUsers)
215
- for (const [edge, off] of assignTracks(users, gutH[g], 12, config["size-edge-track-gap"]))
224
+ for (const [edge, off] of assignTracks(users, gutH[g], GUT_PAD, config["size-edge-track-gap"]))
216
225
  gutOff.set(`${g}:${edge}`, off);
217
226
  const gutY = (g, edge) => gutBase(g) + gutOff.get(`${g}:${edge}`);
218
227
  return { chanX, gutY };
@@ -231,7 +240,8 @@ const routePolys = (edges, plans, col, portPos, boxW, cx, chanX, gutY, config) =
231
240
  if (edge.source === edge.target) {
232
241
  const k = loopUse.get(edge.source) ?? 0;
233
242
  loopUse.set(edge.source, k + 1);
234
- const ox = cx(edge.source) + boxW.get(edge.source) / 2 + 24 + k * config["size-edge-track-gap"];
243
+ const ox = cx(edge.source) + boxW.get(edge.source) / 2 + LOOP_GAP +
244
+ k * config["size-edge-track-gap"];
235
245
  pts = [[sp.x, sp.y], [ox, sp.y], [ox, tp.y], [tp.x, tp.y]];
236
246
  }
237
247
  else if (chans.length === 1) {
@@ -12,23 +12,30 @@ export const render = async (graph, config) => {
12
12
  const gapH = config["grid-gap-horizontal"];
13
13
  const gapV = config["grid-gap-vertical"];
14
14
  const maxCols = Math.max(Math.floor(config["grid-columns-max"]), 1);
15
+ const minCols = Math.max(Math.floor(config["grid-columns-min"]), 1);
15
16
  /* ensure the graph is completely edge-less */
16
17
  if (graph.edges.length > 0) {
17
18
  const edge = graph.edges[0];
18
19
  throw new Error("diagram type \"grid\" does not support edges " +
19
20
  `(found ${graph.edges.length}, first is "${edge.source}" --> "${edge.target}")`);
20
21
  }
21
- /* determine node box sizes (keeping the individual widths)
22
- and unify only their heights into a single tile height */
22
+ /* determine node box sizes and unify their heights into a single
23
+ tile height and, if configured, their widths into a single tile width */
23
24
  const { boxW, boxH, contentH } = measureNodes(nodes, config, () => config["size-node-height-scale"] / 2);
24
25
  const tileH = nodes.reduce((a, node) => Math.max(a, boxH.get(node.id)), 0);
25
- for (const node of nodes)
26
+ const tileW = nodes.reduce((a, node) => Math.max(a, boxW.get(node.id)), 0);
27
+ for (const node of nodes) {
26
28
  boxH.set(node.id, tileH);
29
+ if (config["grid-node-width-equal"])
30
+ boxW.set(node.id, tileW);
31
+ }
27
32
  /* place the nodes in declaration order onto a roughly square,
28
- row-major grid (capped at the configured maximum of columns, so
29
- larger graphs grow only in height), with each column as wide as
30
- its widest tile and each tile left-aligned within its column */
31
- const cols = Math.max(Math.min(Math.ceil(Math.sqrt(nodes.length)), maxCols), 1);
33
+ row-major grid (raised to the configured minimum of columns, so
34
+ few nodes still share a row, and capped at the configured maximum
35
+ of columns and the node count, so larger graphs grow only in
36
+ height), with each column as wide as its widest tile and each
37
+ tile left-aligned within its column */
38
+ const cols = Math.max(Math.min(Math.max(Math.ceil(Math.sqrt(nodes.length)), minCols), maxCols, nodes.length), 1);
32
39
  const colWidth = Array.from({ length: cols }, () => 0);
33
40
  nodes.forEach((node, i) => {
34
41
  colWidth[i % cols] = Math.max(colWidth[i % cols], boxW.get(node.id));