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