querysub 0.517.0 → 0.519.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.
@@ -0,0 +1,1281 @@
1
+ import { qreact } from "../4-dom/qreact";
2
+ import { Button } from "./Button";
3
+ import { URLParam } from "./URLParam";
4
+ import { InputLabelURL } from "./InputLabel";
5
+ import { css } from "typesafecss";
6
+ import { sort } from "socket-function/src/misc";
7
+ import { formatTime } from "socket-function/src/formatting/format";
8
+
9
+ export type LatencyGraphLabelLine = { text: string; color?: string; };
10
+ export type LatencyGraphNode = { id: string; label?: string; latitude?: number; longitude?: number; labelLines?: LatencyGraphLabelLine[]; weight?: number; };
11
+ export type LatencyGraphLink = { source: string; destination: string; latencyMs: number; weight?: number; };
12
+ export type LatencyGraphProps = {
13
+ nodes: LatencyGraphNode[];
14
+ links: LatencyGraphLink[];
15
+ // Formats a link's traffic weight for the label under the latency (e.g. bytes). No label if omitted.
16
+ formatWeight?: (weight: number) => string;
17
+ };
18
+
19
+ const DEFAULT_CONNECTIONS = 3;
20
+ const DEFAULT_MIN_CLUSTER_SIZE = 2;
21
+ const DEFAULT_CLUSTER_FACTOR = 2;
22
+ const DEFAULT_INTER_EXPONENT = 1.5;
23
+ const connectionsParam = new URLParam("lgConnections", DEFAULT_CONNECTIONS);
24
+ const minClusterSizeParam = new URLParam("lgMinClusterSize", DEFAULT_MIN_CLUSTER_SIZE);
25
+ // Exponent on the (normalized) inter-cluster latency when mapping to layout distance, so far clusters push apart harder.
26
+ const interExponentParam = new URLParam("lgInterExponent", DEFAULT_INTER_EXPONENT);
27
+ // Two nodes cluster when their latency is within this factor of the tighter node's own nearest-neighbor latency.
28
+ // Relative (per-node) so a mutually-isolated close pair clusters even if it's far from everything else.
29
+ const clusterFactorParam = new URLParam("lgClusterFactor", DEFAULT_CLUSTER_FACTOR);
30
+ const geoParam = new URLParam("lgGeo", false);
31
+ const showLatenciesParam = new URLParam("lgShowLatencies", true);
32
+ // Pixels per degree of latitude/longitude in geographic mode (equirectangular projection).
33
+ const GEO_SCALE = 6;
34
+
35
+ // One continuous distance mapping for everything (intra and inter): px = MIN + SCALE * (latency / REF) ^ exponent.
36
+ // A single scale is what makes the exponent behave intuitively — the ratio between two distances is just the ratio of
37
+ // their latencies raised to the exponent (so exponent 0.5 turns a 10x latency gap into a ~3x distance gap).
38
+ const LAYOUT_MIN_PX = 45;
39
+ const LAYOUT_SCALE_PX = 320;
40
+ const LAYOUT_REF_MS = 50;
41
+
42
+ // Stress majorization (SMACOF): each iteration is a Guttman transform that never increases stress.
43
+ const COOLING = 0.996;
44
+ const CONVERGENCE_EPS = 0.02;
45
+ const MAX_ITERATIONS = 3000;
46
+ // Classical MDS init: top-2 eigenvectors of the double-centered distance matrix via power iteration.
47
+ const MAX_POWER_ITERS = 200;
48
+ const POWER_EPS = 1e-9;
49
+
50
+ const MIN_OPACITY = 0.05;
51
+ const HOVER_EXTRA_OPACITY = 0.4;
52
+ const NODE_RADIUS = 6;
53
+ // Traffic-driven sizing (normalized to the busiest node/pair). Circles grow with node weight; lines with pair weight.
54
+ const NODE_WEIGHT_MULT = 2.5;
55
+ const LINE_BASE_WIDTH = 1.5;
56
+ const LINE_MIN_WIDTH = 1;
57
+ const LINE_MAX_WIDTH = 7;
58
+ const HOVER_PAD = 6;
59
+ const HULL_PADDING = 26;
60
+ const ZOOM_STEP = 1.1;
61
+ const MIN_SCALE = 0.05;
62
+ const MAX_SCALE = 12;
63
+ // Screen-space margins left around the content when auto-fitting. The top gets much more room because node labels
64
+ // stack upward above the nodes (and are drawn at a fixed screen size regardless of zoom).
65
+ const FIT_PAD = 60;
66
+ const FIT_PAD_TOP = 160;
67
+
68
+ type Edge = { a: number; b: number; latency: number; };
69
+ type SolveEdge = { a: number; b: number; target: number; weight: number; };
70
+ type ClusterPair = { p: number; q: number; min: number; median: number; max: number; target: number; weight: number; };
71
+ type Centroids = { cx: Float64Array; cy: Float64Array; count: Int32Array; radius: Float64Array; };
72
+
73
+ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
74
+ nodes: LatencyGraphNode[] = [];
75
+ edges: Edge[] = [];
76
+ latencyMatrix = new Float64Array(0);
77
+ minLatency = 0;
78
+ maxLatency = 1;
79
+ nodeWeight = new Float64Array(0);
80
+ maxNodeWeight = 0;
81
+ pairWeight = new Map<number, number>();
82
+ maxPairWeight = 0;
83
+ // Cached from props in render() so draw() (a rAF callback, not synced) never reads this.props.
84
+ formatWeight: ((weight: number) => string) | undefined = undefined;
85
+ builtSig = "";
86
+ builtWeightSig = 0;
87
+ builtGeo = false;
88
+ builtConnections = 0;
89
+ builtMinClusterSize = 0;
90
+ builtClusterFactor = 0;
91
+ builtInterExponent = 0;
92
+
93
+ clusterOf = new Int32Array(0);
94
+ clusterCount = 1;
95
+ clusterSize = new Int32Array(0);
96
+ clusterDist = new Float64Array(0);
97
+ clusterIntraMin = new Float64Array(0);
98
+ clusterIntraMedian = new Float64Array(0);
99
+ clusterIntraMax = new Float64Array(0);
100
+ interPairs: ClusterPair[] = [];
101
+ intraSolveEdges: SolveEdge[] = [];
102
+ renderEdges: Edge[] = [];
103
+ renderEdgeSet = new Set<Edge>();
104
+ neighbors: { other: number; edge: Edge; }[][] = [];
105
+ hoverExtras: Edge[] = [];
106
+ interPairMap = new Map<number, ClusterPair>();
107
+ renderInterPairs: ClusterPair[] = [];
108
+ interMin = 0;
109
+ interMax = 1;
110
+ renderMin = 0;
111
+ renderMax = 1;
112
+
113
+ // position = clusterCenter[cluster] + local[node]; the two levels are solved independently.
114
+ clusterCenterX = new Float64Array(0);
115
+ clusterCenterY = new Float64Array(0);
116
+ localX = new Float64Array(0);
117
+ localY = new Float64Array(0);
118
+ positionsX = new Float64Array(0);
119
+ positionsY = new Float64Array(0);
120
+
121
+ temperature = 1;
122
+ iteration = 0;
123
+ settled = false;
124
+
125
+ rafId = 0;
126
+ frameScheduled = false;
127
+ canvas: HTMLCanvasElement | undefined;
128
+
129
+ viewScale = 1;
130
+ panX = 0;
131
+ panY = 0;
132
+ // While false, the view auto-fits the content each frame; the first manual zoom/pan turns it on and we leave it alone.
133
+ userAdjustedView = false;
134
+ isPanning = false;
135
+ lastPointerX = 0;
136
+ lastPointerY = 0;
137
+ hoverNode: number | undefined = undefined;
138
+ mouseX = 0;
139
+ mouseY = 0;
140
+ // Latest values from draw(), needed by pointer handlers to convert screen<->world.
141
+ centroidX = 0;
142
+ centroidY = 0;
143
+ viewWidth = 0;
144
+ viewHeight = 0;
145
+
146
+ build() {
147
+ this.nodes = this.props.nodes.slice();
148
+ let n = this.nodes.length;
149
+ this.positionsX = new Float64Array(n);
150
+ this.positionsY = new Float64Array(n);
151
+ this.localX = new Float64Array(n);
152
+ this.localY = new Float64Array(n);
153
+ this.ingestLinks();
154
+ this.computeWeights();
155
+ this.clusterNodes();
156
+ this.applyLayout();
157
+ }
158
+
159
+ // Traffic weights only affect rendering (circle size, line width), so they can refresh without re-laying-out.
160
+ computeWeights() {
161
+ let n = this.nodes.length;
162
+ let index = new Map<string, number>();
163
+ for (let [i, node] of this.nodes.entries()) {
164
+ index.set(node.id, i);
165
+ }
166
+ this.nodeWeight = new Float64Array(n);
167
+ this.maxNodeWeight = 0;
168
+ for (let node of this.props.nodes) {
169
+ let i = index.get(node.id);
170
+ if (i === undefined) continue;
171
+ this.nodeWeight[i] = node.weight || 0;
172
+ this.maxNodeWeight = Math.max(this.maxNodeWeight, node.weight || 0);
173
+ }
174
+ this.pairWeight = new Map();
175
+ this.maxPairWeight = 0;
176
+ for (let link of this.props.links) {
177
+ if (!link.weight) continue;
178
+ let a = index.get(link.source);
179
+ let b = index.get(link.destination);
180
+ if (a === undefined || b === undefined || a === b) continue;
181
+ let pk = Math.min(a, b) * n + Math.max(a, b);
182
+ let combined = (this.pairWeight.get(pk) || 0) + link.weight;
183
+ this.pairWeight.set(pk, combined);
184
+ this.maxPairWeight = Math.max(this.maxPairWeight, combined);
185
+ }
186
+ }
187
+
188
+ // Combine both directions of each pair into a single undirected edge (mean latency), and build the full matrix.
189
+ ingestLinks() {
190
+ let n = this.nodes.length;
191
+ let index = new Map<string, number>();
192
+ for (let [i, node] of this.nodes.entries()) {
193
+ index.set(node.id, i);
194
+ }
195
+ let paired = new Map<string, { sum: number; count: number; a: number; b: number; }>();
196
+ for (let link of this.props.links) {
197
+ let a = index.get(link.source);
198
+ let b = index.get(link.destination);
199
+ if (a === undefined || b === undefined || a === b) continue;
200
+ // Skip junk latencies (undefined / NaN / infinite) so they can't poison the distance matrix -> NaN positions.
201
+ if (!Number.isFinite(link.latencyMs)) continue;
202
+ let lo = Math.min(a, b);
203
+ let hi = Math.max(a, b);
204
+ let key = `${lo}-${hi}`;
205
+ let entry = paired.get(key);
206
+ if (!entry) {
207
+ entry = { sum: 0, count: 0, a: lo, b: hi };
208
+ paired.set(key, entry);
209
+ }
210
+ entry.sum += link.latencyMs;
211
+ entry.count++;
212
+ }
213
+ this.edges = [...paired.values()].map(e => ({ a: e.a, b: e.b, latency: e.sum / e.count }));
214
+ let latencies = this.edges.map(e => e.latency);
215
+ this.minLatency = latencies.length ? Math.min(...latencies) : 0;
216
+ this.maxLatency = latencies.length ? Math.max(...latencies) : 1;
217
+
218
+ // Unknown pairs treated as maximally distant, so they never falsely cluster.
219
+ this.latencyMatrix = new Float64Array(n * n).fill(this.maxLatency);
220
+ for (let i = 0; i < n; i++) {
221
+ this.latencyMatrix[i * n + i] = 0;
222
+ }
223
+ for (let e of this.edges) {
224
+ this.latencyMatrix[e.a * n + e.b] = e.latency;
225
+ this.latencyMatrix[e.b * n + e.a] = e.latency;
226
+ }
227
+ }
228
+
229
+ // Relative clustering: link two nodes when their latency is within clusterFactor of the tighter node's own
230
+ // nearest-neighbor latency, then take connected components. Adapts to local density, so a mutually-isolated
231
+ // close pair (e.g. Brisbane/Auckland) clusters even though it's far from every other node.
232
+ clusterNodes() {
233
+ let n = this.nodes.length;
234
+ let factor = Math.max(1, clusterFactorParam.value);
235
+ this.builtClusterFactor = factor;
236
+ this.builtInterExponent = interExponentParam.value;
237
+ let nn = this.nearestNeighborLatencies();
238
+
239
+ let parent = new Int32Array(n);
240
+ for (let i = 0; i < n; i++) {
241
+ parent[i] = i;
242
+ }
243
+ let find = (x: number): number => {
244
+ while (parent[x] !== x) {
245
+ parent[x] = parent[parent[x]];
246
+ x = parent[x];
247
+ }
248
+ return x;
249
+ };
250
+ for (let e of this.edges) {
251
+ if (e.latency <= factor * Math.min(nn[e.a], nn[e.b])) {
252
+ parent[find(e.a)] = find(e.b);
253
+ }
254
+ }
255
+ let clusterId = new Map<number, number>();
256
+ this.clusterOf = new Int32Array(n);
257
+ for (let i = 0; i < n; i++) {
258
+ let root = find(i);
259
+ let id = clusterId.get(root);
260
+ if (id === undefined) {
261
+ id = clusterId.size;
262
+ clusterId.set(root, id);
263
+ }
264
+ this.clusterOf[i] = id;
265
+ }
266
+ this.clusterCount = Math.max(1, clusterId.size);
267
+
268
+ this.enforceMinClusterSize();
269
+ this.buildClusterDerived();
270
+ this.computeRenderEdges();
271
+ }
272
+
273
+ nearestNeighborLatencies() {
274
+ let nn = new Float64Array(this.nodes.length).fill(this.maxLatency);
275
+ for (let e of this.edges) {
276
+ if (e.latency < nn[e.a]) nn[e.a] = e.latency;
277
+ if (e.latency < nn[e.b]) nn[e.b] = e.latency;
278
+ }
279
+ return nn;
280
+ }
281
+
282
+ // Clusters smaller than the configured minimum are dissolved into singletons (a node distant from everything).
283
+ enforceMinClusterSize() {
284
+ let n = this.nodes.length;
285
+ let min = Math.max(1, Math.floor(minClusterSizeParam.value));
286
+ this.builtMinClusterSize = min;
287
+ let size = new Int32Array(this.clusterCount);
288
+ for (let i = 0; i < n; i++) {
289
+ size[this.clusterOf[i]]++;
290
+ }
291
+ let remap = new Map<number, number>();
292
+ let next = 0;
293
+ let newOf = new Int32Array(n);
294
+ for (let i = 0; i < n; i++) {
295
+ let old = this.clusterOf[i];
296
+ if (size[old] >= min) {
297
+ let id = remap.get(old);
298
+ if (id === undefined) {
299
+ id = next++;
300
+ remap.set(old, id);
301
+ }
302
+ newOf[i] = id;
303
+ } else {
304
+ newOf[i] = next++;
305
+ }
306
+ }
307
+ this.clusterOf = newOf;
308
+ this.clusterCount = Math.max(1, next);
309
+ }
310
+
311
+ buildClusterDerived() {
312
+ let n = this.nodes.length;
313
+ let k = this.clusterCount;
314
+ this.clusterSize = new Int32Array(k);
315
+ for (let i = 0; i < n; i++) {
316
+ this.clusterSize[this.clusterOf[i]]++;
317
+ }
318
+
319
+ // Node-pair latencies bucketed per cluster pair (cross) and per cluster (within), for min/median/max ranges.
320
+ let lists = new Map<number, number[]>();
321
+ let intraLists: number[][] = Array.from({ length: k }, () => []);
322
+ for (let i = 0; i < n; i++) {
323
+ for (let j = i + 1; j < n; j++) {
324
+ let p = this.clusterOf[i];
325
+ let q = this.clusterOf[j];
326
+ if (p === q) {
327
+ intraLists[p].push(this.latencyMatrix[i * n + j]);
328
+ continue;
329
+ }
330
+ let key = Math.min(p, q) * k + Math.max(p, q);
331
+ let list = lists.get(key);
332
+ if (!list) {
333
+ list = [];
334
+ lists.set(key, list);
335
+ }
336
+ list.push(this.latencyMatrix[i * n + j]);
337
+ }
338
+ }
339
+ this.clusterIntraMin = new Float64Array(k);
340
+ this.clusterIntraMedian = new Float64Array(k);
341
+ this.clusterIntraMax = new Float64Array(k);
342
+ for (let c = 0; c < k; c++) {
343
+ let list = intraLists[c];
344
+ if (!list.length) continue;
345
+ sort(list, x => x);
346
+ this.clusterIntraMin[c] = list[0];
347
+ this.clusterIntraMedian[c] = list[Math.floor(list.length / 2)];
348
+ this.clusterIntraMax[c] = list[list.length - 1];
349
+ }
350
+ this.clusterDist = new Float64Array(k * k).fill(this.maxLatency);
351
+ for (let c = 0; c < k; c++) {
352
+ this.clusterDist[c * k + c] = 0;
353
+ }
354
+ let stats: { p: number; q: number; min: number; median: number; max: number; }[] = [];
355
+ for (let [key, list] of lists) {
356
+ let p = Math.floor(key / k);
357
+ let q = key % k;
358
+ sort(list, x => x);
359
+ let min = list[0];
360
+ let max = list[list.length - 1];
361
+ let median = list[Math.floor(list.length / 2)];
362
+ this.clusterDist[p * k + q] = median;
363
+ this.clusterDist[q * k + p] = median;
364
+ stats.push({ p, q, min, median, max });
365
+ }
366
+ let medians = stats.map(s => s.median);
367
+ this.interMin = medians.length ? Math.min(...medians) : 0;
368
+ this.interMax = medians.length ? Math.max(...medians) : 1;
369
+ this.interPairs = stats.map(s => {
370
+ let target = this.restPx(s.median);
371
+ return { p: s.p, q: s.q, min: s.min, median: s.median, max: s.max, target, weight: 1 / (target * target) };
372
+ });
373
+ this.interPairMap = new Map();
374
+ for (let pair of this.interPairs) {
375
+ this.interPairMap.set(pair.p * k + pair.q, pair);
376
+ }
377
+
378
+ this.intraSolveEdges = [];
379
+ for (let e of this.edges) {
380
+ if (this.clusterOf[e.a] !== this.clusterOf[e.b]) continue;
381
+ let target = this.restPx(e.latency);
382
+ this.intraSolveEdges.push({ a: e.a, b: e.b, target, weight: 1 / (target * target) });
383
+ }
384
+ }
385
+
386
+ computeRenderEdges() {
387
+ let count = Math.max(1, Math.floor(connectionsParam.value));
388
+ this.builtConnections = count;
389
+ let n = this.nodes.length;
390
+ let neighbors: { other: number; edge: Edge; }[][] = Array.from({ length: n }, () => []);
391
+ for (let edge of this.edges) {
392
+ neighbors[edge.a].push({ other: edge.b, edge });
393
+ neighbors[edge.b].push({ other: edge.a, edge });
394
+ }
395
+ this.neighbors = neighbors;
396
+ let selected = new Set<Edge>();
397
+ for (let i = 0; i < n; i++) {
398
+ let candidates = this.nodeCandidates(i);
399
+ for (let j = 0; j < Math.min(count, candidates.length); j++) {
400
+ selected.add(candidates[j].edge);
401
+ }
402
+ }
403
+ this.renderEdgeSet = selected;
404
+ this.renderEdges = [...selected];
405
+ let nodeLatencies = this.renderEdges.map(e => e.latency);
406
+ this.renderMin = nodeLatencies.length ? Math.min(...nodeLatencies) : 0;
407
+ this.renderMax = nodeLatencies.length ? Math.max(...nodeLatencies) : 1;
408
+ }
409
+
410
+ // A node's connection candidates in nearest-first order: clustered nodes stay in-cluster, singletons reach anywhere.
411
+ nodeCandidates(i: number) {
412
+ let clustered = this.clusterSize[this.clusterOf[i]] >= 2;
413
+ let candidates = clustered ? this.neighbors[i].filter(nb => this.clusterOf[nb.other] === this.clusterOf[i]) : this.neighbors[i].slice();
414
+ sort(candidates, nb => nb.edge.latency);
415
+ return candidates;
416
+ }
417
+
418
+ // While hovering, the node reveals up to 2x its connections; the extra tier (ranks N..2N) is drawn faintly.
419
+ computeHoverExtras() {
420
+ if (this.hoverNode === undefined) return [];
421
+ let candidates = this.nodeCandidates(this.hoverNode);
422
+ let extras: Edge[] = [];
423
+ for (let j = this.builtConnections; j < Math.min(this.builtConnections * 2, candidates.length); j++) {
424
+ if (!this.renderEdgeSet.has(candidates[j].edge)) extras.push(candidates[j].edge);
425
+ }
426
+ return extras;
427
+ }
428
+
429
+ // Each real cluster links to its N nearest clusters by on-screen distance — but only ones it has clear line of
430
+ // sight to: if the segment would pass through another cluster's hull, the link is hidden to keep the chart clean.
431
+ computeRenderInterPairs(centroids: Centroids) {
432
+ let k = this.clusterCount;
433
+ let real: number[] = [];
434
+ for (let c = 0; c < k; c++) {
435
+ if (this.clusterSize[c] >= 2) real.push(c);
436
+ }
437
+ let selected = new Set<ClusterPair>();
438
+ for (let c of real) {
439
+ let others = real.filter(o => o !== c && this.hasLineOfSight(centroids, c, o)).map(o => {
440
+ let dx = centroids.cx[c] - centroids.cx[o];
441
+ let dy = centroids.cy[c] - centroids.cy[o];
442
+ return { o, d: dx * dx + dy * dy };
443
+ });
444
+ sort(others, entry => entry.d);
445
+ for (let j = 0; j < Math.min(this.builtConnections, others.length); j++) {
446
+ let pair = this.interPairMap.get(Math.min(c, others[j].o) * k + Math.max(c, others[j].o));
447
+ if (pair) selected.add(pair);
448
+ }
449
+ }
450
+ this.renderInterPairs = [...selected];
451
+ }
452
+
453
+ hasLineOfSight(centroids: Centroids, from: number, to: number) {
454
+ return this.segmentClearOfClusters(centroids.cx[from], centroids.cy[from], centroids.cx[to], centroids.cy[to], centroids, from, to);
455
+ }
456
+
457
+ // True if the segment doesn't pass through any real cluster's hull (except the two clusters it belongs to).
458
+ segmentClearOfClusters(ax: number, ay: number, bx: number, by: number, centroids: Centroids, exclude0: number, exclude1: number) {
459
+ let dx = bx - ax;
460
+ let dy = by - ay;
461
+ let lengthSq = dx * dx + dy * dy || 1;
462
+ for (let m = 0; m < this.clusterCount; m++) {
463
+ if (centroids.count[m] < 2 || m === exclude0 || m === exclude1) continue;
464
+ // Closest point on segment [a,b] to cluster m's center, then distance to it.
465
+ let t = Math.max(0, Math.min(1, ((centroids.cx[m] - ax) * dx + (centroids.cy[m] - ay) * dy) / lengthSq));
466
+ let px = ax + t * dx - centroids.cx[m];
467
+ let py = ay + t * dy - centroids.cy[m];
468
+ if (px * px + py * py < centroids.radius[m] * centroids.radius[m]) return false;
469
+ }
470
+ return true;
471
+ }
472
+
473
+ restPx(latency: number) {
474
+ let l = Number.isFinite(latency) ? Math.max(0, latency) : 0;
475
+ let px = LAYOUT_MIN_PX + LAYOUT_SCALE_PX * Math.pow(l / LAYOUT_REF_MS, interExponentParam.value);
476
+ return Number.isFinite(px) ? px : LAYOUT_MIN_PX;
477
+ }
478
+
479
+ opacityFor(latency: number, min: number, max: number) {
480
+ let norm = (latency - min) / (max - min || 1);
481
+ return Math.max(MIN_OPACITY, 1 - norm);
482
+ }
483
+
484
+ applyLayout() {
485
+ this.builtGeo = geoParam.value;
486
+ if (geoParam.value) {
487
+ this.geoLayout();
488
+ this.scheduleFrame();
489
+ return;
490
+ }
491
+ this.hierarchicalInit();
492
+ this.temperature = 1;
493
+ this.iteration = 0;
494
+ this.settled = false;
495
+ this.scheduleFrame();
496
+ }
497
+
498
+ geoLayout() {
499
+ for (let [i, node] of this.nodes.entries()) {
500
+ this.positionsX[i] = node.longitude !== undefined ? node.longitude * GEO_SCALE : 0;
501
+ this.positionsY[i] = node.latitude !== undefined ? -node.latitude * GEO_SCALE : 0;
502
+ }
503
+ this.temperature = 0;
504
+ this.iteration = 0;
505
+ this.settled = true;
506
+ }
507
+
508
+ // Cluster centers placed by inter-cluster MDS; each cluster's members placed by their own local MDS.
509
+ hierarchicalInit() {
510
+ let n = this.nodes.length;
511
+ let k = this.clusterCount;
512
+ let centers = this.mds(k, (p, q) => {
513
+ let d = this.restPx(this.clusterDist[p * k + q]);
514
+ return d * d;
515
+ });
516
+ this.clusterCenterX = centers.x;
517
+ this.clusterCenterY = centers.y;
518
+
519
+ for (let c = 0; c < k; c++) {
520
+ let members: number[] = [];
521
+ for (let i = 0; i < n; i++) {
522
+ if (this.clusterOf[i] === c) members.push(i);
523
+ }
524
+ let local = this.mds(members.length, (a, b) => {
525
+ let d = this.restPx(this.latencyMatrix[members[a] * n + members[b]]);
526
+ return d * d;
527
+ });
528
+ for (let [mi, node] of members.entries()) {
529
+ this.localX[node] = local.x[mi];
530
+ this.localY[node] = local.y[mi];
531
+ }
532
+ }
533
+ this.composePositions();
534
+ }
535
+
536
+ composePositions() {
537
+ for (let i = 0; i < this.nodes.length; i++) {
538
+ let c = this.clusterOf[i];
539
+ this.positionsX[i] = this.clusterCenterX[c] + this.localX[i];
540
+ this.positionsY[i] = this.clusterCenterY[c] + this.localY[i];
541
+ }
542
+ }
543
+
544
+ // Classical MDS on an m-point set given a squared-target-distance function; top-2 eigenvectors.
545
+ mds(m: number, getSqDist: (a: number, b: number) => number) {
546
+ let x = new Float64Array(m);
547
+ let y = new Float64Array(m);
548
+ if (m < 2) return { x, y };
549
+ let sq = new Float64Array(m * m);
550
+ for (let i = 0; i < m; i++) {
551
+ for (let j = i + 1; j < m; j++) {
552
+ let v = getSqDist(i, j);
553
+ sq[i * m + j] = v;
554
+ sq[j * m + i] = v;
555
+ }
556
+ }
557
+ let rowMean = new Float64Array(m);
558
+ let grand = 0;
559
+ for (let i = 0; i < m; i++) {
560
+ let s = 0;
561
+ for (let j = 0; j < m; j++) {
562
+ s += sq[i * m + j];
563
+ }
564
+ rowMean[i] = s / m;
565
+ grand += s;
566
+ }
567
+ grand /= m * m;
568
+ let b = new Float64Array(m * m);
569
+ for (let i = 0; i < m; i++) {
570
+ for (let j = 0; j < m; j++) {
571
+ b[i * m + j] = -0.5 * (sq[i * m + j] - rowMean[i] - rowMean[j] + grand);
572
+ }
573
+ }
574
+ let shift = 0;
575
+ for (let i = 0; i < m; i++) {
576
+ let s = 0;
577
+ for (let j = 0; j < m; j++) {
578
+ s += Math.abs(b[i * m + j]);
579
+ }
580
+ shift = Math.max(shift, s);
581
+ }
582
+ let matBase = (v: Float64Array, out: Float64Array) => {
583
+ for (let i = 0; i < m; i++) {
584
+ let s = 0;
585
+ for (let j = 0; j < m; j++) {
586
+ s += b[i * m + j] * v[j];
587
+ }
588
+ out[i] = s + shift * v[i];
589
+ }
590
+ };
591
+ let e1 = this.powerIteration(matBase, m);
592
+ let matDeflate = (v: Float64Array, out: Float64Array) => {
593
+ matBase(v, out);
594
+ let d = 0;
595
+ for (let i = 0; i < m; i++) {
596
+ d += e1.vec[i] * v[i];
597
+ }
598
+ for (let i = 0; i < m; i++) {
599
+ out[i] -= e1.val * d * e1.vec[i];
600
+ }
601
+ };
602
+ let e2 = this.powerIteration(matDeflate, m);
603
+ let s1 = Math.sqrt(Math.max(e1.val - shift, 0));
604
+ let s2 = Math.sqrt(Math.max(e2.val - shift, 0));
605
+ for (let i = 0; i < m; i++) {
606
+ x[i] = e1.vec[i] * s1;
607
+ y[i] = e2.vec[i] * s2;
608
+ }
609
+ return { x, y };
610
+ }
611
+
612
+ powerIteration(matvec: (v: Float64Array, out: Float64Array) => void, n: number) {
613
+ let v = new Float64Array(n);
614
+ let mag = 0;
615
+ for (let i = 0; i < n; i++) {
616
+ v[i] = Math.sin(i * 0.1 + 1);
617
+ mag += v[i] * v[i];
618
+ }
619
+ mag = Math.sqrt(mag);
620
+ for (let i = 0; i < n; i++) {
621
+ v[i] /= mag;
622
+ }
623
+ let out = new Float64Array(n);
624
+ let val = 0;
625
+ for (let iter = 0; iter < MAX_POWER_ITERS; iter++) {
626
+ matvec(v, out);
627
+ let norm = 0;
628
+ for (let i = 0; i < n; i++) {
629
+ norm += out[i] * out[i];
630
+ }
631
+ norm = Math.sqrt(norm);
632
+ if (norm < 1e-12) break;
633
+ let dot = 0;
634
+ for (let i = 0; i < n; i++) {
635
+ out[i] /= norm;
636
+ dot += out[i] * v[i];
637
+ }
638
+ for (let i = 0; i < n; i++) {
639
+ v[i] = out[i];
640
+ }
641
+ val = norm;
642
+ if (Math.abs(dot) > 1 - POWER_EPS) break;
643
+ }
644
+ return { vec: v, val };
645
+ }
646
+
647
+ step() {
648
+ if (this.settled) return;
649
+ let maxMove = 0;
650
+ maxMove = Math.max(maxMove, this.majorizeCenters());
651
+ maxMove = Math.max(maxMove, this.majorizeLocals());
652
+ this.composePositions();
653
+ this.temperature *= COOLING;
654
+ this.iteration++;
655
+ if (maxMove < CONVERGENCE_EPS || this.iteration >= MAX_ITERATIONS) {
656
+ this.settled = true;
657
+ }
658
+ }
659
+
660
+ majorizeCenters() {
661
+ let k = this.clusterCount;
662
+ if (k < 2) return 0;
663
+ let numX = new Float64Array(k);
664
+ let numY = new Float64Array(k);
665
+ let denom = new Float64Array(k);
666
+ for (let pair of this.interPairs) {
667
+ let ax = this.clusterCenterX[pair.p];
668
+ let ay = this.clusterCenterY[pair.p];
669
+ let bx = this.clusterCenterX[pair.q];
670
+ let by = this.clusterCenterY[pair.q];
671
+ let dx = bx - ax;
672
+ let dy = by - ay;
673
+ let d = Math.sqrt(dx * dx + dy * dy) || 0.0001;
674
+ let scaled = pair.target / d;
675
+ let w = pair.weight;
676
+ numX[pair.p] += w * (bx + scaled * (ax - bx));
677
+ numY[pair.p] += w * (by + scaled * (ay - by));
678
+ numX[pair.q] += w * (ax + scaled * (bx - ax));
679
+ numY[pair.q] += w * (ay + scaled * (by - ay));
680
+ denom[pair.p] += w;
681
+ denom[pair.q] += w;
682
+ }
683
+ let maxMove = 0;
684
+ for (let c = 0; c < k; c++) {
685
+ if (!denom[c]) continue;
686
+ let moveX = (numX[c] / denom[c] - this.clusterCenterX[c]) * this.temperature;
687
+ let moveY = (numY[c] / denom[c] - this.clusterCenterY[c]) * this.temperature;
688
+ this.clusterCenterX[c] += moveX;
689
+ this.clusterCenterY[c] += moveY;
690
+ maxMove = Math.max(maxMove, Math.abs(moveX), Math.abs(moveY));
691
+ }
692
+ return maxMove;
693
+ }
694
+
695
+ majorizeLocals() {
696
+ let n = this.nodes.length;
697
+ let k = this.clusterCount;
698
+ let numX = new Float64Array(n);
699
+ let numY = new Float64Array(n);
700
+ let denom = new Float64Array(n);
701
+ for (let e of this.intraSolveEdges) {
702
+ let ax = this.localX[e.a];
703
+ let ay = this.localY[e.a];
704
+ let bx = this.localX[e.b];
705
+ let by = this.localY[e.b];
706
+ let dx = bx - ax;
707
+ let dy = by - ay;
708
+ let d = Math.sqrt(dx * dx + dy * dy) || 0.0001;
709
+ let scaled = e.target / d;
710
+ numX[e.a] += e.weight * (bx + scaled * (ax - bx));
711
+ numY[e.a] += e.weight * (by + scaled * (ay - by));
712
+ numX[e.b] += e.weight * (ax + scaled * (bx - ax));
713
+ numY[e.b] += e.weight * (ay + scaled * (by - ay));
714
+ denom[e.a] += e.weight;
715
+ denom[e.b] += e.weight;
716
+ }
717
+ let maxMove = 0;
718
+ for (let i = 0; i < n; i++) {
719
+ if (!denom[i]) continue;
720
+ let moveX = (numX[i] / denom[i] - this.localX[i]) * this.temperature;
721
+ let moveY = (numY[i] / denom[i] - this.localY[i]) * this.temperature;
722
+ this.localX[i] += moveX;
723
+ this.localY[i] += moveY;
724
+ maxMove = Math.max(maxMove, Math.abs(moveX), Math.abs(moveY));
725
+ }
726
+ // Keep each cluster centered on its own center, so global placement stays the centers' job.
727
+ let meanX = new Float64Array(k);
728
+ let meanY = new Float64Array(k);
729
+ let count = new Int32Array(k);
730
+ for (let i = 0; i < n; i++) {
731
+ let c = this.clusterOf[i];
732
+ meanX[c] += this.localX[i];
733
+ meanY[c] += this.localY[i];
734
+ count[c]++;
735
+ }
736
+ for (let c = 0; c < k; c++) {
737
+ if (count[c]) {
738
+ meanX[c] /= count[c];
739
+ meanY[c] /= count[c];
740
+ }
741
+ }
742
+ for (let i = 0; i < n; i++) {
743
+ let c = this.clusterOf[i];
744
+ this.localX[i] -= meanX[c];
745
+ this.localY[i] -= meanY[c];
746
+ }
747
+ return maxMove;
748
+ }
749
+
750
+ clusterHue(c: number) {
751
+ return Math.round(c / Math.max(1, this.clusterCount) * 360);
752
+ }
753
+
754
+ // Screen-space radius; grows with the node's traffic weight, normalized to the busiest node.
755
+ nodeRadius(i: number) {
756
+ if (this.maxNodeWeight <= 0) return NODE_RADIUS;
757
+ return NODE_RADIUS * (1 + NODE_WEIGHT_MULT * (this.nodeWeight[i] / this.maxNodeWeight));
758
+ }
759
+
760
+ lineWidthFor(a: number, b: number) {
761
+ if (this.maxPairWeight <= 0) return LINE_BASE_WIDTH;
762
+ let w = this.pairWeight.get(Math.min(a, b) * this.nodes.length + Math.max(a, b)) || 0;
763
+ return LINE_MIN_WIDTH + (w / this.maxPairWeight) * (LINE_MAX_WIDTH - LINE_MIN_WIDTH);
764
+ }
765
+
766
+ nodeShort(node: LatencyGraphNode) {
767
+ return (node.label || node.id).slice(0, 2);
768
+ }
769
+
770
+ clusterCentroids() {
771
+ let k = this.clusterCount;
772
+ let n = this.nodes.length;
773
+ let cx = new Float64Array(k);
774
+ let cy = new Float64Array(k);
775
+ let count = new Int32Array(k);
776
+ for (let i = 0; i < n; i++) {
777
+ let c = this.clusterOf[i];
778
+ cx[c] += this.positionsX[i];
779
+ cy[c] += this.positionsY[i];
780
+ count[c]++;
781
+ }
782
+ for (let c = 0; c < k; c++) {
783
+ if (count[c]) {
784
+ cx[c] /= count[c];
785
+ cy[c] /= count[c];
786
+ }
787
+ }
788
+ // Hull radius (world units) = farthest member from the centroid, plus padding.
789
+ let radius = new Float64Array(k);
790
+ for (let i = 0; i < n; i++) {
791
+ let c = this.clusterOf[i];
792
+ let dx = this.positionsX[i] - cx[c];
793
+ let dy = this.positionsY[i] - cy[c];
794
+ radius[c] = Math.max(radius[c], Math.sqrt(dx * dx + dy * dy));
795
+ }
796
+ for (let c = 0; c < k; c++) {
797
+ radius[c] += HULL_PADDING;
798
+ }
799
+ return { cx, cy, count, radius };
800
+ }
801
+
802
+ draw() {
803
+ let canvas = this.canvas;
804
+ if (!canvas) return;
805
+ let ctx = canvas.getContext("2d");
806
+ if (!ctx) return;
807
+
808
+ let dpr = window.devicePixelRatio || 1;
809
+ let width = canvas.clientWidth;
810
+ let height = canvas.clientHeight;
811
+ if (canvas.width !== width * dpr || canvas.height !== height * dpr) {
812
+ canvas.width = width * dpr;
813
+ canvas.height = height * dpr;
814
+ }
815
+
816
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
817
+ ctx.fillStyle = "hsl(0, 0%, 6%)";
818
+ ctx.fillRect(0, 0, width, height);
819
+
820
+ let n = this.nodes.length;
821
+ this.viewWidth = width;
822
+ this.viewHeight = height;
823
+ if (n === 0) return;
824
+
825
+ let centroidX = 0;
826
+ let centroidY = 0;
827
+ for (let i = 0; i < n; i++) {
828
+ centroidX += this.positionsX[i];
829
+ centroidY += this.positionsY[i];
830
+ }
831
+ centroidX /= n;
832
+ centroidY /= n;
833
+ this.centroidX = centroidX;
834
+ this.centroidY = centroidY;
835
+
836
+ // Auto-fit: scale/center the content's bounding box into the viewport, until the user manually zooms/pans.
837
+ if (!this.userAdjustedView) {
838
+ let minX = Infinity;
839
+ let maxX = -Infinity;
840
+ let minY = Infinity;
841
+ let maxY = -Infinity;
842
+ for (let i = 0; i < n; i++) {
843
+ minX = Math.min(minX, this.positionsX[i]);
844
+ maxX = Math.max(maxX, this.positionsX[i]);
845
+ minY = Math.min(minY, this.positionsY[i]);
846
+ maxY = Math.max(maxY, this.positionsY[i]);
847
+ }
848
+ let worldW = maxX - minX || 1;
849
+ let worldH = maxY - minY || 1;
850
+ let fit = Math.min((width - 2 * FIT_PAD) / worldW, (height - FIT_PAD_TOP - FIT_PAD) / worldH);
851
+ // Only apply the fit if it's sane — never let a NaN/degenerate value nuke the whole view.
852
+ if (Number.isFinite(fit) && fit > 0 && Number.isFinite(minX) && Number.isFinite(minY)) {
853
+ this.viewScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, fit));
854
+ this.panX = -((minX + maxX) / 2 - centroidX) * this.viewScale;
855
+ // Center within the padded band, which sits lower than the middle because of the reserved top margin.
856
+ let targetCenterY = (FIT_PAD_TOP + (height - FIT_PAD)) / 2;
857
+ this.panY = targetCenterY - height / 2 - ((minY + maxY) / 2 - centroidY) * this.viewScale;
858
+ }
859
+ }
860
+
861
+ let scale = this.viewScale;
862
+ let toScreenX = (wx: number) => width / 2 + (wx - centroidX) * scale + this.panX;
863
+ let toScreenY = (wy: number) => height / 2 + (wy - centroidY) * scale + this.panY;
864
+
865
+ let centroids = this.clusterCentroids();
866
+ this.computeRenderInterPairs(centroids);
867
+ this.hoverExtras = this.computeHoverExtras();
868
+
869
+ // Inter-cluster lines run centroid-to-centroid, then the opaque hulls paint over the parts inside any
870
+ // cluster — so a line only shows in the gaps and reads as entering one cluster edge and exiting another.
871
+ this.drawInterLines(ctx, toScreenX, toScreenY, centroids);
872
+ this.drawClusterHulls(ctx, toScreenX, toScreenY, centroids);
873
+ this.drawNodeConnections(ctx, toScreenX, toScreenY, centroids);
874
+ // Line labels go under the nodes (they're less important); hover labels are drawn last so they still win.
875
+ if (showLatenciesParam.value) {
876
+ this.drawLineLatencies(ctx, toScreenX, toScreenY, centroids);
877
+ }
878
+ this.drawNodes(ctx, toScreenX, toScreenY);
879
+ this.drawInterLabels(ctx, toScreenX, toScreenY, centroids);
880
+ this.drawClusterLabels(ctx, toScreenX, toScreenY, centroids);
881
+ this.drawHover(ctx, toScreenX, toScreenY, centroids);
882
+ }
883
+
884
+ drawClusterHulls(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number, centroids: Centroids) {
885
+ let k = this.clusterCount;
886
+ for (let c = 0; c < k; c++) {
887
+ if (centroids.count[c] < 2) continue;
888
+ let hue = this.clusterHue(c);
889
+ let x = toScreenX(centroids.cx[c]);
890
+ let y = toScreenY(centroids.cy[c]);
891
+ let r = centroids.radius[c] * this.viewScale;
892
+ // Opaque fill so any inter-cluster line underneath is hidden inside the cluster.
893
+ ctx.fillStyle = `hsl(${hue}, 35%, 9%)`;
894
+ ctx.beginPath();
895
+ ctx.arc(x, y, r, 0, Math.PI * 2);
896
+ ctx.fill();
897
+ ctx.lineWidth = 1;
898
+ ctx.strokeStyle = `hsla(${hue}, 60%, 55%, 0.35)`;
899
+ ctx.stroke();
900
+ }
901
+ }
902
+
903
+ drawInterLines(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number, centroids: Centroids) {
904
+ ctx.lineWidth = 2;
905
+ for (let pair of this.renderInterPairs) {
906
+ ctx.strokeStyle = `hsla(0, 0%, 100%, ${this.opacityFor(pair.median, this.interMin, this.interMax)})`;
907
+ ctx.beginPath();
908
+ ctx.moveTo(toScreenX(centroids.cx[pair.p]), toScreenY(centroids.cy[pair.p]));
909
+ ctx.lineTo(toScreenX(centroids.cx[pair.q]), toScreenY(centroids.cy[pair.q]));
910
+ ctx.stroke();
911
+ }
912
+ }
913
+
914
+ drawInterLabels(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number, centroids: Centroids) {
915
+ ctx.font = "11px sans-serif";
916
+ ctx.textAlign = "center";
917
+ ctx.textBaseline = "middle";
918
+ for (let pair of this.renderInterPairs) {
919
+ let midX = (toScreenX(centroids.cx[pair.p]) + toScreenX(centroids.cx[pair.q])) / 2;
920
+ let midY = (toScreenY(centroids.cy[pair.p]) + toScreenY(centroids.cy[pair.q])) / 2;
921
+ let label = `${formatTime(pair.min)} · ${formatTime(pair.median)} · ${formatTime(pair.max)}`;
922
+ let textWidth = ctx.measureText(label).width;
923
+ ctx.fillStyle = "hsla(0, 0%, 0%, 0.6)";
924
+ ctx.fillRect(midX - textWidth / 2 - 3, midY - 8, textWidth + 6, 16);
925
+ ctx.fillStyle = "hsl(0, 0%, 82%)";
926
+ ctx.fillText(label, midX, midY);
927
+ }
928
+ }
929
+
930
+ drawNodeConnections(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number, centroids: Centroids) {
931
+ ctx.lineWidth = 1.5;
932
+ for (let edge of this.renderEdges) {
933
+ this.strokeNodeConnection(ctx, toScreenX, toScreenY, centroids, edge, 1);
934
+ }
935
+ for (let edge of this.hoverExtras) {
936
+ this.strokeNodeConnection(ctx, toScreenX, toScreenY, centroids, edge, HOVER_EXTRA_OPACITY);
937
+ }
938
+ }
939
+
940
+ strokeNodeConnection(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number, centroids: Centroids, edge: Edge, opacityScale: number) {
941
+ // Hide links that tunnel through an unrelated cluster (mainly singleton connections crossing hulls).
942
+ if (!this.segmentClearOfClusters(this.positionsX[edge.a], this.positionsY[edge.a], this.positionsX[edge.b], this.positionsY[edge.b], centroids, this.clusterOf[edge.a], this.clusterOf[edge.b])) return;
943
+ ctx.lineWidth = this.lineWidthFor(edge.a, edge.b);
944
+ let hue = this.clusterHue(this.clusterOf[edge.a]);
945
+ ctx.strokeStyle = `hsla(${hue}, 70%, 62%, ${this.opacityFor(edge.latency, this.renderMin, this.renderMax) * opacityScale})`;
946
+ ctx.beginPath();
947
+ ctx.moveTo(toScreenX(this.positionsX[edge.a]), toScreenY(this.positionsY[edge.a]));
948
+ ctx.lineTo(toScreenX(this.positionsX[edge.b]), toScreenY(this.positionsY[edge.b]));
949
+ ctx.stroke();
950
+ }
951
+
952
+ // Always-on latency labels on the drawn node connections (toggleable), with the connection's traffic below.
953
+ // Skipped for lines masked by a cluster hull.
954
+ drawLineLatencies(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number, centroids: Centroids) {
955
+ ctx.textAlign = "center";
956
+ ctx.textBaseline = "middle";
957
+ for (let edge of this.renderEdges) {
958
+ if (!this.segmentClearOfClusters(this.positionsX[edge.a], this.positionsY[edge.a], this.positionsX[edge.b], this.positionsY[edge.b], centroids, this.clusterOf[edge.a], this.clusterOf[edge.b])) continue;
959
+ let midX = (toScreenX(this.positionsX[edge.a]) + toScreenX(this.positionsX[edge.b])) / 2;
960
+ let midY = (toScreenY(this.positionsY[edge.a]) + toScreenY(this.positionsY[edge.b])) / 2;
961
+
962
+ let traffic = this.pairWeight.get(Math.min(edge.a, edge.b) * this.nodes.length + Math.max(edge.a, edge.b)) || 0;
963
+ let trafficLabel = this.formatWeight && traffic > 0 ? this.formatWeight(traffic) : undefined;
964
+ let latencyY = trafficLabel ? midY - 7 : midY;
965
+
966
+ ctx.font = "10px sans-serif";
967
+ let latencyLabel = formatTime(edge.latency);
968
+ let latencyWidth = ctx.measureText(latencyLabel).width;
969
+ ctx.fillStyle = "hsla(0, 0%, 0%, 0.5)";
970
+ ctx.fillRect(midX - latencyWidth / 2 - 2, latencyY - 7, latencyWidth + 4, 14);
971
+ ctx.fillStyle = "hsl(0, 0%, 72%)";
972
+ ctx.fillText(latencyLabel, midX, latencyY);
973
+
974
+ if (trafficLabel) {
975
+ ctx.font = "9px sans-serif";
976
+ let trafficWidth = ctx.measureText(trafficLabel).width;
977
+ ctx.fillStyle = "hsla(0, 0%, 0%, 0.5)";
978
+ ctx.fillRect(midX - trafficWidth / 2 - 2, midY + 7 - 6, trafficWidth + 4, 12);
979
+ ctx.fillStyle = "hsl(0, 0%, 58%)";
980
+ ctx.fillText(trafficLabel, midX, midY + 7);
981
+ }
982
+ }
983
+ }
984
+
985
+ drawNodes(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number) {
986
+ ctx.textBaseline = "middle";
987
+ ctx.textAlign = "center";
988
+ for (let i = 0; i < this.nodes.length; i++) {
989
+ let x = toScreenX(this.positionsX[i]);
990
+ let y = toScreenY(this.positionsY[i]);
991
+ let radius = this.nodeRadius(i);
992
+ ctx.fillStyle = i === this.hoverNode ? "hsl(40, 90%, 60%)" : `hsl(${this.clusterHue(this.clusterOf[i])}, 65%, 55%)`;
993
+ ctx.beginPath();
994
+ ctx.arc(x, y, radius, 0, Math.PI * 2);
995
+ ctx.fill();
996
+
997
+ let lines = this.nodes[i].labelLines;
998
+ if (lines && lines.length) {
999
+ // Stack the lines above the node: the first line highest, the last just above the dot.
1000
+ let baseY = y - radius - 8;
1001
+ for (let [li, line] of lines.entries()) {
1002
+ let ly = baseY - (lines.length - 1 - li) * 13;
1003
+ ctx.font = li === 0 ? "bold 11px sans-serif" : "10px sans-serif";
1004
+ ctx.lineWidth = 3;
1005
+ ctx.strokeStyle = "hsl(0, 0%, 6%)";
1006
+ ctx.strokeText(line.text, x, ly);
1007
+ ctx.fillStyle = line.color || (li === 0 ? "hsl(0, 0%, 92%)" : "hsl(0, 0%, 62%)");
1008
+ ctx.fillText(line.text, x, ly);
1009
+ }
1010
+ continue;
1011
+ }
1012
+ let short = this.nodeShort(this.nodes[i]);
1013
+ ctx.font = "bold 11px sans-serif";
1014
+ ctx.lineWidth = 3;
1015
+ ctx.strokeStyle = "hsl(0, 0%, 6%)";
1016
+ ctx.strokeText(short, x, y - radius - 8);
1017
+ ctx.fillStyle = "hsl(0, 0%, 92%)";
1018
+ ctx.fillText(short, x, y - radius - 8);
1019
+ }
1020
+ }
1021
+
1022
+ drawClusterLabels(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number, centroids: Centroids) {
1023
+ let k = this.clusterCount;
1024
+ let rep = new Int32Array(k).fill(-1);
1025
+ let repDist = new Float64Array(k).fill(Infinity);
1026
+ for (let i = 0; i < this.nodes.length; i++) {
1027
+ let c = this.clusterOf[i];
1028
+ let dx = this.positionsX[i] - centroids.cx[c];
1029
+ let dy = this.positionsY[i] - centroids.cy[c];
1030
+ let d = dx * dx + dy * dy;
1031
+ if (d < repDist[c]) {
1032
+ repDist[c] = d;
1033
+ rep[c] = i;
1034
+ }
1035
+ }
1036
+ ctx.textAlign = "center";
1037
+ ctx.textBaseline = "middle";
1038
+ for (let c = 0; c < k; c++) {
1039
+ if (rep[c] < 0) continue;
1040
+ let node = this.nodes[rep[c]];
1041
+ let name = node.label || node.id;
1042
+ let label = `${name} (${centroids.count[c]})`;
1043
+ let x = toScreenX(centroids.cx[c]);
1044
+ let hullTop = toScreenY(centroids.cy[c]) - centroids.radius[c] * this.viewScale;
1045
+ let hasStats = centroids.count[c] >= 2;
1046
+ let nameY = hullTop - (hasStats ? 20 : 5);
1047
+ ctx.font = "bold 12px sans-serif";
1048
+ ctx.lineWidth = 3;
1049
+ ctx.strokeStyle = "hsl(0, 0%, 6%)";
1050
+ ctx.strokeText(label, x, nameY);
1051
+ ctx.fillStyle = `hsl(${this.clusterHue(c)}, 70%, 70%)`;
1052
+ ctx.fillText(label, x, nameY);
1053
+
1054
+ if (!hasStats) continue;
1055
+ let stats = `${formatTime(this.clusterIntraMin[c])} · ${formatTime(this.clusterIntraMedian[c])} · ${formatTime(this.clusterIntraMax[c])}`;
1056
+ ctx.font = "11px sans-serif";
1057
+ let statsWidth = ctx.measureText(stats).width;
1058
+ let statsY = hullTop - 5;
1059
+ ctx.fillStyle = "hsla(0, 0%, 0%, 0.6)";
1060
+ ctx.fillRect(x - statsWidth / 2 - 3, statsY - 8, statsWidth + 6, 16);
1061
+ ctx.fillStyle = `hsl(${this.clusterHue(c)}, 40%, 78%)`;
1062
+ ctx.fillText(stats, x, statsY);
1063
+ }
1064
+ }
1065
+
1066
+ // Hovering only labels the node's already-drawn connections (its base tier plus the faint extra tier).
1067
+ drawHover(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number, centroids: Centroids) {
1068
+ if (this.hoverNode === undefined) return;
1069
+ ctx.font = "12px sans-serif";
1070
+ ctx.textAlign = "center";
1071
+ ctx.textBaseline = "middle";
1072
+ let incident = this.renderEdges.filter(edge => edge.a === this.hoverNode || edge.b === this.hoverNode);
1073
+ for (let edge of [...incident, ...this.hoverExtras]) {
1074
+ if (!this.segmentClearOfClusters(this.positionsX[edge.a], this.positionsY[edge.a], this.positionsX[edge.b], this.positionsY[edge.b], centroids, this.clusterOf[edge.a], this.clusterOf[edge.b])) continue;
1075
+ let midX = (toScreenX(this.positionsX[edge.a]) + toScreenX(this.positionsX[edge.b])) / 2;
1076
+ let midY = (toScreenY(this.positionsY[edge.a]) + toScreenY(this.positionsY[edge.b])) / 2;
1077
+ let label = formatTime(edge.latency);
1078
+ let textWidth = ctx.measureText(label).width;
1079
+ ctx.fillStyle = "hsla(0, 0%, 0%, 0.75)";
1080
+ ctx.fillRect(midX - textWidth / 2 - 3, midY - 8, textWidth + 6, 16);
1081
+ ctx.fillStyle = "hsl(40, 90%, 75%)";
1082
+ ctx.fillText(label, midX, midY);
1083
+ }
1084
+ this.drawHoverCard(ctx);
1085
+ }
1086
+
1087
+ drawHoverCard(ctx: CanvasRenderingContext2D) {
1088
+ if (this.hoverNode === undefined) return;
1089
+ let node = this.nodes[this.hoverNode];
1090
+ let lines = [node.label || node.id];
1091
+ if (node.label) lines.push(node.id);
1092
+ if (node.latitude !== undefined && node.longitude !== undefined) {
1093
+ lines.push(`${node.latitude.toFixed(3)}, ${node.longitude.toFixed(3)}`);
1094
+ }
1095
+ lines = lines.filter(Boolean);
1096
+ ctx.font = "12px sans-serif";
1097
+ ctx.textAlign = "left";
1098
+ ctx.textBaseline = "middle";
1099
+ let width = Math.max(...lines.map(l => ctx.measureText(l).width)) + 16;
1100
+ let lineHeight = 16;
1101
+ let height = lines.length * lineHeight + 8;
1102
+ let x = this.mouseX + 14;
1103
+ let y = this.mouseY + 14;
1104
+ if (x + width > this.viewWidth) x = this.mouseX - 14 - width;
1105
+ if (y + height > this.viewHeight) y = this.mouseY - 14 - height;
1106
+ ctx.fillStyle = "hsla(0, 0%, 4%, 0.92)";
1107
+ ctx.fillRect(x, y, width, height);
1108
+ ctx.strokeStyle = "hsl(200, 60%, 45%)";
1109
+ ctx.lineWidth = 1;
1110
+ ctx.strokeRect(x, y, width, height);
1111
+ for (let [i, line] of lines.entries()) {
1112
+ ctx.fillStyle = i === 0 ? "hsl(40, 90%, 75%)" : "hsl(0, 0%, 82%)";
1113
+ ctx.fillText(line, x + 8, y + 8 + i * lineHeight + lineHeight / 2);
1114
+ }
1115
+ }
1116
+
1117
+ // Draw only when something can change: while the layout is still settling, or after an interaction. Once
1118
+ // settled and idle, no frames are scheduled at all, so the tab uses no CPU.
1119
+ scheduleFrame() {
1120
+ if (this.frameScheduled || !this.canvas) return;
1121
+ this.frameScheduled = true;
1122
+ this.rafId = requestAnimationFrame(this.frame);
1123
+ }
1124
+
1125
+ frame = () => {
1126
+ this.frameScheduled = false;
1127
+ this.rafId = 0;
1128
+ this.step();
1129
+ this.draw();
1130
+ if (!this.settled) this.scheduleFrame();
1131
+ };
1132
+
1133
+ onWheel = (e: WheelEvent) => {
1134
+ e.preventDefault();
1135
+ let worldX = this.centroidX + (e.offsetX - this.viewWidth / 2 - this.panX) / this.viewScale;
1136
+ let worldY = this.centroidY + (e.offsetY - this.viewHeight / 2 - this.panY) / this.viewScale;
1137
+ let factor = e.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP;
1138
+ let newScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, this.viewScale * factor));
1139
+ this.viewScale = newScale;
1140
+ this.panX = e.offsetX - this.viewWidth / 2 - (worldX - this.centroidX) * newScale;
1141
+ this.panY = e.offsetY - this.viewHeight / 2 - (worldY - this.centroidY) * newScale;
1142
+ this.userAdjustedView = true;
1143
+ this.scheduleFrame();
1144
+ };
1145
+
1146
+ onMouseDown = (e: MouseEvent) => {
1147
+ if (e.button !== 1) return;
1148
+ e.preventDefault();
1149
+ this.isPanning = true;
1150
+ this.lastPointerX = e.clientX;
1151
+ this.lastPointerY = e.clientY;
1152
+ };
1153
+
1154
+ onMouseMove = (e: MouseEvent) => {
1155
+ if (this.isPanning) {
1156
+ this.panX += e.clientX - this.lastPointerX;
1157
+ this.panY += e.clientY - this.lastPointerY;
1158
+ this.lastPointerX = e.clientX;
1159
+ this.lastPointerY = e.clientY;
1160
+ this.userAdjustedView = true;
1161
+ this.scheduleFrame();
1162
+ return;
1163
+ }
1164
+ let canvas = this.canvas;
1165
+ if (!canvas) return;
1166
+ let rect = canvas.getBoundingClientRect();
1167
+ let mx = e.clientX - rect.left;
1168
+ let my = e.clientY - rect.top;
1169
+ this.mouseX = mx;
1170
+ this.mouseY = my;
1171
+ let scale = this.viewScale;
1172
+ let bestDist = Infinity;
1173
+ let best: number | undefined = undefined;
1174
+ for (let i = 0; i < this.nodes.length; i++) {
1175
+ let sx = this.viewWidth / 2 + (this.positionsX[i] - this.centroidX) * scale + this.panX;
1176
+ let sy = this.viewHeight / 2 + (this.positionsY[i] - this.centroidY) * scale + this.panY;
1177
+ let dist = (sx - mx) ** 2 + (sy - my) ** 2;
1178
+ let hitDist = (this.nodeRadius(i) + HOVER_PAD) ** 2;
1179
+ if (dist <= hitDist && dist < bestDist) {
1180
+ bestDist = dist;
1181
+ best = i;
1182
+ }
1183
+ }
1184
+ let prev = this.hoverNode;
1185
+ this.hoverNode = best;
1186
+ // Redraw while a node is hovered (the info card tracks the cursor) or when the hovered node changes.
1187
+ if (best !== undefined || prev !== undefined) this.scheduleFrame();
1188
+ };
1189
+
1190
+ onMouseLeave = () => {
1191
+ this.hoverNode = undefined;
1192
+ this.scheduleFrame();
1193
+ };
1194
+
1195
+ onMouseUp = (e: MouseEvent) => {
1196
+ if (e.button !== 1) return;
1197
+ this.isPanning = false;
1198
+ };
1199
+
1200
+ componentWillUnmount() {
1201
+ this.mountCanvas(undefined);
1202
+ }
1203
+
1204
+ mountCanvas(elem: HTMLCanvasElement | undefined) {
1205
+ if (this.rafId) {
1206
+ cancelAnimationFrame(this.rafId);
1207
+ this.rafId = 0;
1208
+ }
1209
+ this.frameScheduled = false;
1210
+ if (this.canvas) {
1211
+ this.canvas.removeEventListener("wheel", this.onWheel);
1212
+ this.canvas.removeEventListener("mousedown", this.onMouseDown);
1213
+ this.canvas.removeEventListener("mouseleave", this.onMouseLeave);
1214
+ window.removeEventListener("mousemove", this.onMouseMove);
1215
+ window.removeEventListener("mouseup", this.onMouseUp);
1216
+ }
1217
+ this.canvas = elem;
1218
+ if (elem) {
1219
+ elem.addEventListener("wheel", this.onWheel, { passive: false });
1220
+ elem.addEventListener("mousedown", this.onMouseDown);
1221
+ elem.addEventListener("mouseleave", this.onMouseLeave);
1222
+ window.addEventListener("mousemove", this.onMouseMove);
1223
+ window.addEventListener("mouseup", this.onMouseUp);
1224
+ this.scheduleFrame();
1225
+ }
1226
+ }
1227
+
1228
+ render() {
1229
+ this.formatWeight = this.props.formatWeight;
1230
+ // Rebuild from scratch whenever the data set changes (e.g. a new node's latencies arrive progressively).
1231
+ let sig = `${this.props.nodes.length}:${this.props.links.length}`;
1232
+ let weightSig = 0;
1233
+ for (let node of this.props.nodes) {
1234
+ weightSig += node.weight || 0;
1235
+ }
1236
+ for (let link of this.props.links) {
1237
+ weightSig += link.weight || 0;
1238
+ }
1239
+ if (sig !== this.builtSig || geoParam.value !== this.builtGeo) {
1240
+ // Switching layout mode (geographic vs solved) changes the whole coordinate space, so re-fit the view.
1241
+ if (geoParam.value !== this.builtGeo) this.userAdjustedView = false;
1242
+ this.builtSig = sig;
1243
+ this.builtWeightSig = weightSig;
1244
+ this.build();
1245
+ } else if (weightSig !== this.builtWeightSig) {
1246
+ // Traffic weights changed but the node/link set didn't — just refresh sizing, no re-layout.
1247
+ this.builtWeightSig = weightSig;
1248
+ this.computeWeights();
1249
+ } else if (Math.max(1, Math.floor(minClusterSizeParam.value)) !== this.builtMinClusterSize || Math.max(1, clusterFactorParam.value) !== this.builtClusterFactor || interExponentParam.value !== this.builtInterExponent) {
1250
+ this.clusterNodes();
1251
+ this.applyLayout();
1252
+ } else if (Math.max(1, Math.floor(connectionsParam.value)) !== this.builtConnections) {
1253
+ this.computeRenderEdges();
1254
+ }
1255
+ this.scheduleFrame();
1256
+
1257
+ return <div className={css.relative.fillBoth.overflowHidden}>
1258
+ <canvas
1259
+ ref={elem => this.mountCanvas(elem ?? undefined)}
1260
+ className={css.absolute.pos(0, 0).fillBoth}
1261
+ />
1262
+ <div className={css.vbox(10).pad2(12).absolute.pos(0, 0).width(260).zIndex(2).hsla(0, 0, 8, 0.9).colorhsl(0, 0, 85)}>
1263
+ <InputLabelURL label="Connections" url={connectionsParam} integer />
1264
+ <InputLabelURL label="Cluster factor" url={clusterFactorParam} number />
1265
+ <InputLabelURL label="Cluster distance exponent" url={interExponentParam} number />
1266
+ <InputLabelURL label="Min cluster size" url={minClusterSizeParam} integer />
1267
+ <InputLabelURL label="Show line latencies" url={showLatenciesParam} checkbox />
1268
+ <InputLabelURL label="Geographic (lat/lon)" url={geoParam} checkbox />
1269
+ <div className={css.colorhsl(0, 0, 60)}>
1270
+ {this.nodes.length} nodes · {this.clusterCount} clusters · {this.renderEdges.length} lines
1271
+ </div>
1272
+ <Button onClick={() => { this.userAdjustedView = false; this.applyLayout(); }}>
1273
+ Reheat Layout
1274
+ </Button>
1275
+ <Button onClick={() => console.log("LatencyGraph data:\n" + JSON.stringify({ nodes: this.props.nodes, links: this.props.links }, undefined, 2))}>
1276
+ Log graph data
1277
+ </Button>
1278
+ </div>
1279
+ </div>;
1280
+ }
1281
+ }