@rse/gradia 1.3.0 → 1.4.1
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 +64 -38
- package/dst/gradia-api-config.d.ts +7 -0
- package/dst/gradia-api-config.js +9 -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 +181 -85
- package/dst/gradia-api-type-graph.js +19 -17
- package/dst/gradia-api-type-hub.js +27 -17
- package/dst/gradia-api.umd.js +2 -2
- 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
|
|
@@ -32,13 +32,25 @@ const TAG_DX = 18; /* left offset of the group tag */
|
|
|
32
32
|
const TAG_DY = 12; /* top offset of the group tag */
|
|
33
33
|
/* the half width of the box an edge line segment occupies */
|
|
34
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;
|
|
42
|
+
/* the maximum number of steps a name label is slid along a single
|
|
43
|
+
segment of its route, as a long segment would otherwise offer far
|
|
44
|
+
more candidate positions than a placement can ever need */
|
|
45
|
+
const SLIDE_MAX = 16;
|
|
35
46
|
/* track occupied areas (node boxes and already placed labels) to
|
|
36
|
-
let subsequent labels dodge into a collision-free position,
|
|
47
|
+
let subsequent labels dodge into a collision-free position, plus the
|
|
48
|
+
areas merely reserved for the labels still to be placed, and the
|
|
37
49
|
edge lines (their segments and crossing hops) to let the labels at
|
|
38
50
|
least prefer the position covering the fewest lines (the returned
|
|
39
51
|
"occupied" array grows with every claimed label box and hence also
|
|
40
52
|
serves as the box input of the overall bounding box) */
|
|
41
|
-
const labelPlacer = (layout, hops, hopRadius) => {
|
|
53
|
+
const labelPlacer = (layout, hops, hopRadius, config) => {
|
|
42
54
|
const { nodes, cx, cy, boxW, boxH } = layout;
|
|
43
55
|
const occupied = nodes.map((node) => [
|
|
44
56
|
cx(node.id) - boxW.get(node.id) / 2, cy(node.id) - boxH.get(node.id) / 2,
|
|
@@ -50,27 +62,56 @@ const labelPlacer = (layout, hops, hopRadius) => {
|
|
|
50
62
|
group.x + TAG_DX + textWidth(group.name, FS_GROUP), group.y + TAG_DY + FS_GROUP * 1.2]);
|
|
51
63
|
for (const c of layout.containers ?? [])
|
|
52
64
|
occupied.push([c.x + TAG_DX, c.y + TAG_DY,
|
|
53
|
-
c.x + TAG_DX + textWidth(c.node.name, FS_GROUP), c.y + containerHead(c.node)]);
|
|
54
|
-
/* collect the edge line segments and the hops bulging above them
|
|
55
|
-
|
|
56
|
-
layout.polys.
|
|
65
|
+
c.x + TAG_DX + textWidth(c.node.name, FS_GROUP), c.y + containerHead(c.node, config)]);
|
|
66
|
+
/* collect the edge line segments and the hops bulging above them,
|
|
67
|
+
kept per edge, so a label can tell its own route from the foreign ones */
|
|
68
|
+
const lines = layout.polys.map((poly, i) => {
|
|
69
|
+
const boxes = [];
|
|
57
70
|
for (let k = 0; k < poly.length - 1; k++) {
|
|
58
71
|
const [a, b] = [poly[k], poly[k + 1]];
|
|
59
|
-
|
|
72
|
+
boxes.push([Math.min(a[0], b[0]) - LINE_PAD, Math.min(a[1], b[1]) - LINE_PAD,
|
|
60
73
|
Math.max(a[0], b[0]) + LINE_PAD, Math.max(a[1], b[1]) + LINE_PAD]);
|
|
61
74
|
for (const hx of hops[i].get(k) ?? [])
|
|
62
|
-
|
|
75
|
+
boxes.push([hx - hopRadius, a[1] - hopRadius, hx + hopRadius, a[1]]);
|
|
63
76
|
}
|
|
77
|
+
return boxes;
|
|
64
78
|
});
|
|
79
|
+
const lineCount = lines.reduce((n, boxes) => n + boxes.length, 0);
|
|
65
80
|
/* claim the candidate box colliding with the fewest occupied
|
|
66
81
|
areas and, among those, with the fewest edge lines (earlier
|
|
67
|
-
candidates win ties, so the first collision-free one is taken)
|
|
82
|
+
candidates win ties, so the first collision-free one is taken),
|
|
83
|
+
where the own edge lines count on touch only, as the label sits
|
|
84
|
+
beside its own route by construction, while the foreign ones
|
|
85
|
+
count already within a clearance, so a label hugging a foreign
|
|
86
|
+
line loses against one staying with its own edge. A claim with
|
|
87
|
+
"dodge" unset weighs the lines not at all, as its candidates are
|
|
88
|
+
ordered by a proximity which matters more than a crossed line,
|
|
89
|
+
which the halo of the label keeps readable anyway */
|
|
68
90
|
const collisions = (boxes, box) => boxes.filter((o) => box[0] < o[2] && box[2] > o[0] && box[1] < o[3] && box[3] > o[1]).length;
|
|
69
|
-
const
|
|
91
|
+
const crossings = (box, edge) => {
|
|
92
|
+
const near = [box[0] - LINE_GAP, box[1] - LINE_GAP, box[2] + LINE_GAP, box[3] + LINE_GAP];
|
|
93
|
+
return lines.reduce((n, boxes, i) => n + collisions(boxes, i === edge ? box : near), 0);
|
|
94
|
+
};
|
|
95
|
+
const reserved = [];
|
|
96
|
+
const reserve = (box) => {
|
|
97
|
+
reserved.push(box);
|
|
98
|
+
};
|
|
99
|
+
/* a claim counts the areas reserved for the labels still to come
|
|
100
|
+
like the occupied ones, so an early label yields the position a
|
|
101
|
+
later one depends upon wherever it has an alternative of its
|
|
102
|
+
own, while the "own" reservation of the claiming label is
|
|
103
|
+
released, as that is the very area it is about to take */
|
|
104
|
+
const claim = (candidates, edge, dodge = true, own) => {
|
|
105
|
+
if (own !== undefined) {
|
|
106
|
+
const k = reserved.indexOf(own);
|
|
107
|
+
if (k >= 0)
|
|
108
|
+
reserved.splice(k, 1);
|
|
109
|
+
}
|
|
70
110
|
let box = candidates[0];
|
|
71
111
|
let lowest = Infinity;
|
|
72
112
|
for (const c of candidates) {
|
|
73
|
-
const score = collisions(occupied, c) * (
|
|
113
|
+
const score = (collisions(occupied, c) + collisions(reserved, c)) * (lineCount + 1) +
|
|
114
|
+
(dodge ? crossings(c, edge) : 0);
|
|
74
115
|
if (score < lowest) {
|
|
75
116
|
box = c;
|
|
76
117
|
lowest = score;
|
|
@@ -81,7 +122,7 @@ const labelPlacer = (layout, hops, hopRadius) => {
|
|
|
81
122
|
occupied.push(box);
|
|
82
123
|
return box;
|
|
83
124
|
};
|
|
84
|
-
return { claim, occupied };
|
|
125
|
+
return { claim, reserve, occupied };
|
|
85
126
|
};
|
|
86
127
|
/* generate the SVG fragments for a single node box (a node with a
|
|
87
128
|
"url" attribute becomes a hyperlink covering the whole box) */
|
|
@@ -101,24 +142,25 @@ const renderNode = (node, layout, style, styler, config) => {
|
|
|
101
142
|
the optional type lines shifting the name and attributes down and
|
|
102
143
|
the additional wrapped name lines shifting the attributes down */
|
|
103
144
|
const ty = cy(node.id) - contentH.get(node.id) / 2;
|
|
104
|
-
const th = lines.type.length * TYPE_H;
|
|
105
|
-
const nh = (lines.name.length - 1) * NAME_H;
|
|
145
|
+
const th = lines.type.length * TYPE_H(config);
|
|
146
|
+
const nh = (lines.name.length - 1) * NAME_H(config);
|
|
147
|
+
const asc = config["size-font-node"] * 0.36;
|
|
106
148
|
const nameY = lines.attrs.length > 0 ?
|
|
107
|
-
ty + th + MIN_H / 2 +
|
|
149
|
+
ty + th + MIN_H(config) / 2 + asc : cy(node.id) + th / 2 + asc - nh / 2;
|
|
108
150
|
lines.type.forEach((line, k) => {
|
|
109
151
|
parts.push(`<text x="${cx(node.id)}" ` +
|
|
110
|
-
`y="${nameY - TYPE_D - (lines.type.length - 1 - k) * TYPE_H}" ` +
|
|
111
|
-
`class="${styler.text(style.text,
|
|
152
|
+
`y="${nameY - TYPE_D(config) - (lines.type.length - 1 - k) * TYPE_H(config)}" ` +
|
|
153
|
+
`class="${styler.text(style.text, "size-font-type", { middle: true })}">${escapeXML(line)}</text>`);
|
|
112
154
|
});
|
|
113
155
|
lines.name.forEach((line, k) => {
|
|
114
|
-
parts.push(`<text x="${cx(node.id)}" y="${nameY + k * NAME_H}" ` +
|
|
115
|
-
`class="${styler.text(style.text,
|
|
156
|
+
parts.push(`<text x="${cx(node.id)}" y="${nameY + k * NAME_H(config)}" ` +
|
|
157
|
+
`class="${styler.text(style.text, "size-font-node", { bold: true, middle: true })}">` +
|
|
116
158
|
`${escapeXML(line)}</text>`);
|
|
117
159
|
});
|
|
118
160
|
lines.attrs.forEach((line, k) => {
|
|
119
161
|
parts.push(`<text x="${cx(node.id)}" ` +
|
|
120
|
-
`y="${ty + th + nh + MIN_H + ATTR_P + k * ATTR_H}" ` +
|
|
121
|
-
`class="${styler.text(style.text,
|
|
162
|
+
`y="${ty + th + nh + MIN_H(config) + ATTR_P + k * ATTR_H(config)}" ` +
|
|
163
|
+
`class="${styler.text(style.text, "size-font-prop", { middle: true })}">${escapeXML(line)}</text>`);
|
|
122
164
|
});
|
|
123
165
|
if (url === undefined)
|
|
124
166
|
return parts;
|
|
@@ -153,63 +195,100 @@ const viewBoxOf = (layout, boxes, margin) => {
|
|
|
153
195
|
h: Math.ceil(maxY - minY + margin)
|
|
154
196
|
};
|
|
155
197
|
};
|
|
156
|
-
/*
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
}
|
|
171
|
-
else {
|
|
172
|
-
candidates.push([p.x + 5, p.y - 10, p.x + 5 + w, p.y + 10]);
|
|
173
|
-
candidates.push([p.x - 5 - w, p.y - 10, p.x - 5, p.y + 10]);
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
const box = claim(candidates);
|
|
177
|
-
parts.push(`<text x="${(box[0] + box[2]) / 2}" y="${box[3] - 3}" ` +
|
|
178
|
-
`class="${styler.text("color-edge-name", FS_EDGE, { middle: true, halo: true })}">` +
|
|
179
|
-
`${escapeXML(edge.name)}</text>`);
|
|
180
|
-
}
|
|
181
|
-
if (edge.arity !== undefined) {
|
|
182
|
-
const w = textWidth(edge.arity, FS_ARITY);
|
|
183
|
-
const p = pointAt(poly, 1.0);
|
|
184
|
-
const prev = pointAt(poly, 0.999);
|
|
185
|
-
/* set the arity back from the arrow head along the final
|
|
186
|
-
segment and place it beside the line, so an edge approaching
|
|
187
|
-
vertically keeps its arity next to its own arrow */
|
|
188
|
-
let candidates;
|
|
198
|
+
/* the candidate positions of the arity label of an edge: set back
|
|
199
|
+
from the arrow head along the final segment and placed beside the
|
|
200
|
+
line, so an edge approaching vertically keeps its arity next to its
|
|
201
|
+
own arrow, offering both sides at every further setback, which steps
|
|
202
|
+
by the label extent along the segment, as a shorter step would leave
|
|
203
|
+
the label on top of the very label it dodges */
|
|
204
|
+
const arityBoxes = (arity, poly, config) => {
|
|
205
|
+
const w = textWidth(arity, config["size-font-arity"]);
|
|
206
|
+
const h = arityHeight(config);
|
|
207
|
+
const p = pointAt(poly, 1.0);
|
|
208
|
+
const prev = pointAt(poly, 0.999);
|
|
209
|
+
const candidates = [];
|
|
210
|
+
for (let k = 0; k < ARITY_BACK; k++) {
|
|
211
|
+
const back = k * ((p.horizontal ? w : h) + ARITY_STEP);
|
|
189
212
|
if (p.horizontal) {
|
|
190
213
|
const dx = Math.sign(p.x - prev.x) || 1;
|
|
191
|
-
const ax = p.x - dx * (ARITY_OFF + w / 2);
|
|
192
|
-
candidates
|
|
193
|
-
|
|
194
|
-
[ax - w / 2, p.y + 4, ax + w / 2, p.y + 17],
|
|
195
|
-
[ax - w / 2 - dx * 14, p.y - 17, ax + w / 2 - dx * 14, p.y - 4]
|
|
196
|
-
];
|
|
214
|
+
const ax = p.x - dx * (ARITY_OFF + w / 2 + back);
|
|
215
|
+
candidates.push([ax - w / 2, p.y - ARITY_PAD - h, ax + w / 2, p.y - ARITY_PAD]);
|
|
216
|
+
candidates.push([ax - w / 2, p.y + ARITY_PAD, ax + w / 2, p.y + ARITY_PAD + h]);
|
|
197
217
|
}
|
|
198
218
|
else {
|
|
199
219
|
const dy = Math.sign(p.y - prev.y) || 1;
|
|
200
|
-
const ay = p.y - dy * ARITY_OFF;
|
|
201
|
-
candidates
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
220
|
+
const ay = p.y - dy * (ARITY_OFF + back);
|
|
221
|
+
candidates.push([p.x + 6, ay + 6 - h, p.x + 6 + w, ay + 6]);
|
|
222
|
+
candidates.push([p.x - 6 - w, ay + 6 - h, p.x - 6, ay + 6]);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return candidates;
|
|
226
|
+
};
|
|
227
|
+
/* generate the SVG fragment for the arity label of a single edge,
|
|
228
|
+
which claims one of its candidate positions without dodging the
|
|
229
|
+
edge lines, as the neighboring ports of a node run closer than any
|
|
230
|
+
clearance anyway and it is the very setback from its own arrow head
|
|
231
|
+
which attaches it to its edge, so a line crossed beneath its halo
|
|
232
|
+
weighs less than a drift away from that arrow */
|
|
233
|
+
const renderEdgeArity = (edge, candidates, claim, styler) => {
|
|
234
|
+
if (edge.arity === undefined)
|
|
235
|
+
return [];
|
|
236
|
+
const box = claim(candidates, false, candidates[0]);
|
|
237
|
+
return [`<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>`];
|
|
240
|
+
};
|
|
241
|
+
/* generate the SVG fragment for the name label of a single edge,
|
|
242
|
+
placed near the middle of the route (dodging into a collision-free
|
|
243
|
+
position through the "claim" of the label placer) */
|
|
244
|
+
const renderEdgeName = (edge, poly, claim, styler, config) => {
|
|
245
|
+
if (edge.name === undefined)
|
|
246
|
+
return [];
|
|
247
|
+
const w = textWidth(edge.name, config["size-font-edge"]);
|
|
248
|
+
const h = config["size-font-edge"] + 4;
|
|
249
|
+
/* offer, along every segment of the own route and on both sides
|
|
250
|
+
of its line, the label centered on the segment plus the two
|
|
251
|
+
positions flush with its ends plus, where the segment takes the
|
|
252
|
+
label at all, the positions sliding between those ends in steps
|
|
253
|
+
of half the label extent, as the free space along a route is
|
|
254
|
+
regularly not where the segment centers are, and order the
|
|
255
|
+
candidates by the distance from the middle of the route, so the
|
|
256
|
+
label ends up as close to that middle as the collisions along
|
|
257
|
+
the way allow */
|
|
258
|
+
const slide = (lo, hi, extent) => {
|
|
259
|
+
const first = lo + extent / 2;
|
|
260
|
+
const last = hi - extent / 2;
|
|
261
|
+
const positions = [(lo + hi) / 2, first, last];
|
|
262
|
+
if (last > first) {
|
|
263
|
+
const steps = Math.min(Math.ceil((last - first) / (extent / 2)), SLIDE_MAX);
|
|
264
|
+
for (let k = 1; k < steps; k++)
|
|
265
|
+
positions.push(first + (last - first) * k / steps);
|
|
266
|
+
}
|
|
267
|
+
return positions;
|
|
268
|
+
};
|
|
269
|
+
const mid = pointAt(poly, 0.50);
|
|
270
|
+
const near = (box) => Math.abs((box[0] + box[2]) / 2 - mid.x) + Math.abs((box[1] + box[3]) / 2 - mid.y);
|
|
271
|
+
const candidates = [];
|
|
272
|
+
for (let k = 0; k < poly.length - 1; k++) {
|
|
273
|
+
const [a, b] = [poly[k], poly[k + 1]];
|
|
274
|
+
if (a[1] === b[1]) {
|
|
275
|
+
for (const x of slide(Math.min(a[0], b[0]), Math.max(a[0], b[0]), w)) {
|
|
276
|
+
candidates.push([x - w / 2, a[1] - 3 - h, x + w / 2, a[1] - 3]);
|
|
277
|
+
candidates.push([x - w / 2, a[1] + 3, x + w / 2, a[1] + 3 + h]);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
for (const y of slide(Math.min(a[1], b[1]), Math.max(a[1], b[1]), h)) {
|
|
282
|
+
candidates.push([a[0] + 5, y - h / 2, a[0] + 5 + w, y + h / 2]);
|
|
283
|
+
candidates.push([a[0] - 5 - w, y - h / 2, a[0] - 5, y + h / 2]);
|
|
284
|
+
}
|
|
206
285
|
}
|
|
207
|
-
const box = claim(candidates);
|
|
208
|
-
parts.push(`<text x="${(box[0] + box[2]) / 2}" y="${box[3] - 3}" ` +
|
|
209
|
-
`class="${styler.text("color-edge-arity", FS_ARITY, { middle: true, halo: true })}">` +
|
|
210
|
-
`${escapeXML(edge.arity)}</text>`);
|
|
211
286
|
}
|
|
212
|
-
|
|
287
|
+
candidates.sort((a, b) => near(a) - near(b));
|
|
288
|
+
const box = claim(candidates);
|
|
289
|
+
return [`<text x="${(box[0] + box[2]) / 2}" y="${box[3] - 3}" ` +
|
|
290
|
+
`class="${styler.text("color-edge-name", "size-font-edge", { middle: true, halo: true })}">` +
|
|
291
|
+
`${escapeXML(edge.name)}</text>`];
|
|
213
292
|
};
|
|
214
293
|
/* generate the SVG fragments for a single group box and its tag
|
|
215
294
|
in the top-left corner */
|
|
@@ -223,7 +302,7 @@ const renderGroup = (group, styler) => [
|
|
|
223
302
|
in the top-left corner, with the optional type line above the name
|
|
224
303
|
(a container with a "url" attribute becomes a hyperlink covering
|
|
225
304
|
the whole box) */
|
|
226
|
-
const renderContainer = (c, styler) => {
|
|
305
|
+
const renderContainer = (c, styler, config) => {
|
|
227
306
|
const type = typeOf(c.node);
|
|
228
307
|
const url = urlOf(c.node);
|
|
229
308
|
const parts = [
|
|
@@ -231,10 +310,10 @@ const renderContainer = (c, styler) => {
|
|
|
231
310
|
`class="${styler.box("color-container-box", "color-container-border", 3)}" ` +
|
|
232
311
|
"stroke-dasharray=\"10 6\"/>",
|
|
233
312
|
...(type !== undefined ? [
|
|
234
|
-
`<text x="${c.x + TAG_DX}" y="${c.y + TAG_DY +
|
|
235
|
-
`class="${styler.text("color-container-name",
|
|
313
|
+
`<text x="${c.x + TAG_DX}" y="${c.y + TAG_DY + config["size-font-type"]}" ` +
|
|
314
|
+
`class="${styler.text("color-container-name", "size-font-type")}">${escapeXML(type)}</text>`
|
|
236
315
|
] : []),
|
|
237
|
-
`<text x="${c.x + TAG_DX}" y="${c.y + containerHead(c.node) - HEAD_H + TAG_DY + FS_GROUP}" ` +
|
|
316
|
+
`<text x="${c.x + TAG_DX}" y="${c.y + containerHead(c.node, config) - HEAD_H + TAG_DY + FS_GROUP}" ` +
|
|
238
317
|
`class="${styler.text("color-container-name", FS_GROUP, { bold: true })}">` +
|
|
239
318
|
`${escapeXML(c.node.name)}</text>`
|
|
240
319
|
];
|
|
@@ -275,7 +354,8 @@ export const renderSVG = (layout, config, explicit, seed) => {
|
|
|
275
354
|
return name;
|
|
276
355
|
};
|
|
277
356
|
const styler = {
|
|
278
|
-
text: (fill, size, options = {}) => classOf(`font-family: ${font};
|
|
357
|
+
text: (fill, size, options = {}) => classOf(`font-family: ${font}; ` +
|
|
358
|
+
`font-size: ${typeof size === "number" ? `${size}px` : cssSizeOf(config, size)}; ` +
|
|
279
359
|
(options.bold ? "font-weight: 600; " : "") +
|
|
280
360
|
(options.middle ? "text-anchor: middle; " : "") +
|
|
281
361
|
`fill: ${color(fill)}` +
|
|
@@ -288,14 +368,30 @@ export const renderSVG = (layout, config, explicit, seed) => {
|
|
|
288
368
|
/* detect the edge crossings requiring rendered hops */
|
|
289
369
|
const hops = computeHops(polys);
|
|
290
370
|
/* prepare the collision-free placement of the edge labels */
|
|
291
|
-
const { claim, occupied } = labelPlacer(layout, hops, config["size-edge-hop-radius"]);
|
|
292
|
-
/* generate the SVG fragments for the
|
|
293
|
-
const svgEdges = []
|
|
371
|
+
const { claim, reserve, occupied } = labelPlacer(layout, hops, config["size-edge-hop-radius"], config);
|
|
372
|
+
/* generate the SVG fragments for the edge paths */
|
|
373
|
+
const svgEdges = polys.map((poly, i) => `<path d="${pathOf(poly, hops[i], config["size-edge-corner-radius"], config["size-edge-hop-radius"])}" ` +
|
|
374
|
+
`class="${classEdge}" marker-end="url(#${idArrow})"/>`);
|
|
375
|
+
/* reserve the position every arity label prefers -- the one beside
|
|
376
|
+
its own arrow head -- before any name label is placed, as an
|
|
377
|
+
arity is bound to that arrow while a name may slide along the
|
|
378
|
+
whole route: a name hence yields the reserved area wherever it
|
|
379
|
+
has an alternative, and only a name left without one takes it
|
|
380
|
+
and pushes its arity onto the next setback */
|
|
381
|
+
const arities = edges.map((edge, i) => edge.arity !== undefined ? arityBoxes(edge.arity, polys[i], config) : undefined);
|
|
382
|
+
for (const candidates of arities)
|
|
383
|
+
if (candidates !== undefined)
|
|
384
|
+
reserve(candidates[0]);
|
|
385
|
+
/* generate the SVG fragments for the edge labels (the names
|
|
386
|
+
first, as their reservations keep the arity positions free) */
|
|
294
387
|
const svgLabels = [];
|
|
295
388
|
edges.forEach((edge, i) => {
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
389
|
+
svgLabels.push(...renderEdgeName(edge, polys[i], (c, dodge) => claim(c, i, dodge), styler, config));
|
|
390
|
+
});
|
|
391
|
+
edges.forEach((edge, i) => {
|
|
392
|
+
const candidates = arities[i];
|
|
393
|
+
if (candidates !== undefined)
|
|
394
|
+
svgLabels.push(...renderEdgeArity(edge, candidates, (c, dodge, own) => claim(c, i, dodge, own), styler));
|
|
299
395
|
});
|
|
300
396
|
/* generate the SVG fragments for the node boxes */
|
|
301
397
|
const svgNodes = nodes.flatMap((node) => renderNode(node, layout, styleOf(node), styler, config));
|
|
@@ -303,7 +399,7 @@ export const renderSVG = (layout, config, explicit, seed) => {
|
|
|
303
399
|
everything else) and the container boxes (drawn below the edges
|
|
304
400
|
and nodes, an outer box before its nested ones) */
|
|
305
401
|
const svgGroups = groups.flatMap((group) => renderGroup(group, styler));
|
|
306
|
-
const svgContainers = containers.flatMap((c) => renderContainer(c, styler));
|
|
402
|
+
const svgContainers = containers.flatMap((c) => renderContainer(c, styler, config));
|
|
307
403
|
/* determine the overall bounding box of all rendered elements */
|
|
308
404
|
const groupBoxes = groups.map((group) => [group.x, group.y, group.x + group.w, group.y + group.h]);
|
|
309
405
|
const containerBoxes = containers.map((c) => [c.x, c.y, c.x + c.w, c.y + c.h]);
|
|
@@ -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 */
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
** Copyright (c) 2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
|
|
4
4
|
** Distributed under MIT license <https://spdx.org/licenses/MIT.html>
|
|
5
5
|
*/
|
|
6
|
-
import {
|
|
6
|
+
import { ARITY_OFF, textWidth } from "./gradia-api-render-base.js";
|
|
7
7
|
import { isPrimary, measureNodes, defaultStyleOf } from "./gradia-api-render-node.js";
|
|
8
|
-
import { simplifyPoly, assignPorts, assignTracks } from "./gradia-api-render-edge.js";
|
|
8
|
+
import { simplifyPoly, assignPorts, assignTracks, portGapOf, PORT_PAD } from "./gradia-api-render-edge.js";
|
|
9
9
|
/* the separator between a node id and its placement suffix,
|
|
10
10
|
distinguishing the two clones of a twice-placed node and the
|
|
11
11
|
output clone of a self-referencing primary node */
|
|
@@ -106,13 +106,16 @@ export const render = async (graph, config, level = {}) => {
|
|
|
106
106
|
from the input toward the output column (see classifyTopology),
|
|
107
107
|
hence leaves on the east and enters on the west side */
|
|
108
108
|
const sides = edges.map(() => ({ s: "e", t: "w" }));
|
|
109
|
-
/* determine node box sizes from their textual content (all boxes
|
|
110
|
-
half height scale), then grow every box
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
109
|
+
/* determine node box sizes from their textual content (all boxes
|
|
110
|
+
at half height scale), then grow every box up to the height
|
|
111
|
+
hosting all of its edge attachments at the full port separation,
|
|
112
|
+
and the one whose attachments exceed the configured per-side
|
|
113
|
+
maximum additionally step-wise by one separation per additional
|
|
114
|
+
edge, so the ports of a node never pack tighter than configured,
|
|
115
|
+
however small its content height is (the fixed-size boxes of a
|
|
116
|
+
containment level are exempt) */
|
|
115
117
|
const { boxW, boxH, contentH } = measureNodes(nodes, config, () => scale / 2, level.fixedSize);
|
|
118
|
+
const portGap = portGapOf(edges, config);
|
|
116
119
|
const portCnt = new Map();
|
|
117
120
|
edges.forEach((edge, i) => {
|
|
118
121
|
portCnt.set(`${sides[i].s}:${edge.source}`, (portCnt.get(`${sides[i].s}:${edge.source}`) ?? 0) + 1);
|
|
@@ -122,20 +125,27 @@ export const render = async (graph, config, level = {}) => {
|
|
|
122
125
|
if (level.fixedSize?.has(node.id))
|
|
123
126
|
continue;
|
|
124
127
|
const cnt = Math.max(portCnt.get(`w:${node.id}`) ?? 0, portCnt.get(`e:${node.id}`) ?? 0);
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
(cnt - config["hub-node-degree-max"]) * config["size-edge-port-gap"]);
|
|
128
|
+
const extra = Math.max(0, cnt - config["hub-node-degree-max"]);
|
|
129
|
+
boxH.set(node.id, Math.max(boxH.get(node.id) + extra * portGap, cnt * portGap + PORT_PAD));
|
|
128
130
|
}
|
|
129
131
|
/* a stack of more nodes than the configured maximum wraps into two
|
|
130
132
|
staggered sub-columns: its nodes alternate between the outer
|
|
131
133
|
sub-column and the inner one (adjacent to the channel), and
|
|
132
134
|
every outer node is vertically centered onto a gap between two
|
|
133
135
|
inner nodes, through which its edges reach the channel on their
|
|
134
|
-
straight horizontal lanes (so the edge routing is unaffected)
|
|
136
|
+
straight horizontal lanes (so the edge routing is unaffected).
|
|
137
|
+
The configured maximum decides for the larger stack alone, as the
|
|
138
|
+
height of the diagram follows that one: the tallest (sub-)column
|
|
139
|
+
it ends up with implicitly caps the smaller stack, which hence
|
|
140
|
+
wraps exactly if that lowers the height of the diagram */
|
|
135
141
|
const countMax = config["hub-node-count-max"];
|
|
142
|
+
const bySize = inputs.length >= outputs.length ? [inputs, outputs] : [outputs, inputs];
|
|
143
|
+
const wrapped = [countMax > 0 && bySize[0].length > countMax, false];
|
|
144
|
+
const capped = wrapped[0] ? Math.ceil(bySize[0].length / 2) : bySize[0].length;
|
|
145
|
+
wrapped[1] = countMax > 0 && bySize[1].length > capped;
|
|
136
146
|
const outerSet = new Set();
|
|
137
|
-
for (const list of
|
|
138
|
-
if (
|
|
147
|
+
for (const [c, list] of bySize.entries())
|
|
148
|
+
if (wrapped[c])
|
|
139
149
|
list.filter((_, k) => k % 2 === 0).forEach((node) => outerSet.add(node.id));
|
|
140
150
|
/* the vertical half extent of the lane the edges of an outer node
|
|
141
151
|
occupy around its center: its ports at their maximum separation
|
|
@@ -145,7 +155,7 @@ export const render = async (graph, config, level = {}) => {
|
|
|
145
155
|
const laneOf = (id) => {
|
|
146
156
|
const cnt = Math.max(portCnt.get(`w:${id}`) ?? 0, portCnt.get(`e:${id}`) ?? 0);
|
|
147
157
|
return level.fixedSize?.has(id) ? boxH.get(id) / 2 :
|
|
148
|
-
Math.min(boxH.get(id) / 2, (cnt - 1) *
|
|
158
|
+
Math.min(boxH.get(id) / 2, (cnt - 1) * portGap / 2 + LANE_PAD);
|
|
149
159
|
};
|
|
150
160
|
/* fixed three-column layout: stack the input nodes in the first
|
|
151
161
|
column and the output nodes in the third column (each stack
|
|
@@ -188,7 +198,7 @@ export const render = async (graph, config, level = {}) => {
|
|
|
188
198
|
.reduce((a, node) => Math.max(a, boxW.get(node.id)), 0);
|
|
189
199
|
const outerW = [subWidth(inputs, true), subWidth(outputs, true)];
|
|
190
200
|
const wrapGap = edges.reduce((a, edge) => outerSet.has(edge.target) && edge.arity !== undefined ?
|
|
191
|
-
Math.max(a, ARITY_OFF + textWidth(edge.arity,
|
|
201
|
+
Math.max(a, ARITY_OFF + textWidth(edge.arity, config["size-font-arity"]) + WRAP_PAD) : a, WRAP_GAP);
|
|
192
202
|
const sideW = (list, c) => subWidth(list, false) + (outerW[c] > 0 ? (c === 1 ? wrapGap : WRAP_GAP) + outerW[c] : 0);
|
|
193
203
|
const colWidth = [sideW(inputs, 0), boxW.get(center.id), sideW(outputs, 1)];
|
|
194
204
|
const chanOf = (edge) => Math.min(colOf(edge.source), colOf(edge.target));
|
|
@@ -217,7 +227,7 @@ export const render = async (graph, config, level = {}) => {
|
|
|
217
227
|
};
|
|
218
228
|
const cy = (id) => nodeCY.get(id);
|
|
219
229
|
/* distribute the edge attachment ports along each node side */
|
|
220
|
-
const portPos = assignPorts(edges, sides, cx, cy, boxW, boxH,
|
|
230
|
+
const portPos = assignPorts(edges, sides, cx, cy, boxW, boxH, portGap, undefined, level.fixedPort);
|
|
221
231
|
/* assign the vertical tracks within each channel (see assignTracks
|
|
222
232
|
for the crossing-avoiding ordering scheme; a hub graph has no backward
|
|
223
233
|
edges, hence no channel user is ever mirrored) */
|