@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
|
@@ -3,15 +3,19 @@
|
|
|
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 { ARITY_OFF, textWidth } from "./gradia-api-render-base.js";
|
|
6
7
|
import { isPrimary, measureNodes, defaultStyleOf } from "./gradia-api-render-node.js";
|
|
7
|
-
import { simplifyPoly, assignPorts, assignTracks } from "./gradia-api-render-edge.js";
|
|
8
|
+
import { simplifyPoly, assignPorts, assignTracks, portGapOf, PORT_PAD } from "./gradia-api-render-edge.js";
|
|
8
9
|
/* the separator between a node id and its placement suffix,
|
|
9
10
|
distinguishing the two clones of a twice-placed node and the
|
|
10
11
|
output clone of a self-referencing primary node */
|
|
11
12
|
const CLONE = "\u0000";
|
|
12
13
|
/* rendering geometry constants */
|
|
13
|
-
const CHAN_W1 = 28; /* width of a one-edge inter-column channel
|
|
14
|
-
const CHAN_PAD = 16; /* cross-axis padding inside a channel
|
|
14
|
+
const CHAN_W1 = 28; /* width of a one-edge inter-column channel */
|
|
15
|
+
const CHAN_PAD = 16; /* cross-axis padding inside a channel */
|
|
16
|
+
const WRAP_GAP = 40; /* min horizontal gap between two sub-columns */
|
|
17
|
+
const WRAP_PAD = 8; /* padding of an arity inside that gap */
|
|
18
|
+
const LANE_PAD = 12; /* clearance of an edge lane to a node box */
|
|
15
19
|
/* validate the constrained input topology and classify the declared
|
|
16
20
|
nodes: exactly one node is annotated with "primary: true" and every
|
|
17
21
|
edge connects the primary with another node. Each other node is
|
|
@@ -102,13 +106,16 @@ export const render = async (graph, config, level = {}) => {
|
|
|
102
106
|
from the input toward the output column (see classifyTopology),
|
|
103
107
|
hence leaves on the east and enters on the west side */
|
|
104
108
|
const sides = edges.map(() => ({ s: "e", t: "w" }));
|
|
105
|
-
/* determine node box sizes from their textual content (all boxes
|
|
106
|
-
half height scale), then grow every box
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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) */
|
|
111
117
|
const { boxW, boxH, contentH } = measureNodes(nodes, config, () => scale / 2, level.fixedSize);
|
|
118
|
+
const portGap = portGapOf(edges, config);
|
|
112
119
|
const portCnt = new Map();
|
|
113
120
|
edges.forEach((edge, i) => {
|
|
114
121
|
portCnt.set(`${sides[i].s}:${edge.source}`, (portCnt.get(`${sides[i].s}:${edge.source}`) ?? 0) + 1);
|
|
@@ -118,34 +125,82 @@ export const render = async (graph, config, level = {}) => {
|
|
|
118
125
|
if (level.fixedSize?.has(node.id))
|
|
119
126
|
continue;
|
|
120
127
|
const cnt = Math.max(portCnt.get(`w:${node.id}`) ?? 0, portCnt.get(`e:${node.id}`) ?? 0);
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
(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));
|
|
124
130
|
}
|
|
131
|
+
/* a stack of more nodes than the configured maximum wraps into two
|
|
132
|
+
staggered sub-columns: its nodes alternate between the outer
|
|
133
|
+
sub-column and the inner one (adjacent to the channel), and
|
|
134
|
+
every outer node is vertically centered onto a gap between two
|
|
135
|
+
inner nodes, through which its edges reach the channel on their
|
|
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 */
|
|
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;
|
|
146
|
+
const outerSet = new Set();
|
|
147
|
+
for (const [c, list] of bySize.entries())
|
|
148
|
+
if (wrapped[c])
|
|
149
|
+
list.filter((_, k) => k % 2 === 0).forEach((node) => outerSet.add(node.id));
|
|
150
|
+
/* the vertical half extent of the lane the edges of an outer node
|
|
151
|
+
occupy around its center: its ports at their maximum separation
|
|
152
|
+
plus the clearance to the inner node boxes (a fixed-size box
|
|
153
|
+
carries its ports at fixed positions anywhere along its side,
|
|
154
|
+
so its lane spans its entire height) */
|
|
155
|
+
const laneOf = (id) => {
|
|
156
|
+
const cnt = Math.max(portCnt.get(`w:${id}`) ?? 0, portCnt.get(`e:${id}`) ?? 0);
|
|
157
|
+
return level.fixedSize?.has(id) ? boxH.get(id) / 2 :
|
|
158
|
+
Math.min(boxH.get(id) / 2, (cnt - 1) * portGap / 2 + LANE_PAD);
|
|
159
|
+
};
|
|
125
160
|
/* fixed three-column layout: stack the input nodes in the first
|
|
126
161
|
column and the output nodes in the third column (each stack
|
|
127
162
|
vertically centered), and place the center node in the second
|
|
128
|
-
column at the vertical center of the canvas
|
|
129
|
-
|
|
130
|
-
const
|
|
131
|
-
const nodeCY = new Map();
|
|
163
|
+
column at the vertical center of the canvas, where an outer node
|
|
164
|
+
keeps its lane clear of the inner nodes above and below it */
|
|
165
|
+
const stackY = new Map();
|
|
132
166
|
const stack = (list) => {
|
|
133
|
-
let
|
|
167
|
+
let [innerB, outerB, laneB, bottom] = [-Infinity, -Infinity, -Infinity, 0];
|
|
134
168
|
for (const node of list) {
|
|
135
|
-
|
|
136
|
-
y
|
|
169
|
+
const h = boxH.get(node.id);
|
|
170
|
+
let y = h / 2;
|
|
171
|
+
if (outerSet.has(node.id)) {
|
|
172
|
+
y = Math.max(y, outerB + gap + h / 2, innerB + laneOf(node.id));
|
|
173
|
+
outerB = y + h / 2;
|
|
174
|
+
laneB = y + laneOf(node.id);
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
y = Math.max(y, innerB + gap + h / 2, laneB + h / 2);
|
|
178
|
+
innerB = y + h / 2;
|
|
179
|
+
}
|
|
180
|
+
stackY.set(node.id, y);
|
|
181
|
+
bottom = Math.max(bottom, y + h / 2);
|
|
137
182
|
}
|
|
183
|
+
return bottom;
|
|
138
184
|
};
|
|
139
|
-
stack(inputs);
|
|
140
|
-
|
|
185
|
+
const stackH = [stack(inputs), stack(outputs)];
|
|
186
|
+
const totalH = Math.max(stackH[0], boxH.get(center.id), stackH[1]);
|
|
187
|
+
const nodeCY = new Map();
|
|
188
|
+
for (const [c, list] of [inputs, outputs].entries())
|
|
189
|
+
for (const node of list)
|
|
190
|
+
nodeCY.set(node.id, margin + (totalH - stackH[c]) / 2 + stackY.get(node.id));
|
|
141
191
|
nodeCY.set(center.id, margin + totalH / 2);
|
|
142
192
|
/* determine column widths and left edge positions, with the two
|
|
143
|
-
inter-column channel widths sized by actual edge usage
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
193
|
+
inter-column channel widths sized by actual edge usage (a
|
|
194
|
+
wrapped column spans its two sub-columns and their gap, which
|
|
195
|
+
holds the arities set back from the arrow heads at its outer
|
|
196
|
+
nodes, as the lanes between the inner nodes have no room) */
|
|
197
|
+
const subWidth = (list, outer) => list.filter((node) => outerSet.has(node.id) === outer)
|
|
198
|
+
.reduce((a, node) => Math.max(a, boxW.get(node.id)), 0);
|
|
199
|
+
const outerW = [subWidth(inputs, true), subWidth(outputs, true)];
|
|
200
|
+
const wrapGap = edges.reduce((a, edge) => outerSet.has(edge.target) && edge.arity !== undefined ?
|
|
201
|
+
Math.max(a, ARITY_OFF + textWidth(edge.arity, config["size-font-arity"]) + WRAP_PAD) : a, WRAP_GAP);
|
|
202
|
+
const sideW = (list, c) => subWidth(list, false) + (outerW[c] > 0 ? (c === 1 ? wrapGap : WRAP_GAP) + outerW[c] : 0);
|
|
203
|
+
const colWidth = [sideW(inputs, 0), boxW.get(center.id), sideW(outputs, 1)];
|
|
149
204
|
const chanOf = (edge) => Math.min(colOf(edge.source), colOf(edge.target));
|
|
150
205
|
const chanCnt = [
|
|
151
206
|
edges.filter((edge) => chanOf(edge) === 0).length,
|
|
@@ -158,20 +213,21 @@ export const render = async (graph, config, level = {}) => {
|
|
|
158
213
|
colLX.push(x);
|
|
159
214
|
x += colWidth[c] + (c < 2 ? chanW[c] : 0);
|
|
160
215
|
}
|
|
161
|
-
/* place the nodes within their column: input nodes
|
|
162
|
-
output nodes left-aligned, and the center node
|
|
216
|
+
/* place the nodes within their (sub-)column: input nodes
|
|
217
|
+
right-aligned, output nodes left-aligned, and the center node
|
|
218
|
+
centered */
|
|
163
219
|
const cx = (id) => {
|
|
164
220
|
const c = colOf(id);
|
|
165
221
|
if (c === 0)
|
|
166
|
-
return colLX[0] + colWidth[0] - boxW.get(id) / 2;
|
|
222
|
+
return colLX[0] + (outerSet.has(id) ? outerW[0] : colWidth[0]) - boxW.get(id) / 2;
|
|
167
223
|
else if (c === 2)
|
|
168
|
-
return colLX[2] + boxW.get(id) / 2;
|
|
224
|
+
return colLX[2] + (outerSet.has(id) ? colWidth[2] - outerW[1] : 0) + boxW.get(id) / 2;
|
|
169
225
|
else
|
|
170
226
|
return colLX[1] + colWidth[1] / 2;
|
|
171
227
|
};
|
|
172
228
|
const cy = (id) => nodeCY.get(id);
|
|
173
229
|
/* distribute the edge attachment ports along each node side */
|
|
174
|
-
const portPos = assignPorts(edges, sides, cx, cy, boxW, boxH,
|
|
230
|
+
const portPos = assignPorts(edges, sides, cx, cy, boxW, boxH, portGap, undefined, level.fixedPort);
|
|
175
231
|
/* assign the vertical tracks within each channel (see assignTracks
|
|
176
232
|
for the crossing-avoiding ordering scheme; a hub graph has no backward
|
|
177
233
|
edges, hence no channel user is ever mirrored) */
|