@adecore/diagram 0.39.0 → 0.40.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/src/order.ts CHANGED
@@ -15,23 +15,29 @@ export function orderLayers(layering: Layering): void {
15
15
  const position = new Array<number>(units.length).fill(0);
16
16
  const bandPosition = bands.map((band) => new Array<number>(band.last - band.first + 1).fill(0));
17
17
  const hopsByLayer: number[][] = items.map(() => []);
18
+
18
19
  hops.forEach((hop, index) => {
19
20
  hopsByLayer[units[hop.from]!.layer]!.push(index);
20
21
  });
21
22
 
22
23
  const flatten = (layer: number): void => {
23
24
  let at = 0;
25
+
24
26
  for (const code of items[layer]!) {
25
27
  if (code >= 0) {
26
28
  position[code] = at++;
29
+
27
30
  continue;
28
31
  }
32
+
29
33
  const band = bands[bandOfCode(code)]!;
30
34
  const members = band.members[layer - band.first]!;
31
35
  bandPosition[bandOfCode(code)]![layer - band.first] = at;
36
+
32
37
  if (members.length === 0) {
33
38
  at++;
34
39
  }
40
+
35
41
  for (const member of members) {
36
42
  position[member] = at++;
37
43
  }
@@ -42,13 +48,16 @@ export function orderLayers(layering: Layering): void {
42
48
  const row = items[layer]!;
43
49
  const slots: number[] = [];
44
50
  const present: number[] = [];
51
+
45
52
  row.forEach((code, index) => {
46
53
  if (code < 0) {
47
54
  slots.push(index);
48
55
  present.push(bandOfCode(code));
49
56
  }
50
57
  });
58
+
51
59
  present.sort((left, right) => bands[left]!.rank - bands[right]!.rank);
60
+
52
61
  slots.forEach((slot, index) => {
53
62
  row[slot] = bandCode(present[index]!);
54
63
  });
@@ -57,27 +66,35 @@ export function orderLayers(layering: Layering): void {
57
66
  const sortLayer = (layer: number, towardsUp: boolean): void => {
58
67
  const keyOf = (unit: number): number => {
59
68
  const neighbors = towardsUp ? units[unit]!.up : units[unit]!.down;
69
+
60
70
  if (neighbors.length === 0) {
61
71
  return position[unit]!;
62
72
  }
73
+
63
74
  let sum = 0;
75
+
64
76
  for (const neighbor of neighbors) {
65
77
  sum += position[neighbor]!;
66
78
  }
79
+
67
80
  return sum / neighbors.length;
68
81
  };
82
+
69
83
  const keyed = items[layer]!.map((code, index) => {
70
84
  if (code >= 0) {
71
85
  return { code, index, key: keyOf(code) };
72
86
  }
87
+
73
88
  const band = bands[bandOfCode(code)]!;
74
89
  const slot = layer - band.first;
75
90
  const members = band.members[slot]!.map((unit) => ({ unit, key: keyOf(unit), at: position[unit]! }));
76
91
  members.sort((left, right) => left.key - right.key || left.at - right.at);
77
92
  band.members[slot] = members.map((member) => member.unit);
78
93
  const key = members.length > 0 ? members.reduce((sum, member) => sum + member.key, 0) / members.length : bandPosition[bandOfCode(code)]![slot]!;
94
+
79
95
  return { code, index, key };
80
96
  });
97
+
81
98
  keyed.sort((left, right) => left.key - right.key || left.index - right.index);
82
99
  items[layer] = keyed.map((entry) => entry.code);
83
100
  enforceBandOrder(layer);
@@ -87,13 +104,17 @@ export function orderLayers(layering: Layering): void {
87
104
  const updateRanks = (): void => {
88
105
  const scores = bands.map((band, index) => {
89
106
  let sum = 0;
107
+
90
108
  for (let layer = band.first; layer <= band.last; layer++) {
91
109
  const row = items[layer]!;
92
110
  sum += row.indexOf(bandCode(index)) / row.length;
93
111
  }
112
+
94
113
  return sum / (band.last - band.first + 1);
95
114
  });
115
+
96
116
  const order = bands.map((_, index) => index).sort((left, right) => scores[left]! - scores[right]! || bands[left]!.rank - bands[right]!.rank);
117
+
97
118
  order.forEach((band, rank) => {
98
119
  bands[band]!.rank = rank;
99
120
  });
@@ -105,47 +126,59 @@ export function orderLayers(layering: Layering): void {
105
126
  if (!items[layer]!.some((code) => code < 0)) {
106
127
  continue;
107
128
  }
129
+
108
130
  const previousIndex = new Map<number, number>();
109
131
  (items[layer - 1] ?? []).forEach((code, index) => previousIndex.set(code, index));
110
132
  const free = items[layer]!.filter((code) => code >= 0);
133
+
111
134
  for (const unit of free) {
112
135
  const row = items[layer]!;
113
136
  const present = row.filter((code) => code < 0).map(bandOfCode);
114
137
  let lower = 0;
115
138
  let upper = present.length;
139
+
116
140
  const narrow = (source: number, sourceLayer: number): void => {
117
141
  let low = 0;
118
142
  let high = present.length;
143
+
119
144
  present.forEach((band, slot) => {
120
145
  const target = bands[band]!;
121
146
  const sourceBand = units[source]!.band;
147
+
122
148
  if (sourceLayer < target.first || sourceLayer > target.last || sourceBand === band) {
123
149
  return;
124
150
  }
151
+
125
152
  const before =
126
153
  sourceBand >= 0 ? bands[sourceBand]!.rank < target.rank : previousIndex.get(source)! < previousIndex.get(bandCode(band))!;
154
+
127
155
  if (before) {
128
156
  high = Math.min(high, slot);
129
157
  } else {
130
158
  low = Math.max(low, slot + 1);
131
159
  }
132
160
  });
161
+
133
162
  if (Math.max(lower, low) <= Math.min(upper, high)) {
134
163
  lower = Math.max(lower, low);
135
164
  upper = Math.min(upper, high);
136
165
  }
137
166
  };
167
+
138
168
  for (const source of units[unit]!.up) {
139
169
  narrow(source, layer - 1);
140
170
  }
171
+
141
172
  // A member of a group in the next layer cannot move, so the free unit follows it instead.
142
173
  for (const target of units[unit]!.down) {
143
174
  if (units[target]!.band >= 0) {
144
175
  narrow(target, layer + 1);
145
176
  }
146
177
  }
178
+
147
179
  const at = row.indexOf(unit);
148
180
  const slot = row.slice(0, at).filter((code) => code < 0).length;
181
+
149
182
  if (slot < lower) {
150
183
  row.splice(at, 1);
151
184
  row.splice(row.indexOf(bandCode(present[lower - 1]!)) + 1, 0, unit);
@@ -159,26 +192,34 @@ export function orderLayers(layering: Layering): void {
159
192
 
160
193
  const crossings = (): number => {
161
194
  let total = 0;
195
+
162
196
  for (let layer = 0; layer + 1 < items.length; layer++) {
163
197
  const pairs = hopsByLayer[layer]!.map((index) => [position[hops[index]!.from]!, position[hops[index]!.to]!] as const);
198
+
164
199
  if (pairs.length < 2) {
165
200
  continue;
166
201
  }
202
+
167
203
  pairs.sort((left, right) => left[0] - right[0] || left[1] - right[1]);
168
204
  // A Fenwick tree over the positions of the next layer counts the pairs that come in out of order.
169
205
  const size = pairs.reduce((largest, pair) => Math.max(largest, pair[1]), 0) + 2;
170
206
  const tree = new Array<number>(size + 1).fill(0);
207
+
171
208
  pairs.forEach(([, to], seen) => {
172
209
  let atMost = 0;
210
+
173
211
  for (let i = to + 1; i > 0; i -= i & -i) {
174
212
  atMost += tree[i]!;
175
213
  }
214
+
176
215
  total += seen - atMost;
216
+
177
217
  for (let i = to + 1; i <= size; i += i & -i) {
178
218
  tree[i]! += 1;
179
219
  }
180
220
  });
181
221
  }
222
+
182
223
  return total;
183
224
  };
184
225
 
@@ -186,6 +227,7 @@ export function orderLayers(layering: Layering): void {
186
227
  items.forEach((_, layer) => enforceBandOrder(layer));
187
228
  keepSides();
188
229
  items.forEach((_, layer) => flatten(layer));
230
+
189
231
  return crossings();
190
232
  };
191
233
 
@@ -196,10 +238,10 @@ export function orderLayers(layering: Layering): void {
196
238
  });
197
239
 
198
240
  items.forEach((_, layer) => flatten(layer));
241
+
199
242
  // A band's first layer always holds a member, which is what made it the first.
200
- const firstSeen = bands.map((band, index) => [band.first, position[band.members[0]![0]!]!, index] as const);
201
- firstSeen
202
- .slice()
243
+ bands
244
+ .map((band, index) => [band.first, position[band.members[0]![0]!]!, index] as const)
203
245
  .sort((left, right) => left[0] - right[0] || left[1] - right[1] || left[2] - right[2])
204
246
  .forEach(([, , band], rank) => {
205
247
  bands[band]!.rank = rank;
@@ -207,6 +249,7 @@ export function orderLayers(layering: Layering): void {
207
249
 
208
250
  let bestCount = settle();
209
251
  let best = snapshot();
252
+
210
253
  for (let sweep = 0; sweep < SWEEPS && bestCount > 0; sweep++) {
211
254
  if (sweep % 2 === 0) {
212
255
  for (let layer = 1; layer < items.length; layer++) {
@@ -217,8 +260,10 @@ export function orderLayers(layering: Layering): void {
217
260
  sortLayer(layer, false);
218
261
  }
219
262
  }
263
+
220
264
  updateRanks();
221
265
  const count = settle();
266
+
222
267
  if (count < bestCount) {
223
268
  bestCount = count;
224
269
  best = snapshot();
@@ -228,6 +273,7 @@ export function orderLayers(layering: Layering): void {
228
273
  best.items.forEach((row, layer) => {
229
274
  items[layer] = row;
230
275
  });
276
+
231
277
  bands.forEach((band, index) => {
232
278
  band.members = best.members[index]!;
233
279
  band.rank = best.ranks[index]!;
package/src/protocol.ts CHANGED
@@ -83,6 +83,7 @@ export const EMPTY_DIAGRAM: DiagramDocument = { version: 1, rev: 0, meta: { titl
83
83
  /* One version so far, so reading is a parse. A file that is not a diagram at all reads as null. */
84
84
  export function migrateDiagram(value: unknown): DiagramDocument | null {
85
85
  const parsed = DiagramDocumentSchema.safeParse(value);
86
+
86
87
  return parsed.success ? parsed.data : null;
87
88
  }
88
89
 
@@ -95,35 +96,47 @@ export function diagramProblemIn(content: Pick<DiagramContent, 'nodes' | 'groups
95
96
  if (content.nodes.length > DIAGRAM_LIMITS.nodes) {
96
97
  return `A diagram holds at most ${DIAGRAM_LIMITS.nodes} nodes, and this one has ${content.nodes.length}`;
97
98
  }
99
+
98
100
  if (content.edges.length > DIAGRAM_LIMITS.edges) {
99
101
  return `A diagram holds at most ${DIAGRAM_LIMITS.edges} edges, and this one has ${content.edges.length}`;
100
102
  }
103
+
101
104
  // Nodes and groups share one namespace, so an id always says which box it means.
102
105
  const nodes = new Set<string>();
106
+
103
107
  for (const node of content.nodes) {
104
108
  if (nodes.has(node.id)) {
105
109
  return `Two nodes share the id "${node.id}"`;
106
110
  }
111
+
107
112
  nodes.add(node.id);
108
113
  }
114
+
109
115
  const groups = new Set<string>();
110
116
  const groupOf = new Map<string, string>();
117
+
111
118
  for (const group of content.groups) {
112
119
  if (nodes.has(group.id) || groups.has(group.id)) {
113
120
  return `The group id "${group.id}" is already taken`;
114
121
  }
122
+
115
123
  groups.add(group.id);
124
+
116
125
  for (const id of group.wraps) {
117
126
  if (!nodes.has(id)) {
118
127
  return `The group "${group.id}" wraps "${id}", which is not a node`;
119
128
  }
129
+
120
130
  const other = groupOf.get(id);
131
+
121
132
  if (other !== undefined) {
122
133
  return `The node "${id}" is in two groups, "${other}" and "${group.id}"`;
123
134
  }
135
+
124
136
  groupOf.set(id, group.id);
125
137
  }
126
138
  }
139
+
127
140
  for (const edge of content.edges) {
128
141
  for (const end of [edge.from, edge.to]) {
129
142
  if (!nodes.has(end)) {
@@ -131,5 +144,6 @@ export function diagramProblemIn(content: Pick<DiagramContent, 'nodes' | 'groups
131
144
  }
132
145
  }
133
146
  }
147
+
134
148
  return null;
135
149
  }
@@ -13,14 +13,19 @@ function nameOf(node: DiagramNode | undefined, id: string): string {
13
13
  export function readingOrder(document: Pick<DiagramDocument, 'nodes' | 'groups' | 'edges'>): string[] {
14
14
  const byId = new Map(document.nodes.map((node) => [node.id, node]));
15
15
  const layers = layersOf(document.nodes, document.edges);
16
+
16
17
  const nodes = document.nodes
17
18
  .map((node, index) => ({ node, index, layer: layers.get(node.id) ?? 0 }))
18
19
  .sort((left, right) => left.layer - right.layer || left.index - right.index)
19
20
  .map(({ node }) => (node.sub ? `${nameOf(node, node.id)} (${node.sub})` : nameOf(node, node.id)));
21
+
20
22
  const edges = document.edges.map((edge) => {
21
23
  const line = `${nameOf(byId.get(edge.from), edge.from)} -> ${nameOf(byId.get(edge.to), edge.to)}`;
24
+
22
25
  return edge.label ? `${line}: ${edge.label}` : line;
23
26
  });
27
+
24
28
  const groups = document.groups.map((group) => `${group.label || group.id} wraps: ${group.wraps.map((id) => nameOf(byId.get(id), id)).join(', ')}`);
29
+
25
30
  return [...nodes, ...edges, ...groups];
26
31
  }
package/src/route.ts CHANGED
@@ -1,13 +1,14 @@
1
+ import { intersects } from '@adecore/drawing';
1
2
  import type { Point, Rect } from './layout.ts';
2
3
 
3
- export const TRACK_GAP = 12;
4
+ const TRACK_GAP = 12;
4
5
  /* Space between the lane of labels and the first track. */
5
6
  const LANE_GAP = 12;
6
7
  /* Space between two labels side by side in a lane. */
7
8
  const LABEL_GAP = 8;
8
9
  /* How far a label keeps from the line it belongs to. */
9
10
  const LABEL_OFFSET = 4;
10
- export const MIN_CHANNEL = 64;
11
+ const MIN_CHANNEL = 64;
11
12
  /* Two horizontal lines closer than this read as one. */
12
13
  const COLLINEAR = 4;
13
14
  /* Two vertical ranges closer than this need tracks of their own. */
@@ -48,10 +49,6 @@ export interface ChannelResult {
48
49
  labels: Map<number, Rect>;
49
50
  }
50
51
 
51
- function overlaps(left: Rect, right: Rect): boolean {
52
- return left.x < right.x + right.w && right.x < left.x + left.w && left.y < right.y + right.h && right.y < left.y + left.h;
53
- }
54
-
55
52
  function inflate(rect: Rect, by: number): Rect {
56
53
  return { x: rect.x - by, y: rect.y - by, w: rect.w + by * 2, h: rect.h + by * 2 };
57
54
  }
@@ -60,31 +57,39 @@ function inflate(rect: Rect, by: number): Rect {
60
57
  * How badly two orthogonal polylines get in each other's way: a crossing counts once, a stretch where
61
58
  * they run on top of each other counts four times, since that is the one a reader cannot follow.
62
59
  */
63
- export function conflictsOf(left: readonly Point[], right: readonly Point[]): number {
60
+ function conflictsOf(left: readonly Point[], right: readonly Point[]): number {
64
61
  let total = 0;
62
+
65
63
  for (let i = 1; i < left.length; i++) {
66
64
  const a0 = left[i - 1]!;
67
65
  const a1 = left[i]!;
66
+
68
67
  for (let j = 1; j < right.length; j++) {
69
68
  const b0 = right[j - 1]!;
70
69
  const b1 = right[j]!;
71
70
  const aFlat = a0.y === a1.y;
72
71
  const bFlat = b0.y === b1.y;
72
+
73
73
  if (aFlat !== bFlat) {
74
74
  const [h0, h1, v0, v1] = aFlat ? [a0, a1, b0, b1] : [b0, b1, a0, a1];
75
+
75
76
  if (v0.x > Math.min(h0.x, h1.x) && v0.x < Math.max(h0.x, h1.x) && h0.y > Math.min(v0.y, v1.y) && h0.y < Math.max(v0.y, v1.y)) {
76
77
  total += 1;
77
78
  }
79
+
78
80
  continue;
79
81
  }
82
+
80
83
  const [along0, along1, other0, other1, gap] = aFlat
81
84
  ? [Math.min(a0.x, a1.x), Math.max(a0.x, a1.x), Math.min(b0.x, b1.x), Math.max(b0.x, b1.x), Math.abs(a0.y - b0.y)]
82
85
  : [Math.min(a0.y, a1.y), Math.max(a0.y, a1.y), Math.min(b0.y, b1.y), Math.max(b0.y, b1.y), Math.abs(a0.x - b0.x)];
86
+
83
87
  if (gap < COLLINEAR && Math.min(along1, other1) > Math.max(along0, other0)) {
84
88
  total += 4;
85
89
  }
86
90
  }
87
91
  }
92
+
88
93
  return total;
89
94
  }
90
95
 
@@ -107,11 +112,14 @@ function sketchOf(hop: ChannelHop, track: number): Point[] {
107
112
  export function routeChannel(input: ChannelInput): ChannelResult {
108
113
  const vertical = input.hops.filter((hop) => hop.ya !== hop.yb);
109
114
  const count = vertical.length;
115
+
110
116
  const near = (left: ChannelHop, right: ChannelHop): boolean =>
111
117
  Math.min(Math.max(left.ya, left.yb), Math.max(right.ya, right.yb)) + RANGE_CLEARANCE >
112
118
  Math.max(Math.min(left.ya, left.yb), Math.min(right.ya, right.yb));
119
+
113
120
  // cost[i * count + j]: how much i and j conflict when i's track is left of j's.
114
121
  const cost = new Array<number>(count * count).fill(0);
122
+
115
123
  for (let i = 0; i < count; i++) {
116
124
  for (let j = 0; j < count; j++) {
117
125
  if (i !== j && near(vertical[i]!, vertical[j]!)) {
@@ -119,39 +127,52 @@ export function routeChannel(input: ChannelInput): ChannelResult {
119
127
  }
120
128
  }
121
129
  }
130
+
122
131
  const score = vertical.map((_, i) => {
123
132
  let sum = 0;
133
+
124
134
  for (let j = 0; j < count; j++) {
125
135
  sum += cost[i * count + j]! - cost[j * count + i]!;
126
136
  }
137
+
127
138
  return sum;
128
139
  });
140
+
129
141
  const order = vertical.map((_, i) => i).sort((left, right) => score[left]! - score[right]! || left - right);
142
+
130
143
  for (let pass = 0; pass < count; pass++) {
131
144
  let swapped = false;
145
+
132
146
  for (let slot = 0; slot + 1 < count; slot++) {
133
147
  const left = order[slot]!;
134
148
  const right = order[slot + 1]!;
149
+
135
150
  if (cost[right * count + left]! < cost[left * count + right]!) {
136
151
  order[slot] = right;
137
152
  order[slot + 1] = left;
138
153
  swapped = true;
139
154
  }
140
155
  }
156
+
141
157
  if (!swapped) {
142
158
  break;
143
159
  }
144
160
  }
161
+
145
162
  const track = new Array<number>(count).fill(0);
146
163
  let tracks = 0;
164
+
147
165
  order.forEach((index, slot) => {
148
166
  let at = 0;
167
+
149
168
  for (let earlier = 0; earlier < slot; earlier++) {
150
169
  const other = order[earlier]!;
170
+
151
171
  if (near(vertical[other]!, vertical[index]!)) {
152
172
  at = Math.max(at, track[other]! + 1);
153
173
  }
154
174
  }
175
+
155
176
  track[index] = at;
156
177
  tracks = Math.max(tracks, at + 1);
157
178
  });
@@ -171,25 +192,31 @@ export function routeChannel(input: ChannelInput): ChannelResult {
171
192
 
172
193
  const touches = (x0: number, x1: number, y0: number, y1: number, rect: Rect): boolean =>
173
194
  x1 >= rect.x - 3 && x0 <= rect.x + rect.w + 3 && y1 >= rect.y - 3 && y0 <= rect.y + rect.h + 3;
195
+
174
196
  const blocked = (zone: Zone, rect: Rect): boolean =>
175
- placed.some((other) => other.zone === zone && overlaps(inflate(other.rect, 4), rect)) ||
197
+ placed.some((other) => other.zone === zone && intersects(inflate(other.rect, 4), rect)) ||
176
198
  input.obstacles.some((obstacle) => obstacle.y < rect.y + rect.h && rect.y < obstacle.y + obstacle.h);
199
+
177
200
  const onLine = (zone: Zone, rect: Rect, label: ChannelLabel): boolean =>
178
201
  input.hops.some((hop) => {
179
202
  if (zone === 'before') {
180
203
  return hop.hop !== label.hop && hop.ya > rect.y - 3 && hop.ya < rect.y + rect.h + 3;
181
204
  }
205
+
182
206
  const own = hop.hop === label.hop;
183
207
  const at = trackOf.get(hop.hop);
208
+
184
209
  if (at === undefined) {
185
210
  return !own && touches(-Infinity, Infinity, hop.ya, hop.ya, rect);
186
211
  }
212
+
187
213
  return (
188
214
  (!own && touches(-Infinity, at, hop.ya, hop.ya, rect)) ||
189
215
  (!own && touches(at, at, Math.min(hop.ya, hop.yb), Math.max(hop.ya, hop.yb), rect)) ||
190
216
  touches(at, Infinity, hop.yb, hop.yb, rect)
191
217
  );
192
218
  });
219
+
193
220
  const inLane = (label: ChannelLabel, x: number, y: number, above: boolean): Rect => ({
194
221
  x: x + Math.floor((slotAlong - label.along) / 2),
195
222
  y: above ? y - LABEL_OFFSET - label.across : y + LABEL_OFFSET,
@@ -199,10 +226,14 @@ export function routeChannel(input: ChannelInput): ChannelResult {
199
226
 
200
227
  for (const label of input.labels) {
201
228
  const hop = input.hops.find((candidate) => candidate.hop === label.hop);
229
+
202
230
  const before = (slot: number) => [true, false].map((above) => ({ zone: 'before' as Zone, rect: inLane(label, slot * slotStep, label.y, above) }));
231
+
203
232
  const after = (slot: number) =>
204
233
  hop ? [true, false].map((above) => ({ zone: 'middle' as Zone, rect: inLane(label, afterStart + slot * slotStep, hop.yb, above) })) : [];
234
+
205
235
  const at = trackOf.get(label.hop);
236
+
206
237
  const beside =
207
238
  hop && at !== undefined
208
239
  ? [
@@ -212,32 +243,42 @@ export function routeChannel(input: ChannelInput): ChannelResult {
212
243
  }
213
244
  ]
214
245
  : [];
246
+
215
247
  const existing = [
216
248
  ...Array.from({ length: slotsBefore }, (_, slot) => before(slot)).flat(),
217
249
  ...beside,
218
250
  ...Array.from({ length: slotsAfter }, (_, slot) => after(slot)).flat()
219
251
  ];
252
+
220
253
  const clear = (candidate: { zone: Zone; rect: Rect }): boolean =>
221
254
  !blocked(candidate.zone, candidate.rect) && !onLine(candidate.zone, candidate.rect, label);
255
+
222
256
  let choice = existing.find(clear);
257
+
223
258
  if (!choice) {
224
259
  choice = before(slotsBefore).find(clear);
260
+
225
261
  if (choice) {
226
262
  slotsBefore += 1;
227
263
  }
228
264
  }
265
+
229
266
  if (!choice) {
230
267
  choice = after(slotsAfter).find(clear);
268
+
231
269
  if (choice) {
232
270
  slotsAfter += 1;
233
271
  }
234
272
  }
273
+
235
274
  // A place beside no line may not exist, since every line in a lane runs its whole width; a free place is enough.
236
275
  choice ??= existing.find((candidate) => !blocked(candidate.zone, candidate.rect));
276
+
237
277
  if (!choice) {
238
278
  choice = before(slotsBefore)[0]!;
239
279
  slotsBefore += 1;
240
280
  }
281
+
241
282
  placed.push(choice);
242
283
  chosen.set(label.edge, choice);
243
284
  }
@@ -246,11 +287,13 @@ export function routeChannel(input: ChannelInput): ChannelResult {
246
287
  const laneWidth = Math.max(0, ...placed.filter((entry) => entry.zone === 'before').map((entry) => entry.rect.x + entry.rect.w));
247
288
  const middleLabels = placed.filter((entry) => entry.zone === 'middle');
248
289
  const hasMiddle = tracks > 0 || middleLabels.length > 0;
290
+
249
291
  const middleWidth = Math.max(
250
292
  tracksSpan,
251
293
  slotsAfter > 0 ? afterStart + slotsAfter * slotStep - LABEL_GAP : 0,
252
294
  ...middleLabels.map((entry) => entry.rect.x + entry.rect.w)
253
295
  );
296
+
254
297
  const laneGap = laneWidth > 0 && hasMiddle ? LANE_GAP : 0;
255
298
  const needed = input.clearBefore + laneWidth + laneGap + (hasMiddle ? middleWidth : 0) + input.clearAfter;
256
299
  const width = Math.max(MIN_CHANNEL, needed);
@@ -259,8 +302,10 @@ export function routeChannel(input: ChannelInput): ChannelResult {
259
302
  const trackX = new Map<number, number>();
260
303
  trackOf.forEach((x, hop) => trackX.set(hop, middleStart + x));
261
304
  const labels = new Map<number, Rect>();
305
+
262
306
  chosen.forEach((entry, edge) => {
263
307
  labels.set(edge, { ...entry.rect, x: entry.rect.x + (entry.zone === 'before' ? laneStart : middleStart) });
264
308
  });
309
+
265
310
  return { width, trackX, labels };
266
311
  }
package/src/shapes.ts CHANGED
@@ -18,6 +18,7 @@ function roundedRect(box: Rect, radius: number): string {
18
18
  const corner = Math.round(Math.min(radius, box.w / 2, box.h / 2));
19
19
  const right = box.x + box.w;
20
20
  const bottom = box.y + box.h;
21
+
21
22
  return [
22
23
  `M${box.x + corner} ${box.y}`,
23
24
  `H${right - corner}`,
@@ -37,6 +38,7 @@ export function shapePaths(shape: DiagramShape | undefined, box: Rect): ShapePat
37
38
  const bottom = box.y + box.h;
38
39
  const middleX = Math.round(box.x + box.w / 2);
39
40
  const middleY = Math.round(box.y + box.h / 2);
41
+
40
42
  switch (shape ?? 'rect') {
41
43
  case 'round':
42
44
  return { body: roundedRect(box, ROUND_RADIUS), detail: null };
@@ -50,6 +52,7 @@ export function shapePaths(shape: DiagramShape | undefined, box: Rect): ShapePat
50
52
  const ry = Math.round(lid / 2);
51
53
  const top = box.y + ry;
52
54
  const base = bottom - ry;
55
+
53
56
  return {
54
57
  body: `M${box.x} ${top} A${rx} ${ry} 0 0 1 ${right} ${top} V${base} A${rx} ${ry} 0 0 1 ${box.x} ${base} Z`,
55
58
  detail: `M${box.x} ${top} A${rx} ${ry} 0 0 0 ${right} ${top}`
@@ -77,11 +80,13 @@ export function textLinesOf(box: Pick<NodeBox, 'x' | 'y' | 'w' | 'h' | 'label' |
77
80
  const label = box.label.map((text, index) => ({ text, x, y: top + index * LABEL_LINE + 14, size: LABEL_SIZE, bold: true, muted: false }));
78
81
  const subTop = top + box.label.length * LABEL_LINE;
79
82
  const sub = box.sub.map((text, index) => ({ text, x, y: subTop + index * SUB_LINE + 12, size: SUB_SIZE, bold: false, muted: true }));
83
+
80
84
  return [...label, ...sub];
81
85
  }
82
86
 
83
87
  export function edgeLabelLinesOf(label: EdgeLabelBox): TextLine[] {
84
88
  const x = Math.round(label.x + label.w / 2);
89
+
85
90
  return label.lines.map((text, index) => ({
86
91
  text,
87
92
  x,
@@ -105,6 +110,7 @@ export function arrowHeadPath(points: readonly Point[]): string {
105
110
  const baseX = tip.x - ux * HEAD_LENGTH;
106
111
  const baseY = tip.y - uy * HEAD_LENGTH;
107
112
  const round = Math.round;
113
+
108
114
  return `M${tip.x} ${tip.y} L${round(baseX - uy * HEAD_WIDTH)} ${round(baseY + ux * HEAD_WIDTH)} L${round(baseX + uy * HEAD_WIDTH)} ${round(baseY - ux * HEAD_WIDTH)} Z`;
109
115
  }
110
116
 
@@ -112,8 +118,10 @@ export function dashOf(style: DiagramEdgeStyle | undefined): string | null {
112
118
  if (style === 'dashed') {
113
119
  return '8 6';
114
120
  }
121
+
115
122
  if (style === 'dotted') {
116
123
  return '2 5';
117
124
  }
125
+
118
126
  return null;
119
127
  }
package/src/svg.ts CHANGED
@@ -41,6 +41,7 @@ export function toSvg(document: Pick<DiagramDocument, 'meta' | 'nodes' | 'groups
41
41
  const w = layout.bounds.w + margin * 2;
42
42
  const h = layout.bounds.h + margin * 2;
43
43
  const parts: string[] = [];
44
+
44
45
  if (options.background) {
45
46
  parts.push(`<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${options.background}"/>`);
46
47
  }
@@ -48,6 +49,7 @@ export function toSvg(document: Pick<DiagramDocument, 'meta' | 'nodes' | 'groups
48
49
  for (const box of layout.groups) {
49
50
  const group = groups.get(box.id)!;
50
51
  const tone = palette[group.tone ?? DEFAULT_GROUP_TONE];
52
+
51
53
  parts.push(
52
54
  `<rect x="${box.x}" y="${box.y}" width="${box.w}" height="${box.h}" rx="12" fill="none" stroke="${tone}" stroke-width="1" stroke-dasharray="6 4"/>`,
53
55
  `<text x="${box.labelBox.x}" y="${box.y + Math.round(GROUP_LABEL_BAND * 0.7)}" font-size="${SUB_SIZE}" font-weight="600" fill="${tone}">${escapeXml(group.label)}</text>`
@@ -58,12 +60,15 @@ export function toSvg(document: Pick<DiagramDocument, 'meta' | 'nodes' | 'groups
58
60
  const edge = document.edges[route.index]!;
59
61
  const tone = palette[edge.tone ?? DEFAULT_EDGE_TONE];
60
62
  const dash = dashOf(edge.style);
63
+
61
64
  parts.push(
62
65
  `<path d="${edgePath(route.points)}" fill="none" stroke="${tone}" stroke-width="2" stroke-linejoin="round"${dash ? ` stroke-dasharray="${dash}"` : ''}/>`,
63
66
  `<path d="${arrowHeadPath(route.points)}" fill="${tone}"/>`
64
67
  );
68
+
65
69
  if (route.label) {
66
70
  const halo = options.background ? ` stroke="${options.background}" stroke-width="4" paint-order="stroke"` : '';
71
+
67
72
  for (const line of edgeLabelLinesOf(route.label)) {
68
73
  parts.push(
69
74
  `<text x="${line.x}" y="${line.y}" font-size="${line.size}" text-anchor="middle" fill="${tone}"${halo}>${escapeXml(line.text)}</text>`
@@ -77,9 +82,11 @@ export function toSvg(document: Pick<DiagramDocument, 'meta' | 'nodes' | 'groups
77
82
  const tone = node.tone ?? DEFAULT_NODE_TONE;
78
83
  const paths = shapePaths(node.shape, box);
79
84
  parts.push(`<path d="${paths.body}" fill="${paper[tone]}" stroke="${palette[tone]}" stroke-width="2"/>`);
85
+
80
86
  if (paths.detail) {
81
87
  parts.push(`<path d="${paths.detail}" fill="none" stroke="${palette[tone]}" stroke-width="2"/>`);
82
88
  }
89
+
83
90
  for (const line of textLinesOf(box, node.shape)) {
84
91
  parts.push(
85
92
  `<text x="${line.x}" y="${line.y}" font-size="${line.size}"${line.bold ? ' font-weight="600"' : ''} text-anchor="middle" fill="${line.muted ? palette.muted : palette.ink}">${escapeXml(line.text)}</text>`