@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.
- package/README.md +80 -36
- package/dst/gradia-api-config.d.ts +8 -0
- package/dst/gradia-api-config.js +10 -0
- package/dst/gradia-api-render-base.d.ts +4 -6
- package/dst/gradia-api-render-base.js +9 -6
- package/dst/gradia-api-render-container.js +5 -5
- package/dst/gradia-api-render-edge.d.ts +3 -0
- package/dst/gradia-api-render-edge.js +11 -1
- package/dst/gradia-api-render-node.d.ts +7 -7
- package/dst/gradia-api-render-node.js +25 -15
- package/dst/gradia-api-render-svg.js +142 -100
- package/dst/gradia-api-type-graph.js +19 -17
- package/dst/gradia-api-type-hub.js +88 -32
- package/dst/gradia-api.umd.js +2 -2
- package/dst/gradia-cli.js +0 -0
- package/package.json +1 -1
|
@@ -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 {
|
|
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
|
|
24
|
-
the seed and hence stays stable across regenerations
|
|
25
|
-
diagram (the Base16 format keeps it alphanumeric and
|
|
26
|
-
name and CSS/URL fragment
|
|
27
|
-
|
|
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
|
-
|
|
54
|
-
layout.polys.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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) * (
|
|
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,
|
|
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
|
-
`
|
|
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 +
|
|
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}"
|
|
109
|
-
`
|
|
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}"
|
|
114
|
-
`
|
|
115
|
-
|
|
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}"
|
|
120
|
-
`
|
|
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,
|
|
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,
|
|
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 -
|
|
173
|
-
candidates.push([p.x - w / 2, p.y + 3, p.x + w / 2, p.y +
|
|
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 -
|
|
177
|
-
candidates.push([p.x - 5 - w, p.y -
|
|
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}"
|
|
182
|
-
`
|
|
183
|
-
|
|
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,
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
[p.x
|
|
210
|
-
[p.x
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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,
|
|
245
|
+
const renderGroup = (group, styler) => [
|
|
225
246
|
`<rect x="${group.x}" y="${group.y}" width="${group.w}" height="${group.h}" rx="12" ` +
|
|
226
|
-
`
|
|
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
|
-
`
|
|
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,
|
|
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
|
-
`
|
|
242
|
-
|
|
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 +
|
|
245
|
-
`
|
|
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
|
-
`
|
|
250
|
-
|
|
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) =>
|
|
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 =
|
|
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"])}"
|
|
286
|
-
`
|
|
287
|
-
svgLabels.push(...renderEdgeLabels(edge, polys[i], claim,
|
|
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),
|
|
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,
|
|
295
|
-
const svgContainers = containers.flatMap((c) => renderContainer(c,
|
|
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"
|
|
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 {
|
|
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,
|
|
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,
|
|
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
|
|
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,
|
|
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
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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
|
-
|
|
605
|
-
|
|
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 */
|