querysub 0.520.0 → 0.522.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/function-public.js +0 -2
- package/package.json +2 -2
- package/src/-f-node-discovery/TrafficTracking.ts +49 -28
- package/src/3-path-functions/PathFunctionRunner.ts +6 -5
- package/src/3-path-functions/PathFunctionRunnerMain.ts +4 -7
- package/src/config.ts +1 -3
- package/src/deployManager/components/ServiceDetailPage.tsx +32 -8
- package/src/diagnostics/misc-pages/RoutingTablePage.tsx +303 -76
- package/src/library-components/LatencyGraph.tsx +156 -736
|
@@ -14,46 +14,26 @@ export type LatencyGraphProps = {
|
|
|
14
14
|
links: LatencyGraphLink[];
|
|
15
15
|
// Formats a link's traffic weight for the label under the latency (e.g. bytes). No label if omitted.
|
|
16
16
|
formatWeight?: (weight: number) => string;
|
|
17
|
-
//
|
|
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 = 0.5;
|
|
27
|
+
// Exponent on (latency / reference) when mapping to layout distance, so far nodes push apart harder as it grows.
|
|
28
|
+
const latencyExponentParam = new URLParam("lgLatencyExponent", DEFAULT_LATENCY_EXPONENT);
|
|
34
29
|
const geoParam = new URLParam("lgGeo", false);
|
|
35
|
-
|
|
30
|
+
// On by default: each drawn connection shows its latency and (when a weight formatter is given) its traffic.
|
|
31
|
+
const showLatenciesParam = new URLParam("lgShowLatencies", true);
|
|
36
32
|
// The config panel is collapsed by default so it doesn't eat the canvas; clicking the header expands it.
|
|
37
33
|
const configOpenParam = new URLParam("lgConfigOpen", false);
|
|
38
|
-
// A node shows its full label only when its nearest neighbor is at least this many SCREEN pixels away (or it's hovered);
|
|
39
|
-
// crowded nodes fall back to a single title line so the view doesn't turn into a jumble. Zooming in spreads nodes past
|
|
40
|
-
// the threshold, revealing their full labels.
|
|
41
|
-
const labelProximityParam = new URLParam("lgLabelProximityPx", 100);
|
|
42
34
|
// Pixels per degree of latitude/longitude in geographic mode (equirectangular projection).
|
|
43
35
|
const GEO_SCALE = 6;
|
|
44
36
|
|
|
45
|
-
// Inter-cluster gap is measured in units of CLUSTER RADIUS (not absolute px), so it survives auto-fit: `spacing` is
|
|
46
|
-
// literally "edge gap = this many cluster radii" for the closest pair, up to `spacing * INTER_SPREAD` for the farthest.
|
|
47
|
-
// The exponent shapes the in-between. Range is normalized to [closest, farthest] latency so it can't blow up.
|
|
48
|
-
const INTER_SPREAD = 2.5;
|
|
49
|
-
const DEFAULT_INTER_SPACING = -1.5;
|
|
50
|
-
const interSpacingParam = new URLParam("lgInterSpacing", DEFAULT_INTER_SPACING);
|
|
51
|
-
// Intra-cluster layout is solved SEPARATELY and normalized per cluster, so a cluster's on-screen size doesn't depend
|
|
52
|
-
// on its absolute latency (a 3ms-tight cluster and a 30ms one render at similar sizes). A pair at the cluster's median
|
|
53
|
-
// intra-latency sits INTRA_UNIT_PX apart; relative distances within the cluster stay proportional to latency.
|
|
54
|
-
const INTRA_UNIT_PX = 60;
|
|
55
|
-
const INTRA_MIN_PX = 8;
|
|
56
|
-
|
|
57
37
|
// Stress majorization (SMACOF): each iteration is a Guttman transform that never increases stress.
|
|
58
38
|
const COOLING = 0.996;
|
|
59
39
|
const CONVERGENCE_EPS = 0.02;
|
|
@@ -62,14 +42,24 @@ const MAX_ITERATIONS = 3000;
|
|
|
62
42
|
const MAX_POWER_ITERS = 200;
|
|
63
43
|
const POWER_EPS = 1e-9;
|
|
64
44
|
|
|
45
|
+
// Layout distance for a pair: a pair at the reference (median) latency sits DIST_UNIT_PX apart, scaled by the exponent,
|
|
46
|
+
// with a floor. Normalized to the median so the overall scale is latency-magnitude independent.
|
|
47
|
+
const DIST_MIN_PX = 8;
|
|
48
|
+
const DIST_UNIT_PX = 90;
|
|
49
|
+
// A touch of deterministic jitter on the MDS init breaks symmetry, so a handful of nodes settle into a spread (a
|
|
50
|
+
// triangle for three) instead of collapsing onto a single line.
|
|
51
|
+
const INIT_JITTER_PX = 15;
|
|
52
|
+
|
|
65
53
|
const MIN_OPACITY = 0.05;
|
|
66
54
|
const NODE_RADIUS = 6;
|
|
55
|
+
const NODE_HUE = 210;
|
|
56
|
+
const HIGHLIGHT_COLOR = "hsl(40, 90%, 60%)";
|
|
67
57
|
// Traffic-driven sizing (normalized to the busiest node/pair). Circles grow with node weight; lines with pair weight.
|
|
68
58
|
const NODE_WEIGHT_MULT = 2.5;
|
|
69
59
|
const LINE_BASE_WIDTH = 1.5;
|
|
70
60
|
const LINE_MIN_WIDTH = 1;
|
|
71
61
|
const LINE_MAX_WIDTH = 7;
|
|
72
|
-
const
|
|
62
|
+
const LABEL_LINE_HEIGHT = 13;
|
|
73
63
|
const ZOOM_STEP = 1.1;
|
|
74
64
|
const MIN_SCALE = 0.05;
|
|
75
65
|
const MAX_SCALE = 12;
|
|
@@ -82,8 +72,6 @@ const FIT_ZOOM = 0.7;
|
|
|
82
72
|
|
|
83
73
|
type Edge = { a: number; b: number; latency: number; };
|
|
84
74
|
type SolveEdge = { a: number; b: number; target: number; weight: number; };
|
|
85
|
-
type ClusterPair = { p: number; q: number; min: number; median: number; max: number; target: number; weight: number; };
|
|
86
|
-
type Centroids = { cx: Float64Array; cy: Float64Array; count: Int32Array; radius: Float64Array; };
|
|
87
75
|
|
|
88
76
|
export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
89
77
|
nodes: LatencyGraphNode[] = [];
|
|
@@ -91,54 +79,27 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
91
79
|
latencyMatrix = new Float64Array(0);
|
|
92
80
|
minLatency = 0;
|
|
93
81
|
maxLatency = 1;
|
|
82
|
+
latencyRef = 1;
|
|
94
83
|
nodeWeight = new Float64Array(0);
|
|
95
84
|
maxNodeWeight = 0;
|
|
96
85
|
pairWeight = new Map<number, number>();
|
|
97
86
|
maxPairWeight = 0;
|
|
98
|
-
// Summed node-pair traffic between each cluster pair, for the inter-cluster line widths and labels.
|
|
99
|
-
clusterPairWeight = new Map<number, number>();
|
|
100
|
-
maxClusterPairWeight = 0;
|
|
101
87
|
// Cached from props in render() so draw() (a rAF callback, not synced) never reads this.props.
|
|
102
88
|
formatWeight: ((weight: number) => string) | undefined = undefined;
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
hoverCentroids: Centroids | undefined = undefined;
|
|
89
|
+
selectedId: string | undefined = undefined;
|
|
90
|
+
onSelectNode: ((id: string) => void) | undefined = undefined;
|
|
106
91
|
builtSig = "";
|
|
107
92
|
builtWeightSig = 0;
|
|
108
93
|
builtGeo = false;
|
|
109
|
-
|
|
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[] = [];
|
|
94
|
+
builtConnections = 0;
|
|
95
|
+
builtLatencyExponent = 0;
|
|
96
|
+
|
|
97
|
+
solveEdges: SolveEdge[] = [];
|
|
123
98
|
renderEdges: Edge[] = [];
|
|
124
|
-
renderEdgeSet = new Set<Edge>();
|
|
125
99
|
neighbors: { other: number; edge: Edge; }[][] = [];
|
|
126
|
-
interPairMap = new Map<number, ClusterPair>();
|
|
127
|
-
renderInterPairs: ClusterPair[] = [];
|
|
128
|
-
interMin = 0;
|
|
129
|
-
interMax = 1;
|
|
130
100
|
renderMin = 0;
|
|
131
101
|
renderMax = 1;
|
|
132
102
|
|
|
133
|
-
// Per-cluster radius (from the settled intra layout), so inter targets can be edge-to-edge.
|
|
134
|
-
clusterRadius = new Float64Array(0);
|
|
135
|
-
builtInterSpacing = 0;
|
|
136
|
-
|
|
137
|
-
// position = clusterCenter[cluster] + local[node]; the two levels are solved independently.
|
|
138
|
-
clusterCenterX = new Float64Array(0);
|
|
139
|
-
clusterCenterY = new Float64Array(0);
|
|
140
|
-
localX = new Float64Array(0);
|
|
141
|
-
localY = new Float64Array(0);
|
|
142
103
|
positionsX = new Float64Array(0);
|
|
143
104
|
positionsY = new Float64Array(0);
|
|
144
105
|
|
|
@@ -172,21 +133,17 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
172
133
|
let n = this.nodes.length;
|
|
173
134
|
this.positionsX = new Float64Array(n);
|
|
174
135
|
this.positionsY = new Float64Array(n);
|
|
175
|
-
this.localX = new Float64Array(n);
|
|
176
|
-
this.localY = new Float64Array(n);
|
|
177
136
|
this.ingestLinks();
|
|
178
137
|
this.computeWeights();
|
|
179
|
-
this.
|
|
138
|
+
this.computeSolveEdges();
|
|
139
|
+
this.computeRenderEdges();
|
|
180
140
|
this.applyLayout();
|
|
181
141
|
}
|
|
182
142
|
|
|
183
143
|
// Traffic weights only affect rendering (circle size, line width), so they can refresh without re-laying-out.
|
|
184
144
|
computeWeights() {
|
|
185
145
|
let n = this.nodes.length;
|
|
186
|
-
let index =
|
|
187
|
-
for (let [i, node] of this.nodes.entries()) {
|
|
188
|
-
index.set(node.id, i);
|
|
189
|
-
}
|
|
146
|
+
let index = this.nodeIndex();
|
|
190
147
|
this.nodeWeight = new Float64Array(n);
|
|
191
148
|
this.maxNodeWeight = 0;
|
|
192
149
|
for (let node of this.props.nodes) {
|
|
@@ -209,37 +166,18 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
209
166
|
}
|
|
210
167
|
}
|
|
211
168
|
|
|
212
|
-
|
|
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);
|
|
169
|
+
nodeIndex() {
|
|
170
|
+
let index = new Map<string, number>();
|
|
171
|
+
for (let [i, node] of this.nodes.entries()) {
|
|
172
|
+
index.set(node.id, i);
|
|
227
173
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
interLineWidth(p: number, q: number) {
|
|
231
|
-
if (this.maxClusterPairWeight <= 0) return 2;
|
|
232
|
-
let w = this.clusterPairWeight.get(Math.min(p, q) * this.clusterCount + Math.max(p, q)) || 0;
|
|
233
|
-
return LINE_MIN_WIDTH + (w / this.maxClusterPairWeight) * (LINE_MAX_WIDTH - LINE_MIN_WIDTH);
|
|
174
|
+
return index;
|
|
234
175
|
}
|
|
235
176
|
|
|
236
177
|
// Combine both directions of each pair into a single undirected edge (mean latency), and build the full matrix.
|
|
237
178
|
ingestLinks() {
|
|
238
179
|
let n = this.nodes.length;
|
|
239
|
-
let index =
|
|
240
|
-
for (let [i, node] of this.nodes.entries()) {
|
|
241
|
-
index.set(node.id, i);
|
|
242
|
-
}
|
|
180
|
+
let index = this.nodeIndex();
|
|
243
181
|
let paired = new Map<string, { sum: number; count: number; a: number; b: number; }>();
|
|
244
182
|
for (let link of this.props.links) {
|
|
245
183
|
let a = index.get(link.source);
|
|
@@ -262,8 +200,11 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
262
200
|
let latencies = this.edges.map(e => e.latency);
|
|
263
201
|
this.minLatency = latencies.length ? Math.min(...latencies) : 0;
|
|
264
202
|
this.maxLatency = latencies.length ? Math.max(...latencies) : 1;
|
|
203
|
+
let sorted = latencies.slice();
|
|
204
|
+
sort(sorted, x => x);
|
|
205
|
+
this.latencyRef = sorted.length ? sorted[Math.floor(sorted.length / 2)] : 1;
|
|
265
206
|
|
|
266
|
-
// Unknown pairs treated as maximally distant, so they never falsely
|
|
207
|
+
// Unknown pairs treated as maximally distant, so they never falsely collapse together.
|
|
267
208
|
this.latencyMatrix = new Float64Array(n * n).fill(this.maxLatency);
|
|
268
209
|
for (let i = 0; i < n; i++) {
|
|
269
210
|
this.latencyMatrix[i * n + i] = 0;
|
|
@@ -274,166 +215,26 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
274
215
|
}
|
|
275
216
|
}
|
|
276
217
|
|
|
277
|
-
//
|
|
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();
|
|
218
|
+
// Normalized to the median latency so absolute latency magnitude doesn't change the overall scale; the exponent
|
|
219
|
+
// shapes how sharply higher-latency pairs are pushed apart.
|
|
220
|
+
targetDist(latency: number) {
|
|
221
|
+
let ref = this.latencyRef > 0 ? this.latencyRef : 1;
|
|
222
|
+
let l = Number.isFinite(latency) ? Math.max(0, latency) : ref;
|
|
223
|
+
let px = DIST_MIN_PX + DIST_UNIT_PX * Math.pow(l / ref, Math.max(0.01, latencyExponentParam.value));
|
|
224
|
+
return Number.isFinite(px) && px > 0 ? px : DIST_UNIT_PX;
|
|
319
225
|
}
|
|
320
226
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
}
|
|
327
|
-
return nn;
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
// Clusters smaller than the configured minimum are dissolved into singletons (a node distant from everything).
|
|
331
|
-
enforceMinClusterSize() {
|
|
332
|
-
let n = this.nodes.length;
|
|
333
|
-
let min = Math.max(1, Math.floor(minClusterSizeParam.value));
|
|
334
|
-
this.builtMinClusterSize = min;
|
|
335
|
-
let size = new Int32Array(this.clusterCount);
|
|
336
|
-
for (let i = 0; i < n; i++) {
|
|
337
|
-
size[this.clusterOf[i]]++;
|
|
338
|
-
}
|
|
339
|
-
let remap = new Map<number, number>();
|
|
340
|
-
let next = 0;
|
|
341
|
-
let newOf = new Int32Array(n);
|
|
342
|
-
for (let i = 0; i < n; i++) {
|
|
343
|
-
let old = this.clusterOf[i];
|
|
344
|
-
if (size[old] >= min) {
|
|
345
|
-
let id = remap.get(old);
|
|
346
|
-
if (id === undefined) {
|
|
347
|
-
id = next++;
|
|
348
|
-
remap.set(old, id);
|
|
349
|
-
}
|
|
350
|
-
newOf[i] = id;
|
|
351
|
-
} else {
|
|
352
|
-
newOf[i] = next++;
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
this.clusterOf = newOf;
|
|
356
|
-
this.clusterCount = Math.max(1, next);
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
buildClusterDerived() {
|
|
360
|
-
let n = this.nodes.length;
|
|
361
|
-
let k = this.clusterCount;
|
|
362
|
-
this.clusterSize = new Int32Array(k);
|
|
363
|
-
for (let i = 0; i < n; i++) {
|
|
364
|
-
this.clusterSize[this.clusterOf[i]]++;
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
// Node-pair latencies bucketed per cluster pair (cross) and per cluster (within), for min/median/max ranges.
|
|
368
|
-
let lists = new Map<number, number[]>();
|
|
369
|
-
let intraLists: number[][] = Array.from({ length: k }, () => []);
|
|
370
|
-
for (let i = 0; i < n; i++) {
|
|
371
|
-
for (let j = i + 1; j < n; j++) {
|
|
372
|
-
let p = this.clusterOf[i];
|
|
373
|
-
let q = this.clusterOf[j];
|
|
374
|
-
if (p === q) {
|
|
375
|
-
intraLists[p].push(this.latencyMatrix[i * n + j]);
|
|
376
|
-
continue;
|
|
377
|
-
}
|
|
378
|
-
let key = Math.min(p, q) * k + Math.max(p, q);
|
|
379
|
-
let list = lists.get(key);
|
|
380
|
-
if (!list) {
|
|
381
|
-
list = [];
|
|
382
|
-
lists.set(key, list);
|
|
383
|
-
}
|
|
384
|
-
list.push(this.latencyMatrix[i * n + j]);
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
this.clusterIntraMin = new Float64Array(k);
|
|
388
|
-
this.clusterIntraMedian = new Float64Array(k);
|
|
389
|
-
this.clusterIntraMax = new Float64Array(k);
|
|
390
|
-
for (let c = 0; c < k; c++) {
|
|
391
|
-
let list = intraLists[c];
|
|
392
|
-
if (!list.length) continue;
|
|
393
|
-
sort(list, x => x);
|
|
394
|
-
this.clusterIntraMin[c] = list[0];
|
|
395
|
-
this.clusterIntraMedian[c] = list[Math.floor(list.length / 2)];
|
|
396
|
-
this.clusterIntraMax[c] = list[list.length - 1];
|
|
397
|
-
}
|
|
398
|
-
this.clusterDist = new Float64Array(k * k).fill(this.maxLatency);
|
|
399
|
-
for (let c = 0; c < k; c++) {
|
|
400
|
-
this.clusterDist[c * k + c] = 0;
|
|
401
|
-
}
|
|
402
|
-
let stats: { p: number; q: number; min: number; median: number; max: number; }[] = [];
|
|
403
|
-
for (let [key, list] of lists) {
|
|
404
|
-
let p = Math.floor(key / k);
|
|
405
|
-
let q = key % k;
|
|
406
|
-
sort(list, x => x);
|
|
407
|
-
let min = list[0];
|
|
408
|
-
let max = list[list.length - 1];
|
|
409
|
-
let median = list[Math.floor(list.length / 2)];
|
|
410
|
-
this.clusterDist[p * k + q] = median;
|
|
411
|
-
this.clusterDist[q * k + p] = median;
|
|
412
|
-
stats.push({ p, q, min, median, max });
|
|
413
|
-
}
|
|
414
|
-
let medians = stats.map(s => s.median);
|
|
415
|
-
this.interMin = medians.length ? Math.min(...medians) : 0;
|
|
416
|
-
this.interMax = medians.length ? Math.max(...medians) : 1;
|
|
417
|
-
// Targets depend on cluster radii, which aren't known until the intra layout is solved, so they're filled in by
|
|
418
|
-
// hierarchicalInit; here we just record the latency stats.
|
|
419
|
-
this.interPairs = stats.map(s => ({ p: s.p, q: s.q, min: s.min, median: s.median, max: s.max, target: 1, weight: 1 }));
|
|
420
|
-
this.interPairMap = new Map();
|
|
421
|
-
for (let pair of this.interPairs) {
|
|
422
|
-
this.interPairMap.set(pair.p * k + pair.q, pair);
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
// Intra edges use the per-cluster normalized scale, so cluster size is decoupled from absolute latency.
|
|
426
|
-
this.intraSolveEdges = [];
|
|
427
|
-
for (let e of this.edges) {
|
|
428
|
-
if (this.clusterOf[e.a] !== this.clusterOf[e.b]) continue;
|
|
429
|
-
let target = this.intraTarget(e.latency, this.clusterIntraMedian[this.clusterOf[e.a]]);
|
|
430
|
-
this.intraSolveEdges.push({ a: e.a, b: e.b, target, weight: 1 / (target * target) });
|
|
431
|
-
}
|
|
227
|
+
computeSolveEdges() {
|
|
228
|
+
this.builtLatencyExponent = latencyExponentParam.value;
|
|
229
|
+
this.solveEdges = this.edges.map(e => {
|
|
230
|
+
let target = this.targetDist(e.latency);
|
|
231
|
+
return { a: e.a, b: e.b, target, weight: 1 / (target * target) };
|
|
232
|
+
});
|
|
432
233
|
}
|
|
433
234
|
|
|
434
235
|
computeRenderEdges() {
|
|
435
|
-
let count = Math.max(1, Math.floor(
|
|
436
|
-
this.
|
|
236
|
+
let count = Math.max(1, Math.floor(connectionsParam.value));
|
|
237
|
+
this.builtConnections = count;
|
|
437
238
|
let n = this.nodes.length;
|
|
438
239
|
let neighbors: { other: number; edge: Edge; }[][] = Array.from({ length: n }, () => []);
|
|
439
240
|
for (let edge of this.edges) {
|
|
@@ -443,93 +244,16 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
443
244
|
this.neighbors = neighbors;
|
|
444
245
|
let selected = new Set<Edge>();
|
|
445
246
|
for (let i = 0; i < n; i++) {
|
|
446
|
-
let candidates =
|
|
247
|
+
let candidates = neighbors[i].slice();
|
|
248
|
+
sort(candidates, nb => nb.edge.latency);
|
|
447
249
|
for (let j = 0; j < Math.min(count, candidates.length); j++) {
|
|
448
250
|
selected.add(candidates[j].edge);
|
|
449
251
|
}
|
|
450
252
|
}
|
|
451
|
-
this.renderEdgeSet = selected;
|
|
452
253
|
this.renderEdges = [...selected];
|
|
453
|
-
let
|
|
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;
|
|
254
|
+
let latencies = this.renderEdges.map(e => e.latency);
|
|
255
|
+
this.renderMin = latencies.length ? Math.min(...latencies) : 0;
|
|
256
|
+
this.renderMax = latencies.length ? Math.max(...latencies) : 1;
|
|
533
257
|
}
|
|
534
258
|
|
|
535
259
|
opacityFor(latency: number, min: number, max: number) {
|
|
@@ -544,7 +268,7 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
544
268
|
this.scheduleFrame();
|
|
545
269
|
return;
|
|
546
270
|
}
|
|
547
|
-
this.
|
|
271
|
+
this.mdsInit();
|
|
548
272
|
this.temperature = 1;
|
|
549
273
|
this.iteration = 0;
|
|
550
274
|
this.settled = false;
|
|
@@ -561,53 +285,19 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
561
285
|
this.settled = true;
|
|
562
286
|
}
|
|
563
287
|
|
|
564
|
-
//
|
|
565
|
-
|
|
566
|
-
hierarchicalInit() {
|
|
288
|
+
// Classical MDS on the full latency matrix for a good starting layout; SMACOF then refines using the real edges.
|
|
289
|
+
mdsInit() {
|
|
567
290
|
let n = this.nodes.length;
|
|
568
|
-
let
|
|
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]);
|
|
291
|
+
let pos = this.mds(n, (a, b) => {
|
|
292
|
+
let d = this.targetDist(this.latencyMatrix[a * n + b]);
|
|
598
293
|
return d * d;
|
|
599
294
|
});
|
|
600
|
-
this.
|
|
601
|
-
this.
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
composePositions() {
|
|
607
|
-
for (let i = 0; i < this.nodes.length; i++) {
|
|
608
|
-
let c = this.clusterOf[i];
|
|
609
|
-
this.positionsX[i] = this.clusterCenterX[c] + this.localX[i];
|
|
610
|
-
this.positionsY[i] = this.clusterCenterY[c] + this.localY[i];
|
|
295
|
+
this.positionsX = pos.x;
|
|
296
|
+
this.positionsY = pos.y;
|
|
297
|
+
// Deterministic per-node jitter (different frequencies for x/y so it isn't itself collinear) to break symmetry.
|
|
298
|
+
for (let i = 0; i < n; i++) {
|
|
299
|
+
this.positionsX[i] += Math.sin(i * 12.9898 + 1) * INIT_JITTER_PX;
|
|
300
|
+
this.positionsY[i] += Math.cos(i * 78.233 + 1) * INIT_JITTER_PX;
|
|
611
301
|
}
|
|
612
302
|
}
|
|
613
303
|
|
|
@@ -716,10 +406,7 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
716
406
|
|
|
717
407
|
step() {
|
|
718
408
|
if (this.settled) return;
|
|
719
|
-
let maxMove =
|
|
720
|
-
maxMove = Math.max(maxMove, this.majorizeCenters());
|
|
721
|
-
maxMove = Math.max(maxMove, this.majorizeLocals());
|
|
722
|
-
this.composePositions();
|
|
409
|
+
let maxMove = this.majorize();
|
|
723
410
|
this.temperature *= COOLING;
|
|
724
411
|
this.iteration++;
|
|
725
412
|
if (maxMove < CONVERGENCE_EPS || this.iteration >= MAX_ITERATIONS) {
|
|
@@ -727,52 +414,17 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
727
414
|
}
|
|
728
415
|
}
|
|
729
416
|
|
|
730
|
-
|
|
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() {
|
|
417
|
+
// One SMACOF (Guttman transform) iteration over the real edges, moving every node toward its target distances.
|
|
418
|
+
majorize() {
|
|
766
419
|
let n = this.nodes.length;
|
|
767
|
-
let k = this.clusterCount;
|
|
768
420
|
let numX = new Float64Array(n);
|
|
769
421
|
let numY = new Float64Array(n);
|
|
770
422
|
let denom = new Float64Array(n);
|
|
771
|
-
for (let e of this.
|
|
772
|
-
let ax = this.
|
|
773
|
-
let ay = this.
|
|
774
|
-
let bx = this.
|
|
775
|
-
let by = this.
|
|
423
|
+
for (let e of this.solveEdges) {
|
|
424
|
+
let ax = this.positionsX[e.a];
|
|
425
|
+
let ay = this.positionsY[e.a];
|
|
426
|
+
let bx = this.positionsX[e.b];
|
|
427
|
+
let by = this.positionsY[e.b];
|
|
776
428
|
let dx = bx - ax;
|
|
777
429
|
let dy = by - ay;
|
|
778
430
|
let d = Math.sqrt(dx * dx + dy * dy) || 0.0001;
|
|
@@ -787,40 +439,15 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
787
439
|
let maxMove = 0;
|
|
788
440
|
for (let i = 0; i < n; i++) {
|
|
789
441
|
if (!denom[i]) continue;
|
|
790
|
-
let moveX = (numX[i] / denom[i] - this.
|
|
791
|
-
let moveY = (numY[i] / denom[i] - this.
|
|
792
|
-
this.
|
|
793
|
-
this.
|
|
442
|
+
let moveX = (numX[i] / denom[i] - this.positionsX[i]) * this.temperature;
|
|
443
|
+
let moveY = (numY[i] / denom[i] - this.positionsY[i]) * this.temperature;
|
|
444
|
+
this.positionsX[i] += moveX;
|
|
445
|
+
this.positionsY[i] += moveY;
|
|
794
446
|
maxMove = Math.max(maxMove, Math.abs(moveX), Math.abs(moveY));
|
|
795
447
|
}
|
|
796
|
-
// Keep each cluster centered on its own center, so global placement stays the centers' job.
|
|
797
|
-
let meanX = new Float64Array(k);
|
|
798
|
-
let meanY = new Float64Array(k);
|
|
799
|
-
let count = new Int32Array(k);
|
|
800
|
-
for (let i = 0; i < n; i++) {
|
|
801
|
-
let c = this.clusterOf[i];
|
|
802
|
-
meanX[c] += this.localX[i];
|
|
803
|
-
meanY[c] += this.localY[i];
|
|
804
|
-
count[c]++;
|
|
805
|
-
}
|
|
806
|
-
for (let c = 0; c < k; c++) {
|
|
807
|
-
if (count[c]) {
|
|
808
|
-
meanX[c] /= count[c];
|
|
809
|
-
meanY[c] /= count[c];
|
|
810
|
-
}
|
|
811
|
-
}
|
|
812
|
-
for (let i = 0; i < n; i++) {
|
|
813
|
-
let c = this.clusterOf[i];
|
|
814
|
-
this.localX[i] -= meanX[c];
|
|
815
|
-
this.localY[i] -= meanY[c];
|
|
816
|
-
}
|
|
817
448
|
return maxMove;
|
|
818
449
|
}
|
|
819
450
|
|
|
820
|
-
clusterHue(c: number) {
|
|
821
|
-
return Math.round(c / Math.max(1, this.clusterCount) * 360);
|
|
822
|
-
}
|
|
823
|
-
|
|
824
451
|
// Screen-space radius; grows with the node's traffic weight, normalized to the busiest node.
|
|
825
452
|
nodeRadius(i: number) {
|
|
826
453
|
if (this.maxNodeWeight <= 0) return NODE_RADIUS;
|
|
@@ -837,36 +464,8 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
837
464
|
return (node.label || node.id).slice(0, 2);
|
|
838
465
|
}
|
|
839
466
|
|
|
840
|
-
|
|
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 };
|
|
467
|
+
isSelected(i: number) {
|
|
468
|
+
return this.selectedId !== undefined && this.nodes[i].id === this.selectedId;
|
|
870
469
|
}
|
|
871
470
|
|
|
872
471
|
draw() {
|
|
@@ -932,113 +531,30 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
932
531
|
let toScreenX = (wx: number) => width / 2 + (wx - centroidX) * scale + this.panX;
|
|
933
532
|
let toScreenY = (wy: number) => height / 2 + (wy - centroidY) * scale + this.panY;
|
|
934
533
|
|
|
935
|
-
|
|
936
|
-
this.hoverCentroids = centroids;
|
|
937
|
-
this.computeRenderInterPairs(centroids);
|
|
938
|
-
this.computeClusterPairWeights();
|
|
939
|
-
|
|
940
|
-
// Inter-cluster lines run centroid-to-centroid, then the opaque hulls paint over the parts inside any
|
|
941
|
-
// cluster — so a line only shows in the gaps and reads as entering one cluster edge and exiting another.
|
|
942
|
-
this.drawInterLines(ctx, toScreenX, toScreenY, centroids);
|
|
943
|
-
this.drawClusterHulls(ctx, toScreenX, toScreenY, centroids);
|
|
944
|
-
this.drawNodeConnections(ctx, toScreenX, toScreenY, centroids);
|
|
945
|
-
// Line labels go under the nodes (they're less important); hover labels are drawn last so they still win.
|
|
534
|
+
this.drawEdges(ctx, toScreenX, toScreenY);
|
|
946
535
|
if (showLatenciesParam.value) {
|
|
947
|
-
this.drawLineLatencies(ctx, toScreenX, toScreenY
|
|
536
|
+
this.drawLineLatencies(ctx, toScreenX, toScreenY);
|
|
948
537
|
}
|
|
949
|
-
this.drawNodes(ctx, toScreenX, toScreenY
|
|
950
|
-
this.
|
|
951
|
-
this.drawClusterLabels(ctx, toScreenX, toScreenY, centroids);
|
|
952
|
-
this.drawHover(ctx, toScreenX, toScreenY, centroids);
|
|
538
|
+
this.drawNodes(ctx, toScreenX, toScreenY);
|
|
539
|
+
this.drawHover(ctx, toScreenX, toScreenY);
|
|
953
540
|
}
|
|
954
541
|
|
|
955
|
-
|
|
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)})`;
|
|
542
|
+
drawEdges(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number) {
|
|
543
|
+
for (let edge of this.renderEdges) {
|
|
544
|
+
ctx.lineWidth = this.lineWidthFor(edge.a, edge.b);
|
|
545
|
+
ctx.strokeStyle = `hsla(${NODE_HUE}, 70%, 62%, ${this.opacityFor(edge.latency, this.renderMin, this.renderMax)})`;
|
|
978
546
|
ctx.beginPath();
|
|
979
|
-
ctx.moveTo(toScreenX(
|
|
980
|
-
ctx.lineTo(toScreenX(
|
|
547
|
+
ctx.moveTo(toScreenX(this.positionsX[edge.a]), toScreenY(this.positionsY[edge.a]));
|
|
548
|
+
ctx.lineTo(toScreenX(this.positionsX[edge.b]), toScreenY(this.positionsY[edge.b]));
|
|
981
549
|
ctx.stroke();
|
|
982
550
|
}
|
|
983
551
|
}
|
|
984
552
|
|
|
985
|
-
|
|
986
|
-
|
|
553
|
+
// Always-on latency labels on the drawn connections (toggleable), with the connection's traffic below.
|
|
554
|
+
drawLineLatencies(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number) {
|
|
987
555
|
ctx.textAlign = "center";
|
|
988
556
|
ctx.textBaseline = "middle";
|
|
989
|
-
for (let pair of this.renderInterPairs) {
|
|
990
|
-
let midX = (toScreenX(centroids.cx[pair.p]) + toScreenX(centroids.cx[pair.q])) / 2;
|
|
991
|
-
let midY = (toScreenY(centroids.cy[pair.p]) + toScreenY(centroids.cy[pair.q])) / 2;
|
|
992
|
-
|
|
993
|
-
let traffic = this.clusterPairWeight.get(Math.min(pair.p, pair.q) * this.clusterCount + Math.max(pair.p, pair.q)) || 0;
|
|
994
|
-
let trafficLabel = this.formatWeight && traffic > 0 ? this.formatWeight(traffic) : undefined;
|
|
995
|
-
let latencyY = trafficLabel ? midY - 8 : midY;
|
|
996
|
-
|
|
997
|
-
ctx.font = "11px sans-serif";
|
|
998
|
-
let label = `${formatTime(pair.min)} · ${formatTime(pair.median)} · ${formatTime(pair.max)}`;
|
|
999
|
-
let textWidth = ctx.measureText(label).width;
|
|
1000
|
-
ctx.fillStyle = "hsla(0, 0%, 0%, 0.6)";
|
|
1001
|
-
ctx.fillRect(midX - textWidth / 2 - 3, latencyY - 8, textWidth + 6, 16);
|
|
1002
|
-
ctx.fillStyle = "hsl(0, 0%, 82%)";
|
|
1003
|
-
ctx.fillText(label, midX, latencyY);
|
|
1004
|
-
|
|
1005
|
-
if (trafficLabel) {
|
|
1006
|
-
ctx.font = "10px sans-serif";
|
|
1007
|
-
let trafficWidth = ctx.measureText(trafficLabel).width;
|
|
1008
|
-
ctx.fillStyle = "hsla(0, 0%, 0%, 0.6)";
|
|
1009
|
-
ctx.fillRect(midX - trafficWidth / 2 - 3, midY + 8 - 7, trafficWidth + 6, 14);
|
|
1010
|
-
ctx.fillStyle = "hsl(0, 0%, 70%)";
|
|
1011
|
-
ctx.fillText(trafficLabel, midX, midY + 8);
|
|
1012
|
-
}
|
|
1013
|
-
}
|
|
1014
|
-
}
|
|
1015
|
-
|
|
1016
|
-
drawNodeConnections(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number, centroids: Centroids) {
|
|
1017
|
-
ctx.lineWidth = 1.5;
|
|
1018
557
|
for (let edge of this.renderEdges) {
|
|
1019
|
-
this.strokeNodeConnection(ctx, toScreenX, toScreenY, centroids, edge, 1);
|
|
1020
|
-
}
|
|
1021
|
-
}
|
|
1022
|
-
|
|
1023
|
-
strokeNodeConnection(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number, centroids: Centroids, edge: Edge, opacityScale: number) {
|
|
1024
|
-
// Hide links that tunnel through an unrelated cluster (mainly singleton connections crossing hulls).
|
|
1025
|
-
if (!this.segmentClearOfClusters(this.positionsX[edge.a], this.positionsY[edge.a], this.positionsX[edge.b], this.positionsY[edge.b], centroids, this.clusterOf[edge.a], this.clusterOf[edge.b])) return;
|
|
1026
|
-
ctx.lineWidth = this.lineWidthFor(edge.a, edge.b);
|
|
1027
|
-
let hue = this.clusterHue(this.clusterOf[edge.a]);
|
|
1028
|
-
ctx.strokeStyle = `hsla(${hue}, 70%, 62%, ${this.opacityFor(edge.latency, this.renderMin, this.renderMax) * opacityScale})`;
|
|
1029
|
-
ctx.beginPath();
|
|
1030
|
-
ctx.moveTo(toScreenX(this.positionsX[edge.a]), toScreenY(this.positionsY[edge.a]));
|
|
1031
|
-
ctx.lineTo(toScreenX(this.positionsX[edge.b]), toScreenY(this.positionsY[edge.b]));
|
|
1032
|
-
ctx.stroke();
|
|
1033
|
-
}
|
|
1034
|
-
|
|
1035
|
-
// Always-on latency labels on the drawn node connections (toggleable), with the connection's traffic below.
|
|
1036
|
-
// Skipped for lines masked by a cluster hull.
|
|
1037
|
-
drawLineLatencies(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number, centroids: Centroids) {
|
|
1038
|
-
ctx.textAlign = "center";
|
|
1039
|
-
ctx.textBaseline = "middle";
|
|
1040
|
-
for (let edge of this.renderEdges) {
|
|
1041
|
-
if (!this.segmentClearOfClusters(this.positionsX[edge.a], this.positionsY[edge.a], this.positionsX[edge.b], this.positionsY[edge.b], centroids, this.clusterOf[edge.a], this.clusterOf[edge.b])) continue;
|
|
1042
558
|
let midX = (toScreenX(this.positionsX[edge.a]) + toScreenX(this.positionsX[edge.b])) / 2;
|
|
1043
559
|
let midY = (toScreenY(this.positionsY[edge.a]) + toScreenY(this.positionsY[edge.b])) / 2;
|
|
1044
560
|
|
|
@@ -1065,31 +581,25 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
1065
581
|
}
|
|
1066
582
|
}
|
|
1067
583
|
|
|
1068
|
-
drawNodes(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number
|
|
584
|
+
drawNodes(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number) {
|
|
1069
585
|
ctx.textBaseline = "middle";
|
|
1070
586
|
ctx.textAlign = "center";
|
|
1071
|
-
let proximityPx = labelProximityParam.value;
|
|
1072
|
-
let screenX = this.nodes.map((_, i) => toScreenX(this.positionsX[i]));
|
|
1073
|
-
let screenY = this.nodes.map((_, i) => toScreenY(this.positionsY[i]));
|
|
1074
587
|
for (let i = 0; i < this.nodes.length; i++) {
|
|
1075
|
-
let x =
|
|
1076
|
-
let y =
|
|
588
|
+
let x = toScreenX(this.positionsX[i]);
|
|
589
|
+
let y = toScreenY(this.positionsY[i]);
|
|
1077
590
|
let radius = this.nodeRadius(i);
|
|
1078
|
-
|
|
591
|
+
let highlighted = i === this.hoverNode || this.isSelected(i);
|
|
592
|
+
ctx.fillStyle = highlighted ? HIGHLIGHT_COLOR : `hsl(${NODE_HUE}, 60%, 52%)`;
|
|
1079
593
|
ctx.beginPath();
|
|
1080
594
|
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
|
1081
595
|
ctx.fill();
|
|
1082
596
|
|
|
1083
|
-
// The full label only shows when this node is visually isolated (nearest neighbor on screen is far enough
|
|
1084
|
-
// away), or it's hovered; otherwise it collapses to a single title line above the dot to avoid a jumble.
|
|
1085
|
-
let full = i === this.hoverNode || this.nearestScreenDist(i, screenX, screenY) >= proximityPx;
|
|
1086
597
|
let lines = this.nodes[i].labelLines;
|
|
1087
|
-
if (
|
|
1088
|
-
this.
|
|
598
|
+
if (lines && lines.length) {
|
|
599
|
+
this.drawStackedLabel(ctx, lines, x, y - radius - 8);
|
|
1089
600
|
continue;
|
|
1090
601
|
}
|
|
1091
|
-
|
|
1092
|
-
let short = lines && lines.length ? lines[0].text : this.nodeShort(this.nodes[i]);
|
|
602
|
+
let short = this.nodeShort(this.nodes[i]);
|
|
1093
603
|
ctx.font = "bold 11px sans-serif";
|
|
1094
604
|
ctx.lineWidth = 3;
|
|
1095
605
|
ctx.strokeStyle = "hsl(0, 0%, 6%)";
|
|
@@ -1099,11 +609,10 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
1099
609
|
}
|
|
1100
610
|
}
|
|
1101
611
|
|
|
1102
|
-
// Stack the
|
|
1103
|
-
|
|
1104
|
-
let baseY = y - (lines.length - 1) / 2 * 13;
|
|
612
|
+
// Stack the label ABOVE the node: the block's bottom line sits at bottomY, the rest rising above it.
|
|
613
|
+
drawStackedLabel(ctx: CanvasRenderingContext2D, lines: LatencyGraphLabelLine[], x: number, bottomY: number) {
|
|
1105
614
|
for (let [li, line] of lines.entries()) {
|
|
1106
|
-
let ly =
|
|
615
|
+
let ly = bottomY - (lines.length - 1 - li) * LABEL_LINE_HEIGHT;
|
|
1107
616
|
ctx.font = li === 0 ? "bold 11px sans-serif" : "10px sans-serif";
|
|
1108
617
|
ctx.lineWidth = 3;
|
|
1109
618
|
ctx.strokeStyle = "hsl(0, 0%, 6%)";
|
|
@@ -1113,77 +622,13 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
1113
622
|
}
|
|
1114
623
|
}
|
|
1115
624
|
|
|
1116
|
-
//
|
|
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) {
|
|
625
|
+
// Hovering a node reveals ALL of its measured connections — drawn as highlighted lines with latency labels.
|
|
626
|
+
drawHover(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number) {
|
|
1180
627
|
if (this.hoverNode === undefined) return;
|
|
1181
628
|
let hover = this.hoverNode;
|
|
1182
|
-
let
|
|
1183
|
-
let connections = this.clusterSize[cluster] >= 2
|
|
1184
|
-
? this.neighbors[hover].filter(nb => this.clusterOf[nb.other] === cluster).map(nb => nb.edge)
|
|
1185
|
-
: this.renderEdges.filter(edge => edge.a === hover || edge.b === hover);
|
|
629
|
+
let connections = (this.neighbors[hover] || []).map(nb => nb.edge);
|
|
1186
630
|
|
|
631
|
+
// Just highlight the hovered node's connections — their latency/traffic labels are already drawn all the time.
|
|
1187
632
|
ctx.lineWidth = 1.5;
|
|
1188
633
|
for (let edge of connections) {
|
|
1189
634
|
ctx.strokeStyle = "hsla(40, 90%, 60%, 0.6)";
|
|
@@ -1192,24 +637,12 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
1192
637
|
ctx.lineTo(toScreenX(this.positionsX[edge.b]), toScreenY(this.positionsY[edge.b]));
|
|
1193
638
|
ctx.stroke();
|
|
1194
639
|
}
|
|
1195
|
-
ctx.font = "12px sans-serif";
|
|
1196
|
-
ctx.textAlign = "center";
|
|
1197
|
-
ctx.textBaseline = "middle";
|
|
1198
|
-
for (let edge of connections) {
|
|
1199
|
-
let midX = (toScreenX(this.positionsX[edge.a]) + toScreenX(this.positionsX[edge.b])) / 2;
|
|
1200
|
-
let midY = (toScreenY(this.positionsY[edge.a]) + toScreenY(this.positionsY[edge.b])) / 2;
|
|
1201
|
-
let label = formatTime(edge.latency);
|
|
1202
|
-
let textWidth = ctx.measureText(label).width;
|
|
1203
|
-
ctx.fillStyle = "hsla(0, 0%, 0%, 0.75)";
|
|
1204
|
-
ctx.fillRect(midX - textWidth / 2 - 3, midY - 8, textWidth + 6, 16);
|
|
1205
|
-
ctx.fillStyle = "hsl(40, 90%, 75%)";
|
|
1206
|
-
ctx.fillText(label, midX, midY);
|
|
1207
|
-
}
|
|
1208
640
|
// Redraw the hovered node's own label last so it always sits above the connection labels.
|
|
1209
641
|
let hoverLines = this.nodes[hover].labelLines;
|
|
1210
642
|
if (hoverLines && hoverLines.length) {
|
|
1211
643
|
ctx.textAlign = "center";
|
|
1212
|
-
|
|
644
|
+
ctx.textBaseline = "middle";
|
|
645
|
+
this.drawStackedLabel(ctx, hoverLines, toScreenX(this.positionsX[hover]), toScreenY(this.positionsY[hover]) - this.nodeRadius(hover) - 8);
|
|
1213
646
|
}
|
|
1214
647
|
this.drawHoverCard(ctx);
|
|
1215
648
|
}
|
|
@@ -1281,6 +714,23 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
1281
714
|
this.lastPointerY = e.clientY;
|
|
1282
715
|
};
|
|
1283
716
|
|
|
717
|
+
// Index of the node closest to a screen point (undefined only when there are no nodes).
|
|
718
|
+
nearestNodeTo(mx: number, my: number) {
|
|
719
|
+
let scale = this.viewScale;
|
|
720
|
+
let bestDist = Infinity;
|
|
721
|
+
let best: number | undefined = undefined;
|
|
722
|
+
for (let i = 0; i < this.nodes.length; i++) {
|
|
723
|
+
let sx = this.viewWidth / 2 + (this.positionsX[i] - this.centroidX) * scale + this.panX;
|
|
724
|
+
let sy = this.viewHeight / 2 + (this.positionsY[i] - this.centroidY) * scale + this.panY;
|
|
725
|
+
let dist = (sx - mx) ** 2 + (sy - my) ** 2;
|
|
726
|
+
if (dist < bestDist) {
|
|
727
|
+
bestDist = dist;
|
|
728
|
+
best = i;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
return best;
|
|
732
|
+
}
|
|
733
|
+
|
|
1284
734
|
onMouseMove = (e: MouseEvent) => {
|
|
1285
735
|
if (this.isPanning) {
|
|
1286
736
|
this.panX += e.clientX - this.lastPointerX;
|
|
@@ -1298,44 +748,19 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
1298
748
|
let my = e.clientY - rect.top;
|
|
1299
749
|
this.mouseX = mx;
|
|
1300
750
|
this.mouseY = my;
|
|
1301
|
-
//
|
|
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;
|
|
751
|
+
// Always hover whichever node the cursor is closest to, so moving anywhere explores the graph.
|
|
752
|
+
this.hoverNode = this.nearestNodeTo(mx, my);
|
|
1320
753
|
this.scheduleFrame();
|
|
1321
754
|
};
|
|
1322
755
|
|
|
1323
|
-
|
|
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
|
-
}
|
|
756
|
+
onClick = (e: MouseEvent) => {
|
|
757
|
+
let canvas = this.canvas;
|
|
758
|
+
if (!canvas || !this.onSelectNode) return;
|
|
759
|
+
let rect = canvas.getBoundingClientRect();
|
|
760
|
+
let nearest = this.nearestNodeTo(e.clientX - rect.left, e.clientY - rect.top);
|
|
761
|
+
if (nearest === undefined) return;
|
|
762
|
+
this.onSelectNode(this.nodes[nearest].id);
|
|
763
|
+
};
|
|
1339
764
|
|
|
1340
765
|
onMouseLeave = () => {
|
|
1341
766
|
this.hoverNode = undefined;
|
|
@@ -1361,6 +786,7 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
1361
786
|
this.canvas.removeEventListener("wheel", this.onWheel);
|
|
1362
787
|
this.canvas.removeEventListener("mousedown", this.onMouseDown);
|
|
1363
788
|
this.canvas.removeEventListener("mouseleave", this.onMouseLeave);
|
|
789
|
+
this.canvas.removeEventListener("click", this.onClick);
|
|
1364
790
|
window.removeEventListener("mousemove", this.onMouseMove);
|
|
1365
791
|
window.removeEventListener("mouseup", this.onMouseUp);
|
|
1366
792
|
}
|
|
@@ -1369,6 +795,7 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
1369
795
|
elem.addEventListener("wheel", this.onWheel, { passive: false });
|
|
1370
796
|
elem.addEventListener("mousedown", this.onMouseDown);
|
|
1371
797
|
elem.addEventListener("mouseleave", this.onMouseLeave);
|
|
798
|
+
elem.addEventListener("click", this.onClick);
|
|
1372
799
|
window.addEventListener("mousemove", this.onMouseMove);
|
|
1373
800
|
window.addEventListener("mouseup", this.onMouseUp);
|
|
1374
801
|
this.scheduleFrame();
|
|
@@ -1377,7 +804,8 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
1377
804
|
|
|
1378
805
|
render() {
|
|
1379
806
|
this.formatWeight = this.props.formatWeight;
|
|
1380
|
-
this.
|
|
807
|
+
this.selectedId = this.props.selectedId;
|
|
808
|
+
this.onSelectNode = this.props.onSelectNode;
|
|
1381
809
|
// Rebuild from scratch whenever the data set changes (e.g. a new node's latencies arrive progressively).
|
|
1382
810
|
let sig = `${this.props.nodes.length}:${this.props.links.length}`;
|
|
1383
811
|
let weightSig = 0;
|
|
@@ -1397,13 +825,10 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
1397
825
|
// Traffic weights changed but the node/link set didn't — just refresh sizing, no re-layout.
|
|
1398
826
|
this.builtWeightSig = weightSig;
|
|
1399
827
|
this.computeWeights();
|
|
1400
|
-
} else if (
|
|
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).
|
|
828
|
+
} else if (latencyExponentParam.value !== this.builtLatencyExponent) {
|
|
829
|
+
this.computeSolveEdges();
|
|
1405
830
|
this.applyLayout();
|
|
1406
|
-
} else if (Math.max(1, Math.floor(
|
|
831
|
+
} else if (Math.max(1, Math.floor(connectionsParam.value)) !== this.builtConnections) {
|
|
1407
832
|
this.computeRenderEdges();
|
|
1408
833
|
}
|
|
1409
834
|
this.scheduleFrame();
|
|
@@ -1411,7 +836,7 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
1411
836
|
return <div className={css.relative.fillBoth.overflowHidden}>
|
|
1412
837
|
<canvas
|
|
1413
838
|
ref={elem => this.mountCanvas(elem ?? undefined)}
|
|
1414
|
-
className={css.absolute.pos(0, 0).fillBoth}
|
|
839
|
+
className={css.absolute.pos(0, 0).fillBoth.cursor("pointer")}
|
|
1415
840
|
/>
|
|
1416
841
|
<div className={css.vbox(10).pad2(12).absolute.pos(0, 0).width(260).zIndex(2).hsla(0, 0, 8, 0.9).colorhsl(0, 0, 85)}>
|
|
1417
842
|
<div
|
|
@@ -1421,21 +846,16 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
1421
846
|
<span>{configOpenParam.value ? "▾" : "▸"}</span>
|
|
1422
847
|
<span>Configuration</span>
|
|
1423
848
|
<span className={css.marginAuto.colorhsl(0, 0, 60).fontWeight("normal")}>
|
|
1424
|
-
{this.nodes.length} nodes
|
|
849
|
+
{this.nodes.length} nodes
|
|
1425
850
|
</span>
|
|
1426
851
|
</div>
|
|
1427
852
|
{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 />
|
|
853
|
+
<InputLabelURL label="Connections per node" url={connectionsParam} integer />
|
|
854
|
+
<InputLabelURL label="Latency exponent" url={latencyExponentParam} number />
|
|
1435
855
|
<InputLabelURL label="Show line latencies" url={showLatenciesParam} checkbox />
|
|
1436
856
|
<InputLabelURL label="Geographic (lat/lon)" url={geoParam} checkbox />
|
|
1437
857
|
<div className={css.colorhsl(0, 0, 60)}>
|
|
1438
|
-
{this.nodes.length} nodes · {this.
|
|
858
|
+
{this.nodes.length} nodes · {this.renderEdges.length} lines
|
|
1439
859
|
</div>
|
|
1440
860
|
<Button onClick={() => { this.userAdjustedView = false; this.applyLayout(); }}>
|
|
1441
861
|
Reheat Layout
|