querysub 0.519.0 → 0.521.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/-f-node-discovery/TrafficTracking.ts +7 -0
- package/src/0-path-value-core/PathValueController.ts +2 -1
- package/src/deployManager/components/ServiceDetailPage.tsx +32 -8
- package/src/diagnostics/misc-pages/RoutingTablePage.tsx +284 -54
- package/src/library-components/LatencyGraph.tsx +187 -592
|
@@ -14,31 +14,25 @@ export type LatencyGraphProps = {
|
|
|
14
14
|
links: LatencyGraphLink[];
|
|
15
15
|
// Formats a link's traffic weight for the label under the latency (e.g. bytes). No label if omitted.
|
|
16
16
|
formatWeight?: (weight: number) => string;
|
|
17
|
+
// Id of the currently selected node (highlighted + always full-labelled).
|
|
18
|
+
selectedId?: string;
|
|
19
|
+
// Fired when a node is clicked (e.g. to sort a table by that node).
|
|
20
|
+
onSelectNode?: (id: string) => void;
|
|
17
21
|
};
|
|
18
22
|
|
|
19
|
-
const DEFAULT_CONNECTIONS =
|
|
20
|
-
|
|
21
|
-
const DEFAULT_CLUSTER_FACTOR = 2;
|
|
22
|
-
const DEFAULT_INTER_EXPONENT = 1.5;
|
|
23
|
+
const DEFAULT_CONNECTIONS = 4;
|
|
24
|
+
// Nearest neighbors drawn per node (the layout still uses every measured latency; this only thins the drawn lines).
|
|
23
25
|
const connectionsParam = new URLParam("lgConnections", DEFAULT_CONNECTIONS);
|
|
24
|
-
const
|
|
25
|
-
// Exponent on
|
|
26
|
-
const
|
|
27
|
-
// Two nodes cluster when their latency is within this factor of the tighter node's own nearest-neighbor latency.
|
|
28
|
-
// Relative (per-node) so a mutually-isolated close pair clusters even if it's far from everything else.
|
|
29
|
-
const clusterFactorParam = new URLParam("lgClusterFactor", DEFAULT_CLUSTER_FACTOR);
|
|
26
|
+
const DEFAULT_LATENCY_EXPONENT = 1;
|
|
27
|
+
// Exponent on (latency / reference) when mapping to layout distance, so far nodes push apart harder as it grows.
|
|
28
|
+
const latencyExponentParam = new URLParam("lgLatencyExponent", DEFAULT_LATENCY_EXPONENT);
|
|
30
29
|
const geoParam = new URLParam("lgGeo", false);
|
|
31
|
-
const showLatenciesParam = new URLParam("lgShowLatencies",
|
|
30
|
+
const showLatenciesParam = new URLParam("lgShowLatencies", false);
|
|
31
|
+
// The config panel is collapsed by default so it doesn't eat the canvas; clicking the header expands it.
|
|
32
|
+
const configOpenParam = new URLParam("lgConfigOpen", false);
|
|
32
33
|
// Pixels per degree of latitude/longitude in geographic mode (equirectangular projection).
|
|
33
34
|
const GEO_SCALE = 6;
|
|
34
35
|
|
|
35
|
-
// One continuous distance mapping for everything (intra and inter): px = MIN + SCALE * (latency / REF) ^ exponent.
|
|
36
|
-
// A single scale is what makes the exponent behave intuitively — the ratio between two distances is just the ratio of
|
|
37
|
-
// their latencies raised to the exponent (so exponent 0.5 turns a 10x latency gap into a ~3x distance gap).
|
|
38
|
-
const LAYOUT_MIN_PX = 45;
|
|
39
|
-
const LAYOUT_SCALE_PX = 320;
|
|
40
|
-
const LAYOUT_REF_MS = 50;
|
|
41
|
-
|
|
42
36
|
// Stress majorization (SMACOF): each iteration is a Guttman transform that never increases stress.
|
|
43
37
|
const COOLING = 0.996;
|
|
44
38
|
const CONVERGENCE_EPS = 0.02;
|
|
@@ -47,16 +41,21 @@ const MAX_ITERATIONS = 3000;
|
|
|
47
41
|
const MAX_POWER_ITERS = 200;
|
|
48
42
|
const POWER_EPS = 1e-9;
|
|
49
43
|
|
|
44
|
+
// Layout distance for a pair: a pair at the reference (median) latency sits DIST_UNIT_PX apart, scaled by the exponent,
|
|
45
|
+
// with a floor. Normalized to the median so the overall scale is latency-magnitude independent.
|
|
46
|
+
const DIST_MIN_PX = 8;
|
|
47
|
+
const DIST_UNIT_PX = 90;
|
|
48
|
+
|
|
50
49
|
const MIN_OPACITY = 0.05;
|
|
51
|
-
const HOVER_EXTRA_OPACITY = 0.4;
|
|
52
50
|
const NODE_RADIUS = 6;
|
|
51
|
+
const NODE_HUE = 210;
|
|
52
|
+
const HIGHLIGHT_COLOR = "hsl(40, 90%, 60%)";
|
|
53
53
|
// Traffic-driven sizing (normalized to the busiest node/pair). Circles grow with node weight; lines with pair weight.
|
|
54
54
|
const NODE_WEIGHT_MULT = 2.5;
|
|
55
55
|
const LINE_BASE_WIDTH = 1.5;
|
|
56
56
|
const LINE_MIN_WIDTH = 1;
|
|
57
57
|
const LINE_MAX_WIDTH = 7;
|
|
58
|
-
const
|
|
59
|
-
const HULL_PADDING = 26;
|
|
58
|
+
const LABEL_LINE_HEIGHT = 13;
|
|
60
59
|
const ZOOM_STEP = 1.1;
|
|
61
60
|
const MIN_SCALE = 0.05;
|
|
62
61
|
const MAX_SCALE = 12;
|
|
@@ -64,11 +63,14 @@ const MAX_SCALE = 12;
|
|
|
64
63
|
// stack upward above the nodes (and are drawn at a fixed screen size regardless of zoom).
|
|
65
64
|
const FIT_PAD = 60;
|
|
66
65
|
const FIT_PAD_TOP = 160;
|
|
66
|
+
// Auto-fit leaves a bit of extra breathing room by not zooming all the way in to fill the padded box.
|
|
67
|
+
const FIT_ZOOM = 0.7;
|
|
68
|
+
// Cursor must be within this many screen pixels of a node to hover/click it.
|
|
69
|
+
const HOVER_HIT_PX = 60;
|
|
70
|
+
const CLICK_HIT_PX = 40;
|
|
67
71
|
|
|
68
72
|
type Edge = { a: number; b: number; latency: number; };
|
|
69
73
|
type SolveEdge = { a: number; b: number; target: number; weight: number; };
|
|
70
|
-
type ClusterPair = { p: number; q: number; min: number; median: number; max: number; target: number; weight: number; };
|
|
71
|
-
type Centroids = { cx: Float64Array; cy: Float64Array; count: Int32Array; radius: Float64Array; };
|
|
72
74
|
|
|
73
75
|
export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
74
76
|
nodes: LatencyGraphNode[] = [];
|
|
@@ -76,45 +78,27 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
76
78
|
latencyMatrix = new Float64Array(0);
|
|
77
79
|
minLatency = 0;
|
|
78
80
|
maxLatency = 1;
|
|
81
|
+
latencyRef = 1;
|
|
79
82
|
nodeWeight = new Float64Array(0);
|
|
80
83
|
maxNodeWeight = 0;
|
|
81
84
|
pairWeight = new Map<number, number>();
|
|
82
85
|
maxPairWeight = 0;
|
|
83
86
|
// Cached from props in render() so draw() (a rAF callback, not synced) never reads this.props.
|
|
84
87
|
formatWeight: ((weight: number) => string) | undefined = undefined;
|
|
88
|
+
selectedId: string | undefined = undefined;
|
|
89
|
+
onSelectNode: ((id: string) => void) | undefined = undefined;
|
|
85
90
|
builtSig = "";
|
|
86
91
|
builtWeightSig = 0;
|
|
87
92
|
builtGeo = false;
|
|
88
93
|
builtConnections = 0;
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
clusterOf = new Int32Array(0);
|
|
94
|
-
clusterCount = 1;
|
|
95
|
-
clusterSize = new Int32Array(0);
|
|
96
|
-
clusterDist = new Float64Array(0);
|
|
97
|
-
clusterIntraMin = new Float64Array(0);
|
|
98
|
-
clusterIntraMedian = new Float64Array(0);
|
|
99
|
-
clusterIntraMax = new Float64Array(0);
|
|
100
|
-
interPairs: ClusterPair[] = [];
|
|
101
|
-
intraSolveEdges: SolveEdge[] = [];
|
|
94
|
+
builtLatencyExponent = 0;
|
|
95
|
+
|
|
96
|
+
solveEdges: SolveEdge[] = [];
|
|
102
97
|
renderEdges: Edge[] = [];
|
|
103
|
-
renderEdgeSet = new Set<Edge>();
|
|
104
98
|
neighbors: { other: number; edge: Edge; }[][] = [];
|
|
105
|
-
hoverExtras: Edge[] = [];
|
|
106
|
-
interPairMap = new Map<number, ClusterPair>();
|
|
107
|
-
renderInterPairs: ClusterPair[] = [];
|
|
108
|
-
interMin = 0;
|
|
109
|
-
interMax = 1;
|
|
110
99
|
renderMin = 0;
|
|
111
100
|
renderMax = 1;
|
|
112
101
|
|
|
113
|
-
// position = clusterCenter[cluster] + local[node]; the two levels are solved independently.
|
|
114
|
-
clusterCenterX = new Float64Array(0);
|
|
115
|
-
clusterCenterY = new Float64Array(0);
|
|
116
|
-
localX = new Float64Array(0);
|
|
117
|
-
localY = new Float64Array(0);
|
|
118
102
|
positionsX = new Float64Array(0);
|
|
119
103
|
positionsY = new Float64Array(0);
|
|
120
104
|
|
|
@@ -148,21 +132,17 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
148
132
|
let n = this.nodes.length;
|
|
149
133
|
this.positionsX = new Float64Array(n);
|
|
150
134
|
this.positionsY = new Float64Array(n);
|
|
151
|
-
this.localX = new Float64Array(n);
|
|
152
|
-
this.localY = new Float64Array(n);
|
|
153
135
|
this.ingestLinks();
|
|
154
136
|
this.computeWeights();
|
|
155
|
-
this.
|
|
137
|
+
this.computeSolveEdges();
|
|
138
|
+
this.computeRenderEdges();
|
|
156
139
|
this.applyLayout();
|
|
157
140
|
}
|
|
158
141
|
|
|
159
142
|
// Traffic weights only affect rendering (circle size, line width), so they can refresh without re-laying-out.
|
|
160
143
|
computeWeights() {
|
|
161
144
|
let n = this.nodes.length;
|
|
162
|
-
let index =
|
|
163
|
-
for (let [i, node] of this.nodes.entries()) {
|
|
164
|
-
index.set(node.id, i);
|
|
165
|
-
}
|
|
145
|
+
let index = this.nodeIndex();
|
|
166
146
|
this.nodeWeight = new Float64Array(n);
|
|
167
147
|
this.maxNodeWeight = 0;
|
|
168
148
|
for (let node of this.props.nodes) {
|
|
@@ -185,13 +165,18 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
185
165
|
}
|
|
186
166
|
}
|
|
187
167
|
|
|
188
|
-
|
|
189
|
-
ingestLinks() {
|
|
190
|
-
let n = this.nodes.length;
|
|
168
|
+
nodeIndex() {
|
|
191
169
|
let index = new Map<string, number>();
|
|
192
170
|
for (let [i, node] of this.nodes.entries()) {
|
|
193
171
|
index.set(node.id, i);
|
|
194
172
|
}
|
|
173
|
+
return index;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Combine both directions of each pair into a single undirected edge (mean latency), and build the full matrix.
|
|
177
|
+
ingestLinks() {
|
|
178
|
+
let n = this.nodes.length;
|
|
179
|
+
let index = this.nodeIndex();
|
|
195
180
|
let paired = new Map<string, { sum: number; count: number; a: number; b: number; }>();
|
|
196
181
|
for (let link of this.props.links) {
|
|
197
182
|
let a = index.get(link.source);
|
|
@@ -214,8 +199,11 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
214
199
|
let latencies = this.edges.map(e => e.latency);
|
|
215
200
|
this.minLatency = latencies.length ? Math.min(...latencies) : 0;
|
|
216
201
|
this.maxLatency = latencies.length ? Math.max(...latencies) : 1;
|
|
202
|
+
let sorted = latencies.slice();
|
|
203
|
+
sort(sorted, x => x);
|
|
204
|
+
this.latencyRef = sorted.length ? sorted[Math.floor(sorted.length / 2)] : 1;
|
|
217
205
|
|
|
218
|
-
// Unknown pairs treated as maximally distant, so they never falsely
|
|
206
|
+
// Unknown pairs treated as maximally distant, so they never falsely collapse together.
|
|
219
207
|
this.latencyMatrix = new Float64Array(n * n).fill(this.maxLatency);
|
|
220
208
|
for (let i = 0; i < n; i++) {
|
|
221
209
|
this.latencyMatrix[i * n + i] = 0;
|
|
@@ -226,161 +214,21 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
226
214
|
}
|
|
227
215
|
}
|
|
228
216
|
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
let
|
|
234
|
-
let
|
|
235
|
-
|
|
236
|
-
this.builtInterExponent = interExponentParam.value;
|
|
237
|
-
let nn = this.nearestNeighborLatencies();
|
|
238
|
-
|
|
239
|
-
let parent = new Int32Array(n);
|
|
240
|
-
for (let i = 0; i < n; i++) {
|
|
241
|
-
parent[i] = i;
|
|
242
|
-
}
|
|
243
|
-
let find = (x: number): number => {
|
|
244
|
-
while (parent[x] !== x) {
|
|
245
|
-
parent[x] = parent[parent[x]];
|
|
246
|
-
x = parent[x];
|
|
247
|
-
}
|
|
248
|
-
return x;
|
|
249
|
-
};
|
|
250
|
-
for (let e of this.edges) {
|
|
251
|
-
if (e.latency <= factor * Math.min(nn[e.a], nn[e.b])) {
|
|
252
|
-
parent[find(e.a)] = find(e.b);
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
let clusterId = new Map<number, number>();
|
|
256
|
-
this.clusterOf = new Int32Array(n);
|
|
257
|
-
for (let i = 0; i < n; i++) {
|
|
258
|
-
let root = find(i);
|
|
259
|
-
let id = clusterId.get(root);
|
|
260
|
-
if (id === undefined) {
|
|
261
|
-
id = clusterId.size;
|
|
262
|
-
clusterId.set(root, id);
|
|
263
|
-
}
|
|
264
|
-
this.clusterOf[i] = id;
|
|
265
|
-
}
|
|
266
|
-
this.clusterCount = Math.max(1, clusterId.size);
|
|
267
|
-
|
|
268
|
-
this.enforceMinClusterSize();
|
|
269
|
-
this.buildClusterDerived();
|
|
270
|
-
this.computeRenderEdges();
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
nearestNeighborLatencies() {
|
|
274
|
-
let nn = new Float64Array(this.nodes.length).fill(this.maxLatency);
|
|
275
|
-
for (let e of this.edges) {
|
|
276
|
-
if (e.latency < nn[e.a]) nn[e.a] = e.latency;
|
|
277
|
-
if (e.latency < nn[e.b]) nn[e.b] = e.latency;
|
|
278
|
-
}
|
|
279
|
-
return nn;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
// Clusters smaller than the configured minimum are dissolved into singletons (a node distant from everything).
|
|
283
|
-
enforceMinClusterSize() {
|
|
284
|
-
let n = this.nodes.length;
|
|
285
|
-
let min = Math.max(1, Math.floor(minClusterSizeParam.value));
|
|
286
|
-
this.builtMinClusterSize = min;
|
|
287
|
-
let size = new Int32Array(this.clusterCount);
|
|
288
|
-
for (let i = 0; i < n; i++) {
|
|
289
|
-
size[this.clusterOf[i]]++;
|
|
290
|
-
}
|
|
291
|
-
let remap = new Map<number, number>();
|
|
292
|
-
let next = 0;
|
|
293
|
-
let newOf = new Int32Array(n);
|
|
294
|
-
for (let i = 0; i < n; i++) {
|
|
295
|
-
let old = this.clusterOf[i];
|
|
296
|
-
if (size[old] >= min) {
|
|
297
|
-
let id = remap.get(old);
|
|
298
|
-
if (id === undefined) {
|
|
299
|
-
id = next++;
|
|
300
|
-
remap.set(old, id);
|
|
301
|
-
}
|
|
302
|
-
newOf[i] = id;
|
|
303
|
-
} else {
|
|
304
|
-
newOf[i] = next++;
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
this.clusterOf = newOf;
|
|
308
|
-
this.clusterCount = Math.max(1, next);
|
|
217
|
+
// Normalized to the median latency so absolute latency magnitude doesn't change the overall scale; the exponent
|
|
218
|
+
// shapes how sharply higher-latency pairs are pushed apart.
|
|
219
|
+
targetDist(latency: number) {
|
|
220
|
+
let ref = this.latencyRef > 0 ? this.latencyRef : 1;
|
|
221
|
+
let l = Number.isFinite(latency) ? Math.max(0, latency) : ref;
|
|
222
|
+
let px = DIST_MIN_PX + DIST_UNIT_PX * Math.pow(l / ref, Math.max(0.01, latencyExponentParam.value));
|
|
223
|
+
return Number.isFinite(px) && px > 0 ? px : DIST_UNIT_PX;
|
|
309
224
|
}
|
|
310
225
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
this.clusterSize[this.clusterOf[i]]++;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
// Node-pair latencies bucketed per cluster pair (cross) and per cluster (within), for min/median/max ranges.
|
|
320
|
-
let lists = new Map<number, number[]>();
|
|
321
|
-
let intraLists: number[][] = Array.from({ length: k }, () => []);
|
|
322
|
-
for (let i = 0; i < n; i++) {
|
|
323
|
-
for (let j = i + 1; j < n; j++) {
|
|
324
|
-
let p = this.clusterOf[i];
|
|
325
|
-
let q = this.clusterOf[j];
|
|
326
|
-
if (p === q) {
|
|
327
|
-
intraLists[p].push(this.latencyMatrix[i * n + j]);
|
|
328
|
-
continue;
|
|
329
|
-
}
|
|
330
|
-
let key = Math.min(p, q) * k + Math.max(p, q);
|
|
331
|
-
let list = lists.get(key);
|
|
332
|
-
if (!list) {
|
|
333
|
-
list = [];
|
|
334
|
-
lists.set(key, list);
|
|
335
|
-
}
|
|
336
|
-
list.push(this.latencyMatrix[i * n + j]);
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
this.clusterIntraMin = new Float64Array(k);
|
|
340
|
-
this.clusterIntraMedian = new Float64Array(k);
|
|
341
|
-
this.clusterIntraMax = new Float64Array(k);
|
|
342
|
-
for (let c = 0; c < k; c++) {
|
|
343
|
-
let list = intraLists[c];
|
|
344
|
-
if (!list.length) continue;
|
|
345
|
-
sort(list, x => x);
|
|
346
|
-
this.clusterIntraMin[c] = list[0];
|
|
347
|
-
this.clusterIntraMedian[c] = list[Math.floor(list.length / 2)];
|
|
348
|
-
this.clusterIntraMax[c] = list[list.length - 1];
|
|
349
|
-
}
|
|
350
|
-
this.clusterDist = new Float64Array(k * k).fill(this.maxLatency);
|
|
351
|
-
for (let c = 0; c < k; c++) {
|
|
352
|
-
this.clusterDist[c * k + c] = 0;
|
|
353
|
-
}
|
|
354
|
-
let stats: { p: number; q: number; min: number; median: number; max: number; }[] = [];
|
|
355
|
-
for (let [key, list] of lists) {
|
|
356
|
-
let p = Math.floor(key / k);
|
|
357
|
-
let q = key % k;
|
|
358
|
-
sort(list, x => x);
|
|
359
|
-
let min = list[0];
|
|
360
|
-
let max = list[list.length - 1];
|
|
361
|
-
let median = list[Math.floor(list.length / 2)];
|
|
362
|
-
this.clusterDist[p * k + q] = median;
|
|
363
|
-
this.clusterDist[q * k + p] = median;
|
|
364
|
-
stats.push({ p, q, min, median, max });
|
|
365
|
-
}
|
|
366
|
-
let medians = stats.map(s => s.median);
|
|
367
|
-
this.interMin = medians.length ? Math.min(...medians) : 0;
|
|
368
|
-
this.interMax = medians.length ? Math.max(...medians) : 1;
|
|
369
|
-
this.interPairs = stats.map(s => {
|
|
370
|
-
let target = this.restPx(s.median);
|
|
371
|
-
return { p: s.p, q: s.q, min: s.min, median: s.median, max: s.max, target, weight: 1 / (target * target) };
|
|
226
|
+
computeSolveEdges() {
|
|
227
|
+
this.builtLatencyExponent = latencyExponentParam.value;
|
|
228
|
+
this.solveEdges = this.edges.map(e => {
|
|
229
|
+
let target = this.targetDist(e.latency);
|
|
230
|
+
return { a: e.a, b: e.b, target, weight: 1 / (target * target) };
|
|
372
231
|
});
|
|
373
|
-
this.interPairMap = new Map();
|
|
374
|
-
for (let pair of this.interPairs) {
|
|
375
|
-
this.interPairMap.set(pair.p * k + pair.q, pair);
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
this.intraSolveEdges = [];
|
|
379
|
-
for (let e of this.edges) {
|
|
380
|
-
if (this.clusterOf[e.a] !== this.clusterOf[e.b]) continue;
|
|
381
|
-
let target = this.restPx(e.latency);
|
|
382
|
-
this.intraSolveEdges.push({ a: e.a, b: e.b, target, weight: 1 / (target * target) });
|
|
383
|
-
}
|
|
384
232
|
}
|
|
385
233
|
|
|
386
234
|
computeRenderEdges() {
|
|
@@ -395,85 +243,16 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
395
243
|
this.neighbors = neighbors;
|
|
396
244
|
let selected = new Set<Edge>();
|
|
397
245
|
for (let i = 0; i < n; i++) {
|
|
398
|
-
let candidates =
|
|
246
|
+
let candidates = neighbors[i].slice();
|
|
247
|
+
sort(candidates, nb => nb.edge.latency);
|
|
399
248
|
for (let j = 0; j < Math.min(count, candidates.length); j++) {
|
|
400
249
|
selected.add(candidates[j].edge);
|
|
401
250
|
}
|
|
402
251
|
}
|
|
403
|
-
this.renderEdgeSet = selected;
|
|
404
252
|
this.renderEdges = [...selected];
|
|
405
|
-
let
|
|
406
|
-
this.renderMin =
|
|
407
|
-
this.renderMax =
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
// A node's connection candidates in nearest-first order: clustered nodes stay in-cluster, singletons reach anywhere.
|
|
411
|
-
nodeCandidates(i: number) {
|
|
412
|
-
let clustered = this.clusterSize[this.clusterOf[i]] >= 2;
|
|
413
|
-
let candidates = clustered ? this.neighbors[i].filter(nb => this.clusterOf[nb.other] === this.clusterOf[i]) : this.neighbors[i].slice();
|
|
414
|
-
sort(candidates, nb => nb.edge.latency);
|
|
415
|
-
return candidates;
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
// While hovering, the node reveals up to 2x its connections; the extra tier (ranks N..2N) is drawn faintly.
|
|
419
|
-
computeHoverExtras() {
|
|
420
|
-
if (this.hoverNode === undefined) return [];
|
|
421
|
-
let candidates = this.nodeCandidates(this.hoverNode);
|
|
422
|
-
let extras: Edge[] = [];
|
|
423
|
-
for (let j = this.builtConnections; j < Math.min(this.builtConnections * 2, candidates.length); j++) {
|
|
424
|
-
if (!this.renderEdgeSet.has(candidates[j].edge)) extras.push(candidates[j].edge);
|
|
425
|
-
}
|
|
426
|
-
return extras;
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
// Each real cluster links to its N nearest clusters by on-screen distance — but only ones it has clear line of
|
|
430
|
-
// sight to: if the segment would pass through another cluster's hull, the link is hidden to keep the chart clean.
|
|
431
|
-
computeRenderInterPairs(centroids: Centroids) {
|
|
432
|
-
let k = this.clusterCount;
|
|
433
|
-
let real: number[] = [];
|
|
434
|
-
for (let c = 0; c < k; c++) {
|
|
435
|
-
if (this.clusterSize[c] >= 2) real.push(c);
|
|
436
|
-
}
|
|
437
|
-
let selected = new Set<ClusterPair>();
|
|
438
|
-
for (let c of real) {
|
|
439
|
-
let others = real.filter(o => o !== c && this.hasLineOfSight(centroids, c, o)).map(o => {
|
|
440
|
-
let dx = centroids.cx[c] - centroids.cx[o];
|
|
441
|
-
let dy = centroids.cy[c] - centroids.cy[o];
|
|
442
|
-
return { o, d: dx * dx + dy * dy };
|
|
443
|
-
});
|
|
444
|
-
sort(others, entry => entry.d);
|
|
445
|
-
for (let j = 0; j < Math.min(this.builtConnections, others.length); j++) {
|
|
446
|
-
let pair = this.interPairMap.get(Math.min(c, others[j].o) * k + Math.max(c, others[j].o));
|
|
447
|
-
if (pair) selected.add(pair);
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
this.renderInterPairs = [...selected];
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
hasLineOfSight(centroids: Centroids, from: number, to: number) {
|
|
454
|
-
return this.segmentClearOfClusters(centroids.cx[from], centroids.cy[from], centroids.cx[to], centroids.cy[to], centroids, from, to);
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
// True if the segment doesn't pass through any real cluster's hull (except the two clusters it belongs to).
|
|
458
|
-
segmentClearOfClusters(ax: number, ay: number, bx: number, by: number, centroids: Centroids, exclude0: number, exclude1: number) {
|
|
459
|
-
let dx = bx - ax;
|
|
460
|
-
let dy = by - ay;
|
|
461
|
-
let lengthSq = dx * dx + dy * dy || 1;
|
|
462
|
-
for (let m = 0; m < this.clusterCount; m++) {
|
|
463
|
-
if (centroids.count[m] < 2 || m === exclude0 || m === exclude1) continue;
|
|
464
|
-
// Closest point on segment [a,b] to cluster m's center, then distance to it.
|
|
465
|
-
let t = Math.max(0, Math.min(1, ((centroids.cx[m] - ax) * dx + (centroids.cy[m] - ay) * dy) / lengthSq));
|
|
466
|
-
let px = ax + t * dx - centroids.cx[m];
|
|
467
|
-
let py = ay + t * dy - centroids.cy[m];
|
|
468
|
-
if (px * px + py * py < centroids.radius[m] * centroids.radius[m]) return false;
|
|
469
|
-
}
|
|
470
|
-
return true;
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
restPx(latency: number) {
|
|
474
|
-
let l = Number.isFinite(latency) ? Math.max(0, latency) : 0;
|
|
475
|
-
let px = LAYOUT_MIN_PX + LAYOUT_SCALE_PX * Math.pow(l / LAYOUT_REF_MS, interExponentParam.value);
|
|
476
|
-
return Number.isFinite(px) ? px : LAYOUT_MIN_PX;
|
|
253
|
+
let latencies = this.renderEdges.map(e => e.latency);
|
|
254
|
+
this.renderMin = latencies.length ? Math.min(...latencies) : 0;
|
|
255
|
+
this.renderMax = latencies.length ? Math.max(...latencies) : 1;
|
|
477
256
|
}
|
|
478
257
|
|
|
479
258
|
opacityFor(latency: number, min: number, max: number) {
|
|
@@ -488,7 +267,7 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
488
267
|
this.scheduleFrame();
|
|
489
268
|
return;
|
|
490
269
|
}
|
|
491
|
-
this.
|
|
270
|
+
this.mdsInit();
|
|
492
271
|
this.temperature = 1;
|
|
493
272
|
this.iteration = 0;
|
|
494
273
|
this.settled = false;
|
|
@@ -505,40 +284,15 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
505
284
|
this.settled = true;
|
|
506
285
|
}
|
|
507
286
|
|
|
508
|
-
//
|
|
509
|
-
|
|
287
|
+
// Classical MDS on the full latency matrix for a good starting layout; SMACOF then refines using the real edges.
|
|
288
|
+
mdsInit() {
|
|
510
289
|
let n = this.nodes.length;
|
|
511
|
-
let
|
|
512
|
-
|
|
513
|
-
let d = this.restPx(this.clusterDist[p * k + q]);
|
|
290
|
+
let pos = this.mds(n, (a, b) => {
|
|
291
|
+
let d = this.targetDist(this.latencyMatrix[a * n + b]);
|
|
514
292
|
return d * d;
|
|
515
293
|
});
|
|
516
|
-
this.
|
|
517
|
-
this.
|
|
518
|
-
|
|
519
|
-
for (let c = 0; c < k; c++) {
|
|
520
|
-
let members: number[] = [];
|
|
521
|
-
for (let i = 0; i < n; i++) {
|
|
522
|
-
if (this.clusterOf[i] === c) members.push(i);
|
|
523
|
-
}
|
|
524
|
-
let local = this.mds(members.length, (a, b) => {
|
|
525
|
-
let d = this.restPx(this.latencyMatrix[members[a] * n + members[b]]);
|
|
526
|
-
return d * d;
|
|
527
|
-
});
|
|
528
|
-
for (let [mi, node] of members.entries()) {
|
|
529
|
-
this.localX[node] = local.x[mi];
|
|
530
|
-
this.localY[node] = local.y[mi];
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
this.composePositions();
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
composePositions() {
|
|
537
|
-
for (let i = 0; i < this.nodes.length; i++) {
|
|
538
|
-
let c = this.clusterOf[i];
|
|
539
|
-
this.positionsX[i] = this.clusterCenterX[c] + this.localX[i];
|
|
540
|
-
this.positionsY[i] = this.clusterCenterY[c] + this.localY[i];
|
|
541
|
-
}
|
|
294
|
+
this.positionsX = pos.x;
|
|
295
|
+
this.positionsY = pos.y;
|
|
542
296
|
}
|
|
543
297
|
|
|
544
298
|
// Classical MDS on an m-point set given a squared-target-distance function; top-2 eigenvectors.
|
|
@@ -646,10 +400,7 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
646
400
|
|
|
647
401
|
step() {
|
|
648
402
|
if (this.settled) return;
|
|
649
|
-
let maxMove =
|
|
650
|
-
maxMove = Math.max(maxMove, this.majorizeCenters());
|
|
651
|
-
maxMove = Math.max(maxMove, this.majorizeLocals());
|
|
652
|
-
this.composePositions();
|
|
403
|
+
let maxMove = this.majorize();
|
|
653
404
|
this.temperature *= COOLING;
|
|
654
405
|
this.iteration++;
|
|
655
406
|
if (maxMove < CONVERGENCE_EPS || this.iteration >= MAX_ITERATIONS) {
|
|
@@ -657,52 +408,17 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
657
408
|
}
|
|
658
409
|
}
|
|
659
410
|
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
if (k < 2) return 0;
|
|
663
|
-
let numX = new Float64Array(k);
|
|
664
|
-
let numY = new Float64Array(k);
|
|
665
|
-
let denom = new Float64Array(k);
|
|
666
|
-
for (let pair of this.interPairs) {
|
|
667
|
-
let ax = this.clusterCenterX[pair.p];
|
|
668
|
-
let ay = this.clusterCenterY[pair.p];
|
|
669
|
-
let bx = this.clusterCenterX[pair.q];
|
|
670
|
-
let by = this.clusterCenterY[pair.q];
|
|
671
|
-
let dx = bx - ax;
|
|
672
|
-
let dy = by - ay;
|
|
673
|
-
let d = Math.sqrt(dx * dx + dy * dy) || 0.0001;
|
|
674
|
-
let scaled = pair.target / d;
|
|
675
|
-
let w = pair.weight;
|
|
676
|
-
numX[pair.p] += w * (bx + scaled * (ax - bx));
|
|
677
|
-
numY[pair.p] += w * (by + scaled * (ay - by));
|
|
678
|
-
numX[pair.q] += w * (ax + scaled * (bx - ax));
|
|
679
|
-
numY[pair.q] += w * (ay + scaled * (by - ay));
|
|
680
|
-
denom[pair.p] += w;
|
|
681
|
-
denom[pair.q] += w;
|
|
682
|
-
}
|
|
683
|
-
let maxMove = 0;
|
|
684
|
-
for (let c = 0; c < k; c++) {
|
|
685
|
-
if (!denom[c]) continue;
|
|
686
|
-
let moveX = (numX[c] / denom[c] - this.clusterCenterX[c]) * this.temperature;
|
|
687
|
-
let moveY = (numY[c] / denom[c] - this.clusterCenterY[c]) * this.temperature;
|
|
688
|
-
this.clusterCenterX[c] += moveX;
|
|
689
|
-
this.clusterCenterY[c] += moveY;
|
|
690
|
-
maxMove = Math.max(maxMove, Math.abs(moveX), Math.abs(moveY));
|
|
691
|
-
}
|
|
692
|
-
return maxMove;
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
majorizeLocals() {
|
|
411
|
+
// One SMACOF (Guttman transform) iteration over the real edges, moving every node toward its target distances.
|
|
412
|
+
majorize() {
|
|
696
413
|
let n = this.nodes.length;
|
|
697
|
-
let k = this.clusterCount;
|
|
698
414
|
let numX = new Float64Array(n);
|
|
699
415
|
let numY = new Float64Array(n);
|
|
700
416
|
let denom = new Float64Array(n);
|
|
701
|
-
for (let e of this.
|
|
702
|
-
let ax = this.
|
|
703
|
-
let ay = this.
|
|
704
|
-
let bx = this.
|
|
705
|
-
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];
|
|
706
422
|
let dx = bx - ax;
|
|
707
423
|
let dy = by - ay;
|
|
708
424
|
let d = Math.sqrt(dx * dx + dy * dy) || 0.0001;
|
|
@@ -717,40 +433,15 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
717
433
|
let maxMove = 0;
|
|
718
434
|
for (let i = 0; i < n; i++) {
|
|
719
435
|
if (!denom[i]) continue;
|
|
720
|
-
let moveX = (numX[i] / denom[i] - this.
|
|
721
|
-
let moveY = (numY[i] / denom[i] - this.
|
|
722
|
-
this.
|
|
723
|
-
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;
|
|
724
440
|
maxMove = Math.max(maxMove, Math.abs(moveX), Math.abs(moveY));
|
|
725
441
|
}
|
|
726
|
-
// Keep each cluster centered on its own center, so global placement stays the centers' job.
|
|
727
|
-
let meanX = new Float64Array(k);
|
|
728
|
-
let meanY = new Float64Array(k);
|
|
729
|
-
let count = new Int32Array(k);
|
|
730
|
-
for (let i = 0; i < n; i++) {
|
|
731
|
-
let c = this.clusterOf[i];
|
|
732
|
-
meanX[c] += this.localX[i];
|
|
733
|
-
meanY[c] += this.localY[i];
|
|
734
|
-
count[c]++;
|
|
735
|
-
}
|
|
736
|
-
for (let c = 0; c < k; c++) {
|
|
737
|
-
if (count[c]) {
|
|
738
|
-
meanX[c] /= count[c];
|
|
739
|
-
meanY[c] /= count[c];
|
|
740
|
-
}
|
|
741
|
-
}
|
|
742
|
-
for (let i = 0; i < n; i++) {
|
|
743
|
-
let c = this.clusterOf[i];
|
|
744
|
-
this.localX[i] -= meanX[c];
|
|
745
|
-
this.localY[i] -= meanY[c];
|
|
746
|
-
}
|
|
747
442
|
return maxMove;
|
|
748
443
|
}
|
|
749
444
|
|
|
750
|
-
clusterHue(c: number) {
|
|
751
|
-
return Math.round(c / Math.max(1, this.clusterCount) * 360);
|
|
752
|
-
}
|
|
753
|
-
|
|
754
445
|
// Screen-space radius; grows with the node's traffic weight, normalized to the busiest node.
|
|
755
446
|
nodeRadius(i: number) {
|
|
756
447
|
if (this.maxNodeWeight <= 0) return NODE_RADIUS;
|
|
@@ -767,36 +458,8 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
767
458
|
return (node.label || node.id).slice(0, 2);
|
|
768
459
|
}
|
|
769
460
|
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
let n = this.nodes.length;
|
|
773
|
-
let cx = new Float64Array(k);
|
|
774
|
-
let cy = new Float64Array(k);
|
|
775
|
-
let count = new Int32Array(k);
|
|
776
|
-
for (let i = 0; i < n; i++) {
|
|
777
|
-
let c = this.clusterOf[i];
|
|
778
|
-
cx[c] += this.positionsX[i];
|
|
779
|
-
cy[c] += this.positionsY[i];
|
|
780
|
-
count[c]++;
|
|
781
|
-
}
|
|
782
|
-
for (let c = 0; c < k; c++) {
|
|
783
|
-
if (count[c]) {
|
|
784
|
-
cx[c] /= count[c];
|
|
785
|
-
cy[c] /= count[c];
|
|
786
|
-
}
|
|
787
|
-
}
|
|
788
|
-
// Hull radius (world units) = farthest member from the centroid, plus padding.
|
|
789
|
-
let radius = new Float64Array(k);
|
|
790
|
-
for (let i = 0; i < n; i++) {
|
|
791
|
-
let c = this.clusterOf[i];
|
|
792
|
-
let dx = this.positionsX[i] - cx[c];
|
|
793
|
-
let dy = this.positionsY[i] - cy[c];
|
|
794
|
-
radius[c] = Math.max(radius[c], Math.sqrt(dx * dx + dy * dy));
|
|
795
|
-
}
|
|
796
|
-
for (let c = 0; c < k; c++) {
|
|
797
|
-
radius[c] += HULL_PADDING;
|
|
798
|
-
}
|
|
799
|
-
return { cx, cy, count, radius };
|
|
461
|
+
isSelected(i: number) {
|
|
462
|
+
return this.selectedId !== undefined && this.nodes[i].id === this.selectedId;
|
|
800
463
|
}
|
|
801
464
|
|
|
802
465
|
draw() {
|
|
@@ -847,7 +510,7 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
847
510
|
}
|
|
848
511
|
let worldW = maxX - minX || 1;
|
|
849
512
|
let worldH = maxY - minY || 1;
|
|
850
|
-
let fit = Math.min((width - 2 * FIT_PAD) / worldW, (height - FIT_PAD_TOP - FIT_PAD) / worldH);
|
|
513
|
+
let fit = Math.min((width - 2 * FIT_PAD) / worldW, (height - FIT_PAD_TOP - FIT_PAD) / worldH) * FIT_ZOOM;
|
|
851
514
|
// Only apply the fit if it's sane — never let a NaN/degenerate value nuke the whole view.
|
|
852
515
|
if (Number.isFinite(fit) && fit > 0 && Number.isFinite(minX) && Number.isFinite(minY)) {
|
|
853
516
|
this.viewScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, fit));
|
|
@@ -862,100 +525,30 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
862
525
|
let toScreenX = (wx: number) => width / 2 + (wx - centroidX) * scale + this.panX;
|
|
863
526
|
let toScreenY = (wy: number) => height / 2 + (wy - centroidY) * scale + this.panY;
|
|
864
527
|
|
|
865
|
-
|
|
866
|
-
this.computeRenderInterPairs(centroids);
|
|
867
|
-
this.hoverExtras = this.computeHoverExtras();
|
|
868
|
-
|
|
869
|
-
// Inter-cluster lines run centroid-to-centroid, then the opaque hulls paint over the parts inside any
|
|
870
|
-
// cluster — so a line only shows in the gaps and reads as entering one cluster edge and exiting another.
|
|
871
|
-
this.drawInterLines(ctx, toScreenX, toScreenY, centroids);
|
|
872
|
-
this.drawClusterHulls(ctx, toScreenX, toScreenY, centroids);
|
|
873
|
-
this.drawNodeConnections(ctx, toScreenX, toScreenY, centroids);
|
|
874
|
-
// Line labels go under the nodes (they're less important); hover labels are drawn last so they still win.
|
|
528
|
+
this.drawEdges(ctx, toScreenX, toScreenY);
|
|
875
529
|
if (showLatenciesParam.value) {
|
|
876
|
-
this.drawLineLatencies(ctx, toScreenX, toScreenY
|
|
530
|
+
this.drawLineLatencies(ctx, toScreenX, toScreenY);
|
|
877
531
|
}
|
|
878
532
|
this.drawNodes(ctx, toScreenX, toScreenY);
|
|
879
|
-
this.
|
|
880
|
-
this.drawClusterLabels(ctx, toScreenX, toScreenY, centroids);
|
|
881
|
-
this.drawHover(ctx, toScreenX, toScreenY, centroids);
|
|
533
|
+
this.drawHover(ctx, toScreenX, toScreenY);
|
|
882
534
|
}
|
|
883
535
|
|
|
884
|
-
|
|
885
|
-
let
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
let hue = this.clusterHue(c);
|
|
889
|
-
let x = toScreenX(centroids.cx[c]);
|
|
890
|
-
let y = toScreenY(centroids.cy[c]);
|
|
891
|
-
let r = centroids.radius[c] * this.viewScale;
|
|
892
|
-
// Opaque fill so any inter-cluster line underneath is hidden inside the cluster.
|
|
893
|
-
ctx.fillStyle = `hsl(${hue}, 35%, 9%)`;
|
|
894
|
-
ctx.beginPath();
|
|
895
|
-
ctx.arc(x, y, r, 0, Math.PI * 2);
|
|
896
|
-
ctx.fill();
|
|
897
|
-
ctx.lineWidth = 1;
|
|
898
|
-
ctx.strokeStyle = `hsla(${hue}, 60%, 55%, 0.35)`;
|
|
899
|
-
ctx.stroke();
|
|
900
|
-
}
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
drawInterLines(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number, centroids: Centroids) {
|
|
904
|
-
ctx.lineWidth = 2;
|
|
905
|
-
for (let pair of this.renderInterPairs) {
|
|
906
|
-
ctx.strokeStyle = `hsla(0, 0%, 100%, ${this.opacityFor(pair.median, this.interMin, this.interMax)})`;
|
|
536
|
+
drawEdges(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number) {
|
|
537
|
+
for (let edge of this.renderEdges) {
|
|
538
|
+
ctx.lineWidth = this.lineWidthFor(edge.a, edge.b);
|
|
539
|
+
ctx.strokeStyle = `hsla(${NODE_HUE}, 70%, 62%, ${this.opacityFor(edge.latency, this.renderMin, this.renderMax)})`;
|
|
907
540
|
ctx.beginPath();
|
|
908
|
-
ctx.moveTo(toScreenX(
|
|
909
|
-
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]));
|
|
910
543
|
ctx.stroke();
|
|
911
544
|
}
|
|
912
545
|
}
|
|
913
546
|
|
|
914
|
-
|
|
915
|
-
|
|
547
|
+
// Always-on latency labels on the drawn connections (toggleable), with the connection's traffic below.
|
|
548
|
+
drawLineLatencies(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number) {
|
|
916
549
|
ctx.textAlign = "center";
|
|
917
550
|
ctx.textBaseline = "middle";
|
|
918
|
-
for (let pair of this.renderInterPairs) {
|
|
919
|
-
let midX = (toScreenX(centroids.cx[pair.p]) + toScreenX(centroids.cx[pair.q])) / 2;
|
|
920
|
-
let midY = (toScreenY(centroids.cy[pair.p]) + toScreenY(centroids.cy[pair.q])) / 2;
|
|
921
|
-
let label = `${formatTime(pair.min)} · ${formatTime(pair.median)} · ${formatTime(pair.max)}`;
|
|
922
|
-
let textWidth = ctx.measureText(label).width;
|
|
923
|
-
ctx.fillStyle = "hsla(0, 0%, 0%, 0.6)";
|
|
924
|
-
ctx.fillRect(midX - textWidth / 2 - 3, midY - 8, textWidth + 6, 16);
|
|
925
|
-
ctx.fillStyle = "hsl(0, 0%, 82%)";
|
|
926
|
-
ctx.fillText(label, midX, midY);
|
|
927
|
-
}
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
drawNodeConnections(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number, centroids: Centroids) {
|
|
931
|
-
ctx.lineWidth = 1.5;
|
|
932
551
|
for (let edge of this.renderEdges) {
|
|
933
|
-
this.strokeNodeConnection(ctx, toScreenX, toScreenY, centroids, edge, 1);
|
|
934
|
-
}
|
|
935
|
-
for (let edge of this.hoverExtras) {
|
|
936
|
-
this.strokeNodeConnection(ctx, toScreenX, toScreenY, centroids, edge, HOVER_EXTRA_OPACITY);
|
|
937
|
-
}
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
strokeNodeConnection(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number, centroids: Centroids, edge: Edge, opacityScale: number) {
|
|
941
|
-
// Hide links that tunnel through an unrelated cluster (mainly singleton connections crossing hulls).
|
|
942
|
-
if (!this.segmentClearOfClusters(this.positionsX[edge.a], this.positionsY[edge.a], this.positionsX[edge.b], this.positionsY[edge.b], centroids, this.clusterOf[edge.a], this.clusterOf[edge.b])) return;
|
|
943
|
-
ctx.lineWidth = this.lineWidthFor(edge.a, edge.b);
|
|
944
|
-
let hue = this.clusterHue(this.clusterOf[edge.a]);
|
|
945
|
-
ctx.strokeStyle = `hsla(${hue}, 70%, 62%, ${this.opacityFor(edge.latency, this.renderMin, this.renderMax) * opacityScale})`;
|
|
946
|
-
ctx.beginPath();
|
|
947
|
-
ctx.moveTo(toScreenX(this.positionsX[edge.a]), toScreenY(this.positionsY[edge.a]));
|
|
948
|
-
ctx.lineTo(toScreenX(this.positionsX[edge.b]), toScreenY(this.positionsY[edge.b]));
|
|
949
|
-
ctx.stroke();
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
// Always-on latency labels on the drawn node connections (toggleable), with the connection's traffic below.
|
|
953
|
-
// Skipped for lines masked by a cluster hull.
|
|
954
|
-
drawLineLatencies(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number, centroids: Centroids) {
|
|
955
|
-
ctx.textAlign = "center";
|
|
956
|
-
ctx.textBaseline = "middle";
|
|
957
|
-
for (let edge of this.renderEdges) {
|
|
958
|
-
if (!this.segmentClearOfClusters(this.positionsX[edge.a], this.positionsY[edge.a], this.positionsX[edge.b], this.positionsY[edge.b], centroids, this.clusterOf[edge.a], this.clusterOf[edge.b])) continue;
|
|
959
552
|
let midX = (toScreenX(this.positionsX[edge.a]) + toScreenX(this.positionsX[edge.b])) / 2;
|
|
960
553
|
let midY = (toScreenY(this.positionsY[edge.a]) + toScreenY(this.positionsY[edge.b])) / 2;
|
|
961
554
|
|
|
@@ -989,24 +582,15 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
989
582
|
let x = toScreenX(this.positionsX[i]);
|
|
990
583
|
let y = toScreenY(this.positionsY[i]);
|
|
991
584
|
let radius = this.nodeRadius(i);
|
|
992
|
-
|
|
585
|
+
let highlighted = i === this.hoverNode || this.isSelected(i);
|
|
586
|
+
ctx.fillStyle = highlighted ? HIGHLIGHT_COLOR : `hsl(${NODE_HUE}, 60%, 52%)`;
|
|
993
587
|
ctx.beginPath();
|
|
994
588
|
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
|
995
589
|
ctx.fill();
|
|
996
590
|
|
|
997
591
|
let lines = this.nodes[i].labelLines;
|
|
998
592
|
if (lines && lines.length) {
|
|
999
|
-
|
|
1000
|
-
let baseY = y - radius - 8;
|
|
1001
|
-
for (let [li, line] of lines.entries()) {
|
|
1002
|
-
let ly = baseY - (lines.length - 1 - li) * 13;
|
|
1003
|
-
ctx.font = li === 0 ? "bold 11px sans-serif" : "10px sans-serif";
|
|
1004
|
-
ctx.lineWidth = 3;
|
|
1005
|
-
ctx.strokeStyle = "hsl(0, 0%, 6%)";
|
|
1006
|
-
ctx.strokeText(line.text, x, ly);
|
|
1007
|
-
ctx.fillStyle = line.color || (li === 0 ? "hsl(0, 0%, 92%)" : "hsl(0, 0%, 62%)");
|
|
1008
|
-
ctx.fillText(line.text, x, ly);
|
|
1009
|
-
}
|
|
593
|
+
this.drawStackedLabel(ctx, lines, x, y - radius - 8);
|
|
1010
594
|
continue;
|
|
1011
595
|
}
|
|
1012
596
|
let short = this.nodeShort(this.nodes[i]);
|
|
@@ -1019,59 +603,37 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
1019
603
|
}
|
|
1020
604
|
}
|
|
1021
605
|
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
let
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
let c = this.clusterOf[i];
|
|
1028
|
-
let dx = this.positionsX[i] - centroids.cx[c];
|
|
1029
|
-
let dy = this.positionsY[i] - centroids.cy[c];
|
|
1030
|
-
let d = dx * dx + dy * dy;
|
|
1031
|
-
if (d < repDist[c]) {
|
|
1032
|
-
repDist[c] = d;
|
|
1033
|
-
rep[c] = i;
|
|
1034
|
-
}
|
|
1035
|
-
}
|
|
1036
|
-
ctx.textAlign = "center";
|
|
1037
|
-
ctx.textBaseline = "middle";
|
|
1038
|
-
for (let c = 0; c < k; c++) {
|
|
1039
|
-
if (rep[c] < 0) continue;
|
|
1040
|
-
let node = this.nodes[rep[c]];
|
|
1041
|
-
let name = node.label || node.id;
|
|
1042
|
-
let label = `${name} (${centroids.count[c]})`;
|
|
1043
|
-
let x = toScreenX(centroids.cx[c]);
|
|
1044
|
-
let hullTop = toScreenY(centroids.cy[c]) - centroids.radius[c] * this.viewScale;
|
|
1045
|
-
let hasStats = centroids.count[c] >= 2;
|
|
1046
|
-
let nameY = hullTop - (hasStats ? 20 : 5);
|
|
1047
|
-
ctx.font = "bold 12px sans-serif";
|
|
606
|
+
// Stack the label ABOVE the node: the block's bottom line sits at bottomY, the rest rising above it.
|
|
607
|
+
drawStackedLabel(ctx: CanvasRenderingContext2D, lines: LatencyGraphLabelLine[], x: number, bottomY: number) {
|
|
608
|
+
for (let [li, line] of lines.entries()) {
|
|
609
|
+
let ly = bottomY - (lines.length - 1 - li) * LABEL_LINE_HEIGHT;
|
|
610
|
+
ctx.font = li === 0 ? "bold 11px sans-serif" : "10px sans-serif";
|
|
1048
611
|
ctx.lineWidth = 3;
|
|
1049
612
|
ctx.strokeStyle = "hsl(0, 0%, 6%)";
|
|
1050
|
-
ctx.strokeText(
|
|
1051
|
-
ctx.fillStyle =
|
|
1052
|
-
ctx.fillText(
|
|
1053
|
-
|
|
1054
|
-
if (!hasStats) continue;
|
|
1055
|
-
let stats = `${formatTime(this.clusterIntraMin[c])} · ${formatTime(this.clusterIntraMedian[c])} · ${formatTime(this.clusterIntraMax[c])}`;
|
|
1056
|
-
ctx.font = "11px sans-serif";
|
|
1057
|
-
let statsWidth = ctx.measureText(stats).width;
|
|
1058
|
-
let statsY = hullTop - 5;
|
|
1059
|
-
ctx.fillStyle = "hsla(0, 0%, 0%, 0.6)";
|
|
1060
|
-
ctx.fillRect(x - statsWidth / 2 - 3, statsY - 8, statsWidth + 6, 16);
|
|
1061
|
-
ctx.fillStyle = `hsl(${this.clusterHue(c)}, 40%, 78%)`;
|
|
1062
|
-
ctx.fillText(stats, x, statsY);
|
|
613
|
+
ctx.strokeText(line.text, x, ly);
|
|
614
|
+
ctx.fillStyle = line.color || (li === 0 ? "hsl(0, 0%, 92%)" : "hsl(0, 0%, 62%)");
|
|
615
|
+
ctx.fillText(line.text, x, ly);
|
|
1063
616
|
}
|
|
1064
617
|
}
|
|
1065
618
|
|
|
1066
|
-
// Hovering
|
|
1067
|
-
drawHover(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number
|
|
619
|
+
// Hovering a node reveals ALL of its measured connections — drawn as highlighted lines with latency labels.
|
|
620
|
+
drawHover(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number) {
|
|
1068
621
|
if (this.hoverNode === undefined) return;
|
|
622
|
+
let hover = this.hoverNode;
|
|
623
|
+
let connections = (this.neighbors[hover] || []).map(nb => nb.edge);
|
|
624
|
+
|
|
625
|
+
ctx.lineWidth = 1.5;
|
|
626
|
+
for (let edge of connections) {
|
|
627
|
+
ctx.strokeStyle = "hsla(40, 90%, 60%, 0.6)";
|
|
628
|
+
ctx.beginPath();
|
|
629
|
+
ctx.moveTo(toScreenX(this.positionsX[edge.a]), toScreenY(this.positionsY[edge.a]));
|
|
630
|
+
ctx.lineTo(toScreenX(this.positionsX[edge.b]), toScreenY(this.positionsY[edge.b]));
|
|
631
|
+
ctx.stroke();
|
|
632
|
+
}
|
|
1069
633
|
ctx.font = "12px sans-serif";
|
|
1070
634
|
ctx.textAlign = "center";
|
|
1071
635
|
ctx.textBaseline = "middle";
|
|
1072
|
-
|
|
1073
|
-
for (let edge of [...incident, ...this.hoverExtras]) {
|
|
1074
|
-
if (!this.segmentClearOfClusters(this.positionsX[edge.a], this.positionsY[edge.a], this.positionsX[edge.b], this.positionsY[edge.b], centroids, this.clusterOf[edge.a], this.clusterOf[edge.b])) continue;
|
|
636
|
+
for (let edge of connections) {
|
|
1075
637
|
let midX = (toScreenX(this.positionsX[edge.a]) + toScreenX(this.positionsX[edge.b])) / 2;
|
|
1076
638
|
let midY = (toScreenY(this.positionsY[edge.a]) + toScreenY(this.positionsY[edge.b])) / 2;
|
|
1077
639
|
let label = formatTime(edge.latency);
|
|
@@ -1081,6 +643,13 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
1081
643
|
ctx.fillStyle = "hsl(40, 90%, 75%)";
|
|
1082
644
|
ctx.fillText(label, midX, midY);
|
|
1083
645
|
}
|
|
646
|
+
// Redraw the hovered node's own label last so it always sits above the connection labels.
|
|
647
|
+
let hoverLines = this.nodes[hover].labelLines;
|
|
648
|
+
if (hoverLines && hoverLines.length) {
|
|
649
|
+
ctx.textAlign = "center";
|
|
650
|
+
ctx.textBaseline = "middle";
|
|
651
|
+
this.drawStackedLabel(ctx, hoverLines, toScreenX(this.positionsX[hover]), toScreenY(this.positionsY[hover]) - this.nodeRadius(hover) - 8);
|
|
652
|
+
}
|
|
1084
653
|
this.drawHoverCard(ctx);
|
|
1085
654
|
}
|
|
1086
655
|
|
|
@@ -1151,6 +720,23 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
1151
720
|
this.lastPointerY = e.clientY;
|
|
1152
721
|
};
|
|
1153
722
|
|
|
723
|
+
// Nearest node to a screen point, with its distance in pixels.
|
|
724
|
+
nearestNodeTo(mx: number, my: number) {
|
|
725
|
+
let scale = this.viewScale;
|
|
726
|
+
let bestDist = Infinity;
|
|
727
|
+
let best: number | undefined = undefined;
|
|
728
|
+
for (let i = 0; i < this.nodes.length; i++) {
|
|
729
|
+
let sx = this.viewWidth / 2 + (this.positionsX[i] - this.centroidX) * scale + this.panX;
|
|
730
|
+
let sy = this.viewHeight / 2 + (this.positionsY[i] - this.centroidY) * scale + this.panY;
|
|
731
|
+
let dist = Math.sqrt((sx - mx) ** 2 + (sy - my) ** 2);
|
|
732
|
+
if (dist < bestDist) {
|
|
733
|
+
bestDist = dist;
|
|
734
|
+
best = i;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
return { node: best, dist: bestDist };
|
|
738
|
+
}
|
|
739
|
+
|
|
1154
740
|
onMouseMove = (e: MouseEvent) => {
|
|
1155
741
|
if (this.isPanning) {
|
|
1156
742
|
this.panX += e.clientX - this.lastPointerX;
|
|
@@ -1168,23 +754,18 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
1168
754
|
let my = e.clientY - rect.top;
|
|
1169
755
|
this.mouseX = mx;
|
|
1170
756
|
this.mouseY = my;
|
|
1171
|
-
let
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
}
|
|
1184
|
-
let prev = this.hoverNode;
|
|
1185
|
-
this.hoverNode = best;
|
|
1186
|
-
// Redraw while a node is hovered (the info card tracks the cursor) or when the hovered node changes.
|
|
1187
|
-
if (best !== undefined || prev !== undefined) this.scheduleFrame();
|
|
757
|
+
let nearest = this.nearestNodeTo(mx, my);
|
|
758
|
+
this.hoverNode = nearest.dist <= HOVER_HIT_PX ? nearest.node : undefined;
|
|
759
|
+
this.scheduleFrame();
|
|
760
|
+
};
|
|
761
|
+
|
|
762
|
+
onClick = (e: MouseEvent) => {
|
|
763
|
+
let canvas = this.canvas;
|
|
764
|
+
if (!canvas || !this.onSelectNode) return;
|
|
765
|
+
let rect = canvas.getBoundingClientRect();
|
|
766
|
+
let nearest = this.nearestNodeTo(e.clientX - rect.left, e.clientY - rect.top);
|
|
767
|
+
if (nearest.node === undefined || nearest.dist > CLICK_HIT_PX) return;
|
|
768
|
+
this.onSelectNode(this.nodes[nearest.node].id);
|
|
1188
769
|
};
|
|
1189
770
|
|
|
1190
771
|
onMouseLeave = () => {
|
|
@@ -1211,6 +792,7 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
1211
792
|
this.canvas.removeEventListener("wheel", this.onWheel);
|
|
1212
793
|
this.canvas.removeEventListener("mousedown", this.onMouseDown);
|
|
1213
794
|
this.canvas.removeEventListener("mouseleave", this.onMouseLeave);
|
|
795
|
+
this.canvas.removeEventListener("click", this.onClick);
|
|
1214
796
|
window.removeEventListener("mousemove", this.onMouseMove);
|
|
1215
797
|
window.removeEventListener("mouseup", this.onMouseUp);
|
|
1216
798
|
}
|
|
@@ -1219,6 +801,7 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
1219
801
|
elem.addEventListener("wheel", this.onWheel, { passive: false });
|
|
1220
802
|
elem.addEventListener("mousedown", this.onMouseDown);
|
|
1221
803
|
elem.addEventListener("mouseleave", this.onMouseLeave);
|
|
804
|
+
elem.addEventListener("click", this.onClick);
|
|
1222
805
|
window.addEventListener("mousemove", this.onMouseMove);
|
|
1223
806
|
window.addEventListener("mouseup", this.onMouseUp);
|
|
1224
807
|
this.scheduleFrame();
|
|
@@ -1227,6 +810,8 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
1227
810
|
|
|
1228
811
|
render() {
|
|
1229
812
|
this.formatWeight = this.props.formatWeight;
|
|
813
|
+
this.selectedId = this.props.selectedId;
|
|
814
|
+
this.onSelectNode = this.props.onSelectNode;
|
|
1230
815
|
// Rebuild from scratch whenever the data set changes (e.g. a new node's latencies arrive progressively).
|
|
1231
816
|
let sig = `${this.props.nodes.length}:${this.props.links.length}`;
|
|
1232
817
|
let weightSig = 0;
|
|
@@ -1246,8 +831,8 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
1246
831
|
// Traffic weights changed but the node/link set didn't — just refresh sizing, no re-layout.
|
|
1247
832
|
this.builtWeightSig = weightSig;
|
|
1248
833
|
this.computeWeights();
|
|
1249
|
-
} else if (
|
|
1250
|
-
this.
|
|
834
|
+
} else if (latencyExponentParam.value !== this.builtLatencyExponent) {
|
|
835
|
+
this.computeSolveEdges();
|
|
1251
836
|
this.applyLayout();
|
|
1252
837
|
} else if (Math.max(1, Math.floor(connectionsParam.value)) !== this.builtConnections) {
|
|
1253
838
|
this.computeRenderEdges();
|
|
@@ -1260,21 +845,31 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
1260
845
|
className={css.absolute.pos(0, 0).fillBoth}
|
|
1261
846
|
/>
|
|
1262
847
|
<div className={css.vbox(10).pad2(12).absolute.pos(0, 0).width(260).zIndex(2).hsla(0, 0, 8, 0.9).colorhsl(0, 0, 85)}>
|
|
1263
|
-
<
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
848
|
+
<div
|
|
849
|
+
className={css.hbox(6).fillWidth.button.colorhsl(0, 0, 85).fontWeight("bold")}
|
|
850
|
+
onMouseDown={() => configOpenParam.value = !configOpenParam.value}
|
|
851
|
+
>
|
|
852
|
+
<span>{configOpenParam.value ? "▾" : "▸"}</span>
|
|
853
|
+
<span>Configuration</span>
|
|
854
|
+
<span className={css.marginAuto.colorhsl(0, 0, 60).fontWeight("normal")}>
|
|
855
|
+
{this.nodes.length} nodes
|
|
856
|
+
</span>
|
|
1271
857
|
</div>
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
858
|
+
{configOpenParam.value && <>
|
|
859
|
+
<InputLabelURL label="Connections per node" url={connectionsParam} integer />
|
|
860
|
+
<InputLabelURL label="Latency exponent" url={latencyExponentParam} number />
|
|
861
|
+
<InputLabelURL label="Show line latencies" url={showLatenciesParam} checkbox />
|
|
862
|
+
<InputLabelURL label="Geographic (lat/lon)" url={geoParam} checkbox />
|
|
863
|
+
<div className={css.colorhsl(0, 0, 60)}>
|
|
864
|
+
{this.nodes.length} nodes · {this.renderEdges.length} lines
|
|
865
|
+
</div>
|
|
866
|
+
<Button onClick={() => { this.userAdjustedView = false; this.applyLayout(); }}>
|
|
867
|
+
Reheat Layout
|
|
868
|
+
</Button>
|
|
869
|
+
<Button onClick={() => console.log("LatencyGraph data:\n" + JSON.stringify({ nodes: this.props.nodes, links: this.props.links }, undefined, 2))}>
|
|
870
|
+
Log graph data
|
|
871
|
+
</Button>
|
|
872
|
+
</>}
|
|
1278
873
|
</div>
|
|
1279
874
|
</div>;
|
|
1280
875
|
}
|