querysub 0.519.0 → 0.520.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -25,6 +25,7 @@ export type NodeDataTraffic = { sent: number; received: number; };
|
|
|
25
25
|
export type TrafficStats = {
|
|
26
26
|
functionsExecuted: number;
|
|
27
27
|
pathValuesSent: number;
|
|
28
|
+
pathValuesReceived: number;
|
|
28
29
|
querysubCalls: number;
|
|
29
30
|
// Bytes to/from each known network node, plus one aggregate for everything outside the node list.
|
|
30
31
|
perNode: { [nodeId: string]: NodeDataTraffic };
|
|
@@ -41,6 +42,7 @@ function oldestBucket() {
|
|
|
41
42
|
// bucket => count
|
|
42
43
|
let functionsExecuted = new Map<number, number>();
|
|
43
44
|
let pathValuesSent = new Map<number, number>();
|
|
45
|
+
let pathValuesReceived = new Map<number, number>();
|
|
44
46
|
let querysubCalls = new Map<number, number>();
|
|
45
47
|
// raw connection nodeId => (bucket => bytes)
|
|
46
48
|
let bytesSent = new Map<string, Map<number, number>>();
|
|
@@ -66,6 +68,10 @@ export function recordPathValuesSent(count: number) {
|
|
|
66
68
|
if (count <= 0) return;
|
|
67
69
|
addSimple(pathValuesSent, count);
|
|
68
70
|
}
|
|
71
|
+
export function recordPathValuesReceived(count: number) {
|
|
72
|
+
if (count <= 0) return;
|
|
73
|
+
addSimple(pathValuesReceived, count);
|
|
74
|
+
}
|
|
69
75
|
export function recordQuerysubCall() {
|
|
70
76
|
addSimple(querysubCalls, 1);
|
|
71
77
|
}
|
|
@@ -131,6 +137,7 @@ export function getTrafficStats(): TrafficStats {
|
|
|
131
137
|
return {
|
|
132
138
|
functionsExecuted: sumRecent(functionsExecuted),
|
|
133
139
|
pathValuesSent: sumRecent(pathValuesSent),
|
|
140
|
+
pathValuesReceived: sumRecent(pathValuesReceived),
|
|
134
141
|
querysubCalls: sumRecent(querysubCalls),
|
|
135
142
|
perNode: data.perNode,
|
|
136
143
|
outside: data.outside,
|
|
@@ -12,7 +12,7 @@ import { debugNodeId, debugNodeThread } from "../-c-identity/IdentityController"
|
|
|
12
12
|
import { getSlowdown, isDiskAudit, getDomain } from "../config";
|
|
13
13
|
import { decodeNodeId } from "sliftutils/misc/https/certs";
|
|
14
14
|
import { areNodeIdsEqual, isOwnNodeId } from "../-f-node-discovery/NodeDiscovery";
|
|
15
|
-
import { recordPathValuesSent } from "../-f-node-discovery/TrafficTracking";
|
|
15
|
+
import { recordPathValuesSent, recordPathValuesReceived } from "../-f-node-discovery/TrafficTracking";
|
|
16
16
|
import { getNodeIdIP } from "socket-function/src/nodeCache";
|
|
17
17
|
import { authorityLookup } from "./AuthorityLookup";
|
|
18
18
|
import { timeoutToError } from "../errors";
|
|
@@ -148,6 +148,7 @@ export class PathValueControllerBase {
|
|
|
148
148
|
if (valueBuffers) {
|
|
149
149
|
values = await pathValueSerializer.deserialize(valueBuffers);
|
|
150
150
|
ActionsHistory.OnRead(values);
|
|
151
|
+
recordPathValuesReceived(values.length);
|
|
151
152
|
}
|
|
152
153
|
|
|
153
154
|
if (initialCreation) {
|
|
@@ -160,13 +160,10 @@ function nodeLabelLines(config: {
|
|
|
160
160
|
let thread = (parts?.threadId || "?").slice(0, ID_CHARS);
|
|
161
161
|
let machine = (parts?.machineId || nodeId).slice(0, ID_CHARS);
|
|
162
162
|
let lines: LatencyGraphLabelLine[] = [{ text: `${thread}:${parts?.port ?? "?"}` }];
|
|
163
|
-
// The last segment of the entry-point path is a good human name
|
|
163
|
+
// The last segment of the entry-point path is a good human name; shown on one line with the server (machine) id.
|
|
164
164
|
let entryPoint = info?.entryPoint || runner?.entryPoint;
|
|
165
165
|
let name = entryPoint ? entryPoint.split(/[\\/]/).filter(Boolean).at(-1) : undefined;
|
|
166
|
-
|
|
167
|
-
lines.push({ text: name });
|
|
168
|
-
}
|
|
169
|
-
lines.push({ text: machine });
|
|
166
|
+
lines.push({ text: name ? `${name} ${machine}` : machine });
|
|
170
167
|
// Total data this node sent / received, and the percent of it that went outside our known node list.
|
|
171
168
|
if (traffic) {
|
|
172
169
|
let totals = nodeTotalTraffic(traffic);
|
|
@@ -176,26 +173,28 @@ function nodeLabelLines(config: {
|
|
|
176
173
|
lines.push({ text: `↑${formatNumber(totals.sent)}B ↓${formatNumber(totals.received)}B ${outsidePct}%⊘` });
|
|
177
174
|
}
|
|
178
175
|
}
|
|
176
|
+
// All function-runner info on one purple line: networks, shard ranges, calls, private.
|
|
179
177
|
if (runner) {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
lines.push({ text: network, color: FUNCTION_COLOR });
|
|
183
|
-
}
|
|
178
|
+
let fnParts: string[] = [];
|
|
179
|
+
if (runner.networks.length) fnParts.push(runner.networks.join(" "));
|
|
184
180
|
for (let shard of runner.shards) {
|
|
185
|
-
|
|
186
|
-
}
|
|
187
|
-
lines.push({ text: `${formatNumber(traffic?.functionsExecuted ?? 0)} calls`, color: FUNCTION_COLOR });
|
|
188
|
-
if (!runner.isPublic) {
|
|
189
|
-
lines.push({ text: "PRIVATE" });
|
|
181
|
+
fnParts.push(`fn ${shard.shardRange.startFraction.toFixed(3)}-${shard.shardRange.endFraction.toFixed(3)}`);
|
|
190
182
|
}
|
|
183
|
+
fnParts.push(`${formatNumber(traffic?.functionsExecuted ?? 0)} calls`);
|
|
184
|
+
if (!runner.isPublic) fnParts.push("PRIVATE");
|
|
185
|
+
lines.push({ text: fnParts.join(" "), color: FUNCTION_COLOR });
|
|
191
186
|
}
|
|
192
|
-
//
|
|
187
|
+
// All path-value info on one blue line: shard range (only if real) and values sent.
|
|
193
188
|
let spec = info?.spec;
|
|
189
|
+
let pvParts: string[] = [];
|
|
194
190
|
if (spec && spec.routeStart >= 0 && spec.routeEnd >= 0) {
|
|
195
|
-
|
|
191
|
+
pvParts.push(`pv ${spec.routeStart.toFixed(3)}-${spec.routeEnd.toFixed(3)}`);
|
|
196
192
|
}
|
|
197
|
-
if (traffic
|
|
198
|
-
|
|
193
|
+
if (traffic && (traffic.pathValuesSent || traffic.pathValuesReceived)) {
|
|
194
|
+
pvParts.push(`↑${formatNumber(traffic.pathValuesSent)} ↓${formatNumber(traffic.pathValuesReceived)} values`);
|
|
195
|
+
}
|
|
196
|
+
if (pvParts.length) {
|
|
197
|
+
lines.push({ text: pvParts.join(" "), color: PATHVALUE_COLOR });
|
|
199
198
|
}
|
|
200
199
|
// Querysub activity: how many function calls were submitted to this node's QuerysubController.
|
|
201
200
|
if (traffic?.querysubCalls) {
|
|
@@ -267,10 +266,37 @@ class LatencyGraphSection extends qreact.Component<{ nodeIds: string[]; infos: N
|
|
|
267
266
|
}
|
|
268
267
|
}
|
|
269
268
|
|
|
269
|
+
// Per-cluster summary: sum the same per-node metrics across the cluster's members.
|
|
270
|
+
let clusterSummary = (memberIds: string[]): LatencyGraphLabelLine[] => {
|
|
271
|
+
let sent = 0;
|
|
272
|
+
let received = 0;
|
|
273
|
+
let calls = 0;
|
|
274
|
+
let addCalls = 0;
|
|
275
|
+
let dataSent = 0;
|
|
276
|
+
let dataReceived = 0;
|
|
277
|
+
for (let id of memberIds) {
|
|
278
|
+
let traffic = trafficMaps.get(id);
|
|
279
|
+
if (!traffic) continue;
|
|
280
|
+
sent += traffic.pathValuesSent;
|
|
281
|
+
received += traffic.pathValuesReceived;
|
|
282
|
+
calls += traffic.functionsExecuted;
|
|
283
|
+
addCalls += traffic.querysubCalls;
|
|
284
|
+
let totals = nodeTotalTraffic(traffic);
|
|
285
|
+
dataSent += totals.sent;
|
|
286
|
+
dataReceived += totals.received;
|
|
287
|
+
}
|
|
288
|
+
let summary: LatencyGraphLabelLine[] = [];
|
|
289
|
+
summary.push({ text: `↑${formatNumber(dataSent)}B ↓${formatNumber(dataReceived)}B` });
|
|
290
|
+
summary.push({ text: `↑${formatNumber(sent)} ↓${formatNumber(received)} values`, color: PATHVALUE_COLOR });
|
|
291
|
+
summary.push({ text: `${formatNumber(calls)} calls`, color: FUNCTION_COLOR });
|
|
292
|
+
summary.push({ text: `${formatNumber(addCalls)} addCalls`, color: QUERYSUB_COLOR });
|
|
293
|
+
return summary;
|
|
294
|
+
};
|
|
295
|
+
|
|
270
296
|
return <div className={css.vbox(8).fillWidth}>
|
|
271
297
|
<h2 className={css.margin(0)}>Latency Graph ({respondedSet.size}/{nodeIds.length} nodes reported)</h2>
|
|
272
298
|
<div className={css.relative.fillWidth.height(LATENCY_GRAPH_HEIGHT_PX).bord2(0, 0, 85)}>
|
|
273
|
-
<LatencyGraph nodes={nodes} links={links} formatWeight={weight => formatNumber(weight) + "B"} />
|
|
299
|
+
<LatencyGraph nodes={nodes} links={links} formatWeight={weight => formatNumber(weight) + "B"} clusterSummary={clusterSummary} />
|
|
274
300
|
</div>
|
|
275
301
|
</div>;
|
|
276
302
|
}
|
|
@@ -14,13 +14,17 @@ export type LatencyGraphProps = {
|
|
|
14
14
|
links: LatencyGraphLink[];
|
|
15
15
|
// Formats a link's traffic weight for the label under the latency (e.g. bytes). No label if omitted.
|
|
16
16
|
formatWeight?: (weight: number) => string;
|
|
17
|
+
// Extra lines shown under a cluster's latency stats (e.g. summed traffic), given the cluster's member node ids.
|
|
18
|
+
clusterSummary?: (memberIds: string[]) => LatencyGraphLabelLine[];
|
|
17
19
|
};
|
|
18
20
|
|
|
19
|
-
const
|
|
21
|
+
const DEFAULT_INTRA_CONNECTIONS = 3;
|
|
22
|
+
const DEFAULT_INTER_CONNECTIONS = 5;
|
|
20
23
|
const DEFAULT_MIN_CLUSTER_SIZE = 2;
|
|
21
24
|
const DEFAULT_CLUSTER_FACTOR = 2;
|
|
22
25
|
const DEFAULT_INTER_EXPONENT = 1.5;
|
|
23
|
-
const
|
|
26
|
+
const intraConnectionsParam = new URLParam("lgIntraConnections", DEFAULT_INTRA_CONNECTIONS);
|
|
27
|
+
const interConnectionsParam = new URLParam("lgInterConnections", DEFAULT_INTER_CONNECTIONS);
|
|
24
28
|
const minClusterSizeParam = new URLParam("lgMinClusterSize", DEFAULT_MIN_CLUSTER_SIZE);
|
|
25
29
|
// Exponent on the (normalized) inter-cluster latency when mapping to layout distance, so far clusters push apart harder.
|
|
26
30
|
const interExponentParam = new URLParam("lgInterExponent", DEFAULT_INTER_EXPONENT);
|
|
@@ -28,16 +32,27 @@ const interExponentParam = new URLParam("lgInterExponent", DEFAULT_INTER_EXPONEN
|
|
|
28
32
|
// Relative (per-node) so a mutually-isolated close pair clusters even if it's far from everything else.
|
|
29
33
|
const clusterFactorParam = new URLParam("lgClusterFactor", DEFAULT_CLUSTER_FACTOR);
|
|
30
34
|
const geoParam = new URLParam("lgGeo", false);
|
|
31
|
-
const showLatenciesParam = new URLParam("lgShowLatencies",
|
|
35
|
+
const showLatenciesParam = new URLParam("lgShowLatencies", false);
|
|
36
|
+
// The config panel is collapsed by default so it doesn't eat the canvas; clicking the header expands it.
|
|
37
|
+
const configOpenParam = new URLParam("lgConfigOpen", false);
|
|
38
|
+
// A node shows its full label only when its nearest neighbor is at least this many SCREEN pixels away (or it's hovered);
|
|
39
|
+
// crowded nodes fall back to a single title line so the view doesn't turn into a jumble. Zooming in spreads nodes past
|
|
40
|
+
// the threshold, revealing their full labels.
|
|
41
|
+
const labelProximityParam = new URLParam("lgLabelProximityPx", 100);
|
|
32
42
|
// Pixels per degree of latitude/longitude in geographic mode (equirectangular projection).
|
|
33
43
|
const GEO_SCALE = 6;
|
|
34
44
|
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
const
|
|
39
|
-
const
|
|
40
|
-
const
|
|
45
|
+
// Inter-cluster gap is measured in units of CLUSTER RADIUS (not absolute px), so it survives auto-fit: `spacing` is
|
|
46
|
+
// literally "edge gap = this many cluster radii" for the closest pair, up to `spacing * INTER_SPREAD` for the farthest.
|
|
47
|
+
// The exponent shapes the in-between. Range is normalized to [closest, farthest] latency so it can't blow up.
|
|
48
|
+
const INTER_SPREAD = 2.5;
|
|
49
|
+
const DEFAULT_INTER_SPACING = -1.5;
|
|
50
|
+
const interSpacingParam = new URLParam("lgInterSpacing", DEFAULT_INTER_SPACING);
|
|
51
|
+
// Intra-cluster layout is solved SEPARATELY and normalized per cluster, so a cluster's on-screen size doesn't depend
|
|
52
|
+
// on its absolute latency (a 3ms-tight cluster and a 30ms one render at similar sizes). A pair at the cluster's median
|
|
53
|
+
// intra-latency sits INTRA_UNIT_PX apart; relative distances within the cluster stay proportional to latency.
|
|
54
|
+
const INTRA_UNIT_PX = 60;
|
|
55
|
+
const INTRA_MIN_PX = 8;
|
|
41
56
|
|
|
42
57
|
// Stress majorization (SMACOF): each iteration is a Guttman transform that never increases stress.
|
|
43
58
|
const COOLING = 0.996;
|
|
@@ -48,14 +63,12 @@ const MAX_POWER_ITERS = 200;
|
|
|
48
63
|
const POWER_EPS = 1e-9;
|
|
49
64
|
|
|
50
65
|
const MIN_OPACITY = 0.05;
|
|
51
|
-
const HOVER_EXTRA_OPACITY = 0.4;
|
|
52
66
|
const NODE_RADIUS = 6;
|
|
53
67
|
// Traffic-driven sizing (normalized to the busiest node/pair). Circles grow with node weight; lines with pair weight.
|
|
54
68
|
const NODE_WEIGHT_MULT = 2.5;
|
|
55
69
|
const LINE_BASE_WIDTH = 1.5;
|
|
56
70
|
const LINE_MIN_WIDTH = 1;
|
|
57
71
|
const LINE_MAX_WIDTH = 7;
|
|
58
|
-
const HOVER_PAD = 6;
|
|
59
72
|
const HULL_PADDING = 26;
|
|
60
73
|
const ZOOM_STEP = 1.1;
|
|
61
74
|
const MIN_SCALE = 0.05;
|
|
@@ -64,6 +77,8 @@ const MAX_SCALE = 12;
|
|
|
64
77
|
// stack upward above the nodes (and are drawn at a fixed screen size regardless of zoom).
|
|
65
78
|
const FIT_PAD = 60;
|
|
66
79
|
const FIT_PAD_TOP = 160;
|
|
80
|
+
// Auto-fit leaves a bit of extra breathing room by not zooming all the way in to fill the padded box.
|
|
81
|
+
const FIT_ZOOM = 0.7;
|
|
67
82
|
|
|
68
83
|
type Edge = { a: number; b: number; latency: number; };
|
|
69
84
|
type SolveEdge = { a: number; b: number; target: number; weight: number; };
|
|
@@ -80,12 +95,18 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
80
95
|
maxNodeWeight = 0;
|
|
81
96
|
pairWeight = new Map<number, number>();
|
|
82
97
|
maxPairWeight = 0;
|
|
98
|
+
// Summed node-pair traffic between each cluster pair, for the inter-cluster line widths and labels.
|
|
99
|
+
clusterPairWeight = new Map<number, number>();
|
|
100
|
+
maxClusterPairWeight = 0;
|
|
83
101
|
// Cached from props in render() so draw() (a rAF callback, not synced) never reads this.props.
|
|
84
102
|
formatWeight: ((weight: number) => string) | undefined = undefined;
|
|
103
|
+
clusterSummary: ((memberIds: string[]) => LatencyGraphLabelLine[]) | undefined = undefined;
|
|
104
|
+
// Latest cluster centroids/radii from draw(), for the pointer-handler hull test.
|
|
105
|
+
hoverCentroids: Centroids | undefined = undefined;
|
|
85
106
|
builtSig = "";
|
|
86
107
|
builtWeightSig = 0;
|
|
87
108
|
builtGeo = false;
|
|
88
|
-
|
|
109
|
+
builtIntraConnections = 0;
|
|
89
110
|
builtMinClusterSize = 0;
|
|
90
111
|
builtClusterFactor = 0;
|
|
91
112
|
builtInterExponent = 0;
|
|
@@ -102,7 +123,6 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
102
123
|
renderEdges: Edge[] = [];
|
|
103
124
|
renderEdgeSet = new Set<Edge>();
|
|
104
125
|
neighbors: { other: number; edge: Edge; }[][] = [];
|
|
105
|
-
hoverExtras: Edge[] = [];
|
|
106
126
|
interPairMap = new Map<number, ClusterPair>();
|
|
107
127
|
renderInterPairs: ClusterPair[] = [];
|
|
108
128
|
interMin = 0;
|
|
@@ -110,6 +130,10 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
110
130
|
renderMin = 0;
|
|
111
131
|
renderMax = 1;
|
|
112
132
|
|
|
133
|
+
// Per-cluster radius (from the settled intra layout), so inter targets can be edge-to-edge.
|
|
134
|
+
clusterRadius = new Float64Array(0);
|
|
135
|
+
builtInterSpacing = 0;
|
|
136
|
+
|
|
113
137
|
// position = clusterCenter[cluster] + local[node]; the two levels are solved independently.
|
|
114
138
|
clusterCenterX = new Float64Array(0);
|
|
115
139
|
clusterCenterY = new Float64Array(0);
|
|
@@ -185,6 +209,30 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
185
209
|
}
|
|
186
210
|
}
|
|
187
211
|
|
|
212
|
+
// Roll node-pair traffic up into cluster-pair totals (cross-cluster only). Recomputed each frame so it always
|
|
213
|
+
// matches the current clustering; cheap since it iterates the (already small) weighted-pair map.
|
|
214
|
+
computeClusterPairWeights() {
|
|
215
|
+
let n = this.nodes.length;
|
|
216
|
+
let k = this.clusterCount;
|
|
217
|
+
this.clusterPairWeight = new Map();
|
|
218
|
+
this.maxClusterPairWeight = 0;
|
|
219
|
+
for (let [pk, weight] of this.pairWeight) {
|
|
220
|
+
let ca = this.clusterOf[Math.floor(pk / n)];
|
|
221
|
+
let cb = this.clusterOf[pk % n];
|
|
222
|
+
if (ca === cb) continue;
|
|
223
|
+
let ck = Math.min(ca, cb) * k + Math.max(ca, cb);
|
|
224
|
+
let combined = (this.clusterPairWeight.get(ck) || 0) + weight;
|
|
225
|
+
this.clusterPairWeight.set(ck, combined);
|
|
226
|
+
this.maxClusterPairWeight = Math.max(this.maxClusterPairWeight, combined);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
interLineWidth(p: number, q: number) {
|
|
231
|
+
if (this.maxClusterPairWeight <= 0) return 2;
|
|
232
|
+
let w = this.clusterPairWeight.get(Math.min(p, q) * this.clusterCount + Math.max(p, q)) || 0;
|
|
233
|
+
return LINE_MIN_WIDTH + (w / this.maxClusterPairWeight) * (LINE_MAX_WIDTH - LINE_MIN_WIDTH);
|
|
234
|
+
}
|
|
235
|
+
|
|
188
236
|
// Combine both directions of each pair into a single undirected edge (mean latency), and build the full matrix.
|
|
189
237
|
ingestLinks() {
|
|
190
238
|
let n = this.nodes.length;
|
|
@@ -366,26 +414,26 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
366
414
|
let medians = stats.map(s => s.median);
|
|
367
415
|
this.interMin = medians.length ? Math.min(...medians) : 0;
|
|
368
416
|
this.interMax = medians.length ? Math.max(...medians) : 1;
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
});
|
|
417
|
+
// Targets depend on cluster radii, which aren't known until the intra layout is solved, so they're filled in by
|
|
418
|
+
// hierarchicalInit; here we just record the latency stats.
|
|
419
|
+
this.interPairs = stats.map(s => ({ p: s.p, q: s.q, min: s.min, median: s.median, max: s.max, target: 1, weight: 1 }));
|
|
373
420
|
this.interPairMap = new Map();
|
|
374
421
|
for (let pair of this.interPairs) {
|
|
375
422
|
this.interPairMap.set(pair.p * k + pair.q, pair);
|
|
376
423
|
}
|
|
377
424
|
|
|
425
|
+
// Intra edges use the per-cluster normalized scale, so cluster size is decoupled from absolute latency.
|
|
378
426
|
this.intraSolveEdges = [];
|
|
379
427
|
for (let e of this.edges) {
|
|
380
428
|
if (this.clusterOf[e.a] !== this.clusterOf[e.b]) continue;
|
|
381
|
-
let target = this.
|
|
429
|
+
let target = this.intraTarget(e.latency, this.clusterIntraMedian[this.clusterOf[e.a]]);
|
|
382
430
|
this.intraSolveEdges.push({ a: e.a, b: e.b, target, weight: 1 / (target * target) });
|
|
383
431
|
}
|
|
384
432
|
}
|
|
385
433
|
|
|
386
434
|
computeRenderEdges() {
|
|
387
|
-
let count = Math.max(1, Math.floor(
|
|
388
|
-
this.
|
|
435
|
+
let count = Math.max(1, Math.floor(intraConnectionsParam.value));
|
|
436
|
+
this.builtIntraConnections = count;
|
|
389
437
|
let n = this.nodes.length;
|
|
390
438
|
let neighbors: { other: number; edge: Edge; }[][] = Array.from({ length: n }, () => []);
|
|
391
439
|
for (let edge of this.edges) {
|
|
@@ -415,17 +463,6 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
415
463
|
return candidates;
|
|
416
464
|
}
|
|
417
465
|
|
|
418
|
-
// While hovering, the node reveals up to 2x its connections; the extra tier (ranks N..2N) is drawn faintly.
|
|
419
|
-
computeHoverExtras() {
|
|
420
|
-
if (this.hoverNode === undefined) return [];
|
|
421
|
-
let candidates = this.nodeCandidates(this.hoverNode);
|
|
422
|
-
let extras: Edge[] = [];
|
|
423
|
-
for (let j = this.builtConnections; j < Math.min(this.builtConnections * 2, candidates.length); j++) {
|
|
424
|
-
if (!this.renderEdgeSet.has(candidates[j].edge)) extras.push(candidates[j].edge);
|
|
425
|
-
}
|
|
426
|
-
return extras;
|
|
427
|
-
}
|
|
428
|
-
|
|
429
466
|
// Each real cluster links to its N nearest clusters by on-screen distance — but only ones it has clear line of
|
|
430
467
|
// sight to: if the segment would pass through another cluster's hull, the link is hidden to keep the chart clean.
|
|
431
468
|
computeRenderInterPairs(centroids: Centroids) {
|
|
@@ -442,7 +479,8 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
442
479
|
return { o, d: dx * dx + dy * dy };
|
|
443
480
|
});
|
|
444
481
|
sort(others, entry => entry.d);
|
|
445
|
-
|
|
482
|
+
let interCount = Math.max(1, Math.floor(interConnectionsParam.value));
|
|
483
|
+
for (let j = 0; j < Math.min(interCount, others.length); j++) {
|
|
446
484
|
let pair = this.interPairMap.get(Math.min(c, others[j].o) * k + Math.max(c, others[j].o));
|
|
447
485
|
if (pair) selected.add(pair);
|
|
448
486
|
}
|
|
@@ -470,10 +508,28 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
470
508
|
return true;
|
|
471
509
|
}
|
|
472
510
|
|
|
473
|
-
|
|
511
|
+
// Target centroid distance = both radii + an edge gap measured in cluster-radius units (so the gap:size ratio, and
|
|
512
|
+
// therefore the visible spacing, is what `spacing` controls — auto-fit can't cancel it). The gap grows from
|
|
513
|
+
// `spacing` radii (closest pair) to `spacing * INTER_SPREAD` radii (farthest), shaped by the exponent.
|
|
514
|
+
interCentroidTarget(p: number, q: number, latency: number) {
|
|
515
|
+
let rp = this.clusterRadius[p] || 0;
|
|
516
|
+
let rq = this.clusterRadius[q] || 0;
|
|
517
|
+
let span = this.interMax - this.interMin;
|
|
518
|
+
let norm = span > 0 && Number.isFinite(latency) ? (latency - this.interMin) / span : 0;
|
|
519
|
+
norm = Math.max(0, Math.min(1, Number.isFinite(norm) ? norm : 0));
|
|
520
|
+
let shaped = 1 + Math.pow(norm, interExponentParam.value) * (INTER_SPREAD - 1);
|
|
521
|
+
let gap = (rp + rq) / 2 * interSpacingParam.value * shaped;
|
|
522
|
+
let target = rp + rq + gap;
|
|
523
|
+
return Number.isFinite(target) && target > 0 ? target : rp + rq + 1;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Per-cluster normalized intra distance: a pair at the cluster's median latency sits ~INTRA_UNIT_PX apart,
|
|
527
|
+
// regardless of how tight or loose the cluster's absolute latencies are. Relative distances stay proportional.
|
|
528
|
+
intraTarget(latency: number, clusterMedian: number) {
|
|
529
|
+
let ref = clusterMedian > 0 ? clusterMedian : 1;
|
|
474
530
|
let l = Number.isFinite(latency) ? Math.max(0, latency) : 0;
|
|
475
|
-
let px =
|
|
476
|
-
return Number.isFinite(px) ? px :
|
|
531
|
+
let px = INTRA_MIN_PX + INTRA_UNIT_PX * (l / ref);
|
|
532
|
+
return Number.isFinite(px) ? px : INTRA_UNIT_PX;
|
|
477
533
|
}
|
|
478
534
|
|
|
479
535
|
opacityFor(latency: number, min: number, max: number) {
|
|
@@ -505,31 +561,45 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
505
561
|
this.settled = true;
|
|
506
562
|
}
|
|
507
563
|
|
|
508
|
-
//
|
|
564
|
+
// Members placed by each cluster's own local MDS first (so we know cluster radii), then cluster centers by
|
|
565
|
+
// inter-cluster MDS using edge-to-edge (radius-inclusive) targets.
|
|
509
566
|
hierarchicalInit() {
|
|
510
567
|
let n = this.nodes.length;
|
|
511
568
|
let k = this.clusterCount;
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
return d * d;
|
|
515
|
-
});
|
|
516
|
-
this.clusterCenterX = centers.x;
|
|
517
|
-
this.clusterCenterY = centers.y;
|
|
569
|
+
this.builtInterSpacing = interSpacingParam.value;
|
|
570
|
+
this.clusterRadius = new Float64Array(k);
|
|
518
571
|
|
|
519
572
|
for (let c = 0; c < k; c++) {
|
|
520
573
|
let members: number[] = [];
|
|
521
574
|
for (let i = 0; i < n; i++) {
|
|
522
575
|
if (this.clusterOf[i] === c) members.push(i);
|
|
523
576
|
}
|
|
577
|
+
let median = this.clusterIntraMedian[c];
|
|
524
578
|
let local = this.mds(members.length, (a, b) => {
|
|
525
|
-
let d = this.
|
|
579
|
+
let d = this.intraTarget(this.latencyMatrix[members[a] * n + members[b]], median);
|
|
526
580
|
return d * d;
|
|
527
581
|
});
|
|
582
|
+
let radius = 0;
|
|
528
583
|
for (let [mi, node] of members.entries()) {
|
|
529
584
|
this.localX[node] = local.x[mi];
|
|
530
585
|
this.localY[node] = local.y[mi];
|
|
586
|
+
radius = Math.max(radius, Math.sqrt(local.x[mi] * local.x[mi] + local.y[mi] * local.y[mi]));
|
|
531
587
|
}
|
|
588
|
+
this.clusterRadius[c] = radius + HULL_PADDING;
|
|
532
589
|
}
|
|
590
|
+
|
|
591
|
+
// Now that radii are known, fill in the inter-cluster targets (edge-to-edge).
|
|
592
|
+
for (let pair of this.interPairs) {
|
|
593
|
+
pair.target = this.interCentroidTarget(pair.p, pair.q, pair.median);
|
|
594
|
+
pair.weight = 1 / (pair.target * pair.target);
|
|
595
|
+
}
|
|
596
|
+
let centers = this.mds(k, (p, q) => {
|
|
597
|
+
let d = this.interCentroidTarget(p, q, this.clusterDist[p * k + q]);
|
|
598
|
+
return d * d;
|
|
599
|
+
});
|
|
600
|
+
this.clusterCenterX = centers.x;
|
|
601
|
+
this.clusterCenterY = centers.y;
|
|
602
|
+
|
|
533
603
|
this.composePositions();
|
|
534
604
|
}
|
|
535
605
|
|
|
@@ -847,7 +917,7 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
847
917
|
}
|
|
848
918
|
let worldW = maxX - minX || 1;
|
|
849
919
|
let worldH = maxY - minY || 1;
|
|
850
|
-
let fit = Math.min((width - 2 * FIT_PAD) / worldW, (height - FIT_PAD_TOP - FIT_PAD) / worldH);
|
|
920
|
+
let fit = Math.min((width - 2 * FIT_PAD) / worldW, (height - FIT_PAD_TOP - FIT_PAD) / worldH) * FIT_ZOOM;
|
|
851
921
|
// Only apply the fit if it's sane — never let a NaN/degenerate value nuke the whole view.
|
|
852
922
|
if (Number.isFinite(fit) && fit > 0 && Number.isFinite(minX) && Number.isFinite(minY)) {
|
|
853
923
|
this.viewScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, fit));
|
|
@@ -863,8 +933,9 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
863
933
|
let toScreenY = (wy: number) => height / 2 + (wy - centroidY) * scale + this.panY;
|
|
864
934
|
|
|
865
935
|
let centroids = this.clusterCentroids();
|
|
936
|
+
this.hoverCentroids = centroids;
|
|
866
937
|
this.computeRenderInterPairs(centroids);
|
|
867
|
-
this.
|
|
938
|
+
this.computeClusterPairWeights();
|
|
868
939
|
|
|
869
940
|
// Inter-cluster lines run centroid-to-centroid, then the opaque hulls paint over the parts inside any
|
|
870
941
|
// cluster — so a line only shows in the gaps and reads as entering one cluster edge and exiting another.
|
|
@@ -875,7 +946,7 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
875
946
|
if (showLatenciesParam.value) {
|
|
876
947
|
this.drawLineLatencies(ctx, toScreenX, toScreenY, centroids);
|
|
877
948
|
}
|
|
878
|
-
this.drawNodes(ctx, toScreenX, toScreenY);
|
|
949
|
+
this.drawNodes(ctx, toScreenX, toScreenY, centroids);
|
|
879
950
|
this.drawInterLabels(ctx, toScreenX, toScreenY, centroids);
|
|
880
951
|
this.drawClusterLabels(ctx, toScreenX, toScreenY, centroids);
|
|
881
952
|
this.drawHover(ctx, toScreenX, toScreenY, centroids);
|
|
@@ -901,8 +972,8 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
901
972
|
}
|
|
902
973
|
|
|
903
974
|
drawInterLines(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number, centroids: Centroids) {
|
|
904
|
-
ctx.lineWidth = 2;
|
|
905
975
|
for (let pair of this.renderInterPairs) {
|
|
976
|
+
ctx.lineWidth = this.interLineWidth(pair.p, pair.q);
|
|
906
977
|
ctx.strokeStyle = `hsla(0, 0%, 100%, ${this.opacityFor(pair.median, this.interMin, this.interMax)})`;
|
|
907
978
|
ctx.beginPath();
|
|
908
979
|
ctx.moveTo(toScreenX(centroids.cx[pair.p]), toScreenY(centroids.cy[pair.p]));
|
|
@@ -918,12 +989,27 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
918
989
|
for (let pair of this.renderInterPairs) {
|
|
919
990
|
let midX = (toScreenX(centroids.cx[pair.p]) + toScreenX(centroids.cx[pair.q])) / 2;
|
|
920
991
|
let midY = (toScreenY(centroids.cy[pair.p]) + toScreenY(centroids.cy[pair.q])) / 2;
|
|
992
|
+
|
|
993
|
+
let traffic = this.clusterPairWeight.get(Math.min(pair.p, pair.q) * this.clusterCount + Math.max(pair.p, pair.q)) || 0;
|
|
994
|
+
let trafficLabel = this.formatWeight && traffic > 0 ? this.formatWeight(traffic) : undefined;
|
|
995
|
+
let latencyY = trafficLabel ? midY - 8 : midY;
|
|
996
|
+
|
|
997
|
+
ctx.font = "11px sans-serif";
|
|
921
998
|
let label = `${formatTime(pair.min)} · ${formatTime(pair.median)} · ${formatTime(pair.max)}`;
|
|
922
999
|
let textWidth = ctx.measureText(label).width;
|
|
923
1000
|
ctx.fillStyle = "hsla(0, 0%, 0%, 0.6)";
|
|
924
|
-
ctx.fillRect(midX - textWidth / 2 - 3,
|
|
1001
|
+
ctx.fillRect(midX - textWidth / 2 - 3, latencyY - 8, textWidth + 6, 16);
|
|
925
1002
|
ctx.fillStyle = "hsl(0, 0%, 82%)";
|
|
926
|
-
ctx.fillText(label, midX,
|
|
1003
|
+
ctx.fillText(label, midX, latencyY);
|
|
1004
|
+
|
|
1005
|
+
if (trafficLabel) {
|
|
1006
|
+
ctx.font = "10px sans-serif";
|
|
1007
|
+
let trafficWidth = ctx.measureText(trafficLabel).width;
|
|
1008
|
+
ctx.fillStyle = "hsla(0, 0%, 0%, 0.6)";
|
|
1009
|
+
ctx.fillRect(midX - trafficWidth / 2 - 3, midY + 8 - 7, trafficWidth + 6, 14);
|
|
1010
|
+
ctx.fillStyle = "hsl(0, 0%, 70%)";
|
|
1011
|
+
ctx.fillText(trafficLabel, midX, midY + 8);
|
|
1012
|
+
}
|
|
927
1013
|
}
|
|
928
1014
|
}
|
|
929
1015
|
|
|
@@ -932,9 +1018,6 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
932
1018
|
for (let edge of this.renderEdges) {
|
|
933
1019
|
this.strokeNodeConnection(ctx, toScreenX, toScreenY, centroids, edge, 1);
|
|
934
1020
|
}
|
|
935
|
-
for (let edge of this.hoverExtras) {
|
|
936
|
-
this.strokeNodeConnection(ctx, toScreenX, toScreenY, centroids, edge, HOVER_EXTRA_OPACITY);
|
|
937
|
-
}
|
|
938
1021
|
}
|
|
939
1022
|
|
|
940
1023
|
strokeNodeConnection(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number, centroids: Centroids, edge: Edge, opacityScale: number) {
|
|
@@ -982,34 +1065,31 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
982
1065
|
}
|
|
983
1066
|
}
|
|
984
1067
|
|
|
985
|
-
drawNodes(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number) {
|
|
1068
|
+
drawNodes(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number, centroids: Centroids) {
|
|
986
1069
|
ctx.textBaseline = "middle";
|
|
987
1070
|
ctx.textAlign = "center";
|
|
1071
|
+
let proximityPx = labelProximityParam.value;
|
|
1072
|
+
let screenX = this.nodes.map((_, i) => toScreenX(this.positionsX[i]));
|
|
1073
|
+
let screenY = this.nodes.map((_, i) => toScreenY(this.positionsY[i]));
|
|
988
1074
|
for (let i = 0; i < this.nodes.length; i++) {
|
|
989
|
-
let x =
|
|
990
|
-
let y =
|
|
1075
|
+
let x = screenX[i];
|
|
1076
|
+
let y = screenY[i];
|
|
991
1077
|
let radius = this.nodeRadius(i);
|
|
992
1078
|
ctx.fillStyle = i === this.hoverNode ? "hsl(40, 90%, 60%)" : `hsl(${this.clusterHue(this.clusterOf[i])}, 65%, 55%)`;
|
|
993
1079
|
ctx.beginPath();
|
|
994
1080
|
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
|
995
1081
|
ctx.fill();
|
|
996
1082
|
|
|
1083
|
+
// The full label only shows when this node is visually isolated (nearest neighbor on screen is far enough
|
|
1084
|
+
// away), or it's hovered; otherwise it collapses to a single title line above the dot to avoid a jumble.
|
|
1085
|
+
let full = i === this.hoverNode || this.nearestScreenDist(i, screenX, screenY) >= proximityPx;
|
|
997
1086
|
let lines = this.nodes[i].labelLines;
|
|
998
|
-
if (lines && lines.length) {
|
|
999
|
-
|
|
1000
|
-
let baseY = y - radius - 8;
|
|
1001
|
-
for (let [li, line] of lines.entries()) {
|
|
1002
|
-
let ly = baseY - (lines.length - 1 - li) * 13;
|
|
1003
|
-
ctx.font = li === 0 ? "bold 11px sans-serif" : "10px sans-serif";
|
|
1004
|
-
ctx.lineWidth = 3;
|
|
1005
|
-
ctx.strokeStyle = "hsl(0, 0%, 6%)";
|
|
1006
|
-
ctx.strokeText(line.text, x, ly);
|
|
1007
|
-
ctx.fillStyle = line.color || (li === 0 ? "hsl(0, 0%, 92%)" : "hsl(0, 0%, 62%)");
|
|
1008
|
-
ctx.fillText(line.text, x, ly);
|
|
1009
|
-
}
|
|
1087
|
+
if (full && lines && lines.length) {
|
|
1088
|
+
this.drawFullNodeLabel(ctx, lines, x, y);
|
|
1010
1089
|
continue;
|
|
1011
1090
|
}
|
|
1012
|
-
|
|
1091
|
+
// Crowded (or unlabeled): just the short title, above the dot.
|
|
1092
|
+
let short = lines && lines.length ? lines[0].text : this.nodeShort(this.nodes[i]);
|
|
1013
1093
|
ctx.font = "bold 11px sans-serif";
|
|
1014
1094
|
ctx.lineWidth = 3;
|
|
1015
1095
|
ctx.strokeStyle = "hsl(0, 0%, 6%)";
|
|
@@ -1019,12 +1099,41 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
1019
1099
|
}
|
|
1020
1100
|
}
|
|
1021
1101
|
|
|
1102
|
+
// Stack the full label centered ON the node so a spread-out node reads its details in place.
|
|
1103
|
+
drawFullNodeLabel(ctx: CanvasRenderingContext2D, lines: LatencyGraphLabelLine[], x: number, y: number) {
|
|
1104
|
+
let baseY = y - (lines.length - 1) / 2 * 13;
|
|
1105
|
+
for (let [li, line] of lines.entries()) {
|
|
1106
|
+
let ly = baseY + li * 13;
|
|
1107
|
+
ctx.font = li === 0 ? "bold 11px sans-serif" : "10px sans-serif";
|
|
1108
|
+
ctx.lineWidth = 3;
|
|
1109
|
+
ctx.strokeStyle = "hsl(0, 0%, 6%)";
|
|
1110
|
+
ctx.strokeText(line.text, x, ly);
|
|
1111
|
+
ctx.fillStyle = line.color || (li === 0 ? "hsl(0, 0%, 92%)" : "hsl(0, 0%, 62%)");
|
|
1112
|
+
ctx.fillText(line.text, x, ly);
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
// Screen-pixel distance from node i to its closest other node (used to decide whether there's room for a full label).
|
|
1117
|
+
nearestScreenDist(i: number, screenX: number[], screenY: number[]) {
|
|
1118
|
+
let best = Infinity;
|
|
1119
|
+
for (let j = 0; j < screenX.length; j++) {
|
|
1120
|
+
if (j === i) continue;
|
|
1121
|
+
let dx = screenX[j] - screenX[i];
|
|
1122
|
+
let dy = screenY[j] - screenY[i];
|
|
1123
|
+
let d = Math.sqrt(dx * dx + dy * dy);
|
|
1124
|
+
if (d < best) best = d;
|
|
1125
|
+
}
|
|
1126
|
+
return best;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1022
1129
|
drawClusterLabels(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number, centroids: Centroids) {
|
|
1023
1130
|
let k = this.clusterCount;
|
|
1024
1131
|
let rep = new Int32Array(k).fill(-1);
|
|
1025
1132
|
let repDist = new Float64Array(k).fill(Infinity);
|
|
1133
|
+
let members: string[][] = Array.from({ length: k }, () => []);
|
|
1026
1134
|
for (let i = 0; i < this.nodes.length; i++) {
|
|
1027
1135
|
let c = this.clusterOf[i];
|
|
1136
|
+
members[c].push(this.nodes[i].id);
|
|
1028
1137
|
let dx = this.positionsX[i] - centroids.cx[c];
|
|
1029
1138
|
let dy = this.positionsY[i] - centroids.cy[c];
|
|
1030
1139
|
let d = dx * dx + dy * dy;
|
|
@@ -1037,41 +1146,56 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
1037
1146
|
ctx.textBaseline = "middle";
|
|
1038
1147
|
for (let c = 0; c < k; c++) {
|
|
1039
1148
|
if (rep[c] < 0) continue;
|
|
1040
|
-
let
|
|
1041
|
-
|
|
1042
|
-
|
|
1149
|
+
let hue = this.clusterHue(c);
|
|
1150
|
+
|
|
1151
|
+
// Build the whole label block, then stack it above the hull (count at top, last summary line just above it).
|
|
1152
|
+
let lines: { text: string; color: string; font: string; }[] = [];
|
|
1153
|
+
lines.push({ text: `${centroids.count[c]} nodes`, color: `hsl(${hue}, 70%, 70%)`, font: "bold 12px sans-serif" });
|
|
1154
|
+
if (centroids.count[c] >= 2) {
|
|
1155
|
+
lines.push({ text: `${formatTime(this.clusterIntraMin[c])} · ${formatTime(this.clusterIntraMedian[c])} · ${formatTime(this.clusterIntraMax[c])}`, color: `hsl(${hue}, 40%, 78%)`, font: "11px sans-serif" });
|
|
1156
|
+
for (let summary of this.clusterSummary?.(members[c]) ?? []) {
|
|
1157
|
+
lines.push({ text: summary.text, color: summary.color || "hsl(0, 0%, 78%)", font: "10px sans-serif" });
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1043
1161
|
let x = toScreenX(centroids.cx[c]);
|
|
1044
1162
|
let hullTop = toScreenY(centroids.cy[c]) - centroids.radius[c] * this.viewScale;
|
|
1045
|
-
let
|
|
1046
|
-
let
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
ctx.font = "11px sans-serif";
|
|
1057
|
-
let statsWidth = ctx.measureText(stats).width;
|
|
1058
|
-
let statsY = hullTop - 5;
|
|
1059
|
-
ctx.fillStyle = "hsla(0, 0%, 0%, 0.6)";
|
|
1060
|
-
ctx.fillRect(x - statsWidth / 2 - 3, statsY - 8, statsWidth + 6, 16);
|
|
1061
|
-
ctx.fillStyle = `hsl(${this.clusterHue(c)}, 40%, 78%)`;
|
|
1062
|
-
ctx.fillText(stats, x, statsY);
|
|
1163
|
+
let lineHeight = 14;
|
|
1164
|
+
let bottomY = hullTop - 6;
|
|
1165
|
+
for (let [i, line] of lines.entries()) {
|
|
1166
|
+
let ly = bottomY - (lines.length - 1 - i) * lineHeight;
|
|
1167
|
+
ctx.font = line.font;
|
|
1168
|
+
let textWidth = ctx.measureText(line.text).width;
|
|
1169
|
+
ctx.fillStyle = "hsla(0, 0%, 0%, 0.6)";
|
|
1170
|
+
ctx.fillRect(x - textWidth / 2 - 3, ly - 8, textWidth + 6, 15);
|
|
1171
|
+
ctx.fillStyle = line.color;
|
|
1172
|
+
ctx.fillText(line.text, x, ly);
|
|
1173
|
+
}
|
|
1063
1174
|
}
|
|
1064
1175
|
}
|
|
1065
1176
|
|
|
1066
|
-
// Hovering
|
|
1177
|
+
// Hovering (the nearest node to the cursor) reveals ALL of that node's connections to other nodes in its cluster
|
|
1178
|
+
// — drawn as highlighted lines with latency labels, even the ones not shown by default.
|
|
1067
1179
|
drawHover(ctx: CanvasRenderingContext2D, toScreenX: (wx: number) => number, toScreenY: (wy: number) => number, centroids: Centroids) {
|
|
1068
1180
|
if (this.hoverNode === undefined) return;
|
|
1181
|
+
let hover = this.hoverNode;
|
|
1182
|
+
let cluster = this.clusterOf[hover];
|
|
1183
|
+
let connections = this.clusterSize[cluster] >= 2
|
|
1184
|
+
? this.neighbors[hover].filter(nb => this.clusterOf[nb.other] === cluster).map(nb => nb.edge)
|
|
1185
|
+
: this.renderEdges.filter(edge => edge.a === hover || edge.b === hover);
|
|
1186
|
+
|
|
1187
|
+
ctx.lineWidth = 1.5;
|
|
1188
|
+
for (let edge of connections) {
|
|
1189
|
+
ctx.strokeStyle = "hsla(40, 90%, 60%, 0.6)";
|
|
1190
|
+
ctx.beginPath();
|
|
1191
|
+
ctx.moveTo(toScreenX(this.positionsX[edge.a]), toScreenY(this.positionsY[edge.a]));
|
|
1192
|
+
ctx.lineTo(toScreenX(this.positionsX[edge.b]), toScreenY(this.positionsY[edge.b]));
|
|
1193
|
+
ctx.stroke();
|
|
1194
|
+
}
|
|
1069
1195
|
ctx.font = "12px sans-serif";
|
|
1070
1196
|
ctx.textAlign = "center";
|
|
1071
1197
|
ctx.textBaseline = "middle";
|
|
1072
|
-
|
|
1073
|
-
for (let edge of [...incident, ...this.hoverExtras]) {
|
|
1074
|
-
if (!this.segmentClearOfClusters(this.positionsX[edge.a], this.positionsY[edge.a], this.positionsX[edge.b], this.positionsY[edge.b], centroids, this.clusterOf[edge.a], this.clusterOf[edge.b])) continue;
|
|
1198
|
+
for (let edge of connections) {
|
|
1075
1199
|
let midX = (toScreenX(this.positionsX[edge.a]) + toScreenX(this.positionsX[edge.b])) / 2;
|
|
1076
1200
|
let midY = (toScreenY(this.positionsY[edge.a]) + toScreenY(this.positionsY[edge.b])) / 2;
|
|
1077
1201
|
let label = formatTime(edge.latency);
|
|
@@ -1081,6 +1205,12 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
1081
1205
|
ctx.fillStyle = "hsl(40, 90%, 75%)";
|
|
1082
1206
|
ctx.fillText(label, midX, midY);
|
|
1083
1207
|
}
|
|
1208
|
+
// Redraw the hovered node's own label last so it always sits above the connection labels.
|
|
1209
|
+
let hoverLines = this.nodes[hover].labelLines;
|
|
1210
|
+
if (hoverLines && hoverLines.length) {
|
|
1211
|
+
ctx.textAlign = "center";
|
|
1212
|
+
this.drawFullNodeLabel(ctx, hoverLines, toScreenX(this.positionsX[hover]), toScreenY(this.positionsY[hover]));
|
|
1213
|
+
}
|
|
1084
1214
|
this.drawHoverCard(ctx);
|
|
1085
1215
|
}
|
|
1086
1216
|
|
|
@@ -1168,6 +1298,8 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
1168
1298
|
let my = e.clientY - rect.top;
|
|
1169
1299
|
this.mouseX = mx;
|
|
1170
1300
|
this.mouseY = my;
|
|
1301
|
+
// No hit radius: the hovered node is just whichever node is closest to the cursor, so you can explore by
|
|
1302
|
+
// moving the mouse around without having to land exactly on a node.
|
|
1171
1303
|
let scale = this.viewScale;
|
|
1172
1304
|
let bestDist = Infinity;
|
|
1173
1305
|
let best: number | undefined = undefined;
|
|
@@ -1175,18 +1307,36 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
1175
1307
|
let sx = this.viewWidth / 2 + (this.positionsX[i] - this.centroidX) * scale + this.panX;
|
|
1176
1308
|
let sy = this.viewHeight / 2 + (this.positionsY[i] - this.centroidY) * scale + this.panY;
|
|
1177
1309
|
let dist = (sx - mx) ** 2 + (sy - my) ** 2;
|
|
1178
|
-
|
|
1179
|
-
if (dist <= hitDist && dist < bestDist) {
|
|
1310
|
+
if (dist < bestDist) {
|
|
1180
1311
|
bestDist = dist;
|
|
1181
1312
|
best = i;
|
|
1182
1313
|
}
|
|
1183
1314
|
}
|
|
1184
|
-
|
|
1315
|
+
// Only highlight if the cursor is actually inside that node's cluster circle (or on a lone node).
|
|
1316
|
+
if (best !== undefined && !this.cursorInCluster(best, mx, my)) {
|
|
1317
|
+
best = undefined;
|
|
1318
|
+
}
|
|
1185
1319
|
this.hoverNode = best;
|
|
1186
|
-
|
|
1187
|
-
if (best !== undefined || prev !== undefined) this.scheduleFrame();
|
|
1320
|
+
this.scheduleFrame();
|
|
1188
1321
|
};
|
|
1189
1322
|
|
|
1323
|
+
// Is the cursor inside node i's cluster hull (or, for a lone node, close to the node itself)?
|
|
1324
|
+
cursorInCluster(i: number, mx: number, my: number) {
|
|
1325
|
+
let scale = this.viewScale;
|
|
1326
|
+
let c = this.clusterOf[i];
|
|
1327
|
+
let centroids = this.hoverCentroids;
|
|
1328
|
+
if (this.clusterSize[c] >= 2 && centroids) {
|
|
1329
|
+
let scx = this.viewWidth / 2 + (centroids.cx[c] - this.centroidX) * scale + this.panX;
|
|
1330
|
+
let scy = this.viewHeight / 2 + (centroids.cy[c] - this.centroidY) * scale + this.panY;
|
|
1331
|
+
let r = centroids.radius[c] * scale;
|
|
1332
|
+
return (mx - scx) ** 2 + (my - scy) ** 2 <= r * r;
|
|
1333
|
+
}
|
|
1334
|
+
let sx = this.viewWidth / 2 + (this.positionsX[i] - this.centroidX) * scale + this.panX;
|
|
1335
|
+
let sy = this.viewHeight / 2 + (this.positionsY[i] - this.centroidY) * scale + this.panY;
|
|
1336
|
+
let r = this.nodeRadius(i) + 8;
|
|
1337
|
+
return (mx - sx) ** 2 + (my - sy) ** 2 <= r * r;
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1190
1340
|
onMouseLeave = () => {
|
|
1191
1341
|
this.hoverNode = undefined;
|
|
1192
1342
|
this.scheduleFrame();
|
|
@@ -1227,6 +1377,7 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
1227
1377
|
|
|
1228
1378
|
render() {
|
|
1229
1379
|
this.formatWeight = this.props.formatWeight;
|
|
1380
|
+
this.clusterSummary = this.props.clusterSummary;
|
|
1230
1381
|
// Rebuild from scratch whenever the data set changes (e.g. a new node's latencies arrive progressively).
|
|
1231
1382
|
let sig = `${this.props.nodes.length}:${this.props.links.length}`;
|
|
1232
1383
|
let weightSig = 0;
|
|
@@ -1249,7 +1400,10 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
1249
1400
|
} else if (Math.max(1, Math.floor(minClusterSizeParam.value)) !== this.builtMinClusterSize || Math.max(1, clusterFactorParam.value) !== this.builtClusterFactor || interExponentParam.value !== this.builtInterExponent) {
|
|
1250
1401
|
this.clusterNodes();
|
|
1251
1402
|
this.applyLayout();
|
|
1252
|
-
} else if (
|
|
1403
|
+
} else if (interSpacingParam.value !== this.builtInterSpacing) {
|
|
1404
|
+
// Only the inter-cluster spacing changed — re-solve the layout (no re-clustering needed).
|
|
1405
|
+
this.applyLayout();
|
|
1406
|
+
} else if (Math.max(1, Math.floor(intraConnectionsParam.value)) !== this.builtIntraConnections) {
|
|
1253
1407
|
this.computeRenderEdges();
|
|
1254
1408
|
}
|
|
1255
1409
|
this.scheduleFrame();
|
|
@@ -1260,21 +1414,36 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
1260
1414
|
className={css.absolute.pos(0, 0).fillBoth}
|
|
1261
1415
|
/>
|
|
1262
1416
|
<div className={css.vbox(10).pad2(12).absolute.pos(0, 0).width(260).zIndex(2).hsla(0, 0, 8, 0.9).colorhsl(0, 0, 85)}>
|
|
1263
|
-
<
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1417
|
+
<div
|
|
1418
|
+
className={css.hbox(6).fillWidth.button.colorhsl(0, 0, 85).fontWeight("bold")}
|
|
1419
|
+
onMouseDown={() => configOpenParam.value = !configOpenParam.value}
|
|
1420
|
+
>
|
|
1421
|
+
<span>{configOpenParam.value ? "▾" : "▸"}</span>
|
|
1422
|
+
<span>Configuration</span>
|
|
1423
|
+
<span className={css.marginAuto.colorhsl(0, 0, 60).fontWeight("normal")}>
|
|
1424
|
+
{this.nodes.length} nodes · {this.clusterCount} clusters
|
|
1425
|
+
</span>
|
|
1271
1426
|
</div>
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1427
|
+
{configOpenParam.value && <>
|
|
1428
|
+
<InputLabelURL label="Connections in cluster" url={intraConnectionsParam} integer />
|
|
1429
|
+
<InputLabelURL label="Connections between clusters" url={interConnectionsParam} integer />
|
|
1430
|
+
<InputLabelURL label="Cluster factor" url={clusterFactorParam} number />
|
|
1431
|
+
<InputLabelURL label="Cluster distance exponent" url={interExponentParam} number />
|
|
1432
|
+
<InputLabelURL label="Cluster spacing" url={interSpacingParam} number />
|
|
1433
|
+
<InputLabelURL label="Min cluster size" url={minClusterSizeParam} integer />
|
|
1434
|
+
<InputLabelURL label="Label proximity px" url={labelProximityParam} number />
|
|
1435
|
+
<InputLabelURL label="Show line latencies" url={showLatenciesParam} checkbox />
|
|
1436
|
+
<InputLabelURL label="Geographic (lat/lon)" url={geoParam} checkbox />
|
|
1437
|
+
<div className={css.colorhsl(0, 0, 60)}>
|
|
1438
|
+
{this.nodes.length} nodes · {this.clusterCount} clusters · {this.renderEdges.length} lines
|
|
1439
|
+
</div>
|
|
1440
|
+
<Button onClick={() => { this.userAdjustedView = false; this.applyLayout(); }}>
|
|
1441
|
+
Reheat Layout
|
|
1442
|
+
</Button>
|
|
1443
|
+
<Button onClick={() => console.log("LatencyGraph data:\n" + JSON.stringify({ nodes: this.props.nodes, links: this.props.links }, undefined, 2))}>
|
|
1444
|
+
Log graph data
|
|
1445
|
+
</Button>
|
|
1446
|
+
</>}
|
|
1278
1447
|
</div>
|
|
1279
1448
|
</div>;
|
|
1280
1449
|
}
|