querysub 0.519.0 → 0.521.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,31 +14,25 @@ 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
+ // 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;
17
21
  };
18
22
 
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 DEFAULT_CONNECTIONS = 4;
24
+ // Nearest neighbors drawn per node (the layout still uses every measured latency; this only thins the drawn lines).
23
25
  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);
26
+ const DEFAULT_LATENCY_EXPONENT = 1;
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);
30
29
  const geoParam = new URLParam("lgGeo", false);
31
- const showLatenciesParam = new URLParam("lgShowLatencies", true);
30
+ const showLatenciesParam = new URLParam("lgShowLatencies", false);
31
+ // The config panel is collapsed by default so it doesn't eat the canvas; clicking the header expands it.
32
+ const configOpenParam = new URLParam("lgConfigOpen", false);
32
33
  // Pixels per degree of latitude/longitude in geographic mode (equirectangular projection).
33
34
  const GEO_SCALE = 6;
34
35
 
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
36
  // Stress majorization (SMACOF): each iteration is a Guttman transform that never increases stress.
43
37
  const COOLING = 0.996;
44
38
  const CONVERGENCE_EPS = 0.02;
@@ -47,16 +41,21 @@ const MAX_ITERATIONS = 3000;
47
41
  const MAX_POWER_ITERS = 200;
48
42
  const POWER_EPS = 1e-9;
49
43
 
44
+ // Layout distance for a pair: a pair at the reference (median) latency sits DIST_UNIT_PX apart, scaled by the exponent,
45
+ // with a floor. Normalized to the median so the overall scale is latency-magnitude independent.
46
+ const DIST_MIN_PX = 8;
47
+ const DIST_UNIT_PX = 90;
48
+
50
49
  const MIN_OPACITY = 0.05;
51
- const HOVER_EXTRA_OPACITY = 0.4;
52
50
  const NODE_RADIUS = 6;
51
+ const NODE_HUE = 210;
52
+ const HIGHLIGHT_COLOR = "hsl(40, 90%, 60%)";
53
53
  // Traffic-driven sizing (normalized to the busiest node/pair). Circles grow with node weight; lines with pair weight.
54
54
  const NODE_WEIGHT_MULT = 2.5;
55
55
  const LINE_BASE_WIDTH = 1.5;
56
56
  const LINE_MIN_WIDTH = 1;
57
57
  const LINE_MAX_WIDTH = 7;
58
- const HOVER_PAD = 6;
59
- const HULL_PADDING = 26;
58
+ const LABEL_LINE_HEIGHT = 13;
60
59
  const ZOOM_STEP = 1.1;
61
60
  const MIN_SCALE = 0.05;
62
61
  const MAX_SCALE = 12;
@@ -64,11 +63,14 @@ const MAX_SCALE = 12;
64
63
  // stack upward above the nodes (and are drawn at a fixed screen size regardless of zoom).
65
64
  const FIT_PAD = 60;
66
65
  const FIT_PAD_TOP = 160;
66
+ // Auto-fit leaves a bit of extra breathing room by not zooming all the way in to fill the padded box.
67
+ const FIT_ZOOM = 0.7;
68
+ // Cursor must be within this many screen pixels of a node to hover/click it.
69
+ const HOVER_HIT_PX = 60;
70
+ const CLICK_HIT_PX = 40;
67
71
 
68
72
  type Edge = { a: number; b: number; latency: number; };
69
73
  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
74
 
73
75
  export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
74
76
  nodes: LatencyGraphNode[] = [];
@@ -76,45 +78,27 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
76
78
  latencyMatrix = new Float64Array(0);
77
79
  minLatency = 0;
78
80
  maxLatency = 1;
81
+ latencyRef = 1;
79
82
  nodeWeight = new Float64Array(0);
80
83
  maxNodeWeight = 0;
81
84
  pairWeight = new Map<number, number>();
82
85
  maxPairWeight = 0;
83
86
  // Cached from props in render() so draw() (a rAF callback, not synced) never reads this.props.
84
87
  formatWeight: ((weight: number) => string) | undefined = undefined;
88
+ selectedId: string | undefined = undefined;
89
+ onSelectNode: ((id: string) => void) | undefined = undefined;
85
90
  builtSig = "";
86
91
  builtWeightSig = 0;
87
92
  builtGeo = false;
88
93
  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[] = [];
94
+ builtLatencyExponent = 0;
95
+
96
+ solveEdges: SolveEdge[] = [];
102
97
  renderEdges: Edge[] = [];
103
- renderEdgeSet = new Set<Edge>();
104
98
  neighbors: { other: number; edge: Edge; }[][] = [];
105
- hoverExtras: Edge[] = [];
106
- interPairMap = new Map<number, ClusterPair>();
107
- renderInterPairs: ClusterPair[] = [];
108
- interMin = 0;
109
- interMax = 1;
110
99
  renderMin = 0;
111
100
  renderMax = 1;
112
101
 
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
102
  positionsX = new Float64Array(0);
119
103
  positionsY = new Float64Array(0);
120
104
 
@@ -148,21 +132,17 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
148
132
  let n = this.nodes.length;
149
133
  this.positionsX = new Float64Array(n);
150
134
  this.positionsY = new Float64Array(n);
151
- this.localX = new Float64Array(n);
152
- this.localY = new Float64Array(n);
153
135
  this.ingestLinks();
154
136
  this.computeWeights();
155
- this.clusterNodes();
137
+ this.computeSolveEdges();
138
+ this.computeRenderEdges();
156
139
  this.applyLayout();
157
140
  }
158
141
 
159
142
  // Traffic weights only affect rendering (circle size, line width), so they can refresh without re-laying-out.
160
143
  computeWeights() {
161
144
  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
- }
145
+ let index = this.nodeIndex();
166
146
  this.nodeWeight = new Float64Array(n);
167
147
  this.maxNodeWeight = 0;
168
148
  for (let node of this.props.nodes) {
@@ -185,13 +165,18 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
185
165
  }
186
166
  }
187
167
 
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;
168
+ nodeIndex() {
191
169
  let index = new Map<string, number>();
192
170
  for (let [i, node] of this.nodes.entries()) {
193
171
  index.set(node.id, i);
194
172
  }
173
+ return index;
174
+ }
175
+
176
+ // Combine both directions of each pair into a single undirected edge (mean latency), and build the full matrix.
177
+ ingestLinks() {
178
+ let n = this.nodes.length;
179
+ let index = this.nodeIndex();
195
180
  let paired = new Map<string, { sum: number; count: number; a: number; b: number; }>();
196
181
  for (let link of this.props.links) {
197
182
  let a = index.get(link.source);
@@ -214,8 +199,11 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
214
199
  let latencies = this.edges.map(e => e.latency);
215
200
  this.minLatency = latencies.length ? Math.min(...latencies) : 0;
216
201
  this.maxLatency = latencies.length ? Math.max(...latencies) : 1;
202
+ let sorted = latencies.slice();
203
+ sort(sorted, x => x);
204
+ this.latencyRef = sorted.length ? sorted[Math.floor(sorted.length / 2)] : 1;
217
205
 
218
- // Unknown pairs treated as maximally distant, so they never falsely cluster.
206
+ // Unknown pairs treated as maximally distant, so they never falsely collapse together.
219
207
  this.latencyMatrix = new Float64Array(n * n).fill(this.maxLatency);
220
208
  for (let i = 0; i < n; i++) {
221
209
  this.latencyMatrix[i * n + i] = 0;
@@ -226,161 +214,21 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
226
214
  }
227
215
  }
228
216
 
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);
217
+ // Normalized to the median latency so absolute latency magnitude doesn't change the overall scale; the exponent
218
+ // shapes how sharply higher-latency pairs are pushed apart.
219
+ targetDist(latency: number) {
220
+ let ref = this.latencyRef > 0 ? this.latencyRef : 1;
221
+ let l = Number.isFinite(latency) ? Math.max(0, latency) : ref;
222
+ let px = DIST_MIN_PX + DIST_UNIT_PX * Math.pow(l / ref, Math.max(0.01, latencyExponentParam.value));
223
+ return Number.isFinite(px) && px > 0 ? px : DIST_UNIT_PX;
309
224
  }
310
225
 
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) };
226
+ computeSolveEdges() {
227
+ this.builtLatencyExponent = latencyExponentParam.value;
228
+ this.solveEdges = this.edges.map(e => {
229
+ let target = this.targetDist(e.latency);
230
+ return { a: e.a, b: e.b, target, weight: 1 / (target * target) };
372
231
  });
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
232
  }
385
233
 
386
234
  computeRenderEdges() {
@@ -395,85 +243,16 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
395
243
  this.neighbors = neighbors;
396
244
  let selected = new Set<Edge>();
397
245
  for (let i = 0; i < n; i++) {
398
- let candidates = this.nodeCandidates(i);
246
+ let candidates = neighbors[i].slice();
247
+ sort(candidates, nb => nb.edge.latency);
399
248
  for (let j = 0; j < Math.min(count, candidates.length); j++) {
400
249
  selected.add(candidates[j].edge);
401
250
  }
402
251
  }
403
- this.renderEdgeSet = selected;
404
252
  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;
253
+ let latencies = this.renderEdges.map(e => e.latency);
254
+ this.renderMin = latencies.length ? Math.min(...latencies) : 0;
255
+ this.renderMax = latencies.length ? Math.max(...latencies) : 1;
477
256
  }
478
257
 
479
258
  opacityFor(latency: number, min: number, max: number) {
@@ -488,7 +267,7 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
488
267
  this.scheduleFrame();
489
268
  return;
490
269
  }
491
- this.hierarchicalInit();
270
+ this.mdsInit();
492
271
  this.temperature = 1;
493
272
  this.iteration = 0;
494
273
  this.settled = false;
@@ -505,40 +284,15 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
505
284
  this.settled = true;
506
285
  }
507
286
 
508
- // Cluster centers placed by inter-cluster MDS; each cluster's members placed by their own local MDS.
509
- hierarchicalInit() {
287
+ // Classical MDS on the full latency matrix for a good starting layout; SMACOF then refines using the real edges.
288
+ mdsInit() {
510
289
  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]);
290
+ let pos = this.mds(n, (a, b) => {
291
+ let d = this.targetDist(this.latencyMatrix[a * n + b]);
514
292
  return d * d;
515
293
  });
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
- }
294
+ this.positionsX = pos.x;
295
+ this.positionsY = pos.y;
542
296
  }
543
297
 
544
298
  // Classical MDS on an m-point set given a squared-target-distance function; top-2 eigenvectors.
@@ -646,10 +400,7 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
646
400
 
647
401
  step() {
648
402
  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();
403
+ let maxMove = this.majorize();
653
404
  this.temperature *= COOLING;
654
405
  this.iteration++;
655
406
  if (maxMove < CONVERGENCE_EPS || this.iteration >= MAX_ITERATIONS) {
@@ -657,52 +408,17 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
657
408
  }
658
409
  }
659
410
 
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() {
411
+ // One SMACOF (Guttman transform) iteration over the real edges, moving every node toward its target distances.
412
+ majorize() {
696
413
  let n = this.nodes.length;
697
- let k = this.clusterCount;
698
414
  let numX = new Float64Array(n);
699
415
  let numY = new Float64Array(n);
700
416
  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];
417
+ for (let e of this.solveEdges) {
418
+ let ax = this.positionsX[e.a];
419
+ let ay = this.positionsY[e.a];
420
+ let bx = this.positionsX[e.b];
421
+ let by = this.positionsY[e.b];
706
422
  let dx = bx - ax;
707
423
  let dy = by - ay;
708
424
  let d = Math.sqrt(dx * dx + dy * dy) || 0.0001;
@@ -717,40 +433,15 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
717
433
  let maxMove = 0;
718
434
  for (let i = 0; i < n; i++) {
719
435
  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;
436
+ let moveX = (numX[i] / denom[i] - this.positionsX[i]) * this.temperature;
437
+ let moveY = (numY[i] / denom[i] - this.positionsY[i]) * this.temperature;
438
+ this.positionsX[i] += moveX;
439
+ this.positionsY[i] += moveY;
724
440
  maxMove = Math.max(maxMove, Math.abs(moveX), Math.abs(moveY));
725
441
  }
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
442
  return maxMove;
748
443
  }
749
444
 
750
- clusterHue(c: number) {
751
- return Math.round(c / Math.max(1, this.clusterCount) * 360);
752
- }
753
-
754
445
  // Screen-space radius; grows with the node's traffic weight, normalized to the busiest node.
755
446
  nodeRadius(i: number) {
756
447
  if (this.maxNodeWeight <= 0) return NODE_RADIUS;
@@ -767,36 +458,8 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
767
458
  return (node.label || node.id).slice(0, 2);
768
459
  }
769
460
 
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 };
461
+ isSelected(i: number) {
462
+ return this.selectedId !== undefined && this.nodes[i].id === this.selectedId;
800
463
  }
801
464
 
802
465
  draw() {
@@ -847,7 +510,7 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
847
510
  }
848
511
  let worldW = maxX - minX || 1;
849
512
  let worldH = maxY - minY || 1;
850
- let fit = Math.min((width - 2 * FIT_PAD) / worldW, (height - FIT_PAD_TOP - FIT_PAD) / worldH);
513
+ let fit = Math.min((width - 2 * FIT_PAD) / worldW, (height - FIT_PAD_TOP - FIT_PAD) / worldH) * FIT_ZOOM;
851
514
  // Only apply the fit if it's sane — never let a NaN/degenerate value nuke the whole view.
852
515
  if (Number.isFinite(fit) && fit > 0 && Number.isFinite(minX) && Number.isFinite(minY)) {
853
516
  this.viewScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, fit));
@@ -862,100 +525,30 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
862
525
  let toScreenX = (wx: number) => width / 2 + (wx - centroidX) * scale + this.panX;
863
526
  let toScreenY = (wy: number) => height / 2 + (wy - centroidY) * scale + this.panY;
864
527
 
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.
528
+ this.drawEdges(ctx, toScreenX, toScreenY);
875
529
  if (showLatenciesParam.value) {
876
- this.drawLineLatencies(ctx, toScreenX, toScreenY, centroids);
530
+ this.drawLineLatencies(ctx, toScreenX, toScreenY);
877
531
  }
878
532
  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);
533
+ this.drawHover(ctx, toScreenX, toScreenY);
882
534
  }
883
535
 
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)})`;
536
+ drawEdges(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number) {
537
+ for (let edge of this.renderEdges) {
538
+ ctx.lineWidth = this.lineWidthFor(edge.a, edge.b);
539
+ ctx.strokeStyle = `hsla(${NODE_HUE}, 70%, 62%, ${this.opacityFor(edge.latency, this.renderMin, this.renderMax)})`;
907
540
  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]));
541
+ ctx.moveTo(toScreenX(this.positionsX[edge.a]), toScreenY(this.positionsY[edge.a]));
542
+ ctx.lineTo(toScreenX(this.positionsX[edge.b]), toScreenY(this.positionsY[edge.b]));
910
543
  ctx.stroke();
911
544
  }
912
545
  }
913
546
 
914
- drawInterLabels(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number, centroids: Centroids) {
915
- ctx.font = "11px sans-serif";
547
+ // Always-on latency labels on the drawn connections (toggleable), with the connection's traffic below.
548
+ drawLineLatencies(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number) {
916
549
  ctx.textAlign = "center";
917
550
  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
551
  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
552
  let midX = (toScreenX(this.positionsX[edge.a]) + toScreenX(this.positionsX[edge.b])) / 2;
960
553
  let midY = (toScreenY(this.positionsY[edge.a]) + toScreenY(this.positionsY[edge.b])) / 2;
961
554
 
@@ -989,24 +582,15 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
989
582
  let x = toScreenX(this.positionsX[i]);
990
583
  let y = toScreenY(this.positionsY[i]);
991
584
  let radius = this.nodeRadius(i);
992
- ctx.fillStyle = i === this.hoverNode ? "hsl(40, 90%, 60%)" : `hsl(${this.clusterHue(this.clusterOf[i])}, 65%, 55%)`;
585
+ let highlighted = i === this.hoverNode || this.isSelected(i);
586
+ ctx.fillStyle = highlighted ? HIGHLIGHT_COLOR : `hsl(${NODE_HUE}, 60%, 52%)`;
993
587
  ctx.beginPath();
994
588
  ctx.arc(x, y, radius, 0, Math.PI * 2);
995
589
  ctx.fill();
996
590
 
997
591
  let lines = this.nodes[i].labelLines;
998
592
  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
- }
593
+ this.drawStackedLabel(ctx, lines, x, y - radius - 8);
1010
594
  continue;
1011
595
  }
1012
596
  let short = this.nodeShort(this.nodes[i]);
@@ -1019,59 +603,37 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
1019
603
  }
1020
604
  }
1021
605
 
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";
606
+ // Stack the label ABOVE the node: the block's bottom line sits at bottomY, the rest rising above it.
607
+ drawStackedLabel(ctx: CanvasRenderingContext2D, lines: LatencyGraphLabelLine[], x: number, bottomY: number) {
608
+ for (let [li, line] of lines.entries()) {
609
+ let ly = bottomY - (lines.length - 1 - li) * LABEL_LINE_HEIGHT;
610
+ ctx.font = li === 0 ? "bold 11px sans-serif" : "10px sans-serif";
1048
611
  ctx.lineWidth = 3;
1049
612
  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);
613
+ ctx.strokeText(line.text, x, ly);
614
+ ctx.fillStyle = line.color || (li === 0 ? "hsl(0, 0%, 92%)" : "hsl(0, 0%, 62%)");
615
+ ctx.fillText(line.text, x, ly);
1063
616
  }
1064
617
  }
1065
618
 
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) {
619
+ // Hovering a node reveals ALL of its measured connections — drawn as highlighted lines with latency labels.
620
+ drawHover(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number) {
1068
621
  if (this.hoverNode === undefined) return;
622
+ let hover = this.hoverNode;
623
+ let connections = (this.neighbors[hover] || []).map(nb => nb.edge);
624
+
625
+ ctx.lineWidth = 1.5;
626
+ for (let edge of connections) {
627
+ ctx.strokeStyle = "hsla(40, 90%, 60%, 0.6)";
628
+ ctx.beginPath();
629
+ ctx.moveTo(toScreenX(this.positionsX[edge.a]), toScreenY(this.positionsY[edge.a]));
630
+ ctx.lineTo(toScreenX(this.positionsX[edge.b]), toScreenY(this.positionsY[edge.b]));
631
+ ctx.stroke();
632
+ }
1069
633
  ctx.font = "12px sans-serif";
1070
634
  ctx.textAlign = "center";
1071
635
  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;
636
+ for (let edge of connections) {
1075
637
  let midX = (toScreenX(this.positionsX[edge.a]) + toScreenX(this.positionsX[edge.b])) / 2;
1076
638
  let midY = (toScreenY(this.positionsY[edge.a]) + toScreenY(this.positionsY[edge.b])) / 2;
1077
639
  let label = formatTime(edge.latency);
@@ -1081,6 +643,13 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
1081
643
  ctx.fillStyle = "hsl(40, 90%, 75%)";
1082
644
  ctx.fillText(label, midX, midY);
1083
645
  }
646
+ // Redraw the hovered node's own label last so it always sits above the connection labels.
647
+ let hoverLines = this.nodes[hover].labelLines;
648
+ if (hoverLines && hoverLines.length) {
649
+ ctx.textAlign = "center";
650
+ ctx.textBaseline = "middle";
651
+ this.drawStackedLabel(ctx, hoverLines, toScreenX(this.positionsX[hover]), toScreenY(this.positionsY[hover]) - this.nodeRadius(hover) - 8);
652
+ }
1084
653
  this.drawHoverCard(ctx);
1085
654
  }
1086
655
 
@@ -1151,6 +720,23 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
1151
720
  this.lastPointerY = e.clientY;
1152
721
  };
1153
722
 
723
+ // Nearest node to a screen point, with its distance in pixels.
724
+ nearestNodeTo(mx: number, my: number) {
725
+ let scale = this.viewScale;
726
+ let bestDist = Infinity;
727
+ let best: number | undefined = undefined;
728
+ for (let i = 0; i < this.nodes.length; i++) {
729
+ let sx = this.viewWidth / 2 + (this.positionsX[i] - this.centroidX) * scale + this.panX;
730
+ let sy = this.viewHeight / 2 + (this.positionsY[i] - this.centroidY) * scale + this.panY;
731
+ let dist = Math.sqrt((sx - mx) ** 2 + (sy - my) ** 2);
732
+ if (dist < bestDist) {
733
+ bestDist = dist;
734
+ best = i;
735
+ }
736
+ }
737
+ return { node: best, dist: bestDist };
738
+ }
739
+
1154
740
  onMouseMove = (e: MouseEvent) => {
1155
741
  if (this.isPanning) {
1156
742
  this.panX += e.clientX - this.lastPointerX;
@@ -1168,23 +754,18 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
1168
754
  let my = e.clientY - rect.top;
1169
755
  this.mouseX = mx;
1170
756
  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();
757
+ let nearest = this.nearestNodeTo(mx, my);
758
+ this.hoverNode = nearest.dist <= HOVER_HIT_PX ? nearest.node : undefined;
759
+ this.scheduleFrame();
760
+ };
761
+
762
+ onClick = (e: MouseEvent) => {
763
+ let canvas = this.canvas;
764
+ if (!canvas || !this.onSelectNode) return;
765
+ let rect = canvas.getBoundingClientRect();
766
+ let nearest = this.nearestNodeTo(e.clientX - rect.left, e.clientY - rect.top);
767
+ if (nearest.node === undefined || nearest.dist > CLICK_HIT_PX) return;
768
+ this.onSelectNode(this.nodes[nearest.node].id);
1188
769
  };
1189
770
 
1190
771
  onMouseLeave = () => {
@@ -1211,6 +792,7 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
1211
792
  this.canvas.removeEventListener("wheel", this.onWheel);
1212
793
  this.canvas.removeEventListener("mousedown", this.onMouseDown);
1213
794
  this.canvas.removeEventListener("mouseleave", this.onMouseLeave);
795
+ this.canvas.removeEventListener("click", this.onClick);
1214
796
  window.removeEventListener("mousemove", this.onMouseMove);
1215
797
  window.removeEventListener("mouseup", this.onMouseUp);
1216
798
  }
@@ -1219,6 +801,7 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
1219
801
  elem.addEventListener("wheel", this.onWheel, { passive: false });
1220
802
  elem.addEventListener("mousedown", this.onMouseDown);
1221
803
  elem.addEventListener("mouseleave", this.onMouseLeave);
804
+ elem.addEventListener("click", this.onClick);
1222
805
  window.addEventListener("mousemove", this.onMouseMove);
1223
806
  window.addEventListener("mouseup", this.onMouseUp);
1224
807
  this.scheduleFrame();
@@ -1227,6 +810,8 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
1227
810
 
1228
811
  render() {
1229
812
  this.formatWeight = this.props.formatWeight;
813
+ this.selectedId = this.props.selectedId;
814
+ this.onSelectNode = this.props.onSelectNode;
1230
815
  // Rebuild from scratch whenever the data set changes (e.g. a new node's latencies arrive progressively).
1231
816
  let sig = `${this.props.nodes.length}:${this.props.links.length}`;
1232
817
  let weightSig = 0;
@@ -1246,8 +831,8 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
1246
831
  // Traffic weights changed but the node/link set didn't — just refresh sizing, no re-layout.
1247
832
  this.builtWeightSig = weightSig;
1248
833
  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();
834
+ } else if (latencyExponentParam.value !== this.builtLatencyExponent) {
835
+ this.computeSolveEdges();
1251
836
  this.applyLayout();
1252
837
  } else if (Math.max(1, Math.floor(connectionsParam.value)) !== this.builtConnections) {
1253
838
  this.computeRenderEdges();
@@ -1260,21 +845,31 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
1260
845
  className={css.absolute.pos(0, 0).fillBoth}
1261
846
  />
1262
847
  <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
848
+ <div
849
+ className={css.hbox(6).fillWidth.button.colorhsl(0, 0, 85).fontWeight("bold")}
850
+ onMouseDown={() => configOpenParam.value = !configOpenParam.value}
851
+ >
852
+ <span>{configOpenParam.value ? "▾" : "▸"}</span>
853
+ <span>Configuration</span>
854
+ <span className={css.marginAuto.colorhsl(0, 0, 60).fontWeight("normal")}>
855
+ {this.nodes.length} nodes
856
+ </span>
1271
857
  </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>
858
+ {configOpenParam.value && <>
859
+ <InputLabelURL label="Connections per node" url={connectionsParam} integer />
860
+ <InputLabelURL label="Latency exponent" url={latencyExponentParam} number />
861
+ <InputLabelURL label="Show line latencies" url={showLatenciesParam} checkbox />
862
+ <InputLabelURL label="Geographic (lat/lon)" url={geoParam} checkbox />
863
+ <div className={css.colorhsl(0, 0, 60)}>
864
+ {this.nodes.length} nodes · {this.renderEdges.length} lines
865
+ </div>
866
+ <Button onClick={() => { this.userAdjustedView = false; this.applyLayout(); }}>
867
+ Reheat Layout
868
+ </Button>
869
+ <Button onClick={() => console.log("LatencyGraph data:\n" + JSON.stringify({ nodes: this.props.nodes, links: this.props.links }, undefined, 2))}>
870
+ Log graph data
871
+ </Button>
872
+ </>}
1278
873
  </div>
1279
874
  </div>;
1280
875
  }