querysub 0.518.0 → 0.520.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/-f-node-discovery/LatencyTracking.ts +116 -0
- package/src/-f-node-discovery/NodeDiscovery.ts +9 -1
- package/src/-f-node-discovery/TrafficTracking.ts +159 -0
- package/src/0-path-value-core/PathRouter.ts +53 -5
- package/src/0-path-value-core/PathValueController.ts +3 -0
- package/src/0-path-value-core/pathValueArchives.ts +2 -1
- package/src/0-path-value-core/startupAuthority.ts +2 -2
- package/src/3-path-functions/PathFunctionRunner.ts +2 -0
- package/src/4-querysub/FunctionRunnerTracking.ts +5 -8
- package/src/4-querysub/Querysub.ts +4 -2
- package/src/4-querysub/QuerysubController.ts +2 -0
- package/src/4-querysub/querysubPrediction.ts +12 -11
- package/src/deployManager/components/MachineDetailPage.tsx +2 -2
- package/src/deployManager/components/ServiceDetailPage.tsx +60 -9
- package/src/deployManager/components/ServicesListPage.tsx +2 -2
- package/src/deployManager/components/Tools.tsx +6 -10
- package/src/deployManager/machineApplyMainCode.ts +13 -11
- package/src/deployManager/machineSchema.ts +50 -6
- package/src/diagnostics/managementPages.tsx +3 -3
- package/src/diagnostics/misc-pages/RoutingTablePage.tsx +324 -0
- package/src/diagnostics/pathAuditer.ts +75 -40
- package/src/library-components/LatencyGraph.tsx +1450 -0
- package/src/src.d.ts +3 -1
- package/src/diagnostics/misc-pages/AuthoritySpecPage.tsx +0 -146
|
@@ -0,0 +1,1450 @@
|
|
|
1
|
+
import { qreact } from "../4-dom/qreact";
|
|
2
|
+
import { Button } from "./Button";
|
|
3
|
+
import { URLParam } from "./URLParam";
|
|
4
|
+
import { InputLabelURL } from "./InputLabel";
|
|
5
|
+
import { css } from "typesafecss";
|
|
6
|
+
import { sort } from "socket-function/src/misc";
|
|
7
|
+
import { formatTime } from "socket-function/src/formatting/format";
|
|
8
|
+
|
|
9
|
+
export type LatencyGraphLabelLine = { text: string; color?: string; };
|
|
10
|
+
export type LatencyGraphNode = { id: string; label?: string; latitude?: number; longitude?: number; labelLines?: LatencyGraphLabelLine[]; weight?: number; };
|
|
11
|
+
export type LatencyGraphLink = { source: string; destination: string; latencyMs: number; weight?: number; };
|
|
12
|
+
export type LatencyGraphProps = {
|
|
13
|
+
nodes: LatencyGraphNode[];
|
|
14
|
+
links: LatencyGraphLink[];
|
|
15
|
+
// Formats a link's traffic weight for the label under the latency (e.g. bytes). No label if omitted.
|
|
16
|
+
formatWeight?: (weight: number) => string;
|
|
17
|
+
// Extra lines shown under a cluster's latency stats (e.g. summed traffic), given the cluster's member node ids.
|
|
18
|
+
clusterSummary?: (memberIds: string[]) => LatencyGraphLabelLine[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const DEFAULT_INTRA_CONNECTIONS = 3;
|
|
22
|
+
const DEFAULT_INTER_CONNECTIONS = 5;
|
|
23
|
+
const DEFAULT_MIN_CLUSTER_SIZE = 2;
|
|
24
|
+
const DEFAULT_CLUSTER_FACTOR = 2;
|
|
25
|
+
const DEFAULT_INTER_EXPONENT = 1.5;
|
|
26
|
+
const intraConnectionsParam = new URLParam("lgIntraConnections", DEFAULT_INTRA_CONNECTIONS);
|
|
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);
|
|
34
|
+
const geoParam = new URLParam("lgGeo", false);
|
|
35
|
+
const showLatenciesParam = new URLParam("lgShowLatencies", false);
|
|
36
|
+
// The config panel is collapsed by default so it doesn't eat the canvas; clicking the header expands it.
|
|
37
|
+
const configOpenParam = new URLParam("lgConfigOpen", false);
|
|
38
|
+
// A node shows its full label only when its nearest neighbor is at least this many SCREEN pixels away (or it's hovered);
|
|
39
|
+
// crowded nodes fall back to a single title line so the view doesn't turn into a jumble. Zooming in spreads nodes past
|
|
40
|
+
// the threshold, revealing their full labels.
|
|
41
|
+
const labelProximityParam = new URLParam("lgLabelProximityPx", 100);
|
|
42
|
+
// Pixels per degree of latitude/longitude in geographic mode (equirectangular projection).
|
|
43
|
+
const GEO_SCALE = 6;
|
|
44
|
+
|
|
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
|
+
// Stress majorization (SMACOF): each iteration is a Guttman transform that never increases stress.
|
|
58
|
+
const COOLING = 0.996;
|
|
59
|
+
const CONVERGENCE_EPS = 0.02;
|
|
60
|
+
const MAX_ITERATIONS = 3000;
|
|
61
|
+
// Classical MDS init: top-2 eigenvectors of the double-centered distance matrix via power iteration.
|
|
62
|
+
const MAX_POWER_ITERS = 200;
|
|
63
|
+
const POWER_EPS = 1e-9;
|
|
64
|
+
|
|
65
|
+
const MIN_OPACITY = 0.05;
|
|
66
|
+
const NODE_RADIUS = 6;
|
|
67
|
+
// Traffic-driven sizing (normalized to the busiest node/pair). Circles grow with node weight; lines with pair weight.
|
|
68
|
+
const NODE_WEIGHT_MULT = 2.5;
|
|
69
|
+
const LINE_BASE_WIDTH = 1.5;
|
|
70
|
+
const LINE_MIN_WIDTH = 1;
|
|
71
|
+
const LINE_MAX_WIDTH = 7;
|
|
72
|
+
const HULL_PADDING = 26;
|
|
73
|
+
const ZOOM_STEP = 1.1;
|
|
74
|
+
const MIN_SCALE = 0.05;
|
|
75
|
+
const MAX_SCALE = 12;
|
|
76
|
+
// Screen-space margins left around the content when auto-fitting. The top gets much more room because node labels
|
|
77
|
+
// stack upward above the nodes (and are drawn at a fixed screen size regardless of zoom).
|
|
78
|
+
const FIT_PAD = 60;
|
|
79
|
+
const FIT_PAD_TOP = 160;
|
|
80
|
+
// Auto-fit leaves a bit of extra breathing room by not zooming all the way in to fill the padded box.
|
|
81
|
+
const FIT_ZOOM = 0.7;
|
|
82
|
+
|
|
83
|
+
type Edge = { a: number; b: number; latency: number; };
|
|
84
|
+
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
|
+
|
|
88
|
+
export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
89
|
+
nodes: LatencyGraphNode[] = [];
|
|
90
|
+
edges: Edge[] = [];
|
|
91
|
+
latencyMatrix = new Float64Array(0);
|
|
92
|
+
minLatency = 0;
|
|
93
|
+
maxLatency = 1;
|
|
94
|
+
nodeWeight = new Float64Array(0);
|
|
95
|
+
maxNodeWeight = 0;
|
|
96
|
+
pairWeight = new Map<number, number>();
|
|
97
|
+
maxPairWeight = 0;
|
|
98
|
+
// Summed node-pair traffic between each cluster pair, for the inter-cluster line widths and labels.
|
|
99
|
+
clusterPairWeight = new Map<number, number>();
|
|
100
|
+
maxClusterPairWeight = 0;
|
|
101
|
+
// Cached from props in render() so draw() (a rAF callback, not synced) never reads this.props.
|
|
102
|
+
formatWeight: ((weight: number) => string) | undefined = undefined;
|
|
103
|
+
clusterSummary: ((memberIds: string[]) => LatencyGraphLabelLine[]) | undefined = undefined;
|
|
104
|
+
// Latest cluster centroids/radii from draw(), for the pointer-handler hull test.
|
|
105
|
+
hoverCentroids: Centroids | undefined = undefined;
|
|
106
|
+
builtSig = "";
|
|
107
|
+
builtWeightSig = 0;
|
|
108
|
+
builtGeo = false;
|
|
109
|
+
builtIntraConnections = 0;
|
|
110
|
+
builtMinClusterSize = 0;
|
|
111
|
+
builtClusterFactor = 0;
|
|
112
|
+
builtInterExponent = 0;
|
|
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[] = [];
|
|
123
|
+
renderEdges: Edge[] = [];
|
|
124
|
+
renderEdgeSet = new Set<Edge>();
|
|
125
|
+
neighbors: { other: number; edge: Edge; }[][] = [];
|
|
126
|
+
interPairMap = new Map<number, ClusterPair>();
|
|
127
|
+
renderInterPairs: ClusterPair[] = [];
|
|
128
|
+
interMin = 0;
|
|
129
|
+
interMax = 1;
|
|
130
|
+
renderMin = 0;
|
|
131
|
+
renderMax = 1;
|
|
132
|
+
|
|
133
|
+
// Per-cluster radius (from the settled intra layout), so inter targets can be edge-to-edge.
|
|
134
|
+
clusterRadius = new Float64Array(0);
|
|
135
|
+
builtInterSpacing = 0;
|
|
136
|
+
|
|
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
|
+
positionsX = new Float64Array(0);
|
|
143
|
+
positionsY = new Float64Array(0);
|
|
144
|
+
|
|
145
|
+
temperature = 1;
|
|
146
|
+
iteration = 0;
|
|
147
|
+
settled = false;
|
|
148
|
+
|
|
149
|
+
rafId = 0;
|
|
150
|
+
frameScheduled = false;
|
|
151
|
+
canvas: HTMLCanvasElement | undefined;
|
|
152
|
+
|
|
153
|
+
viewScale = 1;
|
|
154
|
+
panX = 0;
|
|
155
|
+
panY = 0;
|
|
156
|
+
// While false, the view auto-fits the content each frame; the first manual zoom/pan turns it on and we leave it alone.
|
|
157
|
+
userAdjustedView = false;
|
|
158
|
+
isPanning = false;
|
|
159
|
+
lastPointerX = 0;
|
|
160
|
+
lastPointerY = 0;
|
|
161
|
+
hoverNode: number | undefined = undefined;
|
|
162
|
+
mouseX = 0;
|
|
163
|
+
mouseY = 0;
|
|
164
|
+
// Latest values from draw(), needed by pointer handlers to convert screen<->world.
|
|
165
|
+
centroidX = 0;
|
|
166
|
+
centroidY = 0;
|
|
167
|
+
viewWidth = 0;
|
|
168
|
+
viewHeight = 0;
|
|
169
|
+
|
|
170
|
+
build() {
|
|
171
|
+
this.nodes = this.props.nodes.slice();
|
|
172
|
+
let n = this.nodes.length;
|
|
173
|
+
this.positionsX = new Float64Array(n);
|
|
174
|
+
this.positionsY = new Float64Array(n);
|
|
175
|
+
this.localX = new Float64Array(n);
|
|
176
|
+
this.localY = new Float64Array(n);
|
|
177
|
+
this.ingestLinks();
|
|
178
|
+
this.computeWeights();
|
|
179
|
+
this.clusterNodes();
|
|
180
|
+
this.applyLayout();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Traffic weights only affect rendering (circle size, line width), so they can refresh without re-laying-out.
|
|
184
|
+
computeWeights() {
|
|
185
|
+
let n = this.nodes.length;
|
|
186
|
+
let index = new Map<string, number>();
|
|
187
|
+
for (let [i, node] of this.nodes.entries()) {
|
|
188
|
+
index.set(node.id, i);
|
|
189
|
+
}
|
|
190
|
+
this.nodeWeight = new Float64Array(n);
|
|
191
|
+
this.maxNodeWeight = 0;
|
|
192
|
+
for (let node of this.props.nodes) {
|
|
193
|
+
let i = index.get(node.id);
|
|
194
|
+
if (i === undefined) continue;
|
|
195
|
+
this.nodeWeight[i] = node.weight || 0;
|
|
196
|
+
this.maxNodeWeight = Math.max(this.maxNodeWeight, node.weight || 0);
|
|
197
|
+
}
|
|
198
|
+
this.pairWeight = new Map();
|
|
199
|
+
this.maxPairWeight = 0;
|
|
200
|
+
for (let link of this.props.links) {
|
|
201
|
+
if (!link.weight) continue;
|
|
202
|
+
let a = index.get(link.source);
|
|
203
|
+
let b = index.get(link.destination);
|
|
204
|
+
if (a === undefined || b === undefined || a === b) continue;
|
|
205
|
+
let pk = Math.min(a, b) * n + Math.max(a, b);
|
|
206
|
+
let combined = (this.pairWeight.get(pk) || 0) + link.weight;
|
|
207
|
+
this.pairWeight.set(pk, combined);
|
|
208
|
+
this.maxPairWeight = Math.max(this.maxPairWeight, combined);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Roll node-pair traffic up into cluster-pair totals (cross-cluster only). Recomputed each frame so it always
|
|
213
|
+
// matches the current clustering; cheap since it iterates the (already small) weighted-pair map.
|
|
214
|
+
computeClusterPairWeights() {
|
|
215
|
+
let n = this.nodes.length;
|
|
216
|
+
let k = this.clusterCount;
|
|
217
|
+
this.clusterPairWeight = new Map();
|
|
218
|
+
this.maxClusterPairWeight = 0;
|
|
219
|
+
for (let [pk, weight] of this.pairWeight) {
|
|
220
|
+
let ca = this.clusterOf[Math.floor(pk / n)];
|
|
221
|
+
let cb = this.clusterOf[pk % n];
|
|
222
|
+
if (ca === cb) continue;
|
|
223
|
+
let ck = Math.min(ca, cb) * k + Math.max(ca, cb);
|
|
224
|
+
let combined = (this.clusterPairWeight.get(ck) || 0) + weight;
|
|
225
|
+
this.clusterPairWeight.set(ck, combined);
|
|
226
|
+
this.maxClusterPairWeight = Math.max(this.maxClusterPairWeight, combined);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
interLineWidth(p: number, q: number) {
|
|
231
|
+
if (this.maxClusterPairWeight <= 0) return 2;
|
|
232
|
+
let w = this.clusterPairWeight.get(Math.min(p, q) * this.clusterCount + Math.max(p, q)) || 0;
|
|
233
|
+
return LINE_MIN_WIDTH + (w / this.maxClusterPairWeight) * (LINE_MAX_WIDTH - LINE_MIN_WIDTH);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Combine both directions of each pair into a single undirected edge (mean latency), and build the full matrix.
|
|
237
|
+
ingestLinks() {
|
|
238
|
+
let n = this.nodes.length;
|
|
239
|
+
let index = new Map<string, number>();
|
|
240
|
+
for (let [i, node] of this.nodes.entries()) {
|
|
241
|
+
index.set(node.id, i);
|
|
242
|
+
}
|
|
243
|
+
let paired = new Map<string, { sum: number; count: number; a: number; b: number; }>();
|
|
244
|
+
for (let link of this.props.links) {
|
|
245
|
+
let a = index.get(link.source);
|
|
246
|
+
let b = index.get(link.destination);
|
|
247
|
+
if (a === undefined || b === undefined || a === b) continue;
|
|
248
|
+
// Skip junk latencies (undefined / NaN / infinite) so they can't poison the distance matrix -> NaN positions.
|
|
249
|
+
if (!Number.isFinite(link.latencyMs)) continue;
|
|
250
|
+
let lo = Math.min(a, b);
|
|
251
|
+
let hi = Math.max(a, b);
|
|
252
|
+
let key = `${lo}-${hi}`;
|
|
253
|
+
let entry = paired.get(key);
|
|
254
|
+
if (!entry) {
|
|
255
|
+
entry = { sum: 0, count: 0, a: lo, b: hi };
|
|
256
|
+
paired.set(key, entry);
|
|
257
|
+
}
|
|
258
|
+
entry.sum += link.latencyMs;
|
|
259
|
+
entry.count++;
|
|
260
|
+
}
|
|
261
|
+
this.edges = [...paired.values()].map(e => ({ a: e.a, b: e.b, latency: e.sum / e.count }));
|
|
262
|
+
let latencies = this.edges.map(e => e.latency);
|
|
263
|
+
this.minLatency = latencies.length ? Math.min(...latencies) : 0;
|
|
264
|
+
this.maxLatency = latencies.length ? Math.max(...latencies) : 1;
|
|
265
|
+
|
|
266
|
+
// Unknown pairs treated as maximally distant, so they never falsely cluster.
|
|
267
|
+
this.latencyMatrix = new Float64Array(n * n).fill(this.maxLatency);
|
|
268
|
+
for (let i = 0; i < n; i++) {
|
|
269
|
+
this.latencyMatrix[i * n + i] = 0;
|
|
270
|
+
}
|
|
271
|
+
for (let e of this.edges) {
|
|
272
|
+
this.latencyMatrix[e.a * n + e.b] = e.latency;
|
|
273
|
+
this.latencyMatrix[e.b * n + e.a] = e.latency;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Relative clustering: link two nodes when their latency is within clusterFactor of the tighter node's own
|
|
278
|
+
// nearest-neighbor latency, then take connected components. Adapts to local density, so a mutually-isolated
|
|
279
|
+
// close pair (e.g. Brisbane/Auckland) clusters even though it's far from every other node.
|
|
280
|
+
clusterNodes() {
|
|
281
|
+
let n = this.nodes.length;
|
|
282
|
+
let factor = Math.max(1, clusterFactorParam.value);
|
|
283
|
+
this.builtClusterFactor = factor;
|
|
284
|
+
this.builtInterExponent = interExponentParam.value;
|
|
285
|
+
let nn = this.nearestNeighborLatencies();
|
|
286
|
+
|
|
287
|
+
let parent = new Int32Array(n);
|
|
288
|
+
for (let i = 0; i < n; i++) {
|
|
289
|
+
parent[i] = i;
|
|
290
|
+
}
|
|
291
|
+
let find = (x: number): number => {
|
|
292
|
+
while (parent[x] !== x) {
|
|
293
|
+
parent[x] = parent[parent[x]];
|
|
294
|
+
x = parent[x];
|
|
295
|
+
}
|
|
296
|
+
return x;
|
|
297
|
+
};
|
|
298
|
+
for (let e of this.edges) {
|
|
299
|
+
if (e.latency <= factor * Math.min(nn[e.a], nn[e.b])) {
|
|
300
|
+
parent[find(e.a)] = find(e.b);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
let clusterId = new Map<number, number>();
|
|
304
|
+
this.clusterOf = new Int32Array(n);
|
|
305
|
+
for (let i = 0; i < n; i++) {
|
|
306
|
+
let root = find(i);
|
|
307
|
+
let id = clusterId.get(root);
|
|
308
|
+
if (id === undefined) {
|
|
309
|
+
id = clusterId.size;
|
|
310
|
+
clusterId.set(root, id);
|
|
311
|
+
}
|
|
312
|
+
this.clusterOf[i] = id;
|
|
313
|
+
}
|
|
314
|
+
this.clusterCount = Math.max(1, clusterId.size);
|
|
315
|
+
|
|
316
|
+
this.enforceMinClusterSize();
|
|
317
|
+
this.buildClusterDerived();
|
|
318
|
+
this.computeRenderEdges();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
nearestNeighborLatencies() {
|
|
322
|
+
let nn = new Float64Array(this.nodes.length).fill(this.maxLatency);
|
|
323
|
+
for (let e of this.edges) {
|
|
324
|
+
if (e.latency < nn[e.a]) nn[e.a] = e.latency;
|
|
325
|
+
if (e.latency < nn[e.b]) nn[e.b] = e.latency;
|
|
326
|
+
}
|
|
327
|
+
return nn;
|
|
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
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
computeRenderEdges() {
|
|
435
|
+
let count = Math.max(1, Math.floor(intraConnectionsParam.value));
|
|
436
|
+
this.builtIntraConnections = count;
|
|
437
|
+
let n = this.nodes.length;
|
|
438
|
+
let neighbors: { other: number; edge: Edge; }[][] = Array.from({ length: n }, () => []);
|
|
439
|
+
for (let edge of this.edges) {
|
|
440
|
+
neighbors[edge.a].push({ other: edge.b, edge });
|
|
441
|
+
neighbors[edge.b].push({ other: edge.a, edge });
|
|
442
|
+
}
|
|
443
|
+
this.neighbors = neighbors;
|
|
444
|
+
let selected = new Set<Edge>();
|
|
445
|
+
for (let i = 0; i < n; i++) {
|
|
446
|
+
let candidates = this.nodeCandidates(i);
|
|
447
|
+
for (let j = 0; j < Math.min(count, candidates.length); j++) {
|
|
448
|
+
selected.add(candidates[j].edge);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
this.renderEdgeSet = selected;
|
|
452
|
+
this.renderEdges = [...selected];
|
|
453
|
+
let nodeLatencies = this.renderEdges.map(e => e.latency);
|
|
454
|
+
this.renderMin = nodeLatencies.length ? Math.min(...nodeLatencies) : 0;
|
|
455
|
+
this.renderMax = nodeLatencies.length ? Math.max(...nodeLatencies) : 1;
|
|
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;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
opacityFor(latency: number, min: number, max: number) {
|
|
536
|
+
let norm = (latency - min) / (max - min || 1);
|
|
537
|
+
return Math.max(MIN_OPACITY, 1 - norm);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
applyLayout() {
|
|
541
|
+
this.builtGeo = geoParam.value;
|
|
542
|
+
if (geoParam.value) {
|
|
543
|
+
this.geoLayout();
|
|
544
|
+
this.scheduleFrame();
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
this.hierarchicalInit();
|
|
548
|
+
this.temperature = 1;
|
|
549
|
+
this.iteration = 0;
|
|
550
|
+
this.settled = false;
|
|
551
|
+
this.scheduleFrame();
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
geoLayout() {
|
|
555
|
+
for (let [i, node] of this.nodes.entries()) {
|
|
556
|
+
this.positionsX[i] = node.longitude !== undefined ? node.longitude * GEO_SCALE : 0;
|
|
557
|
+
this.positionsY[i] = node.latitude !== undefined ? -node.latitude * GEO_SCALE : 0;
|
|
558
|
+
}
|
|
559
|
+
this.temperature = 0;
|
|
560
|
+
this.iteration = 0;
|
|
561
|
+
this.settled = true;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Members placed by each cluster's own local MDS first (so we know cluster radii), then cluster centers by
|
|
565
|
+
// inter-cluster MDS using edge-to-edge (radius-inclusive) targets.
|
|
566
|
+
hierarchicalInit() {
|
|
567
|
+
let n = this.nodes.length;
|
|
568
|
+
let k = this.clusterCount;
|
|
569
|
+
this.builtInterSpacing = interSpacingParam.value;
|
|
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]);
|
|
598
|
+
return d * d;
|
|
599
|
+
});
|
|
600
|
+
this.clusterCenterX = centers.x;
|
|
601
|
+
this.clusterCenterY = centers.y;
|
|
602
|
+
|
|
603
|
+
this.composePositions();
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
composePositions() {
|
|
607
|
+
for (let i = 0; i < this.nodes.length; i++) {
|
|
608
|
+
let c = this.clusterOf[i];
|
|
609
|
+
this.positionsX[i] = this.clusterCenterX[c] + this.localX[i];
|
|
610
|
+
this.positionsY[i] = this.clusterCenterY[c] + this.localY[i];
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Classical MDS on an m-point set given a squared-target-distance function; top-2 eigenvectors.
|
|
615
|
+
mds(m: number, getSqDist: (a: number, b: number) => number) {
|
|
616
|
+
let x = new Float64Array(m);
|
|
617
|
+
let y = new Float64Array(m);
|
|
618
|
+
if (m < 2) return { x, y };
|
|
619
|
+
let sq = new Float64Array(m * m);
|
|
620
|
+
for (let i = 0; i < m; i++) {
|
|
621
|
+
for (let j = i + 1; j < m; j++) {
|
|
622
|
+
let v = getSqDist(i, j);
|
|
623
|
+
sq[i * m + j] = v;
|
|
624
|
+
sq[j * m + i] = v;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
let rowMean = new Float64Array(m);
|
|
628
|
+
let grand = 0;
|
|
629
|
+
for (let i = 0; i < m; i++) {
|
|
630
|
+
let s = 0;
|
|
631
|
+
for (let j = 0; j < m; j++) {
|
|
632
|
+
s += sq[i * m + j];
|
|
633
|
+
}
|
|
634
|
+
rowMean[i] = s / m;
|
|
635
|
+
grand += s;
|
|
636
|
+
}
|
|
637
|
+
grand /= m * m;
|
|
638
|
+
let b = new Float64Array(m * m);
|
|
639
|
+
for (let i = 0; i < m; i++) {
|
|
640
|
+
for (let j = 0; j < m; j++) {
|
|
641
|
+
b[i * m + j] = -0.5 * (sq[i * m + j] - rowMean[i] - rowMean[j] + grand);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
let shift = 0;
|
|
645
|
+
for (let i = 0; i < m; i++) {
|
|
646
|
+
let s = 0;
|
|
647
|
+
for (let j = 0; j < m; j++) {
|
|
648
|
+
s += Math.abs(b[i * m + j]);
|
|
649
|
+
}
|
|
650
|
+
shift = Math.max(shift, s);
|
|
651
|
+
}
|
|
652
|
+
let matBase = (v: Float64Array, out: Float64Array) => {
|
|
653
|
+
for (let i = 0; i < m; i++) {
|
|
654
|
+
let s = 0;
|
|
655
|
+
for (let j = 0; j < m; j++) {
|
|
656
|
+
s += b[i * m + j] * v[j];
|
|
657
|
+
}
|
|
658
|
+
out[i] = s + shift * v[i];
|
|
659
|
+
}
|
|
660
|
+
};
|
|
661
|
+
let e1 = this.powerIteration(matBase, m);
|
|
662
|
+
let matDeflate = (v: Float64Array, out: Float64Array) => {
|
|
663
|
+
matBase(v, out);
|
|
664
|
+
let d = 0;
|
|
665
|
+
for (let i = 0; i < m; i++) {
|
|
666
|
+
d += e1.vec[i] * v[i];
|
|
667
|
+
}
|
|
668
|
+
for (let i = 0; i < m; i++) {
|
|
669
|
+
out[i] -= e1.val * d * e1.vec[i];
|
|
670
|
+
}
|
|
671
|
+
};
|
|
672
|
+
let e2 = this.powerIteration(matDeflate, m);
|
|
673
|
+
let s1 = Math.sqrt(Math.max(e1.val - shift, 0));
|
|
674
|
+
let s2 = Math.sqrt(Math.max(e2.val - shift, 0));
|
|
675
|
+
for (let i = 0; i < m; i++) {
|
|
676
|
+
x[i] = e1.vec[i] * s1;
|
|
677
|
+
y[i] = e2.vec[i] * s2;
|
|
678
|
+
}
|
|
679
|
+
return { x, y };
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
powerIteration(matvec: (v: Float64Array, out: Float64Array) => void, n: number) {
|
|
683
|
+
let v = new Float64Array(n);
|
|
684
|
+
let mag = 0;
|
|
685
|
+
for (let i = 0; i < n; i++) {
|
|
686
|
+
v[i] = Math.sin(i * 0.1 + 1);
|
|
687
|
+
mag += v[i] * v[i];
|
|
688
|
+
}
|
|
689
|
+
mag = Math.sqrt(mag);
|
|
690
|
+
for (let i = 0; i < n; i++) {
|
|
691
|
+
v[i] /= mag;
|
|
692
|
+
}
|
|
693
|
+
let out = new Float64Array(n);
|
|
694
|
+
let val = 0;
|
|
695
|
+
for (let iter = 0; iter < MAX_POWER_ITERS; iter++) {
|
|
696
|
+
matvec(v, out);
|
|
697
|
+
let norm = 0;
|
|
698
|
+
for (let i = 0; i < n; i++) {
|
|
699
|
+
norm += out[i] * out[i];
|
|
700
|
+
}
|
|
701
|
+
norm = Math.sqrt(norm);
|
|
702
|
+
if (norm < 1e-12) break;
|
|
703
|
+
let dot = 0;
|
|
704
|
+
for (let i = 0; i < n; i++) {
|
|
705
|
+
out[i] /= norm;
|
|
706
|
+
dot += out[i] * v[i];
|
|
707
|
+
}
|
|
708
|
+
for (let i = 0; i < n; i++) {
|
|
709
|
+
v[i] = out[i];
|
|
710
|
+
}
|
|
711
|
+
val = norm;
|
|
712
|
+
if (Math.abs(dot) > 1 - POWER_EPS) break;
|
|
713
|
+
}
|
|
714
|
+
return { vec: v, val };
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
step() {
|
|
718
|
+
if (this.settled) return;
|
|
719
|
+
let maxMove = 0;
|
|
720
|
+
maxMove = Math.max(maxMove, this.majorizeCenters());
|
|
721
|
+
maxMove = Math.max(maxMove, this.majorizeLocals());
|
|
722
|
+
this.composePositions();
|
|
723
|
+
this.temperature *= COOLING;
|
|
724
|
+
this.iteration++;
|
|
725
|
+
if (maxMove < CONVERGENCE_EPS || this.iteration >= MAX_ITERATIONS) {
|
|
726
|
+
this.settled = true;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
majorizeCenters() {
|
|
731
|
+
let k = this.clusterCount;
|
|
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() {
|
|
766
|
+
let n = this.nodes.length;
|
|
767
|
+
let k = this.clusterCount;
|
|
768
|
+
let numX = new Float64Array(n);
|
|
769
|
+
let numY = new Float64Array(n);
|
|
770
|
+
let denom = new Float64Array(n);
|
|
771
|
+
for (let e of this.intraSolveEdges) {
|
|
772
|
+
let ax = this.localX[e.a];
|
|
773
|
+
let ay = this.localY[e.a];
|
|
774
|
+
let bx = this.localX[e.b];
|
|
775
|
+
let by = this.localY[e.b];
|
|
776
|
+
let dx = bx - ax;
|
|
777
|
+
let dy = by - ay;
|
|
778
|
+
let d = Math.sqrt(dx * dx + dy * dy) || 0.0001;
|
|
779
|
+
let scaled = e.target / d;
|
|
780
|
+
numX[e.a] += e.weight * (bx + scaled * (ax - bx));
|
|
781
|
+
numY[e.a] += e.weight * (by + scaled * (ay - by));
|
|
782
|
+
numX[e.b] += e.weight * (ax + scaled * (bx - ax));
|
|
783
|
+
numY[e.b] += e.weight * (ay + scaled * (by - ay));
|
|
784
|
+
denom[e.a] += e.weight;
|
|
785
|
+
denom[e.b] += e.weight;
|
|
786
|
+
}
|
|
787
|
+
let maxMove = 0;
|
|
788
|
+
for (let i = 0; i < n; i++) {
|
|
789
|
+
if (!denom[i]) continue;
|
|
790
|
+
let moveX = (numX[i] / denom[i] - this.localX[i]) * this.temperature;
|
|
791
|
+
let moveY = (numY[i] / denom[i] - this.localY[i]) * this.temperature;
|
|
792
|
+
this.localX[i] += moveX;
|
|
793
|
+
this.localY[i] += moveY;
|
|
794
|
+
maxMove = Math.max(maxMove, Math.abs(moveX), Math.abs(moveY));
|
|
795
|
+
}
|
|
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
|
+
return maxMove;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
clusterHue(c: number) {
|
|
821
|
+
return Math.round(c / Math.max(1, this.clusterCount) * 360);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// Screen-space radius; grows with the node's traffic weight, normalized to the busiest node.
|
|
825
|
+
nodeRadius(i: number) {
|
|
826
|
+
if (this.maxNodeWeight <= 0) return NODE_RADIUS;
|
|
827
|
+
return NODE_RADIUS * (1 + NODE_WEIGHT_MULT * (this.nodeWeight[i] / this.maxNodeWeight));
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
lineWidthFor(a: number, b: number) {
|
|
831
|
+
if (this.maxPairWeight <= 0) return LINE_BASE_WIDTH;
|
|
832
|
+
let w = this.pairWeight.get(Math.min(a, b) * this.nodes.length + Math.max(a, b)) || 0;
|
|
833
|
+
return LINE_MIN_WIDTH + (w / this.maxPairWeight) * (LINE_MAX_WIDTH - LINE_MIN_WIDTH);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
nodeShort(node: LatencyGraphNode) {
|
|
837
|
+
return (node.label || node.id).slice(0, 2);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
clusterCentroids() {
|
|
841
|
+
let k = this.clusterCount;
|
|
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 };
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
draw() {
|
|
873
|
+
let canvas = this.canvas;
|
|
874
|
+
if (!canvas) return;
|
|
875
|
+
let ctx = canvas.getContext("2d");
|
|
876
|
+
if (!ctx) return;
|
|
877
|
+
|
|
878
|
+
let dpr = window.devicePixelRatio || 1;
|
|
879
|
+
let width = canvas.clientWidth;
|
|
880
|
+
let height = canvas.clientHeight;
|
|
881
|
+
if (canvas.width !== width * dpr || canvas.height !== height * dpr) {
|
|
882
|
+
canvas.width = width * dpr;
|
|
883
|
+
canvas.height = height * dpr;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
887
|
+
ctx.fillStyle = "hsl(0, 0%, 6%)";
|
|
888
|
+
ctx.fillRect(0, 0, width, height);
|
|
889
|
+
|
|
890
|
+
let n = this.nodes.length;
|
|
891
|
+
this.viewWidth = width;
|
|
892
|
+
this.viewHeight = height;
|
|
893
|
+
if (n === 0) return;
|
|
894
|
+
|
|
895
|
+
let centroidX = 0;
|
|
896
|
+
let centroidY = 0;
|
|
897
|
+
for (let i = 0; i < n; i++) {
|
|
898
|
+
centroidX += this.positionsX[i];
|
|
899
|
+
centroidY += this.positionsY[i];
|
|
900
|
+
}
|
|
901
|
+
centroidX /= n;
|
|
902
|
+
centroidY /= n;
|
|
903
|
+
this.centroidX = centroidX;
|
|
904
|
+
this.centroidY = centroidY;
|
|
905
|
+
|
|
906
|
+
// Auto-fit: scale/center the content's bounding box into the viewport, until the user manually zooms/pans.
|
|
907
|
+
if (!this.userAdjustedView) {
|
|
908
|
+
let minX = Infinity;
|
|
909
|
+
let maxX = -Infinity;
|
|
910
|
+
let minY = Infinity;
|
|
911
|
+
let maxY = -Infinity;
|
|
912
|
+
for (let i = 0; i < n; i++) {
|
|
913
|
+
minX = Math.min(minX, this.positionsX[i]);
|
|
914
|
+
maxX = Math.max(maxX, this.positionsX[i]);
|
|
915
|
+
minY = Math.min(minY, this.positionsY[i]);
|
|
916
|
+
maxY = Math.max(maxY, this.positionsY[i]);
|
|
917
|
+
}
|
|
918
|
+
let worldW = maxX - minX || 1;
|
|
919
|
+
let worldH = maxY - minY || 1;
|
|
920
|
+
let fit = Math.min((width - 2 * FIT_PAD) / worldW, (height - FIT_PAD_TOP - FIT_PAD) / worldH) * FIT_ZOOM;
|
|
921
|
+
// Only apply the fit if it's sane — never let a NaN/degenerate value nuke the whole view.
|
|
922
|
+
if (Number.isFinite(fit) && fit > 0 && Number.isFinite(minX) && Number.isFinite(minY)) {
|
|
923
|
+
this.viewScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, fit));
|
|
924
|
+
this.panX = -((minX + maxX) / 2 - centroidX) * this.viewScale;
|
|
925
|
+
// Center within the padded band, which sits lower than the middle because of the reserved top margin.
|
|
926
|
+
let targetCenterY = (FIT_PAD_TOP + (height - FIT_PAD)) / 2;
|
|
927
|
+
this.panY = targetCenterY - height / 2 - ((minY + maxY) / 2 - centroidY) * this.viewScale;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
let scale = this.viewScale;
|
|
932
|
+
let toScreenX = (wx: number) => width / 2 + (wx - centroidX) * scale + this.panX;
|
|
933
|
+
let toScreenY = (wy: number) => height / 2 + (wy - centroidY) * scale + this.panY;
|
|
934
|
+
|
|
935
|
+
let centroids = this.clusterCentroids();
|
|
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.
|
|
946
|
+
if (showLatenciesParam.value) {
|
|
947
|
+
this.drawLineLatencies(ctx, toScreenX, toScreenY, centroids);
|
|
948
|
+
}
|
|
949
|
+
this.drawNodes(ctx, toScreenX, toScreenY, centroids);
|
|
950
|
+
this.drawInterLabels(ctx, toScreenX, toScreenY, centroids);
|
|
951
|
+
this.drawClusterLabels(ctx, toScreenX, toScreenY, centroids);
|
|
952
|
+
this.drawHover(ctx, toScreenX, toScreenY, centroids);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
drawClusterHulls(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number, centroids: Centroids) {
|
|
956
|
+
let k = this.clusterCount;
|
|
957
|
+
for (let c = 0; c < k; c++) {
|
|
958
|
+
if (centroids.count[c] < 2) continue;
|
|
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)})`;
|
|
978
|
+
ctx.beginPath();
|
|
979
|
+
ctx.moveTo(toScreenX(centroids.cx[pair.p]), toScreenY(centroids.cy[pair.p]));
|
|
980
|
+
ctx.lineTo(toScreenX(centroids.cx[pair.q]), toScreenY(centroids.cy[pair.q]));
|
|
981
|
+
ctx.stroke();
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
drawInterLabels(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number, centroids: Centroids) {
|
|
986
|
+
ctx.font = "11px sans-serif";
|
|
987
|
+
ctx.textAlign = "center";
|
|
988
|
+
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
|
+
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
|
+
let midX = (toScreenX(this.positionsX[edge.a]) + toScreenX(this.positionsX[edge.b])) / 2;
|
|
1043
|
+
let midY = (toScreenY(this.positionsY[edge.a]) + toScreenY(this.positionsY[edge.b])) / 2;
|
|
1044
|
+
|
|
1045
|
+
let traffic = this.pairWeight.get(Math.min(edge.a, edge.b) * this.nodes.length + Math.max(edge.a, edge.b)) || 0;
|
|
1046
|
+
let trafficLabel = this.formatWeight && traffic > 0 ? this.formatWeight(traffic) : undefined;
|
|
1047
|
+
let latencyY = trafficLabel ? midY - 7 : midY;
|
|
1048
|
+
|
|
1049
|
+
ctx.font = "10px sans-serif";
|
|
1050
|
+
let latencyLabel = formatTime(edge.latency);
|
|
1051
|
+
let latencyWidth = ctx.measureText(latencyLabel).width;
|
|
1052
|
+
ctx.fillStyle = "hsla(0, 0%, 0%, 0.5)";
|
|
1053
|
+
ctx.fillRect(midX - latencyWidth / 2 - 2, latencyY - 7, latencyWidth + 4, 14);
|
|
1054
|
+
ctx.fillStyle = "hsl(0, 0%, 72%)";
|
|
1055
|
+
ctx.fillText(latencyLabel, midX, latencyY);
|
|
1056
|
+
|
|
1057
|
+
if (trafficLabel) {
|
|
1058
|
+
ctx.font = "9px sans-serif";
|
|
1059
|
+
let trafficWidth = ctx.measureText(trafficLabel).width;
|
|
1060
|
+
ctx.fillStyle = "hsla(0, 0%, 0%, 0.5)";
|
|
1061
|
+
ctx.fillRect(midX - trafficWidth / 2 - 2, midY + 7 - 6, trafficWidth + 4, 12);
|
|
1062
|
+
ctx.fillStyle = "hsl(0, 0%, 58%)";
|
|
1063
|
+
ctx.fillText(trafficLabel, midX, midY + 7);
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
drawNodes(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number, centroids: Centroids) {
|
|
1069
|
+
ctx.textBaseline = "middle";
|
|
1070
|
+
ctx.textAlign = "center";
|
|
1071
|
+
let proximityPx = labelProximityParam.value;
|
|
1072
|
+
let screenX = this.nodes.map((_, i) => toScreenX(this.positionsX[i]));
|
|
1073
|
+
let screenY = this.nodes.map((_, i) => toScreenY(this.positionsY[i]));
|
|
1074
|
+
for (let i = 0; i < this.nodes.length; i++) {
|
|
1075
|
+
let x = screenX[i];
|
|
1076
|
+
let y = screenY[i];
|
|
1077
|
+
let radius = this.nodeRadius(i);
|
|
1078
|
+
ctx.fillStyle = i === this.hoverNode ? "hsl(40, 90%, 60%)" : `hsl(${this.clusterHue(this.clusterOf[i])}, 65%, 55%)`;
|
|
1079
|
+
ctx.beginPath();
|
|
1080
|
+
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
|
1081
|
+
ctx.fill();
|
|
1082
|
+
|
|
1083
|
+
// The full label only shows when this node is visually isolated (nearest neighbor on screen is far enough
|
|
1084
|
+
// away), or it's hovered; otherwise it collapses to a single title line above the dot to avoid a jumble.
|
|
1085
|
+
let full = i === this.hoverNode || this.nearestScreenDist(i, screenX, screenY) >= proximityPx;
|
|
1086
|
+
let lines = this.nodes[i].labelLines;
|
|
1087
|
+
if (full && lines && lines.length) {
|
|
1088
|
+
this.drawFullNodeLabel(ctx, lines, x, y);
|
|
1089
|
+
continue;
|
|
1090
|
+
}
|
|
1091
|
+
// Crowded (or unlabeled): just the short title, above the dot.
|
|
1092
|
+
let short = lines && lines.length ? lines[0].text : this.nodeShort(this.nodes[i]);
|
|
1093
|
+
ctx.font = "bold 11px sans-serif";
|
|
1094
|
+
ctx.lineWidth = 3;
|
|
1095
|
+
ctx.strokeStyle = "hsl(0, 0%, 6%)";
|
|
1096
|
+
ctx.strokeText(short, x, y - radius - 8);
|
|
1097
|
+
ctx.fillStyle = "hsl(0, 0%, 92%)";
|
|
1098
|
+
ctx.fillText(short, x, y - radius - 8);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
// Stack the full label centered ON the node so a spread-out node reads its details in place.
|
|
1103
|
+
drawFullNodeLabel(ctx: CanvasRenderingContext2D, lines: LatencyGraphLabelLine[], x: number, y: number) {
|
|
1104
|
+
let baseY = y - (lines.length - 1) / 2 * 13;
|
|
1105
|
+
for (let [li, line] of lines.entries()) {
|
|
1106
|
+
let ly = baseY + li * 13;
|
|
1107
|
+
ctx.font = li === 0 ? "bold 11px sans-serif" : "10px sans-serif";
|
|
1108
|
+
ctx.lineWidth = 3;
|
|
1109
|
+
ctx.strokeStyle = "hsl(0, 0%, 6%)";
|
|
1110
|
+
ctx.strokeText(line.text, x, ly);
|
|
1111
|
+
ctx.fillStyle = line.color || (li === 0 ? "hsl(0, 0%, 92%)" : "hsl(0, 0%, 62%)");
|
|
1112
|
+
ctx.fillText(line.text, x, ly);
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
// Screen-pixel distance from node i to its closest other node (used to decide whether there's room for a full label).
|
|
1117
|
+
nearestScreenDist(i: number, screenX: number[], screenY: number[]) {
|
|
1118
|
+
let best = Infinity;
|
|
1119
|
+
for (let j = 0; j < screenX.length; j++) {
|
|
1120
|
+
if (j === i) continue;
|
|
1121
|
+
let dx = screenX[j] - screenX[i];
|
|
1122
|
+
let dy = screenY[j] - screenY[i];
|
|
1123
|
+
let d = Math.sqrt(dx * dx + dy * dy);
|
|
1124
|
+
if (d < best) best = d;
|
|
1125
|
+
}
|
|
1126
|
+
return best;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
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) {
|
|
1180
|
+
if (this.hoverNode === undefined) return;
|
|
1181
|
+
let hover = this.hoverNode;
|
|
1182
|
+
let cluster = this.clusterOf[hover];
|
|
1183
|
+
let connections = this.clusterSize[cluster] >= 2
|
|
1184
|
+
? this.neighbors[hover].filter(nb => this.clusterOf[nb.other] === cluster).map(nb => nb.edge)
|
|
1185
|
+
: this.renderEdges.filter(edge => edge.a === hover || edge.b === hover);
|
|
1186
|
+
|
|
1187
|
+
ctx.lineWidth = 1.5;
|
|
1188
|
+
for (let edge of connections) {
|
|
1189
|
+
ctx.strokeStyle = "hsla(40, 90%, 60%, 0.6)";
|
|
1190
|
+
ctx.beginPath();
|
|
1191
|
+
ctx.moveTo(toScreenX(this.positionsX[edge.a]), toScreenY(this.positionsY[edge.a]));
|
|
1192
|
+
ctx.lineTo(toScreenX(this.positionsX[edge.b]), toScreenY(this.positionsY[edge.b]));
|
|
1193
|
+
ctx.stroke();
|
|
1194
|
+
}
|
|
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
|
+
// Redraw the hovered node's own label last so it always sits above the connection labels.
|
|
1209
|
+
let hoverLines = this.nodes[hover].labelLines;
|
|
1210
|
+
if (hoverLines && hoverLines.length) {
|
|
1211
|
+
ctx.textAlign = "center";
|
|
1212
|
+
this.drawFullNodeLabel(ctx, hoverLines, toScreenX(this.positionsX[hover]), toScreenY(this.positionsY[hover]));
|
|
1213
|
+
}
|
|
1214
|
+
this.drawHoverCard(ctx);
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
drawHoverCard(ctx: CanvasRenderingContext2D) {
|
|
1218
|
+
if (this.hoverNode === undefined) return;
|
|
1219
|
+
let node = this.nodes[this.hoverNode];
|
|
1220
|
+
let lines = [node.label || node.id];
|
|
1221
|
+
if (node.label) lines.push(node.id);
|
|
1222
|
+
if (node.latitude !== undefined && node.longitude !== undefined) {
|
|
1223
|
+
lines.push(`${node.latitude.toFixed(3)}, ${node.longitude.toFixed(3)}`);
|
|
1224
|
+
}
|
|
1225
|
+
lines = lines.filter(Boolean);
|
|
1226
|
+
ctx.font = "12px sans-serif";
|
|
1227
|
+
ctx.textAlign = "left";
|
|
1228
|
+
ctx.textBaseline = "middle";
|
|
1229
|
+
let width = Math.max(...lines.map(l => ctx.measureText(l).width)) + 16;
|
|
1230
|
+
let lineHeight = 16;
|
|
1231
|
+
let height = lines.length * lineHeight + 8;
|
|
1232
|
+
let x = this.mouseX + 14;
|
|
1233
|
+
let y = this.mouseY + 14;
|
|
1234
|
+
if (x + width > this.viewWidth) x = this.mouseX - 14 - width;
|
|
1235
|
+
if (y + height > this.viewHeight) y = this.mouseY - 14 - height;
|
|
1236
|
+
ctx.fillStyle = "hsla(0, 0%, 4%, 0.92)";
|
|
1237
|
+
ctx.fillRect(x, y, width, height);
|
|
1238
|
+
ctx.strokeStyle = "hsl(200, 60%, 45%)";
|
|
1239
|
+
ctx.lineWidth = 1;
|
|
1240
|
+
ctx.strokeRect(x, y, width, height);
|
|
1241
|
+
for (let [i, line] of lines.entries()) {
|
|
1242
|
+
ctx.fillStyle = i === 0 ? "hsl(40, 90%, 75%)" : "hsl(0, 0%, 82%)";
|
|
1243
|
+
ctx.fillText(line, x + 8, y + 8 + i * lineHeight + lineHeight / 2);
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
// Draw only when something can change: while the layout is still settling, or after an interaction. Once
|
|
1248
|
+
// settled and idle, no frames are scheduled at all, so the tab uses no CPU.
|
|
1249
|
+
scheduleFrame() {
|
|
1250
|
+
if (this.frameScheduled || !this.canvas) return;
|
|
1251
|
+
this.frameScheduled = true;
|
|
1252
|
+
this.rafId = requestAnimationFrame(this.frame);
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
frame = () => {
|
|
1256
|
+
this.frameScheduled = false;
|
|
1257
|
+
this.rafId = 0;
|
|
1258
|
+
this.step();
|
|
1259
|
+
this.draw();
|
|
1260
|
+
if (!this.settled) this.scheduleFrame();
|
|
1261
|
+
};
|
|
1262
|
+
|
|
1263
|
+
onWheel = (e: WheelEvent) => {
|
|
1264
|
+
e.preventDefault();
|
|
1265
|
+
let worldX = this.centroidX + (e.offsetX - this.viewWidth / 2 - this.panX) / this.viewScale;
|
|
1266
|
+
let worldY = this.centroidY + (e.offsetY - this.viewHeight / 2 - this.panY) / this.viewScale;
|
|
1267
|
+
let factor = e.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP;
|
|
1268
|
+
let newScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, this.viewScale * factor));
|
|
1269
|
+
this.viewScale = newScale;
|
|
1270
|
+
this.panX = e.offsetX - this.viewWidth / 2 - (worldX - this.centroidX) * newScale;
|
|
1271
|
+
this.panY = e.offsetY - this.viewHeight / 2 - (worldY - this.centroidY) * newScale;
|
|
1272
|
+
this.userAdjustedView = true;
|
|
1273
|
+
this.scheduleFrame();
|
|
1274
|
+
};
|
|
1275
|
+
|
|
1276
|
+
onMouseDown = (e: MouseEvent) => {
|
|
1277
|
+
if (e.button !== 1) return;
|
|
1278
|
+
e.preventDefault();
|
|
1279
|
+
this.isPanning = true;
|
|
1280
|
+
this.lastPointerX = e.clientX;
|
|
1281
|
+
this.lastPointerY = e.clientY;
|
|
1282
|
+
};
|
|
1283
|
+
|
|
1284
|
+
onMouseMove = (e: MouseEvent) => {
|
|
1285
|
+
if (this.isPanning) {
|
|
1286
|
+
this.panX += e.clientX - this.lastPointerX;
|
|
1287
|
+
this.panY += e.clientY - this.lastPointerY;
|
|
1288
|
+
this.lastPointerX = e.clientX;
|
|
1289
|
+
this.lastPointerY = e.clientY;
|
|
1290
|
+
this.userAdjustedView = true;
|
|
1291
|
+
this.scheduleFrame();
|
|
1292
|
+
return;
|
|
1293
|
+
}
|
|
1294
|
+
let canvas = this.canvas;
|
|
1295
|
+
if (!canvas) return;
|
|
1296
|
+
let rect = canvas.getBoundingClientRect();
|
|
1297
|
+
let mx = e.clientX - rect.left;
|
|
1298
|
+
let my = e.clientY - rect.top;
|
|
1299
|
+
this.mouseX = mx;
|
|
1300
|
+
this.mouseY = my;
|
|
1301
|
+
// No hit radius: the hovered node is just whichever node is closest to the cursor, so you can explore by
|
|
1302
|
+
// moving the mouse around without having to land exactly on a node.
|
|
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;
|
|
1320
|
+
this.scheduleFrame();
|
|
1321
|
+
};
|
|
1322
|
+
|
|
1323
|
+
// Is the cursor inside node i's cluster hull (or, for a lone node, close to the node itself)?
|
|
1324
|
+
cursorInCluster(i: number, mx: number, my: number) {
|
|
1325
|
+
let scale = this.viewScale;
|
|
1326
|
+
let c = this.clusterOf[i];
|
|
1327
|
+
let centroids = this.hoverCentroids;
|
|
1328
|
+
if (this.clusterSize[c] >= 2 && centroids) {
|
|
1329
|
+
let scx = this.viewWidth / 2 + (centroids.cx[c] - this.centroidX) * scale + this.panX;
|
|
1330
|
+
let scy = this.viewHeight / 2 + (centroids.cy[c] - this.centroidY) * scale + this.panY;
|
|
1331
|
+
let r = centroids.radius[c] * scale;
|
|
1332
|
+
return (mx - scx) ** 2 + (my - scy) ** 2 <= r * r;
|
|
1333
|
+
}
|
|
1334
|
+
let sx = this.viewWidth / 2 + (this.positionsX[i] - this.centroidX) * scale + this.panX;
|
|
1335
|
+
let sy = this.viewHeight / 2 + (this.positionsY[i] - this.centroidY) * scale + this.panY;
|
|
1336
|
+
let r = this.nodeRadius(i) + 8;
|
|
1337
|
+
return (mx - sx) ** 2 + (my - sy) ** 2 <= r * r;
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
onMouseLeave = () => {
|
|
1341
|
+
this.hoverNode = undefined;
|
|
1342
|
+
this.scheduleFrame();
|
|
1343
|
+
};
|
|
1344
|
+
|
|
1345
|
+
onMouseUp = (e: MouseEvent) => {
|
|
1346
|
+
if (e.button !== 1) return;
|
|
1347
|
+
this.isPanning = false;
|
|
1348
|
+
};
|
|
1349
|
+
|
|
1350
|
+
componentWillUnmount() {
|
|
1351
|
+
this.mountCanvas(undefined);
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
mountCanvas(elem: HTMLCanvasElement | undefined) {
|
|
1355
|
+
if (this.rafId) {
|
|
1356
|
+
cancelAnimationFrame(this.rafId);
|
|
1357
|
+
this.rafId = 0;
|
|
1358
|
+
}
|
|
1359
|
+
this.frameScheduled = false;
|
|
1360
|
+
if (this.canvas) {
|
|
1361
|
+
this.canvas.removeEventListener("wheel", this.onWheel);
|
|
1362
|
+
this.canvas.removeEventListener("mousedown", this.onMouseDown);
|
|
1363
|
+
this.canvas.removeEventListener("mouseleave", this.onMouseLeave);
|
|
1364
|
+
window.removeEventListener("mousemove", this.onMouseMove);
|
|
1365
|
+
window.removeEventListener("mouseup", this.onMouseUp);
|
|
1366
|
+
}
|
|
1367
|
+
this.canvas = elem;
|
|
1368
|
+
if (elem) {
|
|
1369
|
+
elem.addEventListener("wheel", this.onWheel, { passive: false });
|
|
1370
|
+
elem.addEventListener("mousedown", this.onMouseDown);
|
|
1371
|
+
elem.addEventListener("mouseleave", this.onMouseLeave);
|
|
1372
|
+
window.addEventListener("mousemove", this.onMouseMove);
|
|
1373
|
+
window.addEventListener("mouseup", this.onMouseUp);
|
|
1374
|
+
this.scheduleFrame();
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
render() {
|
|
1379
|
+
this.formatWeight = this.props.formatWeight;
|
|
1380
|
+
this.clusterSummary = this.props.clusterSummary;
|
|
1381
|
+
// Rebuild from scratch whenever the data set changes (e.g. a new node's latencies arrive progressively).
|
|
1382
|
+
let sig = `${this.props.nodes.length}:${this.props.links.length}`;
|
|
1383
|
+
let weightSig = 0;
|
|
1384
|
+
for (let node of this.props.nodes) {
|
|
1385
|
+
weightSig += node.weight || 0;
|
|
1386
|
+
}
|
|
1387
|
+
for (let link of this.props.links) {
|
|
1388
|
+
weightSig += link.weight || 0;
|
|
1389
|
+
}
|
|
1390
|
+
if (sig !== this.builtSig || geoParam.value !== this.builtGeo) {
|
|
1391
|
+
// Switching layout mode (geographic vs solved) changes the whole coordinate space, so re-fit the view.
|
|
1392
|
+
if (geoParam.value !== this.builtGeo) this.userAdjustedView = false;
|
|
1393
|
+
this.builtSig = sig;
|
|
1394
|
+
this.builtWeightSig = weightSig;
|
|
1395
|
+
this.build();
|
|
1396
|
+
} else if (weightSig !== this.builtWeightSig) {
|
|
1397
|
+
// Traffic weights changed but the node/link set didn't — just refresh sizing, no re-layout.
|
|
1398
|
+
this.builtWeightSig = weightSig;
|
|
1399
|
+
this.computeWeights();
|
|
1400
|
+
} else if (Math.max(1, Math.floor(minClusterSizeParam.value)) !== this.builtMinClusterSize || Math.max(1, clusterFactorParam.value) !== this.builtClusterFactor || interExponentParam.value !== this.builtInterExponent) {
|
|
1401
|
+
this.clusterNodes();
|
|
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).
|
|
1405
|
+
this.applyLayout();
|
|
1406
|
+
} else if (Math.max(1, Math.floor(intraConnectionsParam.value)) !== this.builtIntraConnections) {
|
|
1407
|
+
this.computeRenderEdges();
|
|
1408
|
+
}
|
|
1409
|
+
this.scheduleFrame();
|
|
1410
|
+
|
|
1411
|
+
return <div className={css.relative.fillBoth.overflowHidden}>
|
|
1412
|
+
<canvas
|
|
1413
|
+
ref={elem => this.mountCanvas(elem ?? undefined)}
|
|
1414
|
+
className={css.absolute.pos(0, 0).fillBoth}
|
|
1415
|
+
/>
|
|
1416
|
+
<div className={css.vbox(10).pad2(12).absolute.pos(0, 0).width(260).zIndex(2).hsla(0, 0, 8, 0.9).colorhsl(0, 0, 85)}>
|
|
1417
|
+
<div
|
|
1418
|
+
className={css.hbox(6).fillWidth.button.colorhsl(0, 0, 85).fontWeight("bold")}
|
|
1419
|
+
onMouseDown={() => configOpenParam.value = !configOpenParam.value}
|
|
1420
|
+
>
|
|
1421
|
+
<span>{configOpenParam.value ? "▾" : "▸"}</span>
|
|
1422
|
+
<span>Configuration</span>
|
|
1423
|
+
<span className={css.marginAuto.colorhsl(0, 0, 60).fontWeight("normal")}>
|
|
1424
|
+
{this.nodes.length} nodes · {this.clusterCount} clusters
|
|
1425
|
+
</span>
|
|
1426
|
+
</div>
|
|
1427
|
+
{configOpenParam.value && <>
|
|
1428
|
+
<InputLabelURL label="Connections in cluster" url={intraConnectionsParam} integer />
|
|
1429
|
+
<InputLabelURL label="Connections between clusters" url={interConnectionsParam} integer />
|
|
1430
|
+
<InputLabelURL label="Cluster factor" url={clusterFactorParam} number />
|
|
1431
|
+
<InputLabelURL label="Cluster distance exponent" url={interExponentParam} number />
|
|
1432
|
+
<InputLabelURL label="Cluster spacing" url={interSpacingParam} number />
|
|
1433
|
+
<InputLabelURL label="Min cluster size" url={minClusterSizeParam} integer />
|
|
1434
|
+
<InputLabelURL label="Label proximity px" url={labelProximityParam} number />
|
|
1435
|
+
<InputLabelURL label="Show line latencies" url={showLatenciesParam} checkbox />
|
|
1436
|
+
<InputLabelURL label="Geographic (lat/lon)" url={geoParam} checkbox />
|
|
1437
|
+
<div className={css.colorhsl(0, 0, 60)}>
|
|
1438
|
+
{this.nodes.length} nodes · {this.clusterCount} clusters · {this.renderEdges.length} lines
|
|
1439
|
+
</div>
|
|
1440
|
+
<Button onClick={() => { this.userAdjustedView = false; this.applyLayout(); }}>
|
|
1441
|
+
Reheat Layout
|
|
1442
|
+
</Button>
|
|
1443
|
+
<Button onClick={() => console.log("LatencyGraph data:\n" + JSON.stringify({ nodes: this.props.nodes, links: this.props.links }, undefined, 2))}>
|
|
1444
|
+
Log graph data
|
|
1445
|
+
</Button>
|
|
1446
|
+
</>}
|
|
1447
|
+
</div>
|
|
1448
|
+
</div>;
|
|
1449
|
+
}
|
|
1450
|
+
}
|