@rse/gradia 1.3.0 → 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 +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 +88 -58
- 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,20 @@ 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;
|
|
35
42
|
/* track occupied areas (node boxes and already placed labels) to
|
|
36
43
|
let subsequent labels dodge into a collision-free position, and the
|
|
37
44
|
edge lines (their segments and crossing hops) to let the labels at
|
|
38
45
|
least prefer the position covering the fewest lines (the returned
|
|
39
46
|
"occupied" array grows with every claimed label box and hence also
|
|
40
47
|
serves as the box input of the overall bounding box) */
|
|
41
|
-
const labelPlacer = (layout, hops, hopRadius) => {
|
|
48
|
+
const labelPlacer = (layout, hops, hopRadius, config) => {
|
|
42
49
|
const { nodes, cx, cy, boxW, boxH } = layout;
|
|
43
50
|
const occupied = nodes.map((node) => [
|
|
44
51
|
cx(node.id) - boxW.get(node.id) / 2, cy(node.id) - boxH.get(node.id) / 2,
|
|
@@ -50,27 +57,41 @@ const labelPlacer = (layout, hops, hopRadius) => {
|
|
|
50
57
|
group.x + TAG_DX + textWidth(group.name, FS_GROUP), group.y + TAG_DY + FS_GROUP * 1.2]);
|
|
51
58
|
for (const c of layout.containers ?? [])
|
|
52
59
|
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.
|
|
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 = [];
|
|
57
65
|
for (let k = 0; k < poly.length - 1; k++) {
|
|
58
66
|
const [a, b] = [poly[k], poly[k + 1]];
|
|
59
|
-
|
|
67
|
+
boxes.push([Math.min(a[0], b[0]) - LINE_PAD, Math.min(a[1], b[1]) - LINE_PAD,
|
|
60
68
|
Math.max(a[0], b[0]) + LINE_PAD, Math.max(a[1], b[1]) + LINE_PAD]);
|
|
61
69
|
for (const hx of hops[i].get(k) ?? [])
|
|
62
|
-
|
|
70
|
+
boxes.push([hx - hopRadius, a[1] - hopRadius, hx + hopRadius, a[1]]);
|
|
63
71
|
}
|
|
72
|
+
return boxes;
|
|
64
73
|
});
|
|
74
|
+
const lineCount = lines.reduce((n, boxes) => n + boxes.length, 0);
|
|
65
75
|
/* claim the candidate box colliding with the fewest occupied
|
|
66
76
|
areas and, among those, with the fewest edge lines (earlier
|
|
67
|
-
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 */
|
|
68
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;
|
|
69
|
-
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) => {
|
|
70
91
|
let box = candidates[0];
|
|
71
92
|
let lowest = Infinity;
|
|
72
93
|
for (const c of candidates) {
|
|
73
|
-
const score = collisions(occupied, c) * (
|
|
94
|
+
const score = collisions(occupied, c) * (lineCount + 1) + (dodge ? crossings(c, edge) : 0);
|
|
74
95
|
if (score < lowest) {
|
|
75
96
|
box = c;
|
|
76
97
|
lowest = score;
|
|
@@ -101,24 +122,25 @@ const renderNode = (node, layout, style, styler, config) => {
|
|
|
101
122
|
the optional type lines shifting the name and attributes down and
|
|
102
123
|
the additional wrapped name lines shifting the attributes down */
|
|
103
124
|
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;
|
|
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;
|
|
106
128
|
const nameY = lines.attrs.length > 0 ?
|
|
107
|
-
ty + th + MIN_H / 2 +
|
|
129
|
+
ty + th + MIN_H(config) / 2 + asc : cy(node.id) + th / 2 + asc - nh / 2;
|
|
108
130
|
lines.type.forEach((line, k) => {
|
|
109
131
|
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,
|
|
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>`);
|
|
112
134
|
});
|
|
113
135
|
lines.name.forEach((line, k) => {
|
|
114
|
-
parts.push(`<text x="${cx(node.id)}" y="${nameY + k * NAME_H}" ` +
|
|
115
|
-
`class="${styler.text(style.text,
|
|
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 })}">` +
|
|
116
138
|
`${escapeXML(line)}</text>`);
|
|
117
139
|
});
|
|
118
140
|
lines.attrs.forEach((line, k) => {
|
|
119
141
|
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,
|
|
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,56 +179,63 @@ 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, styler) => {
|
|
182
|
+
const renderEdgeLabels = (edge, poly, claim, styler, config) => {
|
|
161
183
|
const parts = [];
|
|
162
184
|
if (edge.name !== undefined) {
|
|
163
|
-
const w = textWidth(edge.name,
|
|
185
|
+
const w = textWidth(edge.name, config["size-font-edge"]);
|
|
186
|
+
const h = config["size-font-edge"] + 4;
|
|
164
187
|
const candidates = [];
|
|
165
188
|
for (const f of [0.50, 0.40, 0.60, 0.30, 0.70, 0.20, 0.80]) {
|
|
166
189
|
const p = pointAt(poly, f);
|
|
167
190
|
if (p.horizontal) {
|
|
168
|
-
candidates.push([p.x - w / 2, p.y -
|
|
169
|
-
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]);
|
|
170
193
|
}
|
|
171
194
|
else {
|
|
172
|
-
candidates.push([p.x + 5, p.y -
|
|
173
|
-
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]);
|
|
174
197
|
}
|
|
175
198
|
}
|
|
176
199
|
const box = claim(candidates);
|
|
177
200
|
parts.push(`<text x="${(box[0] + box[2]) / 2}" y="${box[3] - 3}" ` +
|
|
178
|
-
`class="${styler.text("color-edge-name",
|
|
201
|
+
`class="${styler.text("color-edge-name", "size-font-edge", { middle: true, halo: true })}">` +
|
|
179
202
|
`${escapeXML(edge.name)}</text>`);
|
|
180
203
|
}
|
|
181
204
|
if (edge.arity !== undefined) {
|
|
182
|
-
const w = textWidth(edge.arity,
|
|
205
|
+
const w = textWidth(edge.arity, config["size-font-arity"]);
|
|
206
|
+
const h = arityHeight(config);
|
|
183
207
|
const p = pointAt(poly, 1.0);
|
|
184
208
|
const prev = pointAt(poly, 0.999);
|
|
185
209
|
/* set the arity back from the arrow head along the final
|
|
186
210
|
segment and place it beside the line, so an edge approaching
|
|
187
|
-
vertically keeps its arity next to its own arrow
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
[p.x
|
|
204
|
-
[p.x
|
|
205
|
-
|
|
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
|
+
}
|
|
206
230
|
}
|
|
207
|
-
|
|
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);
|
|
208
237
|
parts.push(`<text x="${(box[0] + box[2]) / 2}" y="${box[3] - 3}" ` +
|
|
209
|
-
`class="${styler.text("color-edge-arity",
|
|
238
|
+
`class="${styler.text("color-edge-arity", "size-font-arity", { middle: true, halo: true })}">` +
|
|
210
239
|
`${escapeXML(edge.arity)}</text>`);
|
|
211
240
|
}
|
|
212
241
|
return parts;
|
|
@@ -223,7 +252,7 @@ const renderGroup = (group, styler) => [
|
|
|
223
252
|
in the top-left corner, with the optional type line above the name
|
|
224
253
|
(a container with a "url" attribute becomes a hyperlink covering
|
|
225
254
|
the whole box) */
|
|
226
|
-
const renderContainer = (c, styler) => {
|
|
255
|
+
const renderContainer = (c, styler, config) => {
|
|
227
256
|
const type = typeOf(c.node);
|
|
228
257
|
const url = urlOf(c.node);
|
|
229
258
|
const parts = [
|
|
@@ -231,10 +260,10 @@ const renderContainer = (c, styler) => {
|
|
|
231
260
|
`class="${styler.box("color-container-box", "color-container-border", 3)}" ` +
|
|
232
261
|
"stroke-dasharray=\"10 6\"/>",
|
|
233
262
|
...(type !== undefined ? [
|
|
234
|
-
`<text x="${c.x + TAG_DX}" y="${c.y + TAG_DY +
|
|
235
|
-
`class="${styler.text("color-container-name",
|
|
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>`
|
|
236
265
|
] : []),
|
|
237
|
-
`<text x="${c.x + TAG_DX}" y="${c.y + containerHead(c.node) - HEAD_H + TAG_DY + FS_GROUP}" ` +
|
|
266
|
+
`<text x="${c.x + TAG_DX}" y="${c.y + containerHead(c.node, config) - HEAD_H + TAG_DY + FS_GROUP}" ` +
|
|
238
267
|
`class="${styler.text("color-container-name", FS_GROUP, { bold: true })}">` +
|
|
239
268
|
`${escapeXML(c.node.name)}</text>`
|
|
240
269
|
];
|
|
@@ -275,7 +304,8 @@ export const renderSVG = (layout, config, explicit, seed) => {
|
|
|
275
304
|
return name;
|
|
276
305
|
};
|
|
277
306
|
const styler = {
|
|
278
|
-
text: (fill, size, options = {}) => classOf(`font-family: ${font};
|
|
307
|
+
text: (fill, size, options = {}) => classOf(`font-family: ${font}; ` +
|
|
308
|
+
`font-size: ${typeof size === "number" ? `${size}px` : cssSizeOf(config, size)}; ` +
|
|
279
309
|
(options.bold ? "font-weight: 600; " : "") +
|
|
280
310
|
(options.middle ? "text-anchor: middle; " : "") +
|
|
281
311
|
`fill: ${color(fill)}` +
|
|
@@ -288,14 +318,14 @@ export const renderSVG = (layout, config, explicit, seed) => {
|
|
|
288
318
|
/* detect the edge crossings requiring rendered hops */
|
|
289
319
|
const hops = computeHops(polys);
|
|
290
320
|
/* prepare the collision-free placement of the edge labels */
|
|
291
|
-
const { claim, occupied } = labelPlacer(layout, hops, config["size-edge-hop-radius"]);
|
|
321
|
+
const { claim, occupied } = labelPlacer(layout, hops, config["size-edge-hop-radius"], config);
|
|
292
322
|
/* generate the SVG fragments for the edges (paths below, labels above) */
|
|
293
323
|
const svgEdges = [];
|
|
294
324
|
const svgLabels = [];
|
|
295
325
|
edges.forEach((edge, i) => {
|
|
296
326
|
svgEdges.push(`<path d="${pathOf(polys[i], hops[i], config["size-edge-corner-radius"], config["size-edge-hop-radius"])}" ` +
|
|
297
327
|
`class="${classEdge}" marker-end="url(#${idArrow})"/>`);
|
|
298
|
-
svgLabels.push(...renderEdgeLabels(edge, polys[i], claim, styler));
|
|
328
|
+
svgLabels.push(...renderEdgeLabels(edge, polys[i], (c, dodge) => claim(c, i, dodge), styler, config));
|
|
299
329
|
});
|
|
300
330
|
/* generate the SVG fragments for the node boxes */
|
|
301
331
|
const svgNodes = nodes.flatMap((node) => renderNode(node, layout, styleOf(node), styler, config));
|
|
@@ -303,7 +333,7 @@ export const renderSVG = (layout, config, explicit, seed) => {
|
|
|
303
333
|
everything else) and the container boxes (drawn below the edges
|
|
304
334
|
and nodes, an outer box before its nested ones) */
|
|
305
335
|
const svgGroups = groups.flatMap((group) => renderGroup(group, styler));
|
|
306
|
-
const svgContainers = containers.flatMap((c) => renderContainer(c, styler));
|
|
336
|
+
const svgContainers = containers.flatMap((c) => renderContainer(c, styler, config));
|
|
307
337
|
/* determine the overall bounding box of all rendered elements */
|
|
308
338
|
const groupBoxes = groups.map((group) => [group.x, group.y, group.x + group.w, group.y + group.h]);
|
|
309
339
|
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) */
|