querysub 0.519.0 → 0.520.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "querysub",
3
- "version": "0.519.0",
3
+ "version": "0.520.0",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "note1": "note on node-forge fork, see https://github.com/digitalbazaar/forge/issues/744 for details",
@@ -25,6 +25,7 @@ export type NodeDataTraffic = { sent: number; received: number; };
25
25
  export type TrafficStats = {
26
26
  functionsExecuted: number;
27
27
  pathValuesSent: number;
28
+ pathValuesReceived: number;
28
29
  querysubCalls: number;
29
30
  // Bytes to/from each known network node, plus one aggregate for everything outside the node list.
30
31
  perNode: { [nodeId: string]: NodeDataTraffic };
@@ -41,6 +42,7 @@ function oldestBucket() {
41
42
  // bucket => count
42
43
  let functionsExecuted = new Map<number, number>();
43
44
  let pathValuesSent = new Map<number, number>();
45
+ let pathValuesReceived = new Map<number, number>();
44
46
  let querysubCalls = new Map<number, number>();
45
47
  // raw connection nodeId => (bucket => bytes)
46
48
  let bytesSent = new Map<string, Map<number, number>>();
@@ -66,6 +68,10 @@ export function recordPathValuesSent(count: number) {
66
68
  if (count <= 0) return;
67
69
  addSimple(pathValuesSent, count);
68
70
  }
71
+ export function recordPathValuesReceived(count: number) {
72
+ if (count <= 0) return;
73
+ addSimple(pathValuesReceived, count);
74
+ }
69
75
  export function recordQuerysubCall() {
70
76
  addSimple(querysubCalls, 1);
71
77
  }
@@ -131,6 +137,7 @@ export function getTrafficStats(): TrafficStats {
131
137
  return {
132
138
  functionsExecuted: sumRecent(functionsExecuted),
133
139
  pathValuesSent: sumRecent(pathValuesSent),
140
+ pathValuesReceived: sumRecent(pathValuesReceived),
134
141
  querysubCalls: sumRecent(querysubCalls),
135
142
  perNode: data.perNode,
136
143
  outside: data.outside,
@@ -12,7 +12,7 @@ import { debugNodeId, debugNodeThread } from "../-c-identity/IdentityController"
12
12
  import { getSlowdown, isDiskAudit, getDomain } from "../config";
13
13
  import { decodeNodeId } from "sliftutils/misc/https/certs";
14
14
  import { areNodeIdsEqual, isOwnNodeId } from "../-f-node-discovery/NodeDiscovery";
15
- import { recordPathValuesSent } from "../-f-node-discovery/TrafficTracking";
15
+ import { recordPathValuesSent, recordPathValuesReceived } from "../-f-node-discovery/TrafficTracking";
16
16
  import { getNodeIdIP } from "socket-function/src/nodeCache";
17
17
  import { authorityLookup } from "./AuthorityLookup";
18
18
  import { timeoutToError } from "../errors";
@@ -148,6 +148,7 @@ export class PathValueControllerBase {
148
148
  if (valueBuffers) {
149
149
  values = await pathValueSerializer.deserialize(valueBuffers);
150
150
  ActionsHistory.OnRead(values);
151
+ recordPathValuesReceived(values.length);
151
152
  }
152
153
 
153
154
  if (initialCreation) {
@@ -160,13 +160,10 @@ function nodeLabelLines(config: {
160
160
  let thread = (parts?.threadId || "?").slice(0, ID_CHARS);
161
161
  let machine = (parts?.machineId || nodeId).slice(0, ID_CHARS);
162
162
  let lines: LatencyGraphLabelLine[] = [{ text: `${thread}:${parts?.port ?? "?"}` }];
163
- // The last segment of the entry-point path is a good human name, and more useful than the machine id.
163
+ // The last segment of the entry-point path is a good human name; shown on one line with the server (machine) id.
164
164
  let entryPoint = info?.entryPoint || runner?.entryPoint;
165
165
  let name = entryPoint ? entryPoint.split(/[\\/]/).filter(Boolean).at(-1) : undefined;
166
- if (name) {
167
- lines.push({ text: name });
168
- }
169
- lines.push({ text: machine });
166
+ lines.push({ text: name ? `${name} ${machine}` : machine });
170
167
  // Total data this node sent / received, and the percent of it that went outside our known node list.
171
168
  if (traffic) {
172
169
  let totals = nodeTotalTraffic(traffic);
@@ -176,26 +173,28 @@ function nodeLabelLines(config: {
176
173
  lines.push({ text: `↑${formatNumber(totals.sent)}B ↓${formatNumber(totals.received)}B ${outsidePct}%⊘` });
177
174
  }
178
175
  }
176
+ // All function-runner info on one purple line: networks, shard ranges, calls, private.
179
177
  if (runner) {
180
- // Network names are human-readable, so we show them in full.
181
- for (let network of runner.networks) {
182
- lines.push({ text: network, color: FUNCTION_COLOR });
183
- }
178
+ let fnParts: string[] = [];
179
+ if (runner.networks.length) fnParts.push(runner.networks.join(" "));
184
180
  for (let shard of runner.shards) {
185
- lines.push({ text: `fn ${shard.shardRange.startFraction.toFixed(3)}-${shard.shardRange.endFraction.toFixed(3)}`, color: FUNCTION_COLOR });
186
- }
187
- lines.push({ text: `${formatNumber(traffic?.functionsExecuted ?? 0)} calls`, color: FUNCTION_COLOR });
188
- if (!runner.isPublic) {
189
- lines.push({ text: "PRIVATE" });
181
+ fnParts.push(`fn ${shard.shardRange.startFraction.toFixed(3)}-${shard.shardRange.endFraction.toFixed(3)}`);
190
182
  }
183
+ fnParts.push(`${formatNumber(traffic?.functionsExecuted ?? 0)} calls`);
184
+ if (!runner.isPublic) fnParts.push("PRIVATE");
185
+ lines.push({ text: fnParts.join(" "), color: FUNCTION_COLOR });
191
186
  }
192
- // routeStart/routeEnd of -1 means "no path-value sharding", so only show the range when it's real.
187
+ // All path-value info on one blue line: shard range (only if real) and values sent.
193
188
  let spec = info?.spec;
189
+ let pvParts: string[] = [];
194
190
  if (spec && spec.routeStart >= 0 && spec.routeEnd >= 0) {
195
- lines.push({ text: `pv ${spec.routeStart.toFixed(3)}-${spec.routeEnd.toFixed(3)}`, color: PATHVALUE_COLOR });
191
+ pvParts.push(`pv ${spec.routeStart.toFixed(3)}-${spec.routeEnd.toFixed(3)}`);
196
192
  }
197
- if (traffic?.pathValuesSent) {
198
- lines.push({ text: `${formatNumber(traffic.pathValuesSent)} sent`, color: PATHVALUE_COLOR });
193
+ if (traffic && (traffic.pathValuesSent || traffic.pathValuesReceived)) {
194
+ pvParts.push(`↑${formatNumber(traffic.pathValuesSent)} ↓${formatNumber(traffic.pathValuesReceived)} values`);
195
+ }
196
+ if (pvParts.length) {
197
+ lines.push({ text: pvParts.join(" "), color: PATHVALUE_COLOR });
199
198
  }
200
199
  // Querysub activity: how many function calls were submitted to this node's QuerysubController.
201
200
  if (traffic?.querysubCalls) {
@@ -267,10 +266,37 @@ class LatencyGraphSection extends qreact.Component<{ nodeIds: string[]; infos: N
267
266
  }
268
267
  }
269
268
 
269
+ // Per-cluster summary: sum the same per-node metrics across the cluster's members.
270
+ let clusterSummary = (memberIds: string[]): LatencyGraphLabelLine[] => {
271
+ let sent = 0;
272
+ let received = 0;
273
+ let calls = 0;
274
+ let addCalls = 0;
275
+ let dataSent = 0;
276
+ let dataReceived = 0;
277
+ for (let id of memberIds) {
278
+ let traffic = trafficMaps.get(id);
279
+ if (!traffic) continue;
280
+ sent += traffic.pathValuesSent;
281
+ received += traffic.pathValuesReceived;
282
+ calls += traffic.functionsExecuted;
283
+ addCalls += traffic.querysubCalls;
284
+ let totals = nodeTotalTraffic(traffic);
285
+ dataSent += totals.sent;
286
+ dataReceived += totals.received;
287
+ }
288
+ let summary: LatencyGraphLabelLine[] = [];
289
+ summary.push({ text: `↑${formatNumber(dataSent)}B ↓${formatNumber(dataReceived)}B` });
290
+ summary.push({ text: `↑${formatNumber(sent)} ↓${formatNumber(received)} values`, color: PATHVALUE_COLOR });
291
+ summary.push({ text: `${formatNumber(calls)} calls`, color: FUNCTION_COLOR });
292
+ summary.push({ text: `${formatNumber(addCalls)} addCalls`, color: QUERYSUB_COLOR });
293
+ return summary;
294
+ };
295
+
270
296
  return <div className={css.vbox(8).fillWidth}>
271
297
  <h2 className={css.margin(0)}>Latency Graph ({respondedSet.size}/{nodeIds.length} nodes reported)</h2>
272
298
  <div className={css.relative.fillWidth.height(LATENCY_GRAPH_HEIGHT_PX).bord2(0, 0, 85)}>
273
- <LatencyGraph nodes={nodes} links={links} formatWeight={weight => formatNumber(weight) + "B"} />
299
+ <LatencyGraph nodes={nodes} links={links} formatWeight={weight => formatNumber(weight) + "B"} clusterSummary={clusterSummary} />
274
300
  </div>
275
301
  </div>;
276
302
  }
@@ -14,13 +14,17 @@ 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
19
  };
18
20
 
19
- const DEFAULT_CONNECTIONS = 3;
21
+ const DEFAULT_INTRA_CONNECTIONS = 3;
22
+ const DEFAULT_INTER_CONNECTIONS = 5;
20
23
  const DEFAULT_MIN_CLUSTER_SIZE = 2;
21
24
  const DEFAULT_CLUSTER_FACTOR = 2;
22
25
  const DEFAULT_INTER_EXPONENT = 1.5;
23
- const connectionsParam = new URLParam("lgConnections", DEFAULT_CONNECTIONS);
26
+ const intraConnectionsParam = new URLParam("lgIntraConnections", DEFAULT_INTRA_CONNECTIONS);
27
+ const interConnectionsParam = new URLParam("lgInterConnections", DEFAULT_INTER_CONNECTIONS);
24
28
  const minClusterSizeParam = new URLParam("lgMinClusterSize", DEFAULT_MIN_CLUSTER_SIZE);
25
29
  // Exponent on the (normalized) inter-cluster latency when mapping to layout distance, so far clusters push apart harder.
26
30
  const interExponentParam = new URLParam("lgInterExponent", DEFAULT_INTER_EXPONENT);
@@ -28,16 +32,27 @@ const interExponentParam = new URLParam("lgInterExponent", DEFAULT_INTER_EXPONEN
28
32
  // Relative (per-node) so a mutually-isolated close pair clusters even if it's far from everything else.
29
33
  const clusterFactorParam = new URLParam("lgClusterFactor", DEFAULT_CLUSTER_FACTOR);
30
34
  const geoParam = new URLParam("lgGeo", false);
31
- const showLatenciesParam = new URLParam("lgShowLatencies", true);
35
+ const showLatenciesParam = new URLParam("lgShowLatencies", false);
36
+ // The config panel is collapsed by default so it doesn't eat the canvas; clicking the header expands it.
37
+ 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);
32
42
  // Pixels per degree of latitude/longitude in geographic mode (equirectangular projection).
33
43
  const GEO_SCALE = 6;
34
44
 
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;
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;
41
56
 
42
57
  // Stress majorization (SMACOF): each iteration is a Guttman transform that never increases stress.
43
58
  const COOLING = 0.996;
@@ -48,14 +63,12 @@ const MAX_POWER_ITERS = 200;
48
63
  const POWER_EPS = 1e-9;
49
64
 
50
65
  const MIN_OPACITY = 0.05;
51
- const HOVER_EXTRA_OPACITY = 0.4;
52
66
  const NODE_RADIUS = 6;
53
67
  // Traffic-driven sizing (normalized to the busiest node/pair). Circles grow with node weight; lines with pair weight.
54
68
  const NODE_WEIGHT_MULT = 2.5;
55
69
  const LINE_BASE_WIDTH = 1.5;
56
70
  const LINE_MIN_WIDTH = 1;
57
71
  const LINE_MAX_WIDTH = 7;
58
- const HOVER_PAD = 6;
59
72
  const HULL_PADDING = 26;
60
73
  const ZOOM_STEP = 1.1;
61
74
  const MIN_SCALE = 0.05;
@@ -64,6 +77,8 @@ const MAX_SCALE = 12;
64
77
  // stack upward above the nodes (and are drawn at a fixed screen size regardless of zoom).
65
78
  const FIT_PAD = 60;
66
79
  const FIT_PAD_TOP = 160;
80
+ // Auto-fit leaves a bit of extra breathing room by not zooming all the way in to fill the padded box.
81
+ const FIT_ZOOM = 0.7;
67
82
 
68
83
  type Edge = { a: number; b: number; latency: number; };
69
84
  type SolveEdge = { a: number; b: number; target: number; weight: number; };
@@ -80,12 +95,18 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
80
95
  maxNodeWeight = 0;
81
96
  pairWeight = new Map<number, number>();
82
97
  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;
83
101
  // Cached from props in render() so draw() (a rAF callback, not synced) never reads this.props.
84
102
  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;
85
106
  builtSig = "";
86
107
  builtWeightSig = 0;
87
108
  builtGeo = false;
88
- builtConnections = 0;
109
+ builtIntraConnections = 0;
89
110
  builtMinClusterSize = 0;
90
111
  builtClusterFactor = 0;
91
112
  builtInterExponent = 0;
@@ -102,7 +123,6 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
102
123
  renderEdges: Edge[] = [];
103
124
  renderEdgeSet = new Set<Edge>();
104
125
  neighbors: { other: number; edge: Edge; }[][] = [];
105
- hoverExtras: Edge[] = [];
106
126
  interPairMap = new Map<number, ClusterPair>();
107
127
  renderInterPairs: ClusterPair[] = [];
108
128
  interMin = 0;
@@ -110,6 +130,10 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
110
130
  renderMin = 0;
111
131
  renderMax = 1;
112
132
 
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
+
113
137
  // position = clusterCenter[cluster] + local[node]; the two levels are solved independently.
114
138
  clusterCenterX = new Float64Array(0);
115
139
  clusterCenterY = new Float64Array(0);
@@ -185,6 +209,30 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
185
209
  }
186
210
  }
187
211
 
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);
227
+ }
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);
234
+ }
235
+
188
236
  // Combine both directions of each pair into a single undirected edge (mean latency), and build the full matrix.
189
237
  ingestLinks() {
190
238
  let n = this.nodes.length;
@@ -366,26 +414,26 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
366
414
  let medians = stats.map(s => s.median);
367
415
  this.interMin = medians.length ? Math.min(...medians) : 0;
368
416
  this.interMax = medians.length ? Math.max(...medians) : 1;
369
- this.interPairs = stats.map(s => {
370
- let target = this.restPx(s.median);
371
- return { p: s.p, q: s.q, min: s.min, median: s.median, max: s.max, target, weight: 1 / (target * target) };
372
- });
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 }));
373
420
  this.interPairMap = new Map();
374
421
  for (let pair of this.interPairs) {
375
422
  this.interPairMap.set(pair.p * k + pair.q, pair);
376
423
  }
377
424
 
425
+ // Intra edges use the per-cluster normalized scale, so cluster size is decoupled from absolute latency.
378
426
  this.intraSolveEdges = [];
379
427
  for (let e of this.edges) {
380
428
  if (this.clusterOf[e.a] !== this.clusterOf[e.b]) continue;
381
- let target = this.restPx(e.latency);
429
+ let target = this.intraTarget(e.latency, this.clusterIntraMedian[this.clusterOf[e.a]]);
382
430
  this.intraSolveEdges.push({ a: e.a, b: e.b, target, weight: 1 / (target * target) });
383
431
  }
384
432
  }
385
433
 
386
434
  computeRenderEdges() {
387
- let count = Math.max(1, Math.floor(connectionsParam.value));
388
- this.builtConnections = count;
435
+ let count = Math.max(1, Math.floor(intraConnectionsParam.value));
436
+ this.builtIntraConnections = count;
389
437
  let n = this.nodes.length;
390
438
  let neighbors: { other: number; edge: Edge; }[][] = Array.from({ length: n }, () => []);
391
439
  for (let edge of this.edges) {
@@ -415,17 +463,6 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
415
463
  return candidates;
416
464
  }
417
465
 
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
466
  // Each real cluster links to its N nearest clusters by on-screen distance — but only ones it has clear line of
430
467
  // sight to: if the segment would pass through another cluster's hull, the link is hidden to keep the chart clean.
431
468
  computeRenderInterPairs(centroids: Centroids) {
@@ -442,7 +479,8 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
442
479
  return { o, d: dx * dx + dy * dy };
443
480
  });
444
481
  sort(others, entry => entry.d);
445
- for (let j = 0; j < Math.min(this.builtConnections, others.length); j++) {
482
+ let interCount = Math.max(1, Math.floor(interConnectionsParam.value));
483
+ for (let j = 0; j < Math.min(interCount, others.length); j++) {
446
484
  let pair = this.interPairMap.get(Math.min(c, others[j].o) * k + Math.max(c, others[j].o));
447
485
  if (pair) selected.add(pair);
448
486
  }
@@ -470,10 +508,28 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
470
508
  return true;
471
509
  }
472
510
 
473
- restPx(latency: number) {
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;
474
530
  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;
531
+ let px = INTRA_MIN_PX + INTRA_UNIT_PX * (l / ref);
532
+ return Number.isFinite(px) ? px : INTRA_UNIT_PX;
477
533
  }
478
534
 
479
535
  opacityFor(latency: number, min: number, max: number) {
@@ -505,31 +561,45 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
505
561
  this.settled = true;
506
562
  }
507
563
 
508
- // Cluster centers placed by inter-cluster MDS; each cluster's members placed by their own local MDS.
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.
509
566
  hierarchicalInit() {
510
567
  let n = this.nodes.length;
511
568
  let k = this.clusterCount;
512
- let centers = this.mds(k, (p, q) => {
513
- let d = this.restPx(this.clusterDist[p * k + q]);
514
- return d * d;
515
- });
516
- this.clusterCenterX = centers.x;
517
- this.clusterCenterY = centers.y;
569
+ this.builtInterSpacing = interSpacingParam.value;
570
+ this.clusterRadius = new Float64Array(k);
518
571
 
519
572
  for (let c = 0; c < k; c++) {
520
573
  let members: number[] = [];
521
574
  for (let i = 0; i < n; i++) {
522
575
  if (this.clusterOf[i] === c) members.push(i);
523
576
  }
577
+ let median = this.clusterIntraMedian[c];
524
578
  let local = this.mds(members.length, (a, b) => {
525
- let d = this.restPx(this.latencyMatrix[members[a] * n + members[b]]);
579
+ let d = this.intraTarget(this.latencyMatrix[members[a] * n + members[b]], median);
526
580
  return d * d;
527
581
  });
582
+ let radius = 0;
528
583
  for (let [mi, node] of members.entries()) {
529
584
  this.localX[node] = local.x[mi];
530
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]));
531
587
  }
588
+ this.clusterRadius[c] = radius + HULL_PADDING;
532
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]);
598
+ return d * d;
599
+ });
600
+ this.clusterCenterX = centers.x;
601
+ this.clusterCenterY = centers.y;
602
+
533
603
  this.composePositions();
534
604
  }
535
605
 
@@ -847,7 +917,7 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
847
917
  }
848
918
  let worldW = maxX - minX || 1;
849
919
  let worldH = maxY - minY || 1;
850
- let fit = Math.min((width - 2 * FIT_PAD) / worldW, (height - FIT_PAD_TOP - FIT_PAD) / worldH);
920
+ let fit = Math.min((width - 2 * FIT_PAD) / worldW, (height - FIT_PAD_TOP - FIT_PAD) / worldH) * FIT_ZOOM;
851
921
  // Only apply the fit if it's sane — never let a NaN/degenerate value nuke the whole view.
852
922
  if (Number.isFinite(fit) && fit > 0 && Number.isFinite(minX) && Number.isFinite(minY)) {
853
923
  this.viewScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, fit));
@@ -863,8 +933,9 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
863
933
  let toScreenY = (wy: number) => height / 2 + (wy - centroidY) * scale + this.panY;
864
934
 
865
935
  let centroids = this.clusterCentroids();
936
+ this.hoverCentroids = centroids;
866
937
  this.computeRenderInterPairs(centroids);
867
- this.hoverExtras = this.computeHoverExtras();
938
+ this.computeClusterPairWeights();
868
939
 
869
940
  // Inter-cluster lines run centroid-to-centroid, then the opaque hulls paint over the parts inside any
870
941
  // cluster — so a line only shows in the gaps and reads as entering one cluster edge and exiting another.
@@ -875,7 +946,7 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
875
946
  if (showLatenciesParam.value) {
876
947
  this.drawLineLatencies(ctx, toScreenX, toScreenY, centroids);
877
948
  }
878
- this.drawNodes(ctx, toScreenX, toScreenY);
949
+ this.drawNodes(ctx, toScreenX, toScreenY, centroids);
879
950
  this.drawInterLabels(ctx, toScreenX, toScreenY, centroids);
880
951
  this.drawClusterLabels(ctx, toScreenX, toScreenY, centroids);
881
952
  this.drawHover(ctx, toScreenX, toScreenY, centroids);
@@ -901,8 +972,8 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
901
972
  }
902
973
 
903
974
  drawInterLines(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number, centroids: Centroids) {
904
- ctx.lineWidth = 2;
905
975
  for (let pair of this.renderInterPairs) {
976
+ ctx.lineWidth = this.interLineWidth(pair.p, pair.q);
906
977
  ctx.strokeStyle = `hsla(0, 0%, 100%, ${this.opacityFor(pair.median, this.interMin, this.interMax)})`;
907
978
  ctx.beginPath();
908
979
  ctx.moveTo(toScreenX(centroids.cx[pair.p]), toScreenY(centroids.cy[pair.p]));
@@ -918,12 +989,27 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
918
989
  for (let pair of this.renderInterPairs) {
919
990
  let midX = (toScreenX(centroids.cx[pair.p]) + toScreenX(centroids.cx[pair.q])) / 2;
920
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";
921
998
  let label = `${formatTime(pair.min)} · ${formatTime(pair.median)} · ${formatTime(pair.max)}`;
922
999
  let textWidth = ctx.measureText(label).width;
923
1000
  ctx.fillStyle = "hsla(0, 0%, 0%, 0.6)";
924
- ctx.fillRect(midX - textWidth / 2 - 3, midY - 8, textWidth + 6, 16);
1001
+ ctx.fillRect(midX - textWidth / 2 - 3, latencyY - 8, textWidth + 6, 16);
925
1002
  ctx.fillStyle = "hsl(0, 0%, 82%)";
926
- ctx.fillText(label, midX, midY);
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
+ }
927
1013
  }
928
1014
  }
929
1015
 
@@ -932,9 +1018,6 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
932
1018
  for (let edge of this.renderEdges) {
933
1019
  this.strokeNodeConnection(ctx, toScreenX, toScreenY, centroids, edge, 1);
934
1020
  }
935
- for (let edge of this.hoverExtras) {
936
- this.strokeNodeConnection(ctx, toScreenX, toScreenY, centroids, edge, HOVER_EXTRA_OPACITY);
937
- }
938
1021
  }
939
1022
 
940
1023
  strokeNodeConnection(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number, centroids: Centroids, edge: Edge, opacityScale: number) {
@@ -982,34 +1065,31 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
982
1065
  }
983
1066
  }
984
1067
 
985
- drawNodes(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number) {
1068
+ drawNodes(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number, centroids: Centroids) {
986
1069
  ctx.textBaseline = "middle";
987
1070
  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]));
988
1074
  for (let i = 0; i < this.nodes.length; i++) {
989
- let x = toScreenX(this.positionsX[i]);
990
- let y = toScreenY(this.positionsY[i]);
1075
+ let x = screenX[i];
1076
+ let y = screenY[i];
991
1077
  let radius = this.nodeRadius(i);
992
1078
  ctx.fillStyle = i === this.hoverNode ? "hsl(40, 90%, 60%)" : `hsl(${this.clusterHue(this.clusterOf[i])}, 65%, 55%)`;
993
1079
  ctx.beginPath();
994
1080
  ctx.arc(x, y, radius, 0, Math.PI * 2);
995
1081
  ctx.fill();
996
1082
 
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;
997
1086
  let lines = this.nodes[i].labelLines;
998
- if (lines && lines.length) {
999
- // Stack the lines above the node: the first line highest, the last just above the dot.
1000
- let baseY = y - radius - 8;
1001
- for (let [li, line] of lines.entries()) {
1002
- let ly = baseY - (lines.length - 1 - li) * 13;
1003
- ctx.font = li === 0 ? "bold 11px sans-serif" : "10px sans-serif";
1004
- ctx.lineWidth = 3;
1005
- ctx.strokeStyle = "hsl(0, 0%, 6%)";
1006
- ctx.strokeText(line.text, x, ly);
1007
- ctx.fillStyle = line.color || (li === 0 ? "hsl(0, 0%, 92%)" : "hsl(0, 0%, 62%)");
1008
- ctx.fillText(line.text, x, ly);
1009
- }
1087
+ if (full && lines && lines.length) {
1088
+ this.drawFullNodeLabel(ctx, lines, x, y);
1010
1089
  continue;
1011
1090
  }
1012
- let short = this.nodeShort(this.nodes[i]);
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]);
1013
1093
  ctx.font = "bold 11px sans-serif";
1014
1094
  ctx.lineWidth = 3;
1015
1095
  ctx.strokeStyle = "hsl(0, 0%, 6%)";
@@ -1019,12 +1099,41 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
1019
1099
  }
1020
1100
  }
1021
1101
 
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;
1105
+ for (let [li, line] of lines.entries()) {
1106
+ let ly = baseY + li * 13;
1107
+ ctx.font = li === 0 ? "bold 11px sans-serif" : "10px sans-serif";
1108
+ ctx.lineWidth = 3;
1109
+ ctx.strokeStyle = "hsl(0, 0%, 6%)";
1110
+ ctx.strokeText(line.text, x, ly);
1111
+ ctx.fillStyle = line.color || (li === 0 ? "hsl(0, 0%, 92%)" : "hsl(0, 0%, 62%)");
1112
+ ctx.fillText(line.text, x, ly);
1113
+ }
1114
+ }
1115
+
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
+
1022
1129
  drawClusterLabels(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number, centroids: Centroids) {
1023
1130
  let k = this.clusterCount;
1024
1131
  let rep = new Int32Array(k).fill(-1);
1025
1132
  let repDist = new Float64Array(k).fill(Infinity);
1133
+ let members: string[][] = Array.from({ length: k }, () => []);
1026
1134
  for (let i = 0; i < this.nodes.length; i++) {
1027
1135
  let c = this.clusterOf[i];
1136
+ members[c].push(this.nodes[i].id);
1028
1137
  let dx = this.positionsX[i] - centroids.cx[c];
1029
1138
  let dy = this.positionsY[i] - centroids.cy[c];
1030
1139
  let d = dx * dx + dy * dy;
@@ -1037,41 +1146,56 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
1037
1146
  ctx.textBaseline = "middle";
1038
1147
  for (let c = 0; c < k; c++) {
1039
1148
  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]})`;
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
+
1043
1161
  let x = toScreenX(centroids.cx[c]);
1044
1162
  let hullTop = toScreenY(centroids.cy[c]) - centroids.radius[c] * this.viewScale;
1045
- let hasStats = centroids.count[c] >= 2;
1046
- let nameY = hullTop - (hasStats ? 20 : 5);
1047
- ctx.font = "bold 12px sans-serif";
1048
- ctx.lineWidth = 3;
1049
- ctx.strokeStyle = "hsl(0, 0%, 6%)";
1050
- ctx.strokeText(label, x, nameY);
1051
- ctx.fillStyle = `hsl(${this.clusterHue(c)}, 70%, 70%)`;
1052
- ctx.fillText(label, x, nameY);
1053
-
1054
- if (!hasStats) continue;
1055
- let stats = `${formatTime(this.clusterIntraMin[c])} · ${formatTime(this.clusterIntraMedian[c])} · ${formatTime(this.clusterIntraMax[c])}`;
1056
- ctx.font = "11px sans-serif";
1057
- let statsWidth = ctx.measureText(stats).width;
1058
- let statsY = hullTop - 5;
1059
- ctx.fillStyle = "hsla(0, 0%, 0%, 0.6)";
1060
- ctx.fillRect(x - statsWidth / 2 - 3, statsY - 8, statsWidth + 6, 16);
1061
- ctx.fillStyle = `hsl(${this.clusterHue(c)}, 40%, 78%)`;
1062
- ctx.fillText(stats, x, statsY);
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
+ }
1063
1174
  }
1064
1175
  }
1065
1176
 
1066
- // Hovering only labels the node's already-drawn connections (its base tier plus the faint extra tier).
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.
1067
1179
  drawHover(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number, centroids: Centroids) {
1068
1180
  if (this.hoverNode === undefined) return;
1181
+ 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);
1186
+
1187
+ ctx.lineWidth = 1.5;
1188
+ for (let edge of connections) {
1189
+ ctx.strokeStyle = "hsla(40, 90%, 60%, 0.6)";
1190
+ ctx.beginPath();
1191
+ ctx.moveTo(toScreenX(this.positionsX[edge.a]), toScreenY(this.positionsY[edge.a]));
1192
+ ctx.lineTo(toScreenX(this.positionsX[edge.b]), toScreenY(this.positionsY[edge.b]));
1193
+ ctx.stroke();
1194
+ }
1069
1195
  ctx.font = "12px sans-serif";
1070
1196
  ctx.textAlign = "center";
1071
1197
  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;
1198
+ for (let edge of connections) {
1075
1199
  let midX = (toScreenX(this.positionsX[edge.a]) + toScreenX(this.positionsX[edge.b])) / 2;
1076
1200
  let midY = (toScreenY(this.positionsY[edge.a]) + toScreenY(this.positionsY[edge.b])) / 2;
1077
1201
  let label = formatTime(edge.latency);
@@ -1081,6 +1205,12 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
1081
1205
  ctx.fillStyle = "hsl(40, 90%, 75%)";
1082
1206
  ctx.fillText(label, midX, midY);
1083
1207
  }
1208
+ // Redraw the hovered node's own label last so it always sits above the connection labels.
1209
+ let hoverLines = this.nodes[hover].labelLines;
1210
+ if (hoverLines && hoverLines.length) {
1211
+ ctx.textAlign = "center";
1212
+ this.drawFullNodeLabel(ctx, hoverLines, toScreenX(this.positionsX[hover]), toScreenY(this.positionsY[hover]));
1213
+ }
1084
1214
  this.drawHoverCard(ctx);
1085
1215
  }
1086
1216
 
@@ -1168,6 +1298,8 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
1168
1298
  let my = e.clientY - rect.top;
1169
1299
  this.mouseX = mx;
1170
1300
  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.
1171
1303
  let scale = this.viewScale;
1172
1304
  let bestDist = Infinity;
1173
1305
  let best: number | undefined = undefined;
@@ -1175,18 +1307,36 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
1175
1307
  let sx = this.viewWidth / 2 + (this.positionsX[i] - this.centroidX) * scale + this.panX;
1176
1308
  let sy = this.viewHeight / 2 + (this.positionsY[i] - this.centroidY) * scale + this.panY;
1177
1309
  let dist = (sx - mx) ** 2 + (sy - my) ** 2;
1178
- let hitDist = (this.nodeRadius(i) + HOVER_PAD) ** 2;
1179
- if (dist <= hitDist && dist < bestDist) {
1310
+ if (dist < bestDist) {
1180
1311
  bestDist = dist;
1181
1312
  best = i;
1182
1313
  }
1183
1314
  }
1184
- let prev = this.hoverNode;
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
+ }
1185
1319
  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();
1320
+ this.scheduleFrame();
1188
1321
  };
1189
1322
 
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
+ }
1339
+
1190
1340
  onMouseLeave = () => {
1191
1341
  this.hoverNode = undefined;
1192
1342
  this.scheduleFrame();
@@ -1227,6 +1377,7 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
1227
1377
 
1228
1378
  render() {
1229
1379
  this.formatWeight = this.props.formatWeight;
1380
+ this.clusterSummary = this.props.clusterSummary;
1230
1381
  // Rebuild from scratch whenever the data set changes (e.g. a new node's latencies arrive progressively).
1231
1382
  let sig = `${this.props.nodes.length}:${this.props.links.length}`;
1232
1383
  let weightSig = 0;
@@ -1249,7 +1400,10 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
1249
1400
  } else if (Math.max(1, Math.floor(minClusterSizeParam.value)) !== this.builtMinClusterSize || Math.max(1, clusterFactorParam.value) !== this.builtClusterFactor || interExponentParam.value !== this.builtInterExponent) {
1250
1401
  this.clusterNodes();
1251
1402
  this.applyLayout();
1252
- } else if (Math.max(1, Math.floor(connectionsParam.value)) !== this.builtConnections) {
1403
+ } else if (interSpacingParam.value !== this.builtInterSpacing) {
1404
+ // Only the inter-cluster spacing changed — re-solve the layout (no re-clustering needed).
1405
+ this.applyLayout();
1406
+ } else if (Math.max(1, Math.floor(intraConnectionsParam.value)) !== this.builtIntraConnections) {
1253
1407
  this.computeRenderEdges();
1254
1408
  }
1255
1409
  this.scheduleFrame();
@@ -1260,21 +1414,36 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
1260
1414
  className={css.absolute.pos(0, 0).fillBoth}
1261
1415
  />
1262
1416
  <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
1417
+ <div
1418
+ className={css.hbox(6).fillWidth.button.colorhsl(0, 0, 85).fontWeight("bold")}
1419
+ onMouseDown={() => configOpenParam.value = !configOpenParam.value}
1420
+ >
1421
+ <span>{configOpenParam.value ? "▾" : "▸"}</span>
1422
+ <span>Configuration</span>
1423
+ <span className={css.marginAuto.colorhsl(0, 0, 60).fontWeight("normal")}>
1424
+ {this.nodes.length} nodes · {this.clusterCount} clusters
1425
+ </span>
1271
1426
  </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>
1427
+ {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 />
1435
+ <InputLabelURL label="Show line latencies" url={showLatenciesParam} checkbox />
1436
+ <InputLabelURL label="Geographic (lat/lon)" url={geoParam} checkbox />
1437
+ <div className={css.colorhsl(0, 0, 60)}>
1438
+ {this.nodes.length} nodes · {this.clusterCount} clusters · {this.renderEdges.length} lines
1439
+ </div>
1440
+ <Button onClick={() => { this.userAdjustedView = false; this.applyLayout(); }}>
1441
+ Reheat Layout
1442
+ </Button>
1443
+ <Button onClick={() => console.log("LatencyGraph data:\n" + JSON.stringify({ nodes: this.props.nodes, links: this.props.links }, undefined, 2))}>
1444
+ Log graph data
1445
+ </Button>
1446
+ </>}
1278
1447
  </div>
1279
1448
  </div>;
1280
1449
  }