dsh-model-everything 0.5.0 → 0.5.2
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.en-US.md +12 -0
- package/README.md +4 -0
- package/lib/client.js +2829 -224
- package/lib/render/channels.js +243 -0
- package/lib/render/collapse.js +12 -20
- package/lib/render/components.js +99 -0
- package/lib/render/coords.js +288 -0
- package/lib/render/cycle-break.js +147 -0
- package/lib/render/dummy.js +43 -0
- package/lib/render/from-mermaid.js +34 -10
- package/lib/render/from-plantuml.js +318 -46
- package/lib/render/layer.js +91 -0
- package/lib/render/layout.js +27 -11
- package/lib/render/network-simplex.js +272 -0
- package/lib/render/order.js +122 -0
- package/lib/render/paint.js +1227 -69
- package/lib/render/place.js +393 -121
- package/lib/render/route.js +225 -0
- package/lib/render/svg.js +146 -21
- package/lib/render/symbol.js +108 -0
- package/lib/render/text.js +74 -0
- package/lib/render/trunk.js +213 -0
- package/lib/render/wrap.js +87 -0
- package/lib/types/render/channels.d.ts +63 -0
- package/lib/types/render/components.d.ts +57 -0
- package/lib/types/render/coords.d.ts +64 -0
- package/lib/types/render/cycle-break.d.ts +45 -0
- package/lib/types/render/dummy.d.ts +32 -0
- package/lib/types/render/from-plantuml.d.ts +12 -6
- package/lib/types/render/layer.d.ts +28 -0
- package/lib/types/render/layout.d.ts +43 -11
- package/lib/types/render/model.d.ts +17 -0
- package/lib/types/render/network-simplex.d.ts +54 -0
- package/lib/types/render/order.d.ts +27 -0
- package/lib/types/render/paint.d.ts +157 -10
- package/lib/types/render/place.d.ts +40 -19
- package/lib/types/render/route.d.ts +77 -0
- package/lib/types/render/svg.d.ts +1 -1
- package/lib/types/render/symbol.d.ts +73 -0
- package/lib/types/render/text.d.ts +38 -0
- package/lib/types/render/trunk.d.ts +104 -0
- package/lib/types/render/wrap.d.ts +27 -0
- package/package.json +148 -148
- package/skills/model-everything-diagram/SKILL.md +85 -82
- package/src/render/channels.ts +309 -0
- package/src/render/collapse.ts +150 -155
- package/src/render/components.ts +126 -0
- package/src/render/coords.ts +328 -0
- package/src/render/cycle-break.ts +164 -0
- package/src/render/dummy.ts +65 -0
- package/src/render/from-mermaid.ts +261 -236
- package/src/render/from-plantuml.ts +469 -178
- package/src/render/layer.ts +98 -0
- package/src/render/layout.ts +101 -62
- package/src/render/model.ts +86 -67
- package/src/render/network-simplex.ts +290 -0
- package/src/render/order.ts +139 -0
- package/src/render/paint.ts +1570 -290
- package/src/render/place.ts +532 -234
- package/src/render/route.ts +269 -0
- package/src/render/svg.ts +274 -127
- package/src/render/symbol.ts +137 -0
- package/src/render/text.ts +86 -0
- package/src/render/trunk.ts +275 -0
- package/src/render/wrap.ts +103 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel assignment: where the vertical lanes and the horizontal runs between two rows go.
|
|
3
|
+
*
|
|
4
|
+
* The stages before this one decide where the boxes are, and the painter decides what a box looks like. What is left
|
|
5
|
+
* is the space between two rows, and an edge that decides that space alone will collide with the next one: every long
|
|
6
|
+
* edge has to cross a row somewhere, and every edge whose two ends are not vertically aligned needs one horizontal run
|
|
7
|
+
* inside the gap it crosses. Chosen independently, two runs land on one height and read as a junction, and a run
|
|
8
|
+
* sweeps across a vertical another edge is already using.
|
|
9
|
+
*
|
|
10
|
+
* So this stage assigns both, after the boxes have settled and before anything is drawn:
|
|
11
|
+
*
|
|
12
|
+
* - **A lane's x.** A long edge owns one vertical lane through the rows it crosses. The x is a position no box
|
|
13
|
+
* occupies in any of those rows, and lanes keep the order their dummies have in the ordering, so two lanes never
|
|
14
|
+
* share an x inside one gap. Alignment is worth a detour: a lane that lands inside a box's own port span turns a
|
|
15
|
+
* four-bend staircase into a straight drop, so alignment comes before distance from the proposed position.
|
|
16
|
+
* - **A run's y.** The horizontal runs inside one gap are stacked at distinct heights - Graphviz's virtual nodes - so
|
|
17
|
+
* two runs never lie on one line, and a nested run stays near the box it leaves.
|
|
18
|
+
*
|
|
19
|
+
* Pure: rows, chains and coordinates in; lane positions and run heights out.
|
|
20
|
+
*/
|
|
21
|
+
/** How far a port sits from a box's corner. The painter places ports from this same number. */
|
|
22
|
+
export const PORT_INSET = 10;
|
|
23
|
+
/** The x's a port may sit at on a box's top or bottom side. */
|
|
24
|
+
function spanOf(box) {
|
|
25
|
+
const lo = box.x + PORT_INSET;
|
|
26
|
+
const hi = box.x + box.width - PORT_INSET;
|
|
27
|
+
return hi > lo ? { lo, hi } : { lo: box.x + box.width / 2, hi: box.x + box.width / 2 };
|
|
28
|
+
}
|
|
29
|
+
function clamp(value, span) {
|
|
30
|
+
return Math.min(span.hi, Math.max(span.lo, value));
|
|
31
|
+
}
|
|
32
|
+
function inside(value, span) {
|
|
33
|
+
return value >= span.lo - 0.001 && value <= span.hi + 0.001;
|
|
34
|
+
}
|
|
35
|
+
/** The intervals a blocked strip cuts out of the free ones. */
|
|
36
|
+
function cut(free, blocked) {
|
|
37
|
+
const out = [];
|
|
38
|
+
for (const span of free) {
|
|
39
|
+
if (blocked.hi <= span.lo || blocked.lo >= span.hi) {
|
|
40
|
+
out.push(span);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (blocked.lo > span.lo)
|
|
44
|
+
out.push({ lo: span.lo, hi: blocked.lo });
|
|
45
|
+
if (blocked.hi < span.hi)
|
|
46
|
+
out.push({ lo: blocked.hi, hi: span.hi });
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
/** The nearest free x at or after `value`: a lane pushed right never lands inside a box. */
|
|
51
|
+
function atOrAfter(value, free) {
|
|
52
|
+
let found = value;
|
|
53
|
+
for (const span of free) {
|
|
54
|
+
if (span.hi < value)
|
|
55
|
+
continue;
|
|
56
|
+
found = Math.max(value, span.lo);
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
return found;
|
|
60
|
+
}
|
|
61
|
+
/** The x a lane takes: aligned with a box's own port if it can be, then the proposed position, then the detour. */
|
|
62
|
+
function choose(entry) {
|
|
63
|
+
const candidates = [entry.proposal, entry.source.lo, entry.source.hi, entry.target.lo, entry.target.hi];
|
|
64
|
+
candidates.push((clamp(entry.proposal, entry.source) + clamp(entry.proposal, entry.target)) / 2);
|
|
65
|
+
// Ordered by what the drawing wants: no detour at either end first, then the position coordinate assignment
|
|
66
|
+
// proposed, then the shorter detour. A free x is never this stage's to invent - the corridor is already there.
|
|
67
|
+
let best = entry.proposal;
|
|
68
|
+
let bestBends = Infinity;
|
|
69
|
+
let bestFar = Infinity;
|
|
70
|
+
let bestReach = Infinity;
|
|
71
|
+
for (const span of entry.free) {
|
|
72
|
+
for (const value of candidates) {
|
|
73
|
+
const x = clamp(value, span);
|
|
74
|
+
const bends = (inside(x, entry.source) ? 0 : 1) + (inside(x, entry.target) ? 0 : 1);
|
|
75
|
+
const far = Math.abs(x - entry.proposal);
|
|
76
|
+
const reach = Math.abs(x - clamp(x, entry.source)) + Math.abs(x - clamp(x, entry.target));
|
|
77
|
+
const better = bends < bestBends ||
|
|
78
|
+
(bends === bestBends && (far < bestFar - 0.001 || (Math.abs(far - bestFar) <= 0.001 && reach < bestReach)));
|
|
79
|
+
if (!better)
|
|
80
|
+
continue;
|
|
81
|
+
best = x;
|
|
82
|
+
bestBends = bends;
|
|
83
|
+
bestFar = far;
|
|
84
|
+
bestReach = reach;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return best;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The two ports the painter will place for an edge between two adjacent rows.
|
|
91
|
+
*
|
|
92
|
+
* The painter reads the side off the rows and the position off the box the edge points at; this repeats that rule so
|
|
93
|
+
* the run's height can be stacked against the runs it will actually share a gap with.
|
|
94
|
+
*/
|
|
95
|
+
function adjacentPorts(source, target) {
|
|
96
|
+
const fromMiddle = source.x + source.width / 2;
|
|
97
|
+
const toMiddle = target.x + target.width / 2;
|
|
98
|
+
const fromSpan = spanOf(source);
|
|
99
|
+
const toSpan = spanOf(target);
|
|
100
|
+
const chosen = inside(fromMiddle, toSpan) ? fromMiddle : clamp(toMiddle, fromSpan);
|
|
101
|
+
return { from: clamp(chosen, fromSpan), to: clamp(chosen, toSpan) };
|
|
102
|
+
}
|
|
103
|
+
export function assignChannels(input, options) {
|
|
104
|
+
const { layers, chains, chainOf, boxes, layerY, layerHeight, laneX } = input;
|
|
105
|
+
const rowOf = new Map();
|
|
106
|
+
layers.forEach((row, depth) => {
|
|
107
|
+
for (const id of row)
|
|
108
|
+
rowOf.set(id, depth);
|
|
109
|
+
});
|
|
110
|
+
// A lane's x: free in every row it crosses, and reachable from both ends.
|
|
111
|
+
const long = [];
|
|
112
|
+
for (const chain of chains) {
|
|
113
|
+
if (chain.path.length <= 2)
|
|
114
|
+
continue;
|
|
115
|
+
const head = chain.path[0];
|
|
116
|
+
const tail = chain.path[chain.path.length - 1];
|
|
117
|
+
const from = rowOf.get(head);
|
|
118
|
+
const to = rowOf.get(tail);
|
|
119
|
+
if (from === undefined || to === undefined || to <= from)
|
|
120
|
+
continue;
|
|
121
|
+
const lane = chainOf.get(chain.path[1]);
|
|
122
|
+
const source = boxes.get(head);
|
|
123
|
+
const target = boxes.get(tail);
|
|
124
|
+
// A lane into a boundary has no box to arrive at: that edge is the router's.
|
|
125
|
+
if (lane === undefined || source === undefined || target === undefined)
|
|
126
|
+
continue;
|
|
127
|
+
let free = [{ lo: -Infinity, hi: Infinity }];
|
|
128
|
+
for (let depth = from + 1; depth < to; depth += 1) {
|
|
129
|
+
for (const id of layers[depth]) {
|
|
130
|
+
const box = boxes.get(id);
|
|
131
|
+
if (box === undefined)
|
|
132
|
+
continue;
|
|
133
|
+
free = cut(free, { lo: box.x - options.clearance, hi: box.x + box.width + options.clearance });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const centre = (source.x + source.width / 2 + target.x + target.width / 2) / 2;
|
|
137
|
+
long.push({
|
|
138
|
+
edge: chain.index,
|
|
139
|
+
lane,
|
|
140
|
+
path: chain.path,
|
|
141
|
+
from,
|
|
142
|
+
to,
|
|
143
|
+
free,
|
|
144
|
+
source: spanOf(source),
|
|
145
|
+
target: spanOf(target),
|
|
146
|
+
proposal: laneX.get(lane) ?? centre,
|
|
147
|
+
x: 0,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
for (const entry of long)
|
|
151
|
+
entry.x = choose(entry);
|
|
152
|
+
// The order inside a gap: two lanes never share an x, and the left one is the one the ordering put left.
|
|
153
|
+
const byGap = new Map();
|
|
154
|
+
for (const entry of long) {
|
|
155
|
+
for (let gap = entry.from; gap < entry.to; gap += 1) {
|
|
156
|
+
// The dummy in the lower row of the gap; the last gap has none, and its upper row's dummy orders it.
|
|
157
|
+
const row = gap + 1 < entry.to ? gap + 1 : gap;
|
|
158
|
+
const id = entry.path[row - entry.from];
|
|
159
|
+
const index = layers[row].indexOf(id);
|
|
160
|
+
byGap.set(gap, [...(byGap.get(gap) ?? []), { entry, index }]);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
for (const [, list] of byGap)
|
|
164
|
+
list.sort((left, right) => left.index - right.index);
|
|
165
|
+
for (let round = 0; round < 4; round += 1) {
|
|
166
|
+
let moved = false;
|
|
167
|
+
for (const [, list] of byGap) {
|
|
168
|
+
let previous = -Infinity;
|
|
169
|
+
for (const item of list) {
|
|
170
|
+
const x = item.entry.x < previous + options.spacing ? atOrAfter(previous + options.spacing, item.entry.free) : item.entry.x;
|
|
171
|
+
if (x !== item.entry.x) {
|
|
172
|
+
item.entry.x = x;
|
|
173
|
+
moved = true;
|
|
174
|
+
}
|
|
175
|
+
previous = item.entry.x;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (!moved)
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
// The runs: one per edge end that does not land on its lane, stacked at distinct heights inside the gap.
|
|
182
|
+
const stacks = new Map();
|
|
183
|
+
const want = (gap, edge, left) => {
|
|
184
|
+
stacks.set(gap, [...(stacks.get(gap) ?? []), { edge, gap, left }]);
|
|
185
|
+
};
|
|
186
|
+
for (const entry of long) {
|
|
187
|
+
const head = clamp(entry.x, entry.source);
|
|
188
|
+
const tail = clamp(entry.x, entry.target);
|
|
189
|
+
if (Math.abs(head - entry.x) > 0.5)
|
|
190
|
+
want(entry.from, entry.edge, Math.min(head, entry.x));
|
|
191
|
+
if (Math.abs(tail - entry.x) > 0.5)
|
|
192
|
+
want(entry.to - 1, entry.edge, Math.min(tail, entry.x));
|
|
193
|
+
}
|
|
194
|
+
for (const chain of chains) {
|
|
195
|
+
if (chain.path.length > 2)
|
|
196
|
+
continue;
|
|
197
|
+
const head = chain.path[0];
|
|
198
|
+
const tail = chain.path[chain.path.length - 1];
|
|
199
|
+
const from = rowOf.get(head);
|
|
200
|
+
const to = rowOf.get(tail);
|
|
201
|
+
if (from === undefined || to === undefined || Math.abs(from - to) !== 1)
|
|
202
|
+
continue;
|
|
203
|
+
const source = boxes.get(head);
|
|
204
|
+
const target = boxes.get(tail);
|
|
205
|
+
if (source === undefined || target === undefined)
|
|
206
|
+
continue;
|
|
207
|
+
const ports = adjacentPorts(source, target);
|
|
208
|
+
want(Math.min(from, to), chain.index, Math.min(ports.from, ports.to));
|
|
209
|
+
}
|
|
210
|
+
// A gap always has a row below it, so its strip is bounded: one stack per gap, inside that strip.
|
|
211
|
+
const midOf = (gap) => (layerY[gap] + layerHeight[gap] + layerY[gap + 1]) / 2;
|
|
212
|
+
const heightOf = new Map();
|
|
213
|
+
for (const [gap, list] of stacks) {
|
|
214
|
+
const top = layerY[gap] + layerHeight[gap];
|
|
215
|
+
const bottom = layerY[gap + 1];
|
|
216
|
+
const pad = Math.min(8, Math.max(1, (bottom - top) / 3));
|
|
217
|
+
const lo = top + pad;
|
|
218
|
+
const hi = Math.max(lo, bottom - pad);
|
|
219
|
+
// Left to right, so a run that stays near its box sits near the row it left.
|
|
220
|
+
const sorted = [...list].sort((left, right) => left.left - right.left || left.edge - right.edge);
|
|
221
|
+
sorted.forEach((item, index) => {
|
|
222
|
+
const y = sorted.length === 1 ? (lo + hi) / 2 : lo + ((hi - lo) * (index + 1)) / (sorted.length + 1);
|
|
223
|
+
const heights = heightOf.get(item.edge) ?? new Map();
|
|
224
|
+
heights.set(item.gap, y);
|
|
225
|
+
heightOf.set(item.edge, heights);
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
const lanes = new Map();
|
|
229
|
+
for (const entry of long) {
|
|
230
|
+
const y = [];
|
|
231
|
+
for (let gap = entry.from; gap < entry.to; gap += 1)
|
|
232
|
+
y.push(heightOf.get(entry.edge)?.get(gap) ?? midOf(gap));
|
|
233
|
+
lanes.set(entry.edge, { x: entry.x, y });
|
|
234
|
+
}
|
|
235
|
+
const runs = new Map();
|
|
236
|
+
for (const [edge, heights] of heightOf) {
|
|
237
|
+
// A long edge's heights belong to its lane; an edge between two rows has the one run it asked for.
|
|
238
|
+
if (lanes.has(edge))
|
|
239
|
+
continue;
|
|
240
|
+
runs.set(edge, [...heights.values()][0]);
|
|
241
|
+
}
|
|
242
|
+
return { lanes, runs };
|
|
243
|
+
}
|
package/lib/render/collapse.js
CHANGED
|
@@ -9,20 +9,17 @@
|
|
|
9
9
|
* an architecture diagram that cannot be folded is read at one zoom level forever.
|
|
10
10
|
*/
|
|
11
11
|
/** How many nodes a group holds, counting the whole subtree - what the folded box offers to unfold. */
|
|
12
|
-
function subtreeSize(
|
|
12
|
+
function subtreeSize(groups, groupId, seen) {
|
|
13
13
|
if (seen.has(groupId))
|
|
14
14
|
return 0;
|
|
15
15
|
seen.add(groupId);
|
|
16
|
-
const groups = diagram.groups ?? [];
|
|
17
16
|
const group = groups.find((entry) => entry.id === groupId);
|
|
18
|
-
if (group === undefined)
|
|
19
|
-
return 0;
|
|
20
17
|
const children = groups.filter((entry) => entry.parent === groupId);
|
|
21
|
-
return group.members.length + children.reduce((total, child) => total + subtreeSize(
|
|
18
|
+
return group.members.length + children.reduce((total, child) => total + subtreeSize(groups, child.id, seen), 0);
|
|
22
19
|
}
|
|
23
20
|
/** The group that directly holds a node, when one does. */
|
|
24
|
-
function ownerOf(
|
|
25
|
-
return
|
|
21
|
+
function ownerOf(groups, nodeId) {
|
|
22
|
+
return groups.find((group) => group.members.includes(nodeId))?.id;
|
|
26
23
|
}
|
|
27
24
|
/**
|
|
28
25
|
* Fold the named groups.
|
|
@@ -45,7 +42,7 @@ export function collapse(diagram, folded) {
|
|
|
45
42
|
const targets = new Set();
|
|
46
43
|
for (const id of asked) {
|
|
47
44
|
const group = byId.get(id);
|
|
48
|
-
if (group !== undefined && subtreeSize(
|
|
45
|
+
if (group !== undefined && subtreeSize(groups, id, new Set()) > 0)
|
|
49
46
|
targets.add(id);
|
|
50
47
|
}
|
|
51
48
|
if (targets.size === 0)
|
|
@@ -65,21 +62,16 @@ export function collapse(diagram, folded) {
|
|
|
65
62
|
}
|
|
66
63
|
const removedNodes = new Set();
|
|
67
64
|
const removedGroups = new Set();
|
|
68
|
-
const descend = (groupId
|
|
69
|
-
if (seen.has(groupId))
|
|
70
|
-
return;
|
|
71
|
-
seen.add(groupId);
|
|
65
|
+
const descend = (groupId) => {
|
|
72
66
|
removedGroups.add(groupId);
|
|
73
67
|
const group = byId.get(groupId);
|
|
74
|
-
if (group === undefined)
|
|
75
|
-
return;
|
|
76
68
|
for (const member of group.members)
|
|
77
69
|
removedNodes.add(member);
|
|
78
70
|
for (const child of groups.filter((entry) => entry.parent === groupId))
|
|
79
|
-
descend(child.id
|
|
71
|
+
descend(child.id);
|
|
80
72
|
};
|
|
81
73
|
for (const id of targets)
|
|
82
|
-
descend(id
|
|
74
|
+
descend(id);
|
|
83
75
|
/** The innermost fold that swallowed this id, if any. */
|
|
84
76
|
const foldOf = (id) => {
|
|
85
77
|
for (const target of targets) {
|
|
@@ -92,7 +84,7 @@ export function collapse(diagram, folded) {
|
|
|
92
84
|
return groups.filter((entry) => entry.parent === groupId).some((child) => inSubtree(child.id, seen));
|
|
93
85
|
};
|
|
94
86
|
const group = byId.get(target);
|
|
95
|
-
if (group.members.includes(id) || inSubtree(ownerOf(
|
|
87
|
+
if (group.members.includes(id) || inSubtree(ownerOf(groups, id), new Set()))
|
|
96
88
|
return target;
|
|
97
89
|
}
|
|
98
90
|
return undefined;
|
|
@@ -111,14 +103,14 @@ export function collapse(diagram, folded) {
|
|
|
111
103
|
continue;
|
|
112
104
|
emitted.add(fold);
|
|
113
105
|
const group = byId.get(fold);
|
|
114
|
-
nodes.push({ id: fold, label: group.label + ' (' + String(subtreeSize(
|
|
106
|
+
nodes.push({ id: fold, label: group.label + ' (' + String(subtreeSize(groups, fold, new Set())) + ')', role: 'normal' });
|
|
115
107
|
}
|
|
116
108
|
// A fold whose members are not in `nodes` at all still deserves a box rather than vanishing.
|
|
117
109
|
for (const id of targets) {
|
|
118
110
|
if (emitted.has(id))
|
|
119
111
|
continue;
|
|
120
112
|
const group = byId.get(id);
|
|
121
|
-
nodes.push({ id, label: group.label + ' (' + String(subtreeSize(
|
|
113
|
+
nodes.push({ id, label: group.label + ' (' + String(subtreeSize(groups, id, new Set())) + ')', role: 'normal' });
|
|
122
114
|
}
|
|
123
115
|
const edges = [];
|
|
124
116
|
const seenEdges = new Set();
|
|
@@ -140,7 +132,7 @@ export function collapse(diagram, folded) {
|
|
|
140
132
|
const adopted = new Map();
|
|
141
133
|
for (const id of targets) {
|
|
142
134
|
const parent = byId.get(id)?.parent;
|
|
143
|
-
if (parent === undefined
|
|
135
|
+
if (parent === undefined)
|
|
144
136
|
continue;
|
|
145
137
|
adopted.set(parent, [...(adopted.get(parent) ?? []), id]);
|
|
146
138
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two decompositions a drawing needs before it can be packed.
|
|
3
|
+
*
|
|
4
|
+
* **Weakly connected components.** Two subgraphs with no edge between them are two drawings that happen to share a
|
|
5
|
+
* page. Laying them out together makes them share one set of layers, so the wider one sets every column width and
|
|
6
|
+
* the narrower one is squeezed beside it; moving one moves the other. Split first, lay each out on its own, then pack
|
|
7
|
+
* the rectangles.
|
|
8
|
+
*
|
|
9
|
+
* **Packing.** A pile of rectangles has to become a page. The target is the aspect ratio a page and a screen share -
|
|
10
|
+
* 1.6 - and a shelf packer is enough to get there: sort by height, fill a shelf until the next rectangle would make
|
|
11
|
+
* the drawing wider than the page it is aiming at, then start the next shelf. No overlap, and a deterministic order,
|
|
12
|
+
* so the same rectangles always pack the same way.
|
|
13
|
+
*
|
|
14
|
+
* Pure, and asserted on the rectangles themselves.
|
|
15
|
+
*/
|
|
16
|
+
/** The aspect ratio a page and a landscape screen share. */
|
|
17
|
+
const PAGE_ASPECT = 1.6;
|
|
18
|
+
/**
|
|
19
|
+
* The nodes of each weakly connected component, in the order the nodes were written.
|
|
20
|
+
*
|
|
21
|
+
* An isolated node is a component of its own: it is not noise, it is a drawing with nothing joining it, and the
|
|
22
|
+
* packer is what decides where it goes.
|
|
23
|
+
*/
|
|
24
|
+
export function weakComponents(nodes, edges) {
|
|
25
|
+
const known = new Set(nodes.map((node) => node.id));
|
|
26
|
+
const neighbours = new Map();
|
|
27
|
+
const link = (from, to) => {
|
|
28
|
+
neighbours.set(from, [...(neighbours.get(from) ?? []), to]);
|
|
29
|
+
};
|
|
30
|
+
for (const edge of edges) {
|
|
31
|
+
if (!known.has(edge.from) || !known.has(edge.to) || edge.from === edge.to)
|
|
32
|
+
continue;
|
|
33
|
+
link(edge.from, edge.to);
|
|
34
|
+
link(edge.to, edge.from);
|
|
35
|
+
}
|
|
36
|
+
const seen = new Set();
|
|
37
|
+
const components = [];
|
|
38
|
+
for (const node of nodes) {
|
|
39
|
+
if (seen.has(node.id))
|
|
40
|
+
continue;
|
|
41
|
+
const members = [];
|
|
42
|
+
const queue = [node.id];
|
|
43
|
+
seen.add(node.id);
|
|
44
|
+
for (let head = 0; head < queue.length; head += 1) {
|
|
45
|
+
const id = queue[head];
|
|
46
|
+
members.push(id);
|
|
47
|
+
for (const next of neighbours.get(id) ?? []) {
|
|
48
|
+
if (seen.has(next))
|
|
49
|
+
continue;
|
|
50
|
+
seen.add(next);
|
|
51
|
+
queue.push(next);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
components.push(members);
|
|
55
|
+
}
|
|
56
|
+
return components;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Pack rectangles into a page of roughly `aspect`, using shelves.
|
|
60
|
+
*
|
|
61
|
+
* The shelf width is derived from the total area and the target aspect: that is the width at which a perfect grid
|
|
62
|
+
* would have the wanted shape, so a shelf that fills it lands near the target. Rectangles are taken tallest first,
|
|
63
|
+
* which is what keeps a shelf from being mostly empty air above a short rectangle.
|
|
64
|
+
*/
|
|
65
|
+
export function packRectangles(rectangles, options = {}) {
|
|
66
|
+
const gap = options.gap ?? 30;
|
|
67
|
+
const aspect = options.aspect ?? PAGE_ASPECT;
|
|
68
|
+
if (rectangles.length === 0)
|
|
69
|
+
return { placements: [], width: 0, height: 0 };
|
|
70
|
+
const area = rectangles.reduce((total, rectangle) => total + (rectangle.width + gap) * (rectangle.height + gap), 0);
|
|
71
|
+
const targetWidth = Math.max(gap, Math.sqrt(area * aspect));
|
|
72
|
+
const order = rectangles
|
|
73
|
+
.map((rectangle, index) => ({ rectangle, index }))
|
|
74
|
+
.sort((left, right) => right.rectangle.height - left.rectangle.height || right.rectangle.width - left.rectangle.width || left.index - right.index);
|
|
75
|
+
const placements = rectangles.map((rectangle) => ({ x: 0, y: 0, width: rectangle.width, height: rectangle.height }));
|
|
76
|
+
let x = 0;
|
|
77
|
+
let y = 0;
|
|
78
|
+
let shelfHeight = 0;
|
|
79
|
+
let width = 0;
|
|
80
|
+
for (const entry of order) {
|
|
81
|
+
const rectangle = entry.rectangle;
|
|
82
|
+
if (x > 0 && x + rectangle.width > targetWidth) {
|
|
83
|
+
y += shelfHeight + gap;
|
|
84
|
+
x = 0;
|
|
85
|
+
shelfHeight = 0;
|
|
86
|
+
}
|
|
87
|
+
placements[entry.index] = { x, y, width: rectangle.width, height: rectangle.height };
|
|
88
|
+
x += rectangle.width + gap;
|
|
89
|
+
width = Math.max(width, x - gap);
|
|
90
|
+
shelfHeight = Math.max(shelfHeight, rectangle.height);
|
|
91
|
+
}
|
|
92
|
+
return { placements, width, height: y + shelfHeight };
|
|
93
|
+
}
|
|
94
|
+
/** The aspect ratio of a box, as a number a reader can compare against the page's. */
|
|
95
|
+
export function aspectOf(width, height) {
|
|
96
|
+
if (width <= 0 || height <= 0)
|
|
97
|
+
return 1;
|
|
98
|
+
return Math.max(width, height) / Math.min(width, height);
|
|
99
|
+
}
|