@contraagent/elements 0.9.0 → 0.9.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.
@@ -0,0 +1,331 @@
1
+ // The layout as it was first written, with a scan of every edge per container and per
2
+ // slot: the faster one in layout.ts must give the same drawing.
3
+ import { isPoint } from './arrange.js';
4
+ import type { Arrangement, EdgeStyle, EdgePathStep } from './types.js';
5
+
6
+ export interface Size {
7
+ w: number;
8
+ h: number;
9
+ }
10
+
11
+ /** A rectangle in the drawing. */
12
+ export interface Rect {
13
+ x: number;
14
+ y: number;
15
+ w: number;
16
+ h: number;
17
+ }
18
+
19
+ /** A straight stretch of an edge along the flow, from `a` to `b`, `at` across it; an edge bends between two. */
20
+ export interface Stretch {
21
+ a: number;
22
+ b: number;
23
+ at: number;
24
+ }
25
+
26
+ export interface GraphLayout {
27
+ /** The drawing's size, its margin included. */
28
+ w: number;
29
+ h: number;
30
+ /** Every node, stub and cluster, by id. */
31
+ at: Record<string, Rect>;
32
+ /** Every edge path, with its runs. */
33
+ edges: { from: string; to: string; style: EdgeStyle; runs: Stretch[] }[];
34
+ /** Each cycling cluster's return to its next round. */
35
+ returns: LoopReturn[];
36
+ down: boolean;
37
+ }
38
+
39
+ /**
40
+ * A cluster's return to its next round, along the flow (`u`) and across it (`v`): from
41
+ * the far side of its last layer's outermost item (`from`, at `v0`) out to a band
42
+ * past its content (`band`), back along the band, and in to its first layer's
43
+ * outermost item (`to`, at `v1`). Left to right the band is under the nodes; top to
44
+ * bottom, on their right.
45
+ */
46
+ export interface LoopReturn {
47
+ box: string;
48
+ from: number;
49
+ v0: number;
50
+ to: number;
51
+ v1: number;
52
+ band: number;
53
+ }
54
+
55
+ /** The spaces of a drawing; left to right unless `down`. */
56
+ export interface LayoutSpace {
57
+ /** Between two layers, at the top level and in a cluster. */
58
+ gap: number;
59
+ boxGap: number;
60
+ /** Between two items of a layer, and next to a point an edge passes. */
61
+ sep: number;
62
+ pointSep: number;
63
+ /** A cluster's padding, and the space under its label. */
64
+ pad: number;
65
+ /** The space after a box's content, across the flow: the band its return to the next round runs in. */
66
+ padEnd: number;
67
+ margin: number;
68
+ }
69
+
70
+ /** The band past a cluster's content that its return to the next round runs in, and the return's corner radius. */
71
+ export const RETURN_BAND = 24;
72
+ export const RETURN_R = 6;
73
+
74
+ export const SPACE: Record<'across' | 'down', LayoutSpace> = {
75
+ across: { gap: 56, boxGap: 40, sep: 16, pointSep: 12, pad: 12, padEnd: RETURN_BAND, margin: 16 },
76
+ down: { gap: 40, boxGap: 32, sep: 16, pointSep: 12, pad: 10, padEnd: RETURN_BAND, margin: 16 },
77
+ };
78
+
79
+ /** Tops (across the flow) nearest to `want` that keep the order and each gap: weighted isotonic regression (pool adjacent violators). */
80
+ function placeRow(want: readonly number[], gaps: readonly number[], weight: readonly number[]): number[] {
81
+ const off: number[] = [0];
82
+ for (let i = 1; i < want.length; i++) off.push((off[i - 1] as number) + (gaps[i - 1] as number));
83
+ const blocks: { sum: number; w: number; n: number }[] = [];
84
+ want.forEach((v, i) => {
85
+ const w = weight[i] ?? 1;
86
+ blocks.push({ sum: w * (v - (off[i] as number)), w, n: 1 });
87
+ while (blocks.length > 1) {
88
+ const b = blocks[blocks.length - 1] as { sum: number; w: number; n: number };
89
+ const a = blocks[blocks.length - 2] as { sum: number; w: number; n: number };
90
+ if (a.sum / a.w <= b.sum / b.w) break;
91
+ blocks.splice(-2, 2, { sum: a.sum + b.sum, w: a.w + b.w, n: a.n + b.n });
92
+ }
93
+ });
94
+ const out: number[] = [];
95
+ for (const b of blocks) for (let k = 0; k < b.n; k++) out.push(b.sum / b.w + (off[out.length] as number));
96
+ return out;
97
+ }
98
+
99
+ /**
100
+ * Where everything of a layered graph is drawn, from the sizes of its nodes, its stubs
101
+ * and its clusters' labels (`lbl:<box id>`): left to right, or top to bottom when
102
+ * `down`. Each container is laid out inside out, a box being one item of its parent:
103
+ * its layers side by side, an item at the start of its layer; across the flow, each
104
+ * item as near as its order allows to the items it is joined to (sweeps of weighted
105
+ * isotonic regression), so chains run straight. An edge runs along its points and
106
+ * bends only in the gap between two layers; it enters and leaves a box where its
107
+ * point inside is, and never passes a node or a box's label.
108
+ */
109
+ export function layoutGraph(g: Pick<Arrangement, 'clusters' | 'slots' | 'edgePaths'>, sizes: ReadonlyMap<string, Size>, down = false): GraphLayout {
110
+ const S = SPACE[down ? 'down' : 'across'];
111
+ const len = (s: Size) => (down ? s.h : s.w);
112
+ const brd = (s: Size) => (down ? s.w : s.h);
113
+ const boxIds = new Set(g.clusters.map((b) => b.id));
114
+ const parentOf = new Map(g.clusters.map((b) => [b.id, b.parent]));
115
+ const label = (box: string): Size => sizes.get(`lbl:${box}`) ?? { w: 0, h: 0 };
116
+ // A box's padding: along the flow, and across it. Its label is in a band at its top: across
117
+ // the flow left to right; along it top to bottom, where a gap under the label lets edges in.
118
+ const padOf = (box: string) =>
119
+ down
120
+ ? { u0: label(box).h + S.pad + S.gap / 2, u1: S.pad, v0: S.pad, v1: S.padEnd, minU: 0, minV: label(box).w + 2 * S.pad }
121
+ : { u0: S.pad, u1: S.pad, v0: label(box).h + S.pad, v1: S.padEnd, minU: label(box).w + 2 * S.pad, minV: 0 };
122
+ const extent = new Map<string, { u: number; v: number }>(); // a box's size, along and across
123
+ const slotSize = (id: string): { u: number; v: number } => {
124
+ if (boxIds.has(id)) return extent.get(id) ?? { u: 0, v: 0 };
125
+ if (isPoint(id)) return { u: 0, v: 0 };
126
+ const s = sizes.get(id) ?? { w: 0, h: 0 };
127
+ return { u: len(s), v: brd(s) };
128
+ };
129
+ interface Local {
130
+ rows: string[][];
131
+ start: number[];
132
+ width: number[];
133
+ top: Map<string, number>;
134
+ u: number;
135
+ v: number;
136
+ }
137
+ const local = new Map<string, Local>();
138
+ const layerIn = new Map<string, Map<string, number>>();
139
+ const depth = (c: string): number => (c === '' ? 0 : 1 + depth(parentOf.get(c) ?? ''));
140
+ const containers = ['', ...g.clusters.map((b) => b.id)].sort((a, b) => depth(b) - depth(a));
141
+ // Where an edge meets a slot, across the flow from the slot's top: a node's middle,
142
+ // a point, or (a box) where the edge's step inside it is.
143
+ const attach = (ri: number, si: number): number => {
144
+ const steps = g.edgePaths[ri]?.steps ?? [];
145
+ const s = steps[si] as EdgePathStep;
146
+ if (isPoint(s.id)) return 0;
147
+ if (!boxIds.has(s.id)) return slotSize(s.id).v / 2;
148
+ const inner = steps[si - 1]?.in === s.id ? si - 1 : si + 1;
149
+ const step = steps[inner] as EdgePathStep;
150
+ return padOf(s.id).v0 + (local.get(s.id)?.top.get(step.id) ?? 0) + attach(ri, inner);
151
+ };
152
+ for (const c of containers) {
153
+ const rows = (g.slots[c] ?? []).map((r) => [...r]);
154
+ layerIn.set(c, new Map(rows.flatMap((r, i) => r.map((id) => [id, i] as const))));
155
+ const width = rows.map((r) => Math.max(0, ...r.map((id) => slotSize(id).u)));
156
+ const gap = c === '' ? S.gap : S.boxGap;
157
+ const start: number[] = [];
158
+ width.forEach((_, i) => start.push(i ? (start[i - 1] as number) + (width[i - 1] as number) + gap : 0));
159
+ // The joins inside this container: slot a of layer l to slot b of layer l + 1.
160
+ const links: { a: string; b: string; oa: number; ob: number }[] = [];
161
+ g.edgePaths.forEach((r, ri) =>
162
+ r.steps.forEach((s, si) => {
163
+ const p = r.steps[si - 1];
164
+ if (si && p && p.in === c && s.in === c && layerIn.get(c)?.get(p.id) !== layerIn.get(c)?.get(s.id)) links.push({ a: p.id, b: s.id, oa: attach(ri, si - 1), ob: attach(ri, si) });
165
+ }),
166
+ );
167
+ // Points sit close to each other and to a node, further from a box (its next-pass mark is on its edge).
168
+ const sepOf = (a: string, b: string | undefined) =>
169
+ b === undefined ? 0 : boxIds.has(a) || boxIds.has(b) || !(isPoint(a) || isPoint(b)) ? S.sep : S.pointSep;
170
+ const top = new Map<string, number>();
171
+ for (const r of rows) {
172
+ let v = 0;
173
+ r.forEach((id, i) => {
174
+ top.set(id, v);
175
+ v += slotSize(id).v + sepOf(id, r[i + 1]);
176
+ });
177
+ }
178
+ const place = (r: string[], want: (id: string) => number[]) => {
179
+ const desired = r.map((id) => {
180
+ const xs = want(id);
181
+ return xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : (top.get(id) ?? 0);
182
+ });
183
+ const gaps = r.map((id, i) => slotSize(id).v + sepOf(id, r[i + 1]));
184
+ // The nodes first, as if the points between them were fixed in size; then the points, around the nodes.
185
+ const real = r.map((id, i) => (isPoint(id) ? -1 : i)).filter((i) => i >= 0);
186
+ const span = (i: number, j: number) => gaps.slice(i, j).reduce((a, b) => a + b, 0);
187
+ const at = placeRow(
188
+ real.map((i) => desired[i] as number),
189
+ real.map((i, k) => (k + 1 < real.length ? span(i, real[k + 1] as number) : 0)),
190
+ real.map(() => 1),
191
+ );
192
+ real.forEach((i, k) => (desired[i] = at[k] as number));
193
+ placeRow(desired, gaps, r.map((id) => (isPoint(id) ? 1 : 1e6))).forEach((v, i) => top.set(r[i] as string, Math.round(v * 100) / 100));
194
+ };
195
+ // A node lines up with the nodes it is joined to, when it has any on that side, else with the points;
196
+ // a point lines up with whatever it is joined to, so a chain runs straight and a long edge goes round it.
197
+ const near = (id: string, ls: { other: string; at: number }[]) => {
198
+ const real = isPoint(id) ? ls : ls.filter((x) => !isPoint(x.other));
199
+ return (real.length ? real : ls).map((x) => x.at);
200
+ };
201
+ const fromPrev = (id: string) => near(id, links.filter((l) => l.b === id).map((l) => ({ other: l.a, at: (top.get(l.a) ?? 0) + l.oa - l.ob })));
202
+ const fromNext = (id: string) => near(id, links.filter((l) => l.a === id).map((l) => ({ other: l.b, at: (top.get(l.b) ?? 0) + l.ob - l.oa })));
203
+ for (let sweep = 0; sweep < 8; sweep++) {
204
+ const order = rows.map((_, i) => i);
205
+ if (sweep % 2) order.reverse();
206
+ for (const i of order) place(rows[i] as string[], sweep % 2 ? fromNext : fromPrev);
207
+ }
208
+ for (const r of rows) place(r, (id) => [...fromPrev(id), ...fromNext(id)]);
209
+ const lo = top.size ? Math.min(...top.values()) : 0;
210
+ for (const [id, v] of top) top.set(id, v - lo);
211
+ const u = (start[start.length - 1] ?? 0) + (width[width.length - 1] ?? 0);
212
+ const v = Math.max(0, ...rows.flat().map((id) => (top.get(id) ?? 0) + slotSize(id).v));
213
+ local.set(c, { rows, start, width, top, u, v });
214
+ if (c !== '') {
215
+ const p = padOf(c);
216
+ extent.set(c, { u: Math.max(p.u0 + u + p.u1, p.minU), v: Math.max(p.v0 + v + p.v1, p.minV) });
217
+ }
218
+ }
219
+
220
+ // Inside out, then outside in: each container's place in the drawing.
221
+ const at: Record<string, Rect> = {};
222
+ const origin = new Map<string, { u: number; v: number }>();
223
+ const rect = (u: number, v: number, s: { u: number; v: number }): Rect => (down ? { x: v, y: u, w: s.v, h: s.u } : { x: u, y: v, w: s.u, h: s.v });
224
+ const put = (c: string, ou: number, ov: number) => {
225
+ origin.set(c, { u: ou, v: ov });
226
+ const l = local.get(c) as Local;
227
+ l.rows.forEach((r, i) =>
228
+ r.forEach((id) => {
229
+ if (isPoint(id)) return;
230
+ const u = ou + (l.start[i] as number);
231
+ const v = ov + (l.top.get(id) ?? 0);
232
+ at[id] = rect(u, v, slotSize(id));
233
+ if (boxIds.has(id)) {
234
+ const p = padOf(id);
235
+ put(id, u + p.u0, v + p.v0);
236
+ }
237
+ }),
238
+ );
239
+ };
240
+ put('', S.margin, S.margin);
241
+ const whole = local.get('') as Local;
242
+ const U = whole.u + 2 * S.margin;
243
+ const V = whole.v + 2 * S.margin;
244
+
245
+ // A container's layer l: where it starts and ends along the flow, in the drawing.
246
+ const col = (c: string, l: number): [number, number] => {
247
+ const lc = local.get(c) as Local;
248
+ const o = origin.get(c) ?? { u: 0, v: 0 };
249
+ const c0 = o.u + (lc.start[l] ?? 0);
250
+ return [c0, c0 + (lc.width[l] ?? 0)];
251
+ };
252
+ const layer = (c: string, id: string) => layerIn.get(c)?.get(id) ?? 0;
253
+ const byOf = (s: EdgePathStep | undefined): string => (s && isPoint(s.id) ? (/\|by:([^|]*)\|/.exec(s.id)?.[1] ?? '') : '');
254
+ const edges = g.edgePaths.map((r, ri) => {
255
+ const vOf = (si: number): number => {
256
+ const s = r.steps[si] as EdgePathStep;
257
+ const o = origin.get(s.in) ?? { u: 0, v: 0 };
258
+ return o.v + (local.get(s.in)?.top.get(s.id) ?? 0) + attach(ri, si);
259
+ };
260
+ const runs: Stretch[] = r.steps.map((s, si): Stretch => {
261
+ const prev = r.steps[si - 1];
262
+ const next = r.steps[si + 1];
263
+ const [c0, c1] = col(s.in, layer(s.in, s.id));
264
+ const v = vOf(si);
265
+ if (si === 0) return { a: c0 + slotSize(s.id).u, b: c1, at: v };
266
+ if (si === r.steps.length - 1) return { a: c0, b: c0, at: v };
267
+ if (isPoint(s.id)) {
268
+ const box = byOf(s);
269
+ // Beside a box: from where it left the box's side, or up to where it goes in.
270
+ if (box && prev?.id === box) return { a: col(box, layer(box, r.steps[si - 2]?.id ?? '') + 1)[0], b: c1, at: v };
271
+ if (box && next?.id === box) {
272
+ const ln = layer(box, r.steps[si + 2]?.id ?? '');
273
+ const gap = ln > 0 ? col(box, ln - 1)[1] : (at[box] ? (down ? at[box].y : at[box].x) : c0) + label(box).h + S.pad;
274
+ return { a: c0, b: gap, at: v };
275
+ }
276
+ return { a: c0, b: c1, at: v };
277
+ }
278
+ const p = padOf(s.id);
279
+ const inner = local.get(s.id) as Local;
280
+ // Out of a box: at its far side, or through its side after the layer it leaves.
281
+ if (prev?.in === s.id) {
282
+ if (byOf(next) === s.id) {
283
+ const u = col(s.id, layer(s.id, prev.id) + 1)[0];
284
+ return { a: u, b: u, at: vOf(si + 1) };
285
+ }
286
+ return { a: c0 + p.u0 + inner.u, b: c1, at: v };
287
+ }
288
+ // Into a box: at its near side, or through its side before the layer it goes to.
289
+ if (byOf(prev) === s.id) {
290
+ const u = col(s.id, layer(s.id, next?.id ?? ''))[0];
291
+ return { a: u, b: u, at: vOf(si + 1) };
292
+ }
293
+ return { a: c0, b: c0 + p.u0, at: v };
294
+ });
295
+ return { from: r.from, to: r.to, style: r.style, runs };
296
+ });
297
+ // Each box's return to its next round: from its last layer's item that reaches furthest
298
+ // across the flow, along the band past the content, to its first layer's.
299
+ const returns = g.clusters.flatMap((b): LoopReturn[] => {
300
+ if (!b.cycle) return [];
301
+ const l = local.get(b.id);
302
+ const o = origin.get(b.id);
303
+ if (!l || !o) return [];
304
+ const outer = (row: readonly string[] | undefined) => {
305
+ const ids = (row ?? []).filter((id) => !isPoint(id));
306
+ let best: { u: number; v: number } | null = null;
307
+ for (const id of ids) {
308
+ const s = slotSize(id);
309
+ const v = (l.top.get(id) ?? 0) + s.v;
310
+ if (!best || v > best.v) best = { u: s.u, v };
311
+ }
312
+ return best;
313
+ };
314
+ const real = l.rows.map((r, i) => (r.some((id) => !isPoint(id)) ? i : -1)).filter((i) => i >= 0);
315
+ const fi = real[0] ?? 0;
316
+ const li = real[real.length - 1] ?? 0;
317
+ const last = outer(l.rows[li]);
318
+ const first = outer(l.rows[fi]);
319
+ if (!last || !first) return [];
320
+ // An item's middle along the flow, as wide as its layer; one layer: either side of its middle.
321
+ let from = o.u + (l.start[li] ?? 0) + (l.width[li] ?? 0) / 2;
322
+ let to = o.u + (l.start[fi] ?? 0) + (l.width[fi] ?? 0) / 2;
323
+ if (from - to < 4 * RETURN_R) {
324
+ const d = Math.max(2 * RETURN_R, Math.min(28, Math.min(last.u, first.u) / 4));
325
+ from = (from + to) / 2 + d;
326
+ to = from - 2 * d;
327
+ }
328
+ return [{ box: b.id, from, v0: o.v + last.v, to, v1: o.v + first.v, band: o.v + l.v + RETURN_BAND / 2 }];
329
+ });
330
+ return { w: down ? V : U, h: down ? U : V, at, edges, returns, down };
331
+ }
@@ -149,21 +149,42 @@ export function layoutGraph(g: Pick<Arrangement, 'clusters' | 'slots' | 'edgePat
149
149
  const step = steps[inner] as EdgePathStep;
150
150
  return padOf(s.id).v0 + (local.get(s.id)?.top.get(step.id) ?? 0) + attach(ri, inner);
151
151
  };
152
+ for (const c of containers) layerIn.set(c, new Map((g.slots[c] ?? []).flatMap((r, i) => r.map((id) => [id, i] as const))));
153
+ // Each container's joins, as steps of the edge paths: slot a of layer l to slot b of layer l + 1.
154
+ const joinsIn = new Map<string, [number, number][]>();
155
+ g.edgePaths.forEach((r, ri) =>
156
+ r.steps.forEach((s, si) => {
157
+ const p = r.steps[si - 1];
158
+ if (!si || !p || p.in !== s.in) return;
159
+ const lv = layerIn.get(s.in);
160
+ if (!lv || lv.get(p.id) === lv.get(s.id)) return;
161
+ const js = joinsIn.get(s.in);
162
+ if (js) js.push([ri, si]);
163
+ else joinsIn.set(s.in, [[ri, si]]);
164
+ }),
165
+ );
152
166
  for (const c of containers) {
153
167
  const rows = (g.slots[c] ?? []).map((r) => [...r]);
154
- layerIn.set(c, new Map(rows.flatMap((r, i) => r.map((id) => [id, i] as const))));
155
- const width = rows.map((r) => Math.max(0, ...r.map((id) => slotSize(id).u)));
168
+ const width = rows.map((r) => {
169
+ let w = 0;
170
+ for (const id of r) w = Math.max(w, slotSize(id).u);
171
+ return w;
172
+ });
156
173
  const gap = c === '' ? S.gap : S.boxGap;
157
174
  const start: number[] = [];
158
175
  width.forEach((_, i) => start.push(i ? (start[i - 1] as number) + (width[i - 1] as number) + gap : 0));
159
176
  // The joins inside this container: slot a of layer l to slot b of layer l + 1.
160
- const links: { a: string; b: string; oa: number; ob: number }[] = [];
161
- g.edgePaths.forEach((r, ri) =>
162
- r.steps.forEach((s, si) => {
163
- const p = r.steps[si - 1];
164
- if (si && p && p.in === c && s.in === c && layerIn.get(c)?.get(p.id) !== layerIn.get(c)?.get(s.id)) links.push({ a: p.id, b: s.id, oa: attach(ri, si - 1), ob: attach(ri, si) });
165
- }),
166
- );
177
+ type Link = { a: string; b: string; oa: number; ob: number };
178
+ const links: Link[] = (joinsIn.get(c) ?? []).map(([ri, si]) => {
179
+ const steps = (g.edgePaths[ri] as (typeof g.edgePaths)[number]).steps;
180
+ return { a: (steps[si - 1] as EdgePathStep).id, b: (steps[si] as EdgePathStep).id, oa: attach(ri, si - 1), ob: attach(ri, si) };
181
+ });
182
+ const intoOf = new Map<string, Link[]>();
183
+ const outOf = new Map<string, Link[]>();
184
+ for (const l of links) {
185
+ (intoOf.get(l.b) ?? intoOf.set(l.b, []).get(l.b))?.push(l);
186
+ (outOf.get(l.a) ?? outOf.set(l.a, []).get(l.a))?.push(l);
187
+ }
167
188
  // Points sit close to each other and to a node, further from a box (its next-pass mark is on its edge).
168
189
  const sepOf = (a: string, b: string | undefined) =>
169
190
  b === undefined ? 0 : boxIds.has(a) || boxIds.has(b) || !(isPoint(a) || isPoint(b)) ? S.sep : S.pointSep;
@@ -198,18 +219,20 @@ export function layoutGraph(g: Pick<Arrangement, 'clusters' | 'slots' | 'edgePat
198
219
  const real = isPoint(id) ? ls : ls.filter((x) => !isPoint(x.other));
199
220
  return (real.length ? real : ls).map((x) => x.at);
200
221
  };
201
- const fromPrev = (id: string) => near(id, links.filter((l) => l.b === id).map((l) => ({ other: l.a, at: (top.get(l.a) ?? 0) + l.oa - l.ob })));
202
- const fromNext = (id: string) => near(id, links.filter((l) => l.a === id).map((l) => ({ other: l.b, at: (top.get(l.b) ?? 0) + l.ob - l.oa })));
222
+ const fromPrev = (id: string) => near(id, (intoOf.get(id) ?? []).map((l) => ({ other: l.a, at: (top.get(l.a) ?? 0) + l.oa - l.ob })));
223
+ const fromNext = (id: string) => near(id, (outOf.get(id) ?? []).map((l) => ({ other: l.b, at: (top.get(l.b) ?? 0) + l.ob - l.oa })));
203
224
  for (let sweep = 0; sweep < 8; sweep++) {
204
225
  const order = rows.map((_, i) => i);
205
226
  if (sweep % 2) order.reverse();
206
227
  for (const i of order) place(rows[i] as string[], sweep % 2 ? fromNext : fromPrev);
207
228
  }
208
229
  for (const r of rows) place(r, (id) => [...fromPrev(id), ...fromNext(id)]);
209
- const lo = top.size ? Math.min(...top.values()) : 0;
230
+ let lo = top.size ? Infinity : 0;
231
+ for (const x of top.values()) lo = Math.min(lo, x);
210
232
  for (const [id, v] of top) top.set(id, v - lo);
211
233
  const u = (start[start.length - 1] ?? 0) + (width[width.length - 1] ?? 0);
212
- const v = Math.max(0, ...rows.flat().map((id) => (top.get(id) ?? 0) + slotSize(id).v));
234
+ let v = 0;
235
+ for (const r of rows) for (const id of r) v = Math.max(v, (top.get(id) ?? 0) + slotSize(id).v);
213
236
  local.set(c, { rows, start, width, top, u, v });
214
237
  if (c !== '') {
215
238
  const p = padOf(c);
@@ -18,11 +18,34 @@ export function neighbours(
18
18
  const x = nodes.get(id);
19
19
  return !x ? id : x.cluster ? inCluster(x.label) : x.label;
20
20
  };
21
+ const ix = indexOf(g);
22
+ const stubs = ix.stubsTo.get(n.id) ?? [];
23
+ const edges = ix.edgesTo.get(n.id) ?? [];
21
24
  const into = (style: EdgeStyle) => [
22
- ...g.stubs.filter((s) => s.to === n.id && s.style === style).map((s) => s.label),
23
- ...g.edges.filter((e) => e.to === n.id && e.style === style).map((e) => label(e.from)),
25
+ ...stubs.filter((s) => s.style === style).map((s) => s.label),
26
+ ...edges.filter((e) => e.style === style).map((e) => label(e.from)),
24
27
  ];
25
- return { solid: into('solid'), dotted: into('dotted'), dashed: into('dashed'), later: [...new Set(g.edges.filter((e) => e.from === n.id).map((e) => label(e.to)))] };
28
+ return { solid: into('solid'), dotted: into('dotted'), dashed: into('dashed'), later: [...new Set((ix.edgesFrom.get(n.id) ?? []).map((e) => label(e.to)))] };
29
+ }
30
+
31
+ type Links = Pick<GraphInput, 'edges' | 'stubs'>;
32
+ type LinkIndex = { stubsTo: Map<string, Links['stubs'][number][]>; edgesTo: Map<string, Links['edges'][number][]>; edgesFrom: Map<string, Links['edges'][number][]> };
33
+
34
+ /** A graph's stubs and edges by the node they lead to, and its edges by the node they start at, in their order; once per graph. */
35
+ const indexes = new WeakMap<Links, LinkIndex>();
36
+ function indexOf(g: Links): LinkIndex {
37
+ let ix = indexes.get(g);
38
+ if (!ix) {
39
+ const add = <T>(m: Map<string, T[]>, k: string, v: T) => (m.get(k) ?? m.set(k, []).get(k))?.push(v);
40
+ ix = { stubsTo: new Map(), edgesTo: new Map(), edgesFrom: new Map() };
41
+ for (const s of g.stubs) add(ix.stubsTo, s.to, s);
42
+ for (const e of g.edges) {
43
+ add(ix.edgesTo, e.to, e);
44
+ add(ix.edgesFrom, e.from, e);
45
+ }
46
+ indexes.set(g, ix);
47
+ }
48
+ return ix;
26
49
  }
27
50
 
28
51
  /**
@@ -36,9 +36,14 @@ const STEP_MS = 60e3;
36
36
  export function fitRange(lanes: readonly Lane[], now: number): TimeRange | null {
37
37
  const bars = lanes.flatMap((l) => l.bars);
38
38
  if (!bars.length) return null;
39
- const start = Math.min(...bars.map((b) => b.start));
39
+ let start = Infinity;
40
+ let last = -Infinity;
41
+ for (const b of bars) {
42
+ start = Math.min(start, b.start);
43
+ last = Math.max(last, b.end ?? now);
44
+ }
40
45
  const running = bars.some((b) => b.end === null);
41
- const end = running ? start + Math.ceil((now - start + MARGIN_MS) / STEP_MS) * STEP_MS : Math.max(...bars.map((b) => b.end ?? now));
46
+ const end = running ? start + Math.ceil((now - start + MARGIN_MS) / STEP_MS) * STEP_MS : last;
42
47
  return { start, end };
43
48
  }
44
49
 
@@ -56,6 +56,9 @@ export const isSortable = <R>(c: DataColumn<R>): boolean => c.sortable ?? !!c.co
56
56
  * its cell when that is a string or a number; equal rows keep their order. The rows
57
57
  * as they are when no column matches.
58
58
  */
59
+ /** The order `localeCompare` gives with `numeric`, made once. */
60
+ const numericText = new Intl.Collator(undefined, { numeric: true });
61
+
59
62
  export function sortRows<R>(rows: readonly R[], columns: readonly DataColumn<R>[], sort: SortState | null): R[] {
60
63
  const c = sort && columns.find((x) => x.id === sort.id);
61
64
  if (!sort || !c) return [...rows];
@@ -68,6 +71,6 @@ export function sortRows<R>(rows: readonly R[], columns: readonly DataColumn<R>[
68
71
  };
69
72
  const keyed = rows.map((r, i) => ({ r, i, v: compare ? 0 : value(r, i) }));
70
73
  const cmp = (a: (typeof keyed)[number], b: (typeof keyed)[number]) =>
71
- compare ? compare(a.r, b.r) : typeof a.v === 'number' && typeof b.v === 'number' ? a.v - b.v : String(a.v).localeCompare(String(b.v), undefined, { numeric: true });
74
+ compare ? compare(a.r, b.r) : typeof a.v === 'number' && typeof b.v === 'number' ? a.v - b.v : numericText.compare(String(a.v), String(b.v));
72
75
  return keyed.sort((a, b) => sign * cmp(a, b) || a.i - b.i).map((x) => x.r);
73
76
  }