@rse/gradia 1.1.9 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,16 +18,26 @@ 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
+ /* the special "parent" annotation (nesting the node into a container node) */
22
+ export const parentOf = (node) => node.attrs.findLast((attr) => attr.key === "parent")?.val;
23
+ /* the special "container" annotation (the diagram type laying out the
24
+ members of the node, marking even a member-less node as a container) */
25
+ export const containerTypeOf = (node) => node.attrs.findLast((attr) => attr.key === "container")?.val;
26
+ /* container box head geometry (the head holds the name tag and the
27
+ optional type line above it) */
28
+ export const HEAD_H = 34; /* height of the container box head */
29
+ export const HEAD_T = 20; /* extra head height of a type line */
30
+ export const containerHead = (node) => HEAD_H + (typeOf(node) !== undefined ? HEAD_T : 0);
21
31
  /* the special attributes consumed by the renderers instead of being
22
32
  rendered as node box text lines */
23
- const ATTRS_SPECIAL = new Set(["url", "type", "primary", "group"]);
33
+ const ATTRS_SPECIAL = new Set(["url", "type", "primary", "group", "parent", "container"]);
24
34
  /* split the attributes rendered as text lines from the special ones */
25
35
  export const attrsOfNode = (node) => node.attrs.filter((attr) => !ATTRS_SPECIAL.has(attr.key));
26
36
  /* the special "url" attribute (rendered as a hyperlink on the whole node
27
37
  box, restricted to relative URLs and safe schemes, as the generated
28
38
  SVG anchor would otherwise execute an injected "javascript:" URL) */
29
39
  const URL_SCHEME = /^\s*([A-Za-z][A-Za-z0-9+.-]*):/;
30
- const URL_SAFE = ["http", "https", "mailto"];
40
+ const URL_SAFE = new Set(["http", "https", "mailto"]);
31
41
  export const urlOf = (node) => {
32
42
  const raw = node.attrs.findLast((attr) => attr.key === "url")?.val;
33
43
  if (raw === undefined)
@@ -36,7 +46,7 @@ export const urlOf = (node) => {
36
46
  resolving the URL and they would otherwise hide an unsafe scheme */
37
47
  const url = raw.replace(/[\u0000-\u001F\u007F]/g, "");
38
48
  const scheme = URL_SCHEME.exec(url);
39
- if (scheme !== null && !URL_SAFE.includes(scheme[1].toLowerCase()))
49
+ if (scheme !== null && !URL_SAFE.has(scheme[1].toLowerCase()))
40
50
  return undefined;
41
51
  return url;
42
52
  };
@@ -63,27 +73,31 @@ export const linesOfNode = (node, config) => {
63
73
  };
64
74
  };
65
75
  /* determine node box sizes from their textual content
66
- (boxes are scaled up in height by a per-node factor to
67
- give the edges more attachment room) */
68
- export const measureNodes = (nodes, config, scaleOf) => {
76
+ (boxes are scaled up in height by a per-node factor, but never
77
+ below the content height, to give the edges more attachment
78
+ room), except for the nodes
79
+ of a fixed size (the container placeholders and gate nodes
80
+ of a containment level), which are taken as given */
81
+ export const measureNodes = (nodes, config, scaleOf, fixed) => {
69
82
  /* the resulting node box dimensions */
70
83
  const boxW = new Map();
71
84
  const boxH = new Map();
72
85
  const contentH = new Map();
73
86
  /* measure the text content of every node */
74
87
  for (const node of nodes) {
88
+ const size = fixed?.get(node.id);
89
+ if (size !== undefined) {
90
+ boxW.set(node.id, size.w);
91
+ boxH.set(node.id, size.h);
92
+ contentH.set(node.id, size.h);
93
+ continue;
94
+ }
75
95
  const lines = linesOfNode(node, config);
76
- let w = 0;
77
- for (const line of lines.name)
78
- w = Math.max(w, textWidth(line, FS_NAME));
79
- for (const line of lines.type)
80
- w = Math.max(w, textWidth(line, FS_TYPE));
81
- for (const line of lines.attrs)
82
- w = Math.max(w, textWidth(line, FS_ATTR));
96
+ const w = Math.max(0, ...lines.name.map((line) => textWidth(line, FS_NAME)), ...lines.type.map((line) => textWidth(line, FS_TYPE)), ...lines.attrs.map((line) => textWidth(line, FS_ATTR)));
83
97
  const h = MIN_H + (lines.name.length - 1) * NAME_H + lines.type.length * TYPE_H +
84
98
  (lines.attrs.length > 0 ? ATTR_P + (lines.attrs.length - 1) * ATTR_H + ATTR_B : 0);
85
99
  boxW.set(node.id, Math.max(config["size-node-width-min"], Math.ceil(w) + PAD_W * 2));
86
- boxH.set(node.id, Math.ceil(h * scaleOf(node)));
100
+ boxH.set(node.id, Math.max(h, Math.ceil(h * scaleOf(node))));
87
101
  contentH.set(node.id, h);
88
102
  }
89
103
  return { boxW, boxH, contentH };
@@ -1,23 +1,3 @@
1
- import { Node, Edge } from "./gradia-api-model.js";
2
1
  import { Config } from "./gradia-api-config.js";
3
- import { Poly, NodeStyle } from "./gradia-api-render-base.js";
4
- export interface GroupBox {
5
- name: string;
6
- x: number;
7
- y: number;
8
- w: number;
9
- h: number;
10
- }
11
- export interface Layout {
12
- nodes: Node[];
13
- edges: Edge[];
14
- cx: (id: string) => number;
15
- cy: (id: string) => number;
16
- boxW: Map<string, number>;
17
- boxH: Map<string, number>;
18
- contentH: Map<string, number>;
19
- polys: Poly[];
20
- styleOf?: (node: Node) => NodeStyle;
21
- groups?: GroupBox[];
22
- }
2
+ import { Layout } from "./gradia-api-render-base.js";
23
3
  export declare const renderSVG: (layout: Layout, config: Config, explicit: Partial<Config>, seed: string) => string;
@@ -7,7 +7,7 @@
7
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, ARITY_OFF, textWidth, escapeXML } from "./gradia-api-render-base.js";
10
- import { linesOfNode, urlOf, defaultStyleOf, MIN_H, NAME_H, ATTR_H, ATTR_P, TYPE_H, TYPE_D } from "./gradia-api-render-node.js";
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
13
13
  is resolved by the parser before the CSS is parsed, so quotes and
@@ -28,24 +28,54 @@ const idPrefix = (seed) => `gradia-${new UUID(5, NS_GRADIA, seed).format("b16").
28
28
  /* the placement of a group tag in the top-left corner of its group box */
29
29
  const TAG_DX = 18; /* left offset of the group tag */
30
30
  const TAG_DY = 12; /* top offset of the group tag */
31
+ /* the half width of the box an edge line segment occupies */
32
+ const LINE_PAD = 2;
31
33
  /* track occupied areas (node boxes and already placed labels) to
32
- let subsequent labels dodge into a collision-free position (the
33
- returned "occupied" array grows with every claimed label box and
34
- hence also serves as the box input of the overall bounding box) */
35
- const labelPlacer = (layout) => {
34
+ let subsequent labels dodge into a collision-free position, and the
35
+ edge lines (their segments and crossing hops) to let the labels at
36
+ least prefer the position covering the fewest lines (the returned
37
+ "occupied" array grows with every claimed label box and hence also
38
+ serves as the box input of the overall bounding box) */
39
+ const labelPlacer = (layout, hops, hopRadius) => {
36
40
  const { nodes, cx, cy, boxW, boxH } = layout;
37
41
  const occupied = nodes.map((node) => [
38
42
  cx(node.id) - boxW.get(node.id) / 2, cy(node.id) - boxH.get(node.id) / 2,
39
43
  cx(node.id) + boxW.get(node.id) / 2, cy(node.id) + boxH.get(node.id) / 2
40
44
  ]);
41
- /* let the labels also dodge the group tags in the group box corners */
45
+ /* let the labels also dodge the group and container tags in the box corners */
42
46
  for (const group of layout.groups ?? [])
43
47
  occupied.push([group.x + TAG_DX, group.y + TAG_DY,
44
48
  group.x + TAG_DX + textWidth(group.name, FS_GROUP), group.y + TAG_DY + FS_GROUP * 1.2]);
45
- /* claim the first collision-free candidate box */
46
- const collides = (box) => occupied.some((o) => box[0] < o[2] && box[2] > o[0] && box[1] < o[3] && box[3] > o[1]);
49
+ for (const c of layout.containers ?? [])
50
+ 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) => {
55
+ for (let k = 0; k < poly.length - 1; k++) {
56
+ 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,
58
+ Math.max(a[0], b[0]) + LINE_PAD, Math.max(a[1], b[1]) + LINE_PAD]);
59
+ for (const hx of hops[i].get(k) ?? [])
60
+ lines.push([hx - hopRadius, a[1] - hopRadius, hx + hopRadius, a[1]]);
61
+ }
62
+ });
63
+ /* claim the candidate box colliding with the fewest occupied
64
+ areas and, among those, with the fewest edge lines (earlier
65
+ candidates win ties, so the first collision-free one is taken) */
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;
47
67
  const claim = (candidates) => {
48
- const box = candidates.find((c) => !collides(c)) ?? candidates[0];
68
+ let box = candidates[0];
69
+ let worst = Infinity;
70
+ for (const c of candidates) {
71
+ const score = collisions(occupied, c) * (lines.length + 1) + collisions(lines, c);
72
+ if (score < worst) {
73
+ box = c;
74
+ worst = score;
75
+ }
76
+ if (score === 0)
77
+ break;
78
+ }
49
79
  occupied.push(box);
50
80
  return box;
51
81
  };
@@ -189,11 +219,46 @@ const renderEdgeLabels = (edge, poly, claim, font, color) => {
189
219
  }
190
220
  return parts;
191
221
  };
222
+ /* generate the SVG fragments for a single group box and its tag
223
+ in the top-left corner */
224
+ const renderGroup = (group, font, color) => [
225
+ `<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"/>`,
228
+ `<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>`
231
+ ];
232
+ /* generate the SVG fragments for a single container box and its tag
233
+ in the top-left corner, with the optional type line above the name
234
+ (a container with a "url" attribute becomes a hyperlink covering
235
+ the whole box) */
236
+ const renderContainer = (c, font, color) => {
237
+ const type = typeOf(c.node);
238
+ const url = urlOf(c.node);
239
+ const parts = [
240
+ `<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"/>`,
243
+ ...(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>`
247
+ ] : []),
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>`
251
+ ];
252
+ if (url === undefined)
253
+ return parts;
254
+ return [`<a href="${escapeXML(url)}" xlink:href="${escapeXML(url)}">`, ...parts, "</a>"];
255
+ };
192
256
  /* render a laid out graph into an SVG document (the seed determines the
193
257
  identifier prefix of the document, see "idPrefix" above) */
194
258
  export const renderSVG = (layout, config, explicit, seed) => {
195
259
  const { nodes, edges, polys } = layout;
196
260
  const groups = layout.groups ?? [];
261
+ const containers = layout.containers ?? [];
197
262
  const styleOf = layout.styleOf ?? defaultStyleOf;
198
263
  /* resolve the directly embedded configuration options into CSS
199
264
  values: explicitly configured values are hard-coded, while all
@@ -212,7 +277,7 @@ export const renderSVG = (layout, config, explicit, seed) => {
212
277
  /* detect the edge crossings requiring rendered hops */
213
278
  const hops = computeHops(polys);
214
279
  /* prepare the collision-free placement of the edge labels */
215
- const { claim, occupied } = labelPlacer(layout);
280
+ const { claim, occupied } = labelPlacer(layout, hops, config["size-edge-hop-radius"]);
216
281
  /* generate the SVG fragments for the edges (paths below, labels above) */
217
282
  const svgEdges = [];
218
283
  const svgLabels = [];
@@ -224,18 +289,14 @@ export const renderSVG = (layout, config, explicit, seed) => {
224
289
  /* generate the SVG fragments for the node boxes */
225
290
  const svgNodes = nodes.flatMap((node) => renderNode(node, layout, styleOf(node), font, color, config));
226
291
  /* generate the SVG fragments for the group boxes (drawn below
227
- everything else) and their tags in the top-left corners */
228
- const svgGroups = groups.flatMap((group) => [
229
- `<rect x="${group.x}" y="${group.y}" width="${group.w}" height="${group.h}" rx="12" ` +
230
- `style="fill: ${color("color-group-box")}; ` +
231
- `stroke: ${color("color-group-border")}" stroke-width="3.0"/>`,
232
- `<text x="${group.x + TAG_DX}" y="${group.y + TAG_DY + FS_GROUP}" ` +
233
- `font-size="${FS_GROUP}" font-weight="600" ` +
234
- `style="font-family: ${font}; fill: ${color("color-group-name")}">${escapeXML(group.name)}</text>`
235
- ]);
292
+ everything else) and the container boxes (drawn below the edges
293
+ 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));
236
296
  /* determine the overall bounding box of all rendered elements */
237
297
  const groupBoxes = groups.map((group) => [group.x, group.y, group.x + group.w, group.y + group.h]);
238
- const vb = viewBoxOf(layout, [...occupied, ...groupBoxes], config["size-canvas-margin"]);
298
+ const containerBoxes = containers.map((c) => [c.x, c.y, c.x + c.w, c.y + c.h]);
299
+ const vb = viewBoxOf(layout, [...occupied, ...groupBoxes, ...containerBoxes], config["size-canvas-margin"]);
239
300
  /* assemble the final SVG document */
240
301
  return [
241
302
  "<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
@@ -256,6 +317,7 @@ export const renderSVG = (layout, config, explicit, seed) => {
256
317
  "</marker>",
257
318
  "</defs>",
258
319
  ...svgGroups,
320
+ ...svgContainers,
259
321
  ...svgEdges,
260
322
  ...svgNodes,
261
323
  ...svgLabels,
@@ -1,4 +1,5 @@
1
1
  import { Graph } from "./gradia-api-model.js";
2
2
  import { Config } from "./gradia-api-config.js";
3
- import { Layout } from "./gradia-api-render-svg.js";
4
- export declare const render: (graph: Graph, config: Config) => Promise<Layout>;
3
+ import { Layout } from "./gradia-api-render-base.js";
4
+ import { LevelContext } from "./gradia-api-render-container.js";
5
+ export declare const render: (graph: Graph, config: Config, level?: LevelContext) => Promise<Layout>;