querysub 0.520.0 → 0.522.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.
@@ -14,46 +14,26 @@ export type LatencyGraphProps = {
14
14
  links: LatencyGraphLink[];
15
15
  // Formats a link's traffic weight for the label under the latency (e.g. bytes). No label if omitted.
16
16
  formatWeight?: (weight: number) => string;
17
- // Extra lines shown under a cluster's latency stats (e.g. summed traffic), given the cluster's member node ids.
18
- clusterSummary?: (memberIds: string[]) => LatencyGraphLabelLine[];
17
+ // Id of the currently selected node (highlighted + always full-labelled).
18
+ selectedId?: string;
19
+ // Fired when a node is clicked (e.g. to sort a table by that node).
20
+ onSelectNode?: (id: string) => void;
19
21
  };
20
22
 
21
- const DEFAULT_INTRA_CONNECTIONS = 3;
22
- const DEFAULT_INTER_CONNECTIONS = 5;
23
- const DEFAULT_MIN_CLUSTER_SIZE = 2;
24
- const DEFAULT_CLUSTER_FACTOR = 2;
25
- const DEFAULT_INTER_EXPONENT = 1.5;
26
- const intraConnectionsParam = new URLParam("lgIntraConnections", DEFAULT_INTRA_CONNECTIONS);
27
- const interConnectionsParam = new URLParam("lgInterConnections", DEFAULT_INTER_CONNECTIONS);
28
- const minClusterSizeParam = new URLParam("lgMinClusterSize", DEFAULT_MIN_CLUSTER_SIZE);
29
- // Exponent on the (normalized) inter-cluster latency when mapping to layout distance, so far clusters push apart harder.
30
- const interExponentParam = new URLParam("lgInterExponent", DEFAULT_INTER_EXPONENT);
31
- // Two nodes cluster when their latency is within this factor of the tighter node's own nearest-neighbor latency.
32
- // Relative (per-node) so a mutually-isolated close pair clusters even if it's far from everything else.
33
- const clusterFactorParam = new URLParam("lgClusterFactor", DEFAULT_CLUSTER_FACTOR);
23
+ const DEFAULT_CONNECTIONS = 4;
24
+ // Nearest neighbors drawn per node (the layout still uses every measured latency; this only thins the drawn lines).
25
+ const connectionsParam = new URLParam("lgConnections", DEFAULT_CONNECTIONS);
26
+ const DEFAULT_LATENCY_EXPONENT = 0.5;
27
+ // Exponent on (latency / reference) when mapping to layout distance, so far nodes push apart harder as it grows.
28
+ const latencyExponentParam = new URLParam("lgLatencyExponent", DEFAULT_LATENCY_EXPONENT);
34
29
  const geoParam = new URLParam("lgGeo", false);
35
- const showLatenciesParam = new URLParam("lgShowLatencies", false);
30
+ // On by default: each drawn connection shows its latency and (when a weight formatter is given) its traffic.
31
+ const showLatenciesParam = new URLParam("lgShowLatencies", true);
36
32
  // The config panel is collapsed by default so it doesn't eat the canvas; clicking the header expands it.
37
33
  const configOpenParam = new URLParam("lgConfigOpen", false);
38
- // A node shows its full label only when its nearest neighbor is at least this many SCREEN pixels away (or it's hovered);
39
- // crowded nodes fall back to a single title line so the view doesn't turn into a jumble. Zooming in spreads nodes past
40
- // the threshold, revealing their full labels.
41
- const labelProximityParam = new URLParam("lgLabelProximityPx", 100);
42
34
  // Pixels per degree of latitude/longitude in geographic mode (equirectangular projection).
43
35
  const GEO_SCALE = 6;
44
36
 
45
- // Inter-cluster gap is measured in units of CLUSTER RADIUS (not absolute px), so it survives auto-fit: `spacing` is
46
- // literally "edge gap = this many cluster radii" for the closest pair, up to `spacing * INTER_SPREAD` for the farthest.
47
- // The exponent shapes the in-between. Range is normalized to [closest, farthest] latency so it can't blow up.
48
- const INTER_SPREAD = 2.5;
49
- const DEFAULT_INTER_SPACING = -1.5;
50
- const interSpacingParam = new URLParam("lgInterSpacing", DEFAULT_INTER_SPACING);
51
- // Intra-cluster layout is solved SEPARATELY and normalized per cluster, so a cluster's on-screen size doesn't depend
52
- // on its absolute latency (a 3ms-tight cluster and a 30ms one render at similar sizes). A pair at the cluster's median
53
- // intra-latency sits INTRA_UNIT_PX apart; relative distances within the cluster stay proportional to latency.
54
- const INTRA_UNIT_PX = 60;
55
- const INTRA_MIN_PX = 8;
56
-
57
37
  // Stress majorization (SMACOF): each iteration is a Guttman transform that never increases stress.
58
38
  const COOLING = 0.996;
59
39
  const CONVERGENCE_EPS = 0.02;
@@ -62,14 +42,24 @@ const MAX_ITERATIONS = 3000;
62
42
  const MAX_POWER_ITERS = 200;
63
43
  const POWER_EPS = 1e-9;
64
44
 
45
+ // Layout distance for a pair: a pair at the reference (median) latency sits DIST_UNIT_PX apart, scaled by the exponent,
46
+ // with a floor. Normalized to the median so the overall scale is latency-magnitude independent.
47
+ const DIST_MIN_PX = 8;
48
+ const DIST_UNIT_PX = 90;
49
+ // A touch of deterministic jitter on the MDS init breaks symmetry, so a handful of nodes settle into a spread (a
50
+ // triangle for three) instead of collapsing onto a single line.
51
+ const INIT_JITTER_PX = 15;
52
+
65
53
  const MIN_OPACITY = 0.05;
66
54
  const NODE_RADIUS = 6;
55
+ const NODE_HUE = 210;
56
+ const HIGHLIGHT_COLOR = "hsl(40, 90%, 60%)";
67
57
  // Traffic-driven sizing (normalized to the busiest node/pair). Circles grow with node weight; lines with pair weight.
68
58
  const NODE_WEIGHT_MULT = 2.5;
69
59
  const LINE_BASE_WIDTH = 1.5;
70
60
  const LINE_MIN_WIDTH = 1;
71
61
  const LINE_MAX_WIDTH = 7;
72
- const HULL_PADDING = 26;
62
+ const LABEL_LINE_HEIGHT = 13;
73
63
  const ZOOM_STEP = 1.1;
74
64
  const MIN_SCALE = 0.05;
75
65
  const MAX_SCALE = 12;
@@ -82,8 +72,6 @@ const FIT_ZOOM = 0.7;
82
72
 
83
73
  type Edge = { a: number; b: number; latency: number; };
84
74
  type SolveEdge = { a: number; b: number; target: number; weight: number; };
85
- type ClusterPair = { p: number; q: number; min: number; median: number; max: number; target: number; weight: number; };
86
- type Centroids = { cx: Float64Array; cy: Float64Array; count: Int32Array; radius: Float64Array; };
87
75
 
88
76
  export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
89
77
  nodes: LatencyGraphNode[] = [];
@@ -91,54 +79,27 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
91
79
  latencyMatrix = new Float64Array(0);
92
80
  minLatency = 0;
93
81
  maxLatency = 1;
82
+ latencyRef = 1;
94
83
  nodeWeight = new Float64Array(0);
95
84
  maxNodeWeight = 0;
96
85
  pairWeight = new Map<number, number>();
97
86
  maxPairWeight = 0;
98
- // Summed node-pair traffic between each cluster pair, for the inter-cluster line widths and labels.
99
- clusterPairWeight = new Map<number, number>();
100
- maxClusterPairWeight = 0;
101
87
  // Cached from props in render() so draw() (a rAF callback, not synced) never reads this.props.
102
88
  formatWeight: ((weight: number) => string) | undefined = undefined;
103
- clusterSummary: ((memberIds: string[]) => LatencyGraphLabelLine[]) | undefined = undefined;
104
- // Latest cluster centroids/radii from draw(), for the pointer-handler hull test.
105
- hoverCentroids: Centroids | undefined = undefined;
89
+ selectedId: string | undefined = undefined;
90
+ onSelectNode: ((id: string) => void) | undefined = undefined;
106
91
  builtSig = "";
107
92
  builtWeightSig = 0;
108
93
  builtGeo = false;
109
- builtIntraConnections = 0;
110
- builtMinClusterSize = 0;
111
- builtClusterFactor = 0;
112
- builtInterExponent = 0;
113
-
114
- clusterOf = new Int32Array(0);
115
- clusterCount = 1;
116
- clusterSize = new Int32Array(0);
117
- clusterDist = new Float64Array(0);
118
- clusterIntraMin = new Float64Array(0);
119
- clusterIntraMedian = new Float64Array(0);
120
- clusterIntraMax = new Float64Array(0);
121
- interPairs: ClusterPair[] = [];
122
- intraSolveEdges: SolveEdge[] = [];
94
+ builtConnections = 0;
95
+ builtLatencyExponent = 0;
96
+
97
+ solveEdges: SolveEdge[] = [];
123
98
  renderEdges: Edge[] = [];
124
- renderEdgeSet = new Set<Edge>();
125
99
  neighbors: { other: number; edge: Edge; }[][] = [];
126
- interPairMap = new Map<number, ClusterPair>();
127
- renderInterPairs: ClusterPair[] = [];
128
- interMin = 0;
129
- interMax = 1;
130
100
  renderMin = 0;
131
101
  renderMax = 1;
132
102
 
133
- // Per-cluster radius (from the settled intra layout), so inter targets can be edge-to-edge.
134
- clusterRadius = new Float64Array(0);
135
- builtInterSpacing = 0;
136
-
137
- // position = clusterCenter[cluster] + local[node]; the two levels are solved independently.
138
- clusterCenterX = new Float64Array(0);
139
- clusterCenterY = new Float64Array(0);
140
- localX = new Float64Array(0);
141
- localY = new Float64Array(0);
142
103
  positionsX = new Float64Array(0);
143
104
  positionsY = new Float64Array(0);
144
105
 
@@ -172,21 +133,17 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
172
133
  let n = this.nodes.length;
173
134
  this.positionsX = new Float64Array(n);
174
135
  this.positionsY = new Float64Array(n);
175
- this.localX = new Float64Array(n);
176
- this.localY = new Float64Array(n);
177
136
  this.ingestLinks();
178
137
  this.computeWeights();
179
- this.clusterNodes();
138
+ this.computeSolveEdges();
139
+ this.computeRenderEdges();
180
140
  this.applyLayout();
181
141
  }
182
142
 
183
143
  // Traffic weights only affect rendering (circle size, line width), so they can refresh without re-laying-out.
184
144
  computeWeights() {
185
145
  let n = this.nodes.length;
186
- let index = new Map<string, number>();
187
- for (let [i, node] of this.nodes.entries()) {
188
- index.set(node.id, i);
189
- }
146
+ let index = this.nodeIndex();
190
147
  this.nodeWeight = new Float64Array(n);
191
148
  this.maxNodeWeight = 0;
192
149
  for (let node of this.props.nodes) {
@@ -209,37 +166,18 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
209
166
  }
210
167
  }
211
168
 
212
- // Roll node-pair traffic up into cluster-pair totals (cross-cluster only). Recomputed each frame so it always
213
- // matches the current clustering; cheap since it iterates the (already small) weighted-pair map.
214
- computeClusterPairWeights() {
215
- let n = this.nodes.length;
216
- let k = this.clusterCount;
217
- this.clusterPairWeight = new Map();
218
- this.maxClusterPairWeight = 0;
219
- for (let [pk, weight] of this.pairWeight) {
220
- let ca = this.clusterOf[Math.floor(pk / n)];
221
- let cb = this.clusterOf[pk % n];
222
- if (ca === cb) continue;
223
- let ck = Math.min(ca, cb) * k + Math.max(ca, cb);
224
- let combined = (this.clusterPairWeight.get(ck) || 0) + weight;
225
- this.clusterPairWeight.set(ck, combined);
226
- this.maxClusterPairWeight = Math.max(this.maxClusterPairWeight, combined);
169
+ nodeIndex() {
170
+ let index = new Map<string, number>();
171
+ for (let [i, node] of this.nodes.entries()) {
172
+ index.set(node.id, i);
227
173
  }
228
- }
229
-
230
- interLineWidth(p: number, q: number) {
231
- if (this.maxClusterPairWeight <= 0) return 2;
232
- let w = this.clusterPairWeight.get(Math.min(p, q) * this.clusterCount + Math.max(p, q)) || 0;
233
- return LINE_MIN_WIDTH + (w / this.maxClusterPairWeight) * (LINE_MAX_WIDTH - LINE_MIN_WIDTH);
174
+ return index;
234
175
  }
235
176
 
236
177
  // Combine both directions of each pair into a single undirected edge (mean latency), and build the full matrix.
237
178
  ingestLinks() {
238
179
  let n = this.nodes.length;
239
- let index = new Map<string, number>();
240
- for (let [i, node] of this.nodes.entries()) {
241
- index.set(node.id, i);
242
- }
180
+ let index = this.nodeIndex();
243
181
  let paired = new Map<string, { sum: number; count: number; a: number; b: number; }>();
244
182
  for (let link of this.props.links) {
245
183
  let a = index.get(link.source);
@@ -262,8 +200,11 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
262
200
  let latencies = this.edges.map(e => e.latency);
263
201
  this.minLatency = latencies.length ? Math.min(...latencies) : 0;
264
202
  this.maxLatency = latencies.length ? Math.max(...latencies) : 1;
203
+ let sorted = latencies.slice();
204
+ sort(sorted, x => x);
205
+ this.latencyRef = sorted.length ? sorted[Math.floor(sorted.length / 2)] : 1;
265
206
 
266
- // Unknown pairs treated as maximally distant, so they never falsely cluster.
207
+ // Unknown pairs treated as maximally distant, so they never falsely collapse together.
267
208
  this.latencyMatrix = new Float64Array(n * n).fill(this.maxLatency);
268
209
  for (let i = 0; i < n; i++) {
269
210
  this.latencyMatrix[i * n + i] = 0;
@@ -274,166 +215,26 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
274
215
  }
275
216
  }
276
217
 
277
- // Relative clustering: link two nodes when their latency is within clusterFactor of the tighter node's own
278
- // nearest-neighbor latency, then take connected components. Adapts to local density, so a mutually-isolated
279
- // close pair (e.g. Brisbane/Auckland) clusters even though it's far from every other node.
280
- clusterNodes() {
281
- let n = this.nodes.length;
282
- let factor = Math.max(1, clusterFactorParam.value);
283
- this.builtClusterFactor = factor;
284
- this.builtInterExponent = interExponentParam.value;
285
- let nn = this.nearestNeighborLatencies();
286
-
287
- let parent = new Int32Array(n);
288
- for (let i = 0; i < n; i++) {
289
- parent[i] = i;
290
- }
291
- let find = (x: number): number => {
292
- while (parent[x] !== x) {
293
- parent[x] = parent[parent[x]];
294
- x = parent[x];
295
- }
296
- return x;
297
- };
298
- for (let e of this.edges) {
299
- if (e.latency <= factor * Math.min(nn[e.a], nn[e.b])) {
300
- parent[find(e.a)] = find(e.b);
301
- }
302
- }
303
- let clusterId = new Map<number, number>();
304
- this.clusterOf = new Int32Array(n);
305
- for (let i = 0; i < n; i++) {
306
- let root = find(i);
307
- let id = clusterId.get(root);
308
- if (id === undefined) {
309
- id = clusterId.size;
310
- clusterId.set(root, id);
311
- }
312
- this.clusterOf[i] = id;
313
- }
314
- this.clusterCount = Math.max(1, clusterId.size);
315
-
316
- this.enforceMinClusterSize();
317
- this.buildClusterDerived();
318
- this.computeRenderEdges();
218
+ // Normalized to the median latency so absolute latency magnitude doesn't change the overall scale; the exponent
219
+ // shapes how sharply higher-latency pairs are pushed apart.
220
+ targetDist(latency: number) {
221
+ let ref = this.latencyRef > 0 ? this.latencyRef : 1;
222
+ let l = Number.isFinite(latency) ? Math.max(0, latency) : ref;
223
+ let px = DIST_MIN_PX + DIST_UNIT_PX * Math.pow(l / ref, Math.max(0.01, latencyExponentParam.value));
224
+ return Number.isFinite(px) && px > 0 ? px : DIST_UNIT_PX;
319
225
  }
320
226
 
321
- nearestNeighborLatencies() {
322
- let nn = new Float64Array(this.nodes.length).fill(this.maxLatency);
323
- for (let e of this.edges) {
324
- if (e.latency < nn[e.a]) nn[e.a] = e.latency;
325
- if (e.latency < nn[e.b]) nn[e.b] = e.latency;
326
- }
327
- return nn;
328
- }
329
-
330
- // Clusters smaller than the configured minimum are dissolved into singletons (a node distant from everything).
331
- enforceMinClusterSize() {
332
- let n = this.nodes.length;
333
- let min = Math.max(1, Math.floor(minClusterSizeParam.value));
334
- this.builtMinClusterSize = min;
335
- let size = new Int32Array(this.clusterCount);
336
- for (let i = 0; i < n; i++) {
337
- size[this.clusterOf[i]]++;
338
- }
339
- let remap = new Map<number, number>();
340
- let next = 0;
341
- let newOf = new Int32Array(n);
342
- for (let i = 0; i < n; i++) {
343
- let old = this.clusterOf[i];
344
- if (size[old] >= min) {
345
- let id = remap.get(old);
346
- if (id === undefined) {
347
- id = next++;
348
- remap.set(old, id);
349
- }
350
- newOf[i] = id;
351
- } else {
352
- newOf[i] = next++;
353
- }
354
- }
355
- this.clusterOf = newOf;
356
- this.clusterCount = Math.max(1, next);
357
- }
358
-
359
- buildClusterDerived() {
360
- let n = this.nodes.length;
361
- let k = this.clusterCount;
362
- this.clusterSize = new Int32Array(k);
363
- for (let i = 0; i < n; i++) {
364
- this.clusterSize[this.clusterOf[i]]++;
365
- }
366
-
367
- // Node-pair latencies bucketed per cluster pair (cross) and per cluster (within), for min/median/max ranges.
368
- let lists = new Map<number, number[]>();
369
- let intraLists: number[][] = Array.from({ length: k }, () => []);
370
- for (let i = 0; i < n; i++) {
371
- for (let j = i + 1; j < n; j++) {
372
- let p = this.clusterOf[i];
373
- let q = this.clusterOf[j];
374
- if (p === q) {
375
- intraLists[p].push(this.latencyMatrix[i * n + j]);
376
- continue;
377
- }
378
- let key = Math.min(p, q) * k + Math.max(p, q);
379
- let list = lists.get(key);
380
- if (!list) {
381
- list = [];
382
- lists.set(key, list);
383
- }
384
- list.push(this.latencyMatrix[i * n + j]);
385
- }
386
- }
387
- this.clusterIntraMin = new Float64Array(k);
388
- this.clusterIntraMedian = new Float64Array(k);
389
- this.clusterIntraMax = new Float64Array(k);
390
- for (let c = 0; c < k; c++) {
391
- let list = intraLists[c];
392
- if (!list.length) continue;
393
- sort(list, x => x);
394
- this.clusterIntraMin[c] = list[0];
395
- this.clusterIntraMedian[c] = list[Math.floor(list.length / 2)];
396
- this.clusterIntraMax[c] = list[list.length - 1];
397
- }
398
- this.clusterDist = new Float64Array(k * k).fill(this.maxLatency);
399
- for (let c = 0; c < k; c++) {
400
- this.clusterDist[c * k + c] = 0;
401
- }
402
- let stats: { p: number; q: number; min: number; median: number; max: number; }[] = [];
403
- for (let [key, list] of lists) {
404
- let p = Math.floor(key / k);
405
- let q = key % k;
406
- sort(list, x => x);
407
- let min = list[0];
408
- let max = list[list.length - 1];
409
- let median = list[Math.floor(list.length / 2)];
410
- this.clusterDist[p * k + q] = median;
411
- this.clusterDist[q * k + p] = median;
412
- stats.push({ p, q, min, median, max });
413
- }
414
- let medians = stats.map(s => s.median);
415
- this.interMin = medians.length ? Math.min(...medians) : 0;
416
- this.interMax = medians.length ? Math.max(...medians) : 1;
417
- // Targets depend on cluster radii, which aren't known until the intra layout is solved, so they're filled in by
418
- // hierarchicalInit; here we just record the latency stats.
419
- this.interPairs = stats.map(s => ({ p: s.p, q: s.q, min: s.min, median: s.median, max: s.max, target: 1, weight: 1 }));
420
- this.interPairMap = new Map();
421
- for (let pair of this.interPairs) {
422
- this.interPairMap.set(pair.p * k + pair.q, pair);
423
- }
424
-
425
- // Intra edges use the per-cluster normalized scale, so cluster size is decoupled from absolute latency.
426
- this.intraSolveEdges = [];
427
- for (let e of this.edges) {
428
- if (this.clusterOf[e.a] !== this.clusterOf[e.b]) continue;
429
- let target = this.intraTarget(e.latency, this.clusterIntraMedian[this.clusterOf[e.a]]);
430
- this.intraSolveEdges.push({ a: e.a, b: e.b, target, weight: 1 / (target * target) });
431
- }
227
+ computeSolveEdges() {
228
+ this.builtLatencyExponent = latencyExponentParam.value;
229
+ this.solveEdges = this.edges.map(e => {
230
+ let target = this.targetDist(e.latency);
231
+ return { a: e.a, b: e.b, target, weight: 1 / (target * target) };
232
+ });
432
233
  }
433
234
 
434
235
  computeRenderEdges() {
435
- let count = Math.max(1, Math.floor(intraConnectionsParam.value));
436
- this.builtIntraConnections = count;
236
+ let count = Math.max(1, Math.floor(connectionsParam.value));
237
+ this.builtConnections = count;
437
238
  let n = this.nodes.length;
438
239
  let neighbors: { other: number; edge: Edge; }[][] = Array.from({ length: n }, () => []);
439
240
  for (let edge of this.edges) {
@@ -443,93 +244,16 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
443
244
  this.neighbors = neighbors;
444
245
  let selected = new Set<Edge>();
445
246
  for (let i = 0; i < n; i++) {
446
- let candidates = this.nodeCandidates(i);
247
+ let candidates = neighbors[i].slice();
248
+ sort(candidates, nb => nb.edge.latency);
447
249
  for (let j = 0; j < Math.min(count, candidates.length); j++) {
448
250
  selected.add(candidates[j].edge);
449
251
  }
450
252
  }
451
- this.renderEdgeSet = selected;
452
253
  this.renderEdges = [...selected];
453
- let nodeLatencies = this.renderEdges.map(e => e.latency);
454
- this.renderMin = nodeLatencies.length ? Math.min(...nodeLatencies) : 0;
455
- this.renderMax = nodeLatencies.length ? Math.max(...nodeLatencies) : 1;
456
- }
457
-
458
- // A node's connection candidates in nearest-first order: clustered nodes stay in-cluster, singletons reach anywhere.
459
- nodeCandidates(i: number) {
460
- let clustered = this.clusterSize[this.clusterOf[i]] >= 2;
461
- let candidates = clustered ? this.neighbors[i].filter(nb => this.clusterOf[nb.other] === this.clusterOf[i]) : this.neighbors[i].slice();
462
- sort(candidates, nb => nb.edge.latency);
463
- return candidates;
464
- }
465
-
466
- // Each real cluster links to its N nearest clusters by on-screen distance — but only ones it has clear line of
467
- // sight to: if the segment would pass through another cluster's hull, the link is hidden to keep the chart clean.
468
- computeRenderInterPairs(centroids: Centroids) {
469
- let k = this.clusterCount;
470
- let real: number[] = [];
471
- for (let c = 0; c < k; c++) {
472
- if (this.clusterSize[c] >= 2) real.push(c);
473
- }
474
- let selected = new Set<ClusterPair>();
475
- for (let c of real) {
476
- let others = real.filter(o => o !== c && this.hasLineOfSight(centroids, c, o)).map(o => {
477
- let dx = centroids.cx[c] - centroids.cx[o];
478
- let dy = centroids.cy[c] - centroids.cy[o];
479
- return { o, d: dx * dx + dy * dy };
480
- });
481
- sort(others, entry => entry.d);
482
- let interCount = Math.max(1, Math.floor(interConnectionsParam.value));
483
- for (let j = 0; j < Math.min(interCount, others.length); j++) {
484
- let pair = this.interPairMap.get(Math.min(c, others[j].o) * k + Math.max(c, others[j].o));
485
- if (pair) selected.add(pair);
486
- }
487
- }
488
- this.renderInterPairs = [...selected];
489
- }
490
-
491
- hasLineOfSight(centroids: Centroids, from: number, to: number) {
492
- return this.segmentClearOfClusters(centroids.cx[from], centroids.cy[from], centroids.cx[to], centroids.cy[to], centroids, from, to);
493
- }
494
-
495
- // True if the segment doesn't pass through any real cluster's hull (except the two clusters it belongs to).
496
- segmentClearOfClusters(ax: number, ay: number, bx: number, by: number, centroids: Centroids, exclude0: number, exclude1: number) {
497
- let dx = bx - ax;
498
- let dy = by - ay;
499
- let lengthSq = dx * dx + dy * dy || 1;
500
- for (let m = 0; m < this.clusterCount; m++) {
501
- if (centroids.count[m] < 2 || m === exclude0 || m === exclude1) continue;
502
- // Closest point on segment [a,b] to cluster m's center, then distance to it.
503
- let t = Math.max(0, Math.min(1, ((centroids.cx[m] - ax) * dx + (centroids.cy[m] - ay) * dy) / lengthSq));
504
- let px = ax + t * dx - centroids.cx[m];
505
- let py = ay + t * dy - centroids.cy[m];
506
- if (px * px + py * py < centroids.radius[m] * centroids.radius[m]) return false;
507
- }
508
- return true;
509
- }
510
-
511
- // Target centroid distance = both radii + an edge gap measured in cluster-radius units (so the gap:size ratio, and
512
- // therefore the visible spacing, is what `spacing` controls — auto-fit can't cancel it). The gap grows from
513
- // `spacing` radii (closest pair) to `spacing * INTER_SPREAD` radii (farthest), shaped by the exponent.
514
- interCentroidTarget(p: number, q: number, latency: number) {
515
- let rp = this.clusterRadius[p] || 0;
516
- let rq = this.clusterRadius[q] || 0;
517
- let span = this.interMax - this.interMin;
518
- let norm = span > 0 && Number.isFinite(latency) ? (latency - this.interMin) / span : 0;
519
- norm = Math.max(0, Math.min(1, Number.isFinite(norm) ? norm : 0));
520
- let shaped = 1 + Math.pow(norm, interExponentParam.value) * (INTER_SPREAD - 1);
521
- let gap = (rp + rq) / 2 * interSpacingParam.value * shaped;
522
- let target = rp + rq + gap;
523
- return Number.isFinite(target) && target > 0 ? target : rp + rq + 1;
524
- }
525
-
526
- // Per-cluster normalized intra distance: a pair at the cluster's median latency sits ~INTRA_UNIT_PX apart,
527
- // regardless of how tight or loose the cluster's absolute latencies are. Relative distances stay proportional.
528
- intraTarget(latency: number, clusterMedian: number) {
529
- let ref = clusterMedian > 0 ? clusterMedian : 1;
530
- let l = Number.isFinite(latency) ? Math.max(0, latency) : 0;
531
- let px = INTRA_MIN_PX + INTRA_UNIT_PX * (l / ref);
532
- return Number.isFinite(px) ? px : INTRA_UNIT_PX;
254
+ let latencies = this.renderEdges.map(e => e.latency);
255
+ this.renderMin = latencies.length ? Math.min(...latencies) : 0;
256
+ this.renderMax = latencies.length ? Math.max(...latencies) : 1;
533
257
  }
534
258
 
535
259
  opacityFor(latency: number, min: number, max: number) {
@@ -544,7 +268,7 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
544
268
  this.scheduleFrame();
545
269
  return;
546
270
  }
547
- this.hierarchicalInit();
271
+ this.mdsInit();
548
272
  this.temperature = 1;
549
273
  this.iteration = 0;
550
274
  this.settled = false;
@@ -561,53 +285,19 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
561
285
  this.settled = true;
562
286
  }
563
287
 
564
- // Members placed by each cluster's own local MDS first (so we know cluster radii), then cluster centers by
565
- // inter-cluster MDS using edge-to-edge (radius-inclusive) targets.
566
- hierarchicalInit() {
288
+ // Classical MDS on the full latency matrix for a good starting layout; SMACOF then refines using the real edges.
289
+ mdsInit() {
567
290
  let n = this.nodes.length;
568
- let k = this.clusterCount;
569
- this.builtInterSpacing = interSpacingParam.value;
570
- this.clusterRadius = new Float64Array(k);
571
-
572
- for (let c = 0; c < k; c++) {
573
- let members: number[] = [];
574
- for (let i = 0; i < n; i++) {
575
- if (this.clusterOf[i] === c) members.push(i);
576
- }
577
- let median = this.clusterIntraMedian[c];
578
- let local = this.mds(members.length, (a, b) => {
579
- let d = this.intraTarget(this.latencyMatrix[members[a] * n + members[b]], median);
580
- return d * d;
581
- });
582
- let radius = 0;
583
- for (let [mi, node] of members.entries()) {
584
- this.localX[node] = local.x[mi];
585
- this.localY[node] = local.y[mi];
586
- radius = Math.max(radius, Math.sqrt(local.x[mi] * local.x[mi] + local.y[mi] * local.y[mi]));
587
- }
588
- this.clusterRadius[c] = radius + HULL_PADDING;
589
- }
590
-
591
- // Now that radii are known, fill in the inter-cluster targets (edge-to-edge).
592
- for (let pair of this.interPairs) {
593
- pair.target = this.interCentroidTarget(pair.p, pair.q, pair.median);
594
- pair.weight = 1 / (pair.target * pair.target);
595
- }
596
- let centers = this.mds(k, (p, q) => {
597
- let d = this.interCentroidTarget(p, q, this.clusterDist[p * k + q]);
291
+ let pos = this.mds(n, (a, b) => {
292
+ let d = this.targetDist(this.latencyMatrix[a * n + b]);
598
293
  return d * d;
599
294
  });
600
- this.clusterCenterX = centers.x;
601
- this.clusterCenterY = centers.y;
602
-
603
- this.composePositions();
604
- }
605
-
606
- composePositions() {
607
- for (let i = 0; i < this.nodes.length; i++) {
608
- let c = this.clusterOf[i];
609
- this.positionsX[i] = this.clusterCenterX[c] + this.localX[i];
610
- this.positionsY[i] = this.clusterCenterY[c] + this.localY[i];
295
+ this.positionsX = pos.x;
296
+ this.positionsY = pos.y;
297
+ // Deterministic per-node jitter (different frequencies for x/y so it isn't itself collinear) to break symmetry.
298
+ for (let i = 0; i < n; i++) {
299
+ this.positionsX[i] += Math.sin(i * 12.9898 + 1) * INIT_JITTER_PX;
300
+ this.positionsY[i] += Math.cos(i * 78.233 + 1) * INIT_JITTER_PX;
611
301
  }
612
302
  }
613
303
 
@@ -716,10 +406,7 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
716
406
 
717
407
  step() {
718
408
  if (this.settled) return;
719
- let maxMove = 0;
720
- maxMove = Math.max(maxMove, this.majorizeCenters());
721
- maxMove = Math.max(maxMove, this.majorizeLocals());
722
- this.composePositions();
409
+ let maxMove = this.majorize();
723
410
  this.temperature *= COOLING;
724
411
  this.iteration++;
725
412
  if (maxMove < CONVERGENCE_EPS || this.iteration >= MAX_ITERATIONS) {
@@ -727,52 +414,17 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
727
414
  }
728
415
  }
729
416
 
730
- majorizeCenters() {
731
- let k = this.clusterCount;
732
- if (k < 2) return 0;
733
- let numX = new Float64Array(k);
734
- let numY = new Float64Array(k);
735
- let denom = new Float64Array(k);
736
- for (let pair of this.interPairs) {
737
- let ax = this.clusterCenterX[pair.p];
738
- let ay = this.clusterCenterY[pair.p];
739
- let bx = this.clusterCenterX[pair.q];
740
- let by = this.clusterCenterY[pair.q];
741
- let dx = bx - ax;
742
- let dy = by - ay;
743
- let d = Math.sqrt(dx * dx + dy * dy) || 0.0001;
744
- let scaled = pair.target / d;
745
- let w = pair.weight;
746
- numX[pair.p] += w * (bx + scaled * (ax - bx));
747
- numY[pair.p] += w * (by + scaled * (ay - by));
748
- numX[pair.q] += w * (ax + scaled * (bx - ax));
749
- numY[pair.q] += w * (ay + scaled * (by - ay));
750
- denom[pair.p] += w;
751
- denom[pair.q] += w;
752
- }
753
- let maxMove = 0;
754
- for (let c = 0; c < k; c++) {
755
- if (!denom[c]) continue;
756
- let moveX = (numX[c] / denom[c] - this.clusterCenterX[c]) * this.temperature;
757
- let moveY = (numY[c] / denom[c] - this.clusterCenterY[c]) * this.temperature;
758
- this.clusterCenterX[c] += moveX;
759
- this.clusterCenterY[c] += moveY;
760
- maxMove = Math.max(maxMove, Math.abs(moveX), Math.abs(moveY));
761
- }
762
- return maxMove;
763
- }
764
-
765
- majorizeLocals() {
417
+ // One SMACOF (Guttman transform) iteration over the real edges, moving every node toward its target distances.
418
+ majorize() {
766
419
  let n = this.nodes.length;
767
- let k = this.clusterCount;
768
420
  let numX = new Float64Array(n);
769
421
  let numY = new Float64Array(n);
770
422
  let denom = new Float64Array(n);
771
- for (let e of this.intraSolveEdges) {
772
- let ax = this.localX[e.a];
773
- let ay = this.localY[e.a];
774
- let bx = this.localX[e.b];
775
- let by = this.localY[e.b];
423
+ for (let e of this.solveEdges) {
424
+ let ax = this.positionsX[e.a];
425
+ let ay = this.positionsY[e.a];
426
+ let bx = this.positionsX[e.b];
427
+ let by = this.positionsY[e.b];
776
428
  let dx = bx - ax;
777
429
  let dy = by - ay;
778
430
  let d = Math.sqrt(dx * dx + dy * dy) || 0.0001;
@@ -787,40 +439,15 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
787
439
  let maxMove = 0;
788
440
  for (let i = 0; i < n; i++) {
789
441
  if (!denom[i]) continue;
790
- let moveX = (numX[i] / denom[i] - this.localX[i]) * this.temperature;
791
- let moveY = (numY[i] / denom[i] - this.localY[i]) * this.temperature;
792
- this.localX[i] += moveX;
793
- this.localY[i] += moveY;
442
+ let moveX = (numX[i] / denom[i] - this.positionsX[i]) * this.temperature;
443
+ let moveY = (numY[i] / denom[i] - this.positionsY[i]) * this.temperature;
444
+ this.positionsX[i] += moveX;
445
+ this.positionsY[i] += moveY;
794
446
  maxMove = Math.max(maxMove, Math.abs(moveX), Math.abs(moveY));
795
447
  }
796
- // Keep each cluster centered on its own center, so global placement stays the centers' job.
797
- let meanX = new Float64Array(k);
798
- let meanY = new Float64Array(k);
799
- let count = new Int32Array(k);
800
- for (let i = 0; i < n; i++) {
801
- let c = this.clusterOf[i];
802
- meanX[c] += this.localX[i];
803
- meanY[c] += this.localY[i];
804
- count[c]++;
805
- }
806
- for (let c = 0; c < k; c++) {
807
- if (count[c]) {
808
- meanX[c] /= count[c];
809
- meanY[c] /= count[c];
810
- }
811
- }
812
- for (let i = 0; i < n; i++) {
813
- let c = this.clusterOf[i];
814
- this.localX[i] -= meanX[c];
815
- this.localY[i] -= meanY[c];
816
- }
817
448
  return maxMove;
818
449
  }
819
450
 
820
- clusterHue(c: number) {
821
- return Math.round(c / Math.max(1, this.clusterCount) * 360);
822
- }
823
-
824
451
  // Screen-space radius; grows with the node's traffic weight, normalized to the busiest node.
825
452
  nodeRadius(i: number) {
826
453
  if (this.maxNodeWeight <= 0) return NODE_RADIUS;
@@ -837,36 +464,8 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
837
464
  return (node.label || node.id).slice(0, 2);
838
465
  }
839
466
 
840
- clusterCentroids() {
841
- let k = this.clusterCount;
842
- let n = this.nodes.length;
843
- let cx = new Float64Array(k);
844
- let cy = new Float64Array(k);
845
- let count = new Int32Array(k);
846
- for (let i = 0; i < n; i++) {
847
- let c = this.clusterOf[i];
848
- cx[c] += this.positionsX[i];
849
- cy[c] += this.positionsY[i];
850
- count[c]++;
851
- }
852
- for (let c = 0; c < k; c++) {
853
- if (count[c]) {
854
- cx[c] /= count[c];
855
- cy[c] /= count[c];
856
- }
857
- }
858
- // Hull radius (world units) = farthest member from the centroid, plus padding.
859
- let radius = new Float64Array(k);
860
- for (let i = 0; i < n; i++) {
861
- let c = this.clusterOf[i];
862
- let dx = this.positionsX[i] - cx[c];
863
- let dy = this.positionsY[i] - cy[c];
864
- radius[c] = Math.max(radius[c], Math.sqrt(dx * dx + dy * dy));
865
- }
866
- for (let c = 0; c < k; c++) {
867
- radius[c] += HULL_PADDING;
868
- }
869
- return { cx, cy, count, radius };
467
+ isSelected(i: number) {
468
+ return this.selectedId !== undefined && this.nodes[i].id === this.selectedId;
870
469
  }
871
470
 
872
471
  draw() {
@@ -932,113 +531,30 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
932
531
  let toScreenX = (wx: number) => width / 2 + (wx - centroidX) * scale + this.panX;
933
532
  let toScreenY = (wy: number) => height / 2 + (wy - centroidY) * scale + this.panY;
934
533
 
935
- let centroids = this.clusterCentroids();
936
- this.hoverCentroids = centroids;
937
- this.computeRenderInterPairs(centroids);
938
- this.computeClusterPairWeights();
939
-
940
- // Inter-cluster lines run centroid-to-centroid, then the opaque hulls paint over the parts inside any
941
- // cluster — so a line only shows in the gaps and reads as entering one cluster edge and exiting another.
942
- this.drawInterLines(ctx, toScreenX, toScreenY, centroids);
943
- this.drawClusterHulls(ctx, toScreenX, toScreenY, centroids);
944
- this.drawNodeConnections(ctx, toScreenX, toScreenY, centroids);
945
- // Line labels go under the nodes (they're less important); hover labels are drawn last so they still win.
534
+ this.drawEdges(ctx, toScreenX, toScreenY);
946
535
  if (showLatenciesParam.value) {
947
- this.drawLineLatencies(ctx, toScreenX, toScreenY, centroids);
536
+ this.drawLineLatencies(ctx, toScreenX, toScreenY);
948
537
  }
949
- this.drawNodes(ctx, toScreenX, toScreenY, centroids);
950
- this.drawInterLabels(ctx, toScreenX, toScreenY, centroids);
951
- this.drawClusterLabels(ctx, toScreenX, toScreenY, centroids);
952
- this.drawHover(ctx, toScreenX, toScreenY, centroids);
538
+ this.drawNodes(ctx, toScreenX, toScreenY);
539
+ this.drawHover(ctx, toScreenX, toScreenY);
953
540
  }
954
541
 
955
- drawClusterHulls(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number, centroids: Centroids) {
956
- let k = this.clusterCount;
957
- for (let c = 0; c < k; c++) {
958
- if (centroids.count[c] < 2) continue;
959
- let hue = this.clusterHue(c);
960
- let x = toScreenX(centroids.cx[c]);
961
- let y = toScreenY(centroids.cy[c]);
962
- let r = centroids.radius[c] * this.viewScale;
963
- // Opaque fill so any inter-cluster line underneath is hidden inside the cluster.
964
- ctx.fillStyle = `hsl(${hue}, 35%, 9%)`;
965
- ctx.beginPath();
966
- ctx.arc(x, y, r, 0, Math.PI * 2);
967
- ctx.fill();
968
- ctx.lineWidth = 1;
969
- ctx.strokeStyle = `hsla(${hue}, 60%, 55%, 0.35)`;
970
- ctx.stroke();
971
- }
972
- }
973
-
974
- drawInterLines(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number, centroids: Centroids) {
975
- for (let pair of this.renderInterPairs) {
976
- ctx.lineWidth = this.interLineWidth(pair.p, pair.q);
977
- ctx.strokeStyle = `hsla(0, 0%, 100%, ${this.opacityFor(pair.median, this.interMin, this.interMax)})`;
542
+ drawEdges(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number) {
543
+ for (let edge of this.renderEdges) {
544
+ ctx.lineWidth = this.lineWidthFor(edge.a, edge.b);
545
+ ctx.strokeStyle = `hsla(${NODE_HUE}, 70%, 62%, ${this.opacityFor(edge.latency, this.renderMin, this.renderMax)})`;
978
546
  ctx.beginPath();
979
- ctx.moveTo(toScreenX(centroids.cx[pair.p]), toScreenY(centroids.cy[pair.p]));
980
- ctx.lineTo(toScreenX(centroids.cx[pair.q]), toScreenY(centroids.cy[pair.q]));
547
+ ctx.moveTo(toScreenX(this.positionsX[edge.a]), toScreenY(this.positionsY[edge.a]));
548
+ ctx.lineTo(toScreenX(this.positionsX[edge.b]), toScreenY(this.positionsY[edge.b]));
981
549
  ctx.stroke();
982
550
  }
983
551
  }
984
552
 
985
- drawInterLabels(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number, centroids: Centroids) {
986
- ctx.font = "11px sans-serif";
553
+ // Always-on latency labels on the drawn connections (toggleable), with the connection's traffic below.
554
+ drawLineLatencies(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number) {
987
555
  ctx.textAlign = "center";
988
556
  ctx.textBaseline = "middle";
989
- for (let pair of this.renderInterPairs) {
990
- let midX = (toScreenX(centroids.cx[pair.p]) + toScreenX(centroids.cx[pair.q])) / 2;
991
- let midY = (toScreenY(centroids.cy[pair.p]) + toScreenY(centroids.cy[pair.q])) / 2;
992
-
993
- let traffic = this.clusterPairWeight.get(Math.min(pair.p, pair.q) * this.clusterCount + Math.max(pair.p, pair.q)) || 0;
994
- let trafficLabel = this.formatWeight && traffic > 0 ? this.formatWeight(traffic) : undefined;
995
- let latencyY = trafficLabel ? midY - 8 : midY;
996
-
997
- ctx.font = "11px sans-serif";
998
- let label = `${formatTime(pair.min)} · ${formatTime(pair.median)} · ${formatTime(pair.max)}`;
999
- let textWidth = ctx.measureText(label).width;
1000
- ctx.fillStyle = "hsla(0, 0%, 0%, 0.6)";
1001
- ctx.fillRect(midX - textWidth / 2 - 3, latencyY - 8, textWidth + 6, 16);
1002
- ctx.fillStyle = "hsl(0, 0%, 82%)";
1003
- ctx.fillText(label, midX, latencyY);
1004
-
1005
- if (trafficLabel) {
1006
- ctx.font = "10px sans-serif";
1007
- let trafficWidth = ctx.measureText(trafficLabel).width;
1008
- ctx.fillStyle = "hsla(0, 0%, 0%, 0.6)";
1009
- ctx.fillRect(midX - trafficWidth / 2 - 3, midY + 8 - 7, trafficWidth + 6, 14);
1010
- ctx.fillStyle = "hsl(0, 0%, 70%)";
1011
- ctx.fillText(trafficLabel, midX, midY + 8);
1012
- }
1013
- }
1014
- }
1015
-
1016
- drawNodeConnections(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number, centroids: Centroids) {
1017
- ctx.lineWidth = 1.5;
1018
557
  for (let edge of this.renderEdges) {
1019
- this.strokeNodeConnection(ctx, toScreenX, toScreenY, centroids, edge, 1);
1020
- }
1021
- }
1022
-
1023
- strokeNodeConnection(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number, centroids: Centroids, edge: Edge, opacityScale: number) {
1024
- // Hide links that tunnel through an unrelated cluster (mainly singleton connections crossing hulls).
1025
- 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;
1026
- ctx.lineWidth = this.lineWidthFor(edge.a, edge.b);
1027
- let hue = this.clusterHue(this.clusterOf[edge.a]);
1028
- ctx.strokeStyle = `hsla(${hue}, 70%, 62%, ${this.opacityFor(edge.latency, this.renderMin, this.renderMax) * opacityScale})`;
1029
- ctx.beginPath();
1030
- ctx.moveTo(toScreenX(this.positionsX[edge.a]), toScreenY(this.positionsY[edge.a]));
1031
- ctx.lineTo(toScreenX(this.positionsX[edge.b]), toScreenY(this.positionsY[edge.b]));
1032
- ctx.stroke();
1033
- }
1034
-
1035
- // Always-on latency labels on the drawn node connections (toggleable), with the connection's traffic below.
1036
- // Skipped for lines masked by a cluster hull.
1037
- drawLineLatencies(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number, centroids: Centroids) {
1038
- ctx.textAlign = "center";
1039
- ctx.textBaseline = "middle";
1040
- for (let edge of this.renderEdges) {
1041
- 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;
1042
558
  let midX = (toScreenX(this.positionsX[edge.a]) + toScreenX(this.positionsX[edge.b])) / 2;
1043
559
  let midY = (toScreenY(this.positionsY[edge.a]) + toScreenY(this.positionsY[edge.b])) / 2;
1044
560
 
@@ -1065,31 +581,25 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
1065
581
  }
1066
582
  }
1067
583
 
1068
- drawNodes(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number, centroids: Centroids) {
584
+ drawNodes(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number) {
1069
585
  ctx.textBaseline = "middle";
1070
586
  ctx.textAlign = "center";
1071
- let proximityPx = labelProximityParam.value;
1072
- let screenX = this.nodes.map((_, i) => toScreenX(this.positionsX[i]));
1073
- let screenY = this.nodes.map((_, i) => toScreenY(this.positionsY[i]));
1074
587
  for (let i = 0; i < this.nodes.length; i++) {
1075
- let x = screenX[i];
1076
- let y = screenY[i];
588
+ let x = toScreenX(this.positionsX[i]);
589
+ let y = toScreenY(this.positionsY[i]);
1077
590
  let radius = this.nodeRadius(i);
1078
- ctx.fillStyle = i === this.hoverNode ? "hsl(40, 90%, 60%)" : `hsl(${this.clusterHue(this.clusterOf[i])}, 65%, 55%)`;
591
+ let highlighted = i === this.hoverNode || this.isSelected(i);
592
+ ctx.fillStyle = highlighted ? HIGHLIGHT_COLOR : `hsl(${NODE_HUE}, 60%, 52%)`;
1079
593
  ctx.beginPath();
1080
594
  ctx.arc(x, y, radius, 0, Math.PI * 2);
1081
595
  ctx.fill();
1082
596
 
1083
- // The full label only shows when this node is visually isolated (nearest neighbor on screen is far enough
1084
- // away), or it's hovered; otherwise it collapses to a single title line above the dot to avoid a jumble.
1085
- let full = i === this.hoverNode || this.nearestScreenDist(i, screenX, screenY) >= proximityPx;
1086
597
  let lines = this.nodes[i].labelLines;
1087
- if (full && lines && lines.length) {
1088
- this.drawFullNodeLabel(ctx, lines, x, y);
598
+ if (lines && lines.length) {
599
+ this.drawStackedLabel(ctx, lines, x, y - radius - 8);
1089
600
  continue;
1090
601
  }
1091
- // Crowded (or unlabeled): just the short title, above the dot.
1092
- let short = lines && lines.length ? lines[0].text : this.nodeShort(this.nodes[i]);
602
+ let short = this.nodeShort(this.nodes[i]);
1093
603
  ctx.font = "bold 11px sans-serif";
1094
604
  ctx.lineWidth = 3;
1095
605
  ctx.strokeStyle = "hsl(0, 0%, 6%)";
@@ -1099,11 +609,10 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
1099
609
  }
1100
610
  }
1101
611
 
1102
- // Stack the full label centered ON the node so a spread-out node reads its details in place.
1103
- drawFullNodeLabel(ctx: CanvasRenderingContext2D, lines: LatencyGraphLabelLine[], x: number, y: number) {
1104
- let baseY = y - (lines.length - 1) / 2 * 13;
612
+ // Stack the label ABOVE the node: the block's bottom line sits at bottomY, the rest rising above it.
613
+ drawStackedLabel(ctx: CanvasRenderingContext2D, lines: LatencyGraphLabelLine[], x: number, bottomY: number) {
1105
614
  for (let [li, line] of lines.entries()) {
1106
- let ly = baseY + li * 13;
615
+ let ly = bottomY - (lines.length - 1 - li) * LABEL_LINE_HEIGHT;
1107
616
  ctx.font = li === 0 ? "bold 11px sans-serif" : "10px sans-serif";
1108
617
  ctx.lineWidth = 3;
1109
618
  ctx.strokeStyle = "hsl(0, 0%, 6%)";
@@ -1113,77 +622,13 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
1113
622
  }
1114
623
  }
1115
624
 
1116
- // Screen-pixel distance from node i to its closest other node (used to decide whether there's room for a full label).
1117
- nearestScreenDist(i: number, screenX: number[], screenY: number[]) {
1118
- let best = Infinity;
1119
- for (let j = 0; j < screenX.length; j++) {
1120
- if (j === i) continue;
1121
- let dx = screenX[j] - screenX[i];
1122
- let dy = screenY[j] - screenY[i];
1123
- let d = Math.sqrt(dx * dx + dy * dy);
1124
- if (d < best) best = d;
1125
- }
1126
- return best;
1127
- }
1128
-
1129
- drawClusterLabels(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number, centroids: Centroids) {
1130
- let k = this.clusterCount;
1131
- let rep = new Int32Array(k).fill(-1);
1132
- let repDist = new Float64Array(k).fill(Infinity);
1133
- let members: string[][] = Array.from({ length: k }, () => []);
1134
- for (let i = 0; i < this.nodes.length; i++) {
1135
- let c = this.clusterOf[i];
1136
- members[c].push(this.nodes[i].id);
1137
- let dx = this.positionsX[i] - centroids.cx[c];
1138
- let dy = this.positionsY[i] - centroids.cy[c];
1139
- let d = dx * dx + dy * dy;
1140
- if (d < repDist[c]) {
1141
- repDist[c] = d;
1142
- rep[c] = i;
1143
- }
1144
- }
1145
- ctx.textAlign = "center";
1146
- ctx.textBaseline = "middle";
1147
- for (let c = 0; c < k; c++) {
1148
- if (rep[c] < 0) continue;
1149
- let hue = this.clusterHue(c);
1150
-
1151
- // Build the whole label block, then stack it above the hull (count at top, last summary line just above it).
1152
- let lines: { text: string; color: string; font: string; }[] = [];
1153
- lines.push({ text: `${centroids.count[c]} nodes`, color: `hsl(${hue}, 70%, 70%)`, font: "bold 12px sans-serif" });
1154
- if (centroids.count[c] >= 2) {
1155
- lines.push({ text: `${formatTime(this.clusterIntraMin[c])} · ${formatTime(this.clusterIntraMedian[c])} · ${formatTime(this.clusterIntraMax[c])}`, color: `hsl(${hue}, 40%, 78%)`, font: "11px sans-serif" });
1156
- for (let summary of this.clusterSummary?.(members[c]) ?? []) {
1157
- lines.push({ text: summary.text, color: summary.color || "hsl(0, 0%, 78%)", font: "10px sans-serif" });
1158
- }
1159
- }
1160
-
1161
- let x = toScreenX(centroids.cx[c]);
1162
- let hullTop = toScreenY(centroids.cy[c]) - centroids.radius[c] * this.viewScale;
1163
- let lineHeight = 14;
1164
- let bottomY = hullTop - 6;
1165
- for (let [i, line] of lines.entries()) {
1166
- let ly = bottomY - (lines.length - 1 - i) * lineHeight;
1167
- ctx.font = line.font;
1168
- let textWidth = ctx.measureText(line.text).width;
1169
- ctx.fillStyle = "hsla(0, 0%, 0%, 0.6)";
1170
- ctx.fillRect(x - textWidth / 2 - 3, ly - 8, textWidth + 6, 15);
1171
- ctx.fillStyle = line.color;
1172
- ctx.fillText(line.text, x, ly);
1173
- }
1174
- }
1175
- }
1176
-
1177
- // Hovering (the nearest node to the cursor) reveals ALL of that node's connections to other nodes in its cluster
1178
- // — drawn as highlighted lines with latency labels, even the ones not shown by default.
1179
- drawHover(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number, centroids: Centroids) {
625
+ // Hovering a node reveals ALL of its measured connections drawn as highlighted lines with latency labels.
626
+ drawHover(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number) {
1180
627
  if (this.hoverNode === undefined) return;
1181
628
  let hover = this.hoverNode;
1182
- let cluster = this.clusterOf[hover];
1183
- let connections = this.clusterSize[cluster] >= 2
1184
- ? this.neighbors[hover].filter(nb => this.clusterOf[nb.other] === cluster).map(nb => nb.edge)
1185
- : this.renderEdges.filter(edge => edge.a === hover || edge.b === hover);
629
+ let connections = (this.neighbors[hover] || []).map(nb => nb.edge);
1186
630
 
631
+ // Just highlight the hovered node's connections — their latency/traffic labels are already drawn all the time.
1187
632
  ctx.lineWidth = 1.5;
1188
633
  for (let edge of connections) {
1189
634
  ctx.strokeStyle = "hsla(40, 90%, 60%, 0.6)";
@@ -1192,24 +637,12 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
1192
637
  ctx.lineTo(toScreenX(this.positionsX[edge.b]), toScreenY(this.positionsY[edge.b]));
1193
638
  ctx.stroke();
1194
639
  }
1195
- ctx.font = "12px sans-serif";
1196
- ctx.textAlign = "center";
1197
- ctx.textBaseline = "middle";
1198
- for (let edge of connections) {
1199
- let midX = (toScreenX(this.positionsX[edge.a]) + toScreenX(this.positionsX[edge.b])) / 2;
1200
- let midY = (toScreenY(this.positionsY[edge.a]) + toScreenY(this.positionsY[edge.b])) / 2;
1201
- let label = formatTime(edge.latency);
1202
- let textWidth = ctx.measureText(label).width;
1203
- ctx.fillStyle = "hsla(0, 0%, 0%, 0.75)";
1204
- ctx.fillRect(midX - textWidth / 2 - 3, midY - 8, textWidth + 6, 16);
1205
- ctx.fillStyle = "hsl(40, 90%, 75%)";
1206
- ctx.fillText(label, midX, midY);
1207
- }
1208
640
  // Redraw the hovered node's own label last so it always sits above the connection labels.
1209
641
  let hoverLines = this.nodes[hover].labelLines;
1210
642
  if (hoverLines && hoverLines.length) {
1211
643
  ctx.textAlign = "center";
1212
- this.drawFullNodeLabel(ctx, hoverLines, toScreenX(this.positionsX[hover]), toScreenY(this.positionsY[hover]));
644
+ ctx.textBaseline = "middle";
645
+ this.drawStackedLabel(ctx, hoverLines, toScreenX(this.positionsX[hover]), toScreenY(this.positionsY[hover]) - this.nodeRadius(hover) - 8);
1213
646
  }
1214
647
  this.drawHoverCard(ctx);
1215
648
  }
@@ -1281,6 +714,23 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
1281
714
  this.lastPointerY = e.clientY;
1282
715
  };
1283
716
 
717
+ // Index of the node closest to a screen point (undefined only when there are no nodes).
718
+ nearestNodeTo(mx: number, my: number) {
719
+ let scale = this.viewScale;
720
+ let bestDist = Infinity;
721
+ let best: number | undefined = undefined;
722
+ for (let i = 0; i < this.nodes.length; i++) {
723
+ let sx = this.viewWidth / 2 + (this.positionsX[i] - this.centroidX) * scale + this.panX;
724
+ let sy = this.viewHeight / 2 + (this.positionsY[i] - this.centroidY) * scale + this.panY;
725
+ let dist = (sx - mx) ** 2 + (sy - my) ** 2;
726
+ if (dist < bestDist) {
727
+ bestDist = dist;
728
+ best = i;
729
+ }
730
+ }
731
+ return best;
732
+ }
733
+
1284
734
  onMouseMove = (e: MouseEvent) => {
1285
735
  if (this.isPanning) {
1286
736
  this.panX += e.clientX - this.lastPointerX;
@@ -1298,44 +748,19 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
1298
748
  let my = e.clientY - rect.top;
1299
749
  this.mouseX = mx;
1300
750
  this.mouseY = my;
1301
- // No hit radius: the hovered node is just whichever node is closest to the cursor, so you can explore by
1302
- // moving the mouse around without having to land exactly on a node.
1303
- let scale = this.viewScale;
1304
- let bestDist = Infinity;
1305
- let best: number | undefined = undefined;
1306
- for (let i = 0; i < this.nodes.length; i++) {
1307
- let sx = this.viewWidth / 2 + (this.positionsX[i] - this.centroidX) * scale + this.panX;
1308
- let sy = this.viewHeight / 2 + (this.positionsY[i] - this.centroidY) * scale + this.panY;
1309
- let dist = (sx - mx) ** 2 + (sy - my) ** 2;
1310
- if (dist < bestDist) {
1311
- bestDist = dist;
1312
- best = i;
1313
- }
1314
- }
1315
- // Only highlight if the cursor is actually inside that node's cluster circle (or on a lone node).
1316
- if (best !== undefined && !this.cursorInCluster(best, mx, my)) {
1317
- best = undefined;
1318
- }
1319
- this.hoverNode = best;
751
+ // Always hover whichever node the cursor is closest to, so moving anywhere explores the graph.
752
+ this.hoverNode = this.nearestNodeTo(mx, my);
1320
753
  this.scheduleFrame();
1321
754
  };
1322
755
 
1323
- // Is the cursor inside node i's cluster hull (or, for a lone node, close to the node itself)?
1324
- cursorInCluster(i: number, mx: number, my: number) {
1325
- let scale = this.viewScale;
1326
- let c = this.clusterOf[i];
1327
- let centroids = this.hoverCentroids;
1328
- if (this.clusterSize[c] >= 2 && centroids) {
1329
- let scx = this.viewWidth / 2 + (centroids.cx[c] - this.centroidX) * scale + this.panX;
1330
- let scy = this.viewHeight / 2 + (centroids.cy[c] - this.centroidY) * scale + this.panY;
1331
- let r = centroids.radius[c] * scale;
1332
- return (mx - scx) ** 2 + (my - scy) ** 2 <= r * r;
1333
- }
1334
- let sx = this.viewWidth / 2 + (this.positionsX[i] - this.centroidX) * scale + this.panX;
1335
- let sy = this.viewHeight / 2 + (this.positionsY[i] - this.centroidY) * scale + this.panY;
1336
- let r = this.nodeRadius(i) + 8;
1337
- return (mx - sx) ** 2 + (my - sy) ** 2 <= r * r;
1338
- }
756
+ onClick = (e: MouseEvent) => {
757
+ let canvas = this.canvas;
758
+ if (!canvas || !this.onSelectNode) return;
759
+ let rect = canvas.getBoundingClientRect();
760
+ let nearest = this.nearestNodeTo(e.clientX - rect.left, e.clientY - rect.top);
761
+ if (nearest === undefined) return;
762
+ this.onSelectNode(this.nodes[nearest].id);
763
+ };
1339
764
 
1340
765
  onMouseLeave = () => {
1341
766
  this.hoverNode = undefined;
@@ -1361,6 +786,7 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
1361
786
  this.canvas.removeEventListener("wheel", this.onWheel);
1362
787
  this.canvas.removeEventListener("mousedown", this.onMouseDown);
1363
788
  this.canvas.removeEventListener("mouseleave", this.onMouseLeave);
789
+ this.canvas.removeEventListener("click", this.onClick);
1364
790
  window.removeEventListener("mousemove", this.onMouseMove);
1365
791
  window.removeEventListener("mouseup", this.onMouseUp);
1366
792
  }
@@ -1369,6 +795,7 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
1369
795
  elem.addEventListener("wheel", this.onWheel, { passive: false });
1370
796
  elem.addEventListener("mousedown", this.onMouseDown);
1371
797
  elem.addEventListener("mouseleave", this.onMouseLeave);
798
+ elem.addEventListener("click", this.onClick);
1372
799
  window.addEventListener("mousemove", this.onMouseMove);
1373
800
  window.addEventListener("mouseup", this.onMouseUp);
1374
801
  this.scheduleFrame();
@@ -1377,7 +804,8 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
1377
804
 
1378
805
  render() {
1379
806
  this.formatWeight = this.props.formatWeight;
1380
- this.clusterSummary = this.props.clusterSummary;
807
+ this.selectedId = this.props.selectedId;
808
+ this.onSelectNode = this.props.onSelectNode;
1381
809
  // Rebuild from scratch whenever the data set changes (e.g. a new node's latencies arrive progressively).
1382
810
  let sig = `${this.props.nodes.length}:${this.props.links.length}`;
1383
811
  let weightSig = 0;
@@ -1397,13 +825,10 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
1397
825
  // Traffic weights changed but the node/link set didn't — just refresh sizing, no re-layout.
1398
826
  this.builtWeightSig = weightSig;
1399
827
  this.computeWeights();
1400
- } else if (Math.max(1, Math.floor(minClusterSizeParam.value)) !== this.builtMinClusterSize || Math.max(1, clusterFactorParam.value) !== this.builtClusterFactor || interExponentParam.value !== this.builtInterExponent) {
1401
- this.clusterNodes();
1402
- this.applyLayout();
1403
- } else if (interSpacingParam.value !== this.builtInterSpacing) {
1404
- // Only the inter-cluster spacing changed — re-solve the layout (no re-clustering needed).
828
+ } else if (latencyExponentParam.value !== this.builtLatencyExponent) {
829
+ this.computeSolveEdges();
1405
830
  this.applyLayout();
1406
- } else if (Math.max(1, Math.floor(intraConnectionsParam.value)) !== this.builtIntraConnections) {
831
+ } else if (Math.max(1, Math.floor(connectionsParam.value)) !== this.builtConnections) {
1407
832
  this.computeRenderEdges();
1408
833
  }
1409
834
  this.scheduleFrame();
@@ -1411,7 +836,7 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
1411
836
  return <div className={css.relative.fillBoth.overflowHidden}>
1412
837
  <canvas
1413
838
  ref={elem => this.mountCanvas(elem ?? undefined)}
1414
- className={css.absolute.pos(0, 0).fillBoth}
839
+ className={css.absolute.pos(0, 0).fillBoth.cursor("pointer")}
1415
840
  />
1416
841
  <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)}>
1417
842
  <div
@@ -1421,21 +846,16 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
1421
846
  <span>{configOpenParam.value ? "▾" : "▸"}</span>
1422
847
  <span>Configuration</span>
1423
848
  <span className={css.marginAuto.colorhsl(0, 0, 60).fontWeight("normal")}>
1424
- {this.nodes.length} nodes · {this.clusterCount} clusters
849
+ {this.nodes.length} nodes
1425
850
  </span>
1426
851
  </div>
1427
852
  {configOpenParam.value && <>
1428
- <InputLabelURL label="Connections in cluster" url={intraConnectionsParam} integer />
1429
- <InputLabelURL label="Connections between clusters" url={interConnectionsParam} integer />
1430
- <InputLabelURL label="Cluster factor" url={clusterFactorParam} number />
1431
- <InputLabelURL label="Cluster distance exponent" url={interExponentParam} number />
1432
- <InputLabelURL label="Cluster spacing" url={interSpacingParam} number />
1433
- <InputLabelURL label="Min cluster size" url={minClusterSizeParam} integer />
1434
- <InputLabelURL label="Label proximity px" url={labelProximityParam} number />
853
+ <InputLabelURL label="Connections per node" url={connectionsParam} integer />
854
+ <InputLabelURL label="Latency exponent" url={latencyExponentParam} number />
1435
855
  <InputLabelURL label="Show line latencies" url={showLatenciesParam} checkbox />
1436
856
  <InputLabelURL label="Geographic (lat/lon)" url={geoParam} checkbox />
1437
857
  <div className={css.colorhsl(0, 0, 60)}>
1438
- {this.nodes.length} nodes · {this.clusterCount} clusters · {this.renderEdges.length} lines
858
+ {this.nodes.length} nodes · {this.renderEdges.length} lines
1439
859
  </div>
1440
860
  <Button onClick={() => { this.userAdjustedView = false; this.applyLayout(); }}>
1441
861
  Reheat Layout