@rse/gradia 1.2.3 → 1.2.4

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 grid-columns-max 4
331
+ color-edge-name #333333 grid-columns-min 3
332
+ color-edge-arity #333333 grid-gap-horizontal 40
333
+ color-edge-halo #ffffff grid-gap-vertical 20
334
+ size-canvas-margin 40 grid-node-width-equal true
335
+ size-node-width-min 220 grid-node-height-equal true
336
+ size-node-width-max 0
332
337
  ```
333
338
 
334
339
  The `size-*`, `group-*`, `container-*`, `graph-*`, `hub-*`, and
@@ -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;
@@ -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",
@@ -160,7 +163,7 @@ export const resolveFont = (config) => {
160
163
  return { family };
161
164
  }
162
165
  else if (config["font-embed"])
163
- throw new Error("option \"font-embed\" requires option \"font-family\" to be a built-in font " +
166
+ throw new Error("configuration option \"font-embed\" requires configuration option \"font-family\" to be a built-in font " +
164
167
  `family (${Object.keys(fontsBuiltIn).map((name) => `"${name}"`).join(", ")}) ` +
165
168
  "or the path to a WOFF2 file");
166
169
  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, "&")
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();
@@ -66,12 +66,12 @@ const labelPlacer = (layout, hops, hopRadius) => {
66
66
  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
67
  const claim = (candidates) => {
68
68
  let box = candidates[0];
69
- let worst = Infinity;
69
+ let lowest = Infinity;
70
70
  for (const c of candidates) {
71
71
  const score = collisions(occupied, c) * (lines.length + 1) + collisions(lines, c);
72
- if (score < worst) {
72
+ if (score < lowest) {
73
73
  box = c;
74
- worst = score;
74
+ lowest = score;
75
75
  }
76
76
  if (score === 0)
77
77
  break;
@@ -26,6 +26,7 @@ const NUDGE_PASSES = 3; /* max number of node nudging passes
26
26
  /* snap raw node positions onto a discrete grid by clustering the
27
27
  distinct X coordinates into columns and Y coordinates into rows */
28
28
  const snapToGrid = (nodes, rawX, rawY) => {
29
+ /* cluster the distinct values into representatives more than a threshold apart */
29
30
  const cluster = (values, threshold) => {
30
31
  const sorted = Array.from(new Set(values)).sort((a, b) => a - b);
31
32
  const reps = [];
@@ -36,6 +37,7 @@ const snapToGrid = (nodes, rawX, rawY) => {
36
37
  };
37
38
  const colReps = cluster(nodes.map((node) => rawX.get(node.id)), CLUSTX);
38
39
  const rowReps = cluster(nodes.map((node) => rawY.get(node.id)), CLUSTY);
40
+ /* determine the index of the representative nearest to a value */
39
41
  const nearestIndexOf = (reps, v) => {
40
42
  let best = 0;
41
43
  for (let i = 1; i < reps.length; i++)
@@ -304,7 +306,7 @@ const planRoutes = (edges, col, row, sides, gates) => {
304
306
  chanNeed(plan.chans[0], textWidth(name, FS_EDGE) + 2 * CHAN_LBL);
305
307
  else if (name !== undefined && plan.guts.length > 0)
306
308
  gutLbl.set(plan.guts[0], (gutLbl.get(plan.guts[0]) ?? 0) + 1);
307
- if (arity !== undefined && chans.length > 0)
309
+ if (arity !== undefined)
308
310
  chanNeed(chans[chans.length - 1], ARITY_OFF + textWidth(arity, FS_ARITY) + CHAN_LBL);
309
311
  });
310
312
  /* count the self-loops of every node, as their detours stack up
@@ -375,8 +377,6 @@ const assignTrackCoords = (edges, plans, portPos, grid, config) => {
375
377
  const chanUsers = new Map();
376
378
  edges.forEach((edge, i) => {
377
379
  const { chans, guts, flags, gate } = plans[i];
378
- if (chans.length === 0)
379
- return;
380
380
  const sp = portPos.get(`${i}:s`);
381
381
  const tp = portPos.get(`${i}:t`);
382
382
  if (edge.source === edge.target)
@@ -433,7 +433,7 @@ const routePolys = (edges, plans, portPos, boxH, cy, chanX, gutY) => {
433
433
  return edges.map((edge, i) => {
434
434
  const sp = portPos.get(`${i}:s`);
435
435
  const tp = portPos.get(`${i}:t`);
436
- const { chans, guts, flags } = plans[i];
436
+ const { chans, guts, flags, gate } = plans[i];
437
437
  let pts;
438
438
  if (edge.source === edge.target) {
439
439
  /* route the self-loop counter-clockwise around the top-right
@@ -460,9 +460,9 @@ const routePolys = (edges, plans, portPos, boxH, cy, chanX, gutY) => {
460
460
  const ch1 = chanX(chans[0], i);
461
461
  const ch2 = chanX(chans[1], i);
462
462
  const gy = gutY(guts[0], i);
463
- if (plans[i].gate === "t")
463
+ if (gate === "t")
464
464
  pts = [[sp.x, sp.y], [ch1, sp.y], [ch1, gy], [tp.x, gy]];
465
- else if (plans[i].gate === "s")
465
+ else if (gate === "s")
466
466
  pts = [[sp.x, gy], [ch2, gy], [ch2, tp.y], [tp.x, tp.y]];
467
467
  else
468
468
  pts = [[sp.x, sp.y], [ch1, sp.y], [ch1, gy], [ch2, gy], [ch2, tp.y], [tp.x, tp.y]];
@@ -499,11 +499,13 @@ const routeEdges = (edges, plans, sides, boxW, boxH, cx, cy, grid, config, level
499
499
  rises above the box of its node), and only as long as its own
500
500
  gutter-routed edges keep approaching it from their gutter side */
501
501
  const nudgeNodes = (nodes, edges, plans, col, row, boxH, grid, nrows, dy, route) => {
502
+ /* whether neither a gutter track nor a self-loop detour crosses a column within a gutter */
502
503
  const gutterFree = (g, c) => !plans.some((plan) => plan.guts[0] === g
503
504
  && Math.min(...plan.chans) < c
504
505
  && c <= Math.max(...plan.chans) + (plan.gate === "t" ? 1 : 0))
505
506
  && !edges.some((edge) => edge.source === edge.target
506
507
  && row.get(edge.source) === g + 1 && col.get(edge.source) === c);
508
+ /* the vertical shift range of a node: its row slack plus the room of its free adjacent gutters */
507
509
  const shiftRange = (id) => {
508
510
  const r = row.get(id);
509
511
  const c = col.get(id);
@@ -512,6 +514,7 @@ const nudgeNodes = (nodes, edges, plans, col, row, boxH, grid, nrows, dy, route)
512
514
  const down = r < nrows - 1 && gutterFree(r, c) ? grid.gutH[r] - GUT_H0 : 0;
513
515
  return [-(slack + up), slack + down];
514
516
  };
517
+ /* whether the gutter-routed edges of a node still approach it from their gutter side */
515
518
  const approachesSanely = (id, routing) => edges.every((edge, i) => {
516
519
  const role = edge.source === id ? "s" : edge.target === id ? "t" : null;
517
520
  if (role === null || plans[i].guts.length === 0)
@@ -520,6 +523,7 @@ const nudgeNodes = (nodes, edges, plans, col, row, boxH, grid, nrows, dy, route)
520
523
  const gap = routing.portPos.get(`${i}:${role}`).y - routing.gutY(g, i);
521
524
  return row.get(id) > g ? gap >= NUDGE_GAP : gap <= -NUDGE_GAP;
522
525
  });
526
+ /* rank the routings: fewer crossings first, then fewer jogged direct same-row edges */
523
527
  const direct = edges.map((edge, i) => plans[i].chans.length === 1
524
528
  && !plans[i].flags[0].hairpin
525
529
  && row.get(edge.source) === row.get(edge.target));
@@ -10,6 +10,7 @@ import { measureNodes, orderOf } from "./gradia-api-render-node.js";
10
10
  declaration order, or in the order of their "order" attributes
11
11
  (the tiles without one trailing) */
12
12
  export const render = async (graph, config, level = {}) => {
13
+ /* determine the tiles in their rendering order */
13
14
  const nodes = Array.from(graph.nodes.values())
14
15
  .sort((a, b) => (orderOf(a) ?? Infinity) - (orderOf(b) ?? Infinity) || 0);
15
16
  /* resolve the configurable rendering geometry */
@@ -39,7 +40,7 @@ export const render = async (graph, config, level = {}) => {
39
40
  if (config["grid-node-width-equal"])
40
41
  boxW.set(node.id, tileW);
41
42
  }
42
- /* place the nodes in declaration order onto a roughly square,
43
+ /* place the nodes in their rendering order onto a roughly square,
43
44
  row-major grid (raised to the configured minimum of columns, so
44
45
  few nodes still share a row, and capped at the configured maximum
45
46
  of columns and the node count, so larger graphs grow only in
@@ -6,7 +6,8 @@
6
6
  import { isPrimary, measureNodes, defaultStyleOf } from "./gradia-api-render-node.js";
7
7
  import { simplifyPoly, assignPorts, assignTracks } from "./gradia-api-render-edge.js";
8
8
  /* the separator between a node id and its placement suffix,
9
- distinguishing the two clones of a twice-placed node */
9
+ distinguishing the two clones of a twice-placed node and the
10
+ output clone of a self-referencing primary node */
10
11
  const CLONE = "\u0000";
11
12
  /* rendering geometry constants */
12
13
  const CHAN_W1 = 28; /* width of a one-edge inter-column channel */
@@ -18,8 +19,10 @@ const CHAN_PAD = 16; /* cross-axis padding inside a channel */
18
19
  (edges originate from the primary); a node referenced in both
19
20
  directions is placed twice, once in the input column and once in
20
21
  the output column, with its edges rewritten to attach to the
21
- corresponding placement */
22
+ corresponding placement. A self-loop on the primary is unrolled
23
+ likewise, onto a clone of the primary in the output column */
22
24
  const classifyTopology = (graph) => {
25
+ /* determine the primary node and the ids of its input and output nodes */
23
26
  const declared = Array.from(graph.nodes.values());
24
27
  for (const node of declared)
25
28
  if (node.id.includes(CLONE))
@@ -30,9 +33,10 @@ const classifyTopology = (graph) => {
30
33
  const center = primaries[0];
31
34
  const inSet = new Set();
32
35
  const outSet = new Set();
36
+ let selfLoop = false;
33
37
  for (const edge of graph.edges) {
34
38
  if (edge.source === center.id && edge.target === center.id)
35
- throw new Error(`self-loop on primary node "${center.id}" not supported`);
39
+ selfLoop = true;
36
40
  else if (edge.target === center.id)
37
41
  inSet.add(edge.source);
38
42
  else if (edge.source === center.id)
@@ -61,9 +65,16 @@ const classifyTopology = (graph) => {
61
65
  else
62
66
  outputs.push(node);
63
67
  }
68
+ /* place the clone of a self-referencing primary node (stripped of
69
+ its "primary" annotation) on top of the output column */
70
+ if (selfLoop)
71
+ outputs.unshift({ ...center, id: center.id + CLONE + "self",
72
+ attrs: center.attrs.filter((attr) => attr.key !== "primary") });
64
73
  /* rewrite the edges of the twice-placed nodes onto their clones */
65
74
  const edges = graph.edges.map((edge) => {
66
- if (edge.target === center.id && dual.has(edge.source))
75
+ if (edge.source === center.id && edge.target === center.id)
76
+ return { ...edge, target: edge.target + CLONE + "self" };
77
+ else if (edge.target === center.id && dual.has(edge.source))
67
78
  return { ...edge, source: edge.source + CLONE + "in" };
68
79
  else if (edge.source === center.id && dual.has(edge.target))
69
80
  return { ...edge, target: edge.target + CLONE + "out" };
@@ -195,12 +206,19 @@ export const render = async (graph, config, level = {}) => {
195
206
  });
196
207
  /* hand over the laid out graph for SVG rendering (the output copy
197
208
  of a twice-placed node is rendered as a dashed grey "ghost" box,
198
- all other boxes get the default primary/regular coloring) */
209
+ the output clone of a self-referencing primary node as a dashed,
210
+ darker grey "self" box, and all other boxes get the default
211
+ primary/regular coloring) */
199
212
  const styleOf = (node) => node.id.endsWith(CLONE + "out") ? {
200
213
  fill: "color-node-ghost-box",
201
214
  stroke: "color-node-ghost-border",
202
215
  text: "color-node-ghost-name",
203
216
  dash: "10 6"
217
+ } : node.id.endsWith(CLONE + "self") ? {
218
+ fill: "color-node-self-box",
219
+ stroke: "color-node-self-border",
220
+ text: "color-node-self-name",
221
+ dash: "10 6"
204
222
  } : defaultStyleOf(node);
205
223
  return { nodes, edges, cx, cy, boxW, boxH, contentH, polys, styleOf };
206
224
  };
package/dst/gradia-api.js CHANGED
@@ -122,7 +122,7 @@ export class Gradia {
122
122
  static async render(spec, options = {}) {
123
123
  const graph = Gradia.parse(spec);
124
124
  const type = options.type ?? parseTypeDirective(spec);
125
- const config = { ...parseDirectives(spec), ...(options.config ?? {}) };
125
+ const config = { ...parseDirectives(spec), ...validateConfig(options.config ?? {}, { trusted: true }) };
126
126
  return Gradia.generate(graph, { ...options, type, config });
127
127
  }
128
128
  }