trace-viz 0.3.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/CHANGELOG.md +42 -0
- package/LICENSE +21 -0
- package/README.md +385 -0
- package/dist/adapters/index.d.ts +32 -0
- package/dist/adapters/index.d.ts.map +1 -0
- package/dist/adapters/index.js +83 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/agent.d.ts +66 -0
- package/dist/agent.d.ts.map +1 -0
- package/dist/agent.js +329 -0
- package/dist/agent.js.map +1 -0
- package/dist/atlas/atlas.d.ts +203 -0
- package/dist/atlas/atlas.d.ts.map +1 -0
- package/dist/atlas/atlas.js +1466 -0
- package/dist/atlas/atlas.js.map +1 -0
- package/dist/atlas/index.d.ts +7 -0
- package/dist/atlas/index.d.ts.map +1 -0
- package/dist/atlas/index.js +4 -0
- package/dist/atlas/index.js.map +1 -0
- package/dist/atlas/layout.d.ts +44 -0
- package/dist/atlas/layout.d.ts.map +1 -0
- package/dist/atlas/layout.js +103 -0
- package/dist/atlas/layout.js.map +1 -0
- package/dist/atlas/model.d.ts +164 -0
- package/dist/atlas/model.d.ts.map +1 -0
- package/dist/atlas/model.js +489 -0
- package/dist/atlas/model.js.map +1 -0
- package/dist/channels.d.ts +105 -0
- package/dist/channels.d.ts.map +1 -0
- package/dist/channels.js +411 -0
- package/dist/channels.js.map +1 -0
- package/dist/components.d.ts +76 -0
- package/dist/components.d.ts.map +1 -0
- package/dist/components.js +432 -0
- package/dist/components.js.map +1 -0
- package/dist/estate.d.ts +25 -0
- package/dist/estate.d.ts.map +1 -0
- package/dist/estate.js +285 -0
- package/dist/estate.js.map +1 -0
- package/dist/failure.d.ts +36 -0
- package/dist/failure.d.ts.map +1 -0
- package/dist/failure.js +82 -0
- package/dist/failure.js.map +1 -0
- package/dist/geometry.d.ts +24 -0
- package/dist/geometry.d.ts.map +1 -0
- package/dist/geometry.js +92 -0
- package/dist/geometry.js.map +1 -0
- package/dist/index.d.ts +51 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +34 -0
- package/dist/index.js.map +1 -0
- package/dist/layout.d.ts +66 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.js +152 -0
- package/dist/layout.js.map +1 -0
- package/dist/layout3d.d.ts +98 -0
- package/dist/layout3d.d.ts.map +1 -0
- package/dist/layout3d.js +350 -0
- package/dist/layout3d.js.map +1 -0
- package/dist/mesh.d.ts +23 -0
- package/dist/mesh.d.ts.map +1 -0
- package/dist/mesh.js +263 -0
- package/dist/mesh.js.map +1 -0
- package/dist/model.d.ts +94 -0
- package/dist/model.d.ts.map +1 -0
- package/dist/model.js +84 -0
- package/dist/model.js.map +1 -0
- package/dist/particles.d.ts +80 -0
- package/dist/particles.d.ts.map +1 -0
- package/dist/particles.js +118 -0
- package/dist/particles.js.map +1 -0
- package/dist/project.d.ts +113 -0
- package/dist/project.d.ts.map +1 -0
- package/dist/project.js +288 -0
- package/dist/project.js.map +1 -0
- package/dist/react/index.d.ts +22 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +69 -0
- package/dist/react/index.js.map +1 -0
- package/dist/renderer.d.ts +252 -0
- package/dist/renderer.d.ts.map +1 -0
- package/dist/renderer.js +1710 -0
- package/dist/renderer.js.map +1 -0
- package/dist/scales.d.ts +99 -0
- package/dist/scales.d.ts.map +1 -0
- package/dist/scales.js +126 -0
- package/dist/scales.js.map +1 -0
- package/dist/scene.d.ts +53 -0
- package/dist/scene.d.ts.map +1 -0
- package/dist/scene.js +44 -0
- package/dist/scene.js.map +1 -0
- package/dist/schema.d.ts +24 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +131 -0
- package/dist/schema.js.map +1 -0
- package/dist/seed.d.ts +33 -0
- package/dist/seed.d.ts.map +1 -0
- package/dist/seed.js +173 -0
- package/dist/seed.js.map +1 -0
- package/dist/theme.d.ts +35 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/theme.js +83 -0
- package/dist/theme.js.map +1 -0
- package/dist/trace-viz.esm.js +15 -0
- package/dist/trace-viz.min.js +15 -0
- package/dist/types.d.ts +92 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +9 -0
- package/dist/types.js.map +1 -0
- package/package.json +105 -0
|
@@ -0,0 +1,1466 @@
|
|
|
1
|
+
import { resolveTheme } from '../theme.js';
|
|
2
|
+
import { CATEGORICAL, ERROR_CEIL, ERROR_FLOOR } from '../channels.js';
|
|
3
|
+
import { clamp, edgeWidth, emissionRate, formatLatency, formatRps, logNorm, particleSpeed, rpsDomain } from '../scales.js';
|
|
4
|
+
import { mulberry32 } from '../particles.js';
|
|
5
|
+
import { aggregateEdges, buildAtlas, emptyState, neighbourhood, propagate, search as searchModel, trail as trailModel, visibleUnits, baseOf, } from './model.js';
|
|
6
|
+
import { layoutAtlas } from './layout.js';
|
|
7
|
+
export const LENSES = {
|
|
8
|
+
traffic: { label: 'Traffic', summary: 'ribbon width and particles by rps; async flows as packets' },
|
|
9
|
+
reliability: { label: 'Reliability', summary: 'red glow by error rate, topic lag as pipe fill, dead fraction on tiles' },
|
|
10
|
+
latency: { label: 'Latency', summary: 'ribbons heat from cool to warm as latency grows' },
|
|
11
|
+
ownership: { label: 'Ownership', summary: 'everything coloured by its top-level group' },
|
|
12
|
+
kafka: { label: 'Kafka', summary: 'only async flows: producers, topics, consumer groups, lag' },
|
|
13
|
+
blast: { label: 'Blast radius', summary: 'rigid (fail-closed) vs breakaway (fail-open); kill a service to propagate' },
|
|
14
|
+
};
|
|
15
|
+
const MORPH_MS = 520;
|
|
16
|
+
const CAMERA_MS = 460;
|
|
17
|
+
const PARTICLE_EDGES = 90;
|
|
18
|
+
const FULL_EDGES = 360;
|
|
19
|
+
export class Atlas {
|
|
20
|
+
constructor(container, options = {}) {
|
|
21
|
+
this.model = null;
|
|
22
|
+
this.state = emptyState();
|
|
23
|
+
this.units = [];
|
|
24
|
+
this.unitById = new Map();
|
|
25
|
+
this.expanded = new Set();
|
|
26
|
+
this.layoutResult = null;
|
|
27
|
+
this.edges = [];
|
|
28
|
+
this.unitOf = new Map();
|
|
29
|
+
this.internal = new Map();
|
|
30
|
+
this.domainRps = { min: 1, max: 1000 };
|
|
31
|
+
this.health = { dead: new Set(), degraded: new Set() };
|
|
32
|
+
this.trailIds = null;
|
|
33
|
+
this.trailEdges = new Set();
|
|
34
|
+
this.ego = null;
|
|
35
|
+
/** Where every shape (tile or band) is now, and where it is going. */
|
|
36
|
+
this.shapes = new Map();
|
|
37
|
+
this.shapesFrom = new Map();
|
|
38
|
+
this.shapesTo = new Map();
|
|
39
|
+
this.morphStart = 0;
|
|
40
|
+
this.morphing = false;
|
|
41
|
+
this.appearing = new Set();
|
|
42
|
+
this.camera = { x: 0, y: 0, scale: 1 };
|
|
43
|
+
this.cameraFrom = null;
|
|
44
|
+
this.cameraTo = null;
|
|
45
|
+
this.cameraStart = 0;
|
|
46
|
+
this.fitScale = 1;
|
|
47
|
+
this.views = [];
|
|
48
|
+
this.particles = new Map();
|
|
49
|
+
this.dash = 0;
|
|
50
|
+
this.width = 0;
|
|
51
|
+
this.height = 0;
|
|
52
|
+
this.dpr = 1;
|
|
53
|
+
this.raf = 0;
|
|
54
|
+
this.last = 0;
|
|
55
|
+
this.hovered = null;
|
|
56
|
+
this.pointer = null;
|
|
57
|
+
this.drag = null;
|
|
58
|
+
this.lastSemantic = 0;
|
|
59
|
+
this.groupColour = new Map();
|
|
60
|
+
this.onDown = (ev) => {
|
|
61
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
62
|
+
this.drag = { x: ev.clientX - rect.left, y: ev.clientY - rect.top, cx: this.camera.x, cy: this.camera.y, moved: false };
|
|
63
|
+
this.cameraTo = null;
|
|
64
|
+
this.canvas.setPointerCapture(ev.pointerId);
|
|
65
|
+
this.canvas.style.cursor = 'grabbing';
|
|
66
|
+
};
|
|
67
|
+
this.onMove = (ev) => {
|
|
68
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
69
|
+
const x = ev.clientX - rect.left;
|
|
70
|
+
const y = ev.clientY - rect.top;
|
|
71
|
+
this.pointer = { x, y };
|
|
72
|
+
if (this.drag) {
|
|
73
|
+
const dx = x - this.drag.x;
|
|
74
|
+
const dy = y - this.drag.y;
|
|
75
|
+
if (Math.hypot(dx, dy) > 3)
|
|
76
|
+
this.drag.moved = true;
|
|
77
|
+
if (this.drag.moved) {
|
|
78
|
+
this.camera.x = this.drag.cx - dx / this.camera.scale;
|
|
79
|
+
this.camera.y = this.drag.cy - dy / this.camera.scale;
|
|
80
|
+
this.opts.onCamera?.();
|
|
81
|
+
}
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const h = this.hit(x, y);
|
|
85
|
+
const changed = h?.id !== this.hovered?.id || h?.kind !== this.hovered?.kind;
|
|
86
|
+
this.hovered = h;
|
|
87
|
+
this.canvas.style.cursor = h ? 'pointer' : 'grab';
|
|
88
|
+
if (changed)
|
|
89
|
+
this.opts.onHover?.(h);
|
|
90
|
+
};
|
|
91
|
+
this.onUp = (ev) => {
|
|
92
|
+
const drag = this.drag;
|
|
93
|
+
this.drag = null;
|
|
94
|
+
this.canvas.style.cursor = 'grab';
|
|
95
|
+
if (!drag || drag.moved)
|
|
96
|
+
return;
|
|
97
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
98
|
+
const h = this.hit(ev.clientX - rect.left, ev.clientY - rect.top);
|
|
99
|
+
if (!h) {
|
|
100
|
+
if (this.state.focus)
|
|
101
|
+
this.focus(null);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (h.kind === 'unit') {
|
|
105
|
+
const u = this.unitById.get(h.id);
|
|
106
|
+
if (u?.kind === 'group')
|
|
107
|
+
this.expand(h.id);
|
|
108
|
+
else
|
|
109
|
+
this.focus(this.state.focus === h.id ? null : h.id);
|
|
110
|
+
}
|
|
111
|
+
else if (h.kind === 'band')
|
|
112
|
+
this.collapse(h.id);
|
|
113
|
+
else if (h.kind === 'edge')
|
|
114
|
+
this.opts.onSelect?.(h.id);
|
|
115
|
+
};
|
|
116
|
+
this.onLeave = () => {
|
|
117
|
+
this.pointer = null;
|
|
118
|
+
if (this.hovered)
|
|
119
|
+
this.opts.onHover?.(null);
|
|
120
|
+
this.hovered = null;
|
|
121
|
+
this.tooltip.style.opacity = '0';
|
|
122
|
+
};
|
|
123
|
+
this.onDbl = (ev) => {
|
|
124
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
125
|
+
const h = this.hit(ev.clientX - rect.left, ev.clientY - rect.top);
|
|
126
|
+
if (h?.kind === 'unit') {
|
|
127
|
+
const u = this.unitById.get(h.id);
|
|
128
|
+
if (u?.kind === 'group')
|
|
129
|
+
this.expand(h.id);
|
|
130
|
+
else
|
|
131
|
+
this.fit(h.id);
|
|
132
|
+
}
|
|
133
|
+
else if (h?.kind === 'band')
|
|
134
|
+
this.collapse(h.id);
|
|
135
|
+
};
|
|
136
|
+
this.onWheel = (ev) => {
|
|
137
|
+
ev.preventDefault();
|
|
138
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
139
|
+
const sx = ev.clientX - rect.left;
|
|
140
|
+
const sy = ev.clientY - rect.top;
|
|
141
|
+
if (ev.ctrlKey || ev.metaKey || Math.abs(ev.deltaY) >= Math.abs(ev.deltaX)) {
|
|
142
|
+
const factor = Math.exp(-ev.deltaY * (ev.ctrlKey ? 0.01 : 0.0022));
|
|
143
|
+
this.zoomAt(sx, sy, factor);
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
this.panBy(ev.deltaX, 0);
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
this.container = container;
|
|
150
|
+
this.opts = options;
|
|
151
|
+
this.theme = resolveTheme(options.theme ?? 'auto');
|
|
152
|
+
this.lens = options.lens ?? 'traffic';
|
|
153
|
+
this.rand = mulberry32(options.seed ?? 3);
|
|
154
|
+
if (getComputedStyle(container).position === 'static')
|
|
155
|
+
container.style.position = 'relative';
|
|
156
|
+
this.canvas = document.createElement('canvas');
|
|
157
|
+
Object.assign(this.canvas.style, { display: 'block', width: '100%', height: '100%', cursor: 'grab', touchAction: 'none' });
|
|
158
|
+
container.appendChild(this.canvas);
|
|
159
|
+
const ctx = this.canvas.getContext('2d');
|
|
160
|
+
if (!ctx)
|
|
161
|
+
throw new Error('atlas: 2d canvas unavailable');
|
|
162
|
+
this.ctx = ctx;
|
|
163
|
+
this.tooltip = document.createElement('div');
|
|
164
|
+
Object.assign(this.tooltip.style, {
|
|
165
|
+
position: 'absolute', pointerEvents: 'none', opacity: '0', transition: 'opacity 90ms ease',
|
|
166
|
+
font: '12px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace', padding: '8px 10px', borderRadius: '7px',
|
|
167
|
+
whiteSpace: 'pre', zIndex: '2', maxWidth: '360px', boxShadow: '0 6px 20px rgba(0,0,0,.28)',
|
|
168
|
+
background: this.theme.tooltipBg, color: this.theme.tooltipText,
|
|
169
|
+
});
|
|
170
|
+
container.appendChild(this.tooltip);
|
|
171
|
+
this.canvas.addEventListener('pointerdown', this.onDown);
|
|
172
|
+
this.canvas.addEventListener('pointermove', this.onMove);
|
|
173
|
+
this.canvas.addEventListener('pointerup', this.onUp);
|
|
174
|
+
this.canvas.addEventListener('pointerleave', this.onLeave);
|
|
175
|
+
this.canvas.addEventListener('wheel', this.onWheel, { passive: false });
|
|
176
|
+
this.canvas.addEventListener('dblclick', this.onDbl);
|
|
177
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
178
|
+
this.resizeObserver = new ResizeObserver(() => this.resize());
|
|
179
|
+
this.resizeObserver.observe(container);
|
|
180
|
+
}
|
|
181
|
+
this.resize();
|
|
182
|
+
this.start();
|
|
183
|
+
}
|
|
184
|
+
// --- public API -------------------------------------------------------------
|
|
185
|
+
setTable(table, options = {}) {
|
|
186
|
+
this.model = buildAtlas(table, { ...this.opts, ...options });
|
|
187
|
+
this.groupColour.clear();
|
|
188
|
+
this.model.roots.forEach((r, i) => this.groupColour.set(r, CATEGORICAL[i % CATEGORICAL.length]));
|
|
189
|
+
this.state = emptyState();
|
|
190
|
+
this.shapes.clear();
|
|
191
|
+
this.rebuild(false);
|
|
192
|
+
this.fit(undefined, false);
|
|
193
|
+
}
|
|
194
|
+
getModel() {
|
|
195
|
+
return this.model;
|
|
196
|
+
}
|
|
197
|
+
getState() {
|
|
198
|
+
return { ...this.state, expanded: [...this.state.expanded], killed: [...this.state.killed], pins: [...(this.state.pins ?? [])] };
|
|
199
|
+
}
|
|
200
|
+
setState(state, fitTo) {
|
|
201
|
+
this.state = { ...this.state, ...state, expanded: [...(state.expanded ?? this.state.expanded)], killed: [...(state.killed ?? this.state.killed)], pins: [...(state.pins ?? this.state.pins ?? [])] };
|
|
202
|
+
this.rebuild(true);
|
|
203
|
+
if (fitTo !== undefined)
|
|
204
|
+
this.fit(fitTo ?? undefined);
|
|
205
|
+
}
|
|
206
|
+
getLens() {
|
|
207
|
+
return this.lens;
|
|
208
|
+
}
|
|
209
|
+
setLens(lens) {
|
|
210
|
+
const was = this.lens;
|
|
211
|
+
this.lens = lens;
|
|
212
|
+
this.particles.clear();
|
|
213
|
+
// The Kafka lens aggregates only async flows, so a mixed ribbon shows its
|
|
214
|
+
// event share, not its rpc share.
|
|
215
|
+
if ((was === 'kafka') !== (lens === 'kafka'))
|
|
216
|
+
this.rebuild(false);
|
|
217
|
+
}
|
|
218
|
+
setTheme(theme) {
|
|
219
|
+
this.theme = resolveTheme(theme);
|
|
220
|
+
this.tooltip.style.background = this.theme.tooltipBg;
|
|
221
|
+
this.tooltip.style.color = this.theme.tooltipText;
|
|
222
|
+
}
|
|
223
|
+
expand(id, fit = true) {
|
|
224
|
+
id = baseOf(id);
|
|
225
|
+
if (!this.model?.groups.has(id))
|
|
226
|
+
return;
|
|
227
|
+
if (this.expanded.has(id) && this.state.expanded.includes(id))
|
|
228
|
+
return;
|
|
229
|
+
this.setState({ expanded: [...this.state.expanded, id] }, fit ? id : undefined);
|
|
230
|
+
}
|
|
231
|
+
collapse(id, fit = true) {
|
|
232
|
+
if (!this.model?.groups.has(id))
|
|
233
|
+
return;
|
|
234
|
+
// Collapsing a group also collapses everything under it.
|
|
235
|
+
const drop = new Set([id]);
|
|
236
|
+
for (const gid of this.state.expanded)
|
|
237
|
+
if (gid.startsWith(id + '/'))
|
|
238
|
+
drop.add(gid);
|
|
239
|
+
const expanded = this.state.expanded.filter((g) => !drop.has(g));
|
|
240
|
+
// If the focus lives in this group, it would force it open again.
|
|
241
|
+
const focus = this.state.focus && this.model.leaves.get(this.state.focus)?.path.includes(id) ? null : this.state.focus;
|
|
242
|
+
this.setState({ expanded, focus }, fit ? id : undefined);
|
|
243
|
+
}
|
|
244
|
+
toggle(id) {
|
|
245
|
+
if (this.expanded.has(id))
|
|
246
|
+
this.collapse(id);
|
|
247
|
+
else
|
|
248
|
+
this.expand(id);
|
|
249
|
+
}
|
|
250
|
+
/** Collapse one level: the deepest expanded group containing the focus, else the most recently expanded. */
|
|
251
|
+
up() {
|
|
252
|
+
if (!this.model)
|
|
253
|
+
return;
|
|
254
|
+
const focusPath = this.state.focus ? this.model.leaves.get(this.state.focus)?.path ?? [] : [];
|
|
255
|
+
if (this.state.focus) {
|
|
256
|
+
this.focus(null);
|
|
257
|
+
const deepest = [...focusPath].reverse().find((g) => this.expanded.has(g));
|
|
258
|
+
if (deepest)
|
|
259
|
+
this.fit(deepest);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const last = this.state.expanded[this.state.expanded.length - 1];
|
|
263
|
+
if (last)
|
|
264
|
+
this.collapse(last);
|
|
265
|
+
else
|
|
266
|
+
this.fit();
|
|
267
|
+
}
|
|
268
|
+
collapseAll() {
|
|
269
|
+
this.setState({ expanded: [], focus: null }, null);
|
|
270
|
+
}
|
|
271
|
+
focus(id, hops) {
|
|
272
|
+
if (id && !this.model?.leaves.has(id)) {
|
|
273
|
+
if (this.model?.groups.has(id))
|
|
274
|
+
this.expand(id);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
this.setState({ focus: id, hops: hops ?? this.state.hops }, id ? `ego:${id}` : undefined);
|
|
278
|
+
this.opts.onSelect?.(id);
|
|
279
|
+
}
|
|
280
|
+
setHops(hops) {
|
|
281
|
+
this.setState({ hops: Math.max(0, Math.min(4, hops)) }, this.state.focus ? `ego:${this.state.focus}` : undefined);
|
|
282
|
+
}
|
|
283
|
+
kill(ids) {
|
|
284
|
+
const killed = [...new Set([...this.state.killed, ...ids])];
|
|
285
|
+
this.setState({ killed });
|
|
286
|
+
}
|
|
287
|
+
revive() {
|
|
288
|
+
this.setState({ killed: [] });
|
|
289
|
+
}
|
|
290
|
+
setTrail(from, to) {
|
|
291
|
+
if (!this.model)
|
|
292
|
+
return null;
|
|
293
|
+
const path = trailModel(this.model, from, to);
|
|
294
|
+
this.trailIds = path ? new Set(path) : null;
|
|
295
|
+
this.trailEdges = new Set();
|
|
296
|
+
if (path) {
|
|
297
|
+
for (let i = 0; i < path.length - 1; i++)
|
|
298
|
+
this.trailEdges.add(`${path[i]}>${path[i + 1]}`);
|
|
299
|
+
// The path must be visible: pin it, so its groups open just enough.
|
|
300
|
+
this.setState({ pins: path, focus: null }, `trail`);
|
|
301
|
+
}
|
|
302
|
+
return path;
|
|
303
|
+
}
|
|
304
|
+
clearTrail() {
|
|
305
|
+
const had = this.trailIds;
|
|
306
|
+
this.trailIds = null;
|
|
307
|
+
this.trailEdges = new Set();
|
|
308
|
+
if (had)
|
|
309
|
+
this.setState({ pins: [] });
|
|
310
|
+
}
|
|
311
|
+
search(query, limit = 12) {
|
|
312
|
+
return this.model ? searchModel(this.model, query, limit) : [];
|
|
313
|
+
}
|
|
314
|
+
/** Expand down to a unit and centre it. Works for leaves and groups. */
|
|
315
|
+
goTo(id) {
|
|
316
|
+
if (!this.model)
|
|
317
|
+
return;
|
|
318
|
+
if (this.model.leaves.has(id)) {
|
|
319
|
+
const expanded = new Set(this.state.expanded);
|
|
320
|
+
for (const g of this.model.leaves.get(id).path)
|
|
321
|
+
expanded.add(g);
|
|
322
|
+
this.setState({ expanded: [...expanded], focus: id }, `ego:${id}`);
|
|
323
|
+
this.opts.onSelect?.(id);
|
|
324
|
+
}
|
|
325
|
+
else if (this.model.groups.has(id)) {
|
|
326
|
+
this.expand(id);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
/** Where the viewer is: the expanded chain that contains the focus, or the deepest expanded chain. */
|
|
330
|
+
breadcrumb() {
|
|
331
|
+
if (!this.model)
|
|
332
|
+
return [];
|
|
333
|
+
const chain = [];
|
|
334
|
+
if (this.state.focus && this.model.leaves.has(this.state.focus)) {
|
|
335
|
+
chain.push(...this.model.leaves.get(this.state.focus).path, this.state.focus);
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
const last = this.state.expanded[this.state.expanded.length - 1];
|
|
339
|
+
if (last) {
|
|
340
|
+
let g = this.model.groups.get(last);
|
|
341
|
+
const p = [];
|
|
342
|
+
while (g) {
|
|
343
|
+
p.unshift(g.id);
|
|
344
|
+
g = g.parent ? this.model.groups.get(g.parent) : undefined;
|
|
345
|
+
}
|
|
346
|
+
chain.push(...p);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return chain.map((id) => {
|
|
350
|
+
const g = this.model.groups.get(id);
|
|
351
|
+
return g
|
|
352
|
+
? { id, label: g.label, kind: this.model.hierarchy[g.level] ?? 'group' }
|
|
353
|
+
: { id, label: id, kind: this.model.leaves.get(id)?.kind ?? 'leaf' };
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
inspect(id) {
|
|
357
|
+
const m = this.model;
|
|
358
|
+
if (!m)
|
|
359
|
+
return null;
|
|
360
|
+
id = baseOf(id);
|
|
361
|
+
const leaf = m.leaves.get(id);
|
|
362
|
+
const group = m.groups.get(id);
|
|
363
|
+
if (!leaf && !group)
|
|
364
|
+
return null;
|
|
365
|
+
const members = leaf ? [id] : group.all;
|
|
366
|
+
const memberSet = new Set(members);
|
|
367
|
+
const up = new Map();
|
|
368
|
+
const down = new Map();
|
|
369
|
+
let inRps = 0;
|
|
370
|
+
let outRps = 0;
|
|
371
|
+
let errW = 0;
|
|
372
|
+
const add = (map, other, e) => {
|
|
373
|
+
const cur = map.get(other) ?? { id: other, label: other, kind: m.leaves.get(other)?.kind ?? 'service', rps: 0, latencyMs: 0, errorRate: 0, async: e.async, rigid: e.rigid, apis: [], lat: 0, err: 0 };
|
|
374
|
+
cur.rps += e.rps;
|
|
375
|
+
cur.lat += e.latencyMs * e.rps;
|
|
376
|
+
cur.err += e.errorRate * e.rps;
|
|
377
|
+
cur.async = cur.async && e.async;
|
|
378
|
+
cur.rigid = cur.rigid || e.rigid;
|
|
379
|
+
cur.apis = [...new Set([...cur.apis, ...e.apis])];
|
|
380
|
+
map.set(other, cur);
|
|
381
|
+
};
|
|
382
|
+
for (const mid of members) {
|
|
383
|
+
for (const eid of m.inc.get(mid) ?? []) {
|
|
384
|
+
const e = m.byId.get(eid);
|
|
385
|
+
if (memberSet.has(e.from))
|
|
386
|
+
continue;
|
|
387
|
+
inRps += e.rps;
|
|
388
|
+
errW += e.errorRate * e.rps;
|
|
389
|
+
add(up, e.from, e);
|
|
390
|
+
}
|
|
391
|
+
for (const eid of m.out.get(mid) ?? []) {
|
|
392
|
+
const e = m.byId.get(eid);
|
|
393
|
+
if (memberSet.has(e.to))
|
|
394
|
+
continue;
|
|
395
|
+
outRps += e.rps;
|
|
396
|
+
add(down, e.to, e);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
const finish = (map) => [...map.values()]
|
|
400
|
+
.map((x) => ({ id: x.id, label: x.label, kind: x.kind, rps: x.rps, latencyMs: x.rps ? x.lat / x.rps : 0, errorRate: x.rps ? x.err / x.rps : 0, async: x.async, rigid: x.rigid, apis: x.apis }))
|
|
401
|
+
.sort((a, b) => b.rps - a.rps);
|
|
402
|
+
const health = this.health.dead.has(id) ? 'dead' : this.health.degraded.has(id) ? 'degraded' : 'ok';
|
|
403
|
+
return leaf
|
|
404
|
+
? { id, kind: 'leaf', label: leaf.label, path: leaf.path, dims: leaf.dims, attrs: leaf.attrs, inRps: leaf.inRps, outRps: leaf.outRps, errorRate: leaf.errorRate, members: 1, upstream: finish(up), downstream: finish(down), health }
|
|
405
|
+
: { id, kind: 'group', label: group.label, path: group.parent ? [group.parent] : [], dims: {}, attrs: { lag: group.lag }, inRps, outRps, errorRate: inRps ? errW / inRps : 0, members: members.length, upstream: finish(up), downstream: finish(down), health };
|
|
406
|
+
}
|
|
407
|
+
/** Animate the camera to a unit, a band, `ego:<leaf>`, `trail`, or the whole map. */
|
|
408
|
+
fit(target, animate = true) {
|
|
409
|
+
if (!this.layoutResult)
|
|
410
|
+
return;
|
|
411
|
+
const L = this.layoutResult;
|
|
412
|
+
let rect = null;
|
|
413
|
+
if (!target)
|
|
414
|
+
rect = L.world;
|
|
415
|
+
else if (target.startsWith('ego:'))
|
|
416
|
+
rect = this.egoRect(target.slice(4));
|
|
417
|
+
else if (target === 'trail')
|
|
418
|
+
rect = this.trailIds ? this.union([...this.trailIds].map((id) => this.shapesTo.get(id) ?? L.rects.get(id))) : L.world;
|
|
419
|
+
else
|
|
420
|
+
rect = this.shapesTo.get(target) ?? L.rects.get(target) ?? L.bands.find((b) => b.id === target) ?? null;
|
|
421
|
+
if (!rect)
|
|
422
|
+
rect = L.world;
|
|
423
|
+
this.zoomTo(rect, animate);
|
|
424
|
+
}
|
|
425
|
+
zoomTo(rect, animate = true) {
|
|
426
|
+
// Fit into the part of the screen the page's chrome leaves free.
|
|
427
|
+
const ins = { top: 24, right: 24, bottom: 24, left: 24, ...this.opts.insets };
|
|
428
|
+
const availW = Math.max(100, this.width - ins.left - ins.right);
|
|
429
|
+
const availH = Math.max(100, this.height - ins.top - ins.bottom);
|
|
430
|
+
const scale = clamp(Math.min(availW / Math.max(1, rect.w), availH / Math.max(1, rect.h)), this.fitScale * 0.5, 1.35);
|
|
431
|
+
const cx = ins.left + availW / 2;
|
|
432
|
+
const cy = ins.top + availH / 2;
|
|
433
|
+
const target = {
|
|
434
|
+
x: rect.x + rect.w / 2 - cx / scale,
|
|
435
|
+
y: rect.y + rect.h / 2 - cy / scale,
|
|
436
|
+
scale,
|
|
437
|
+
};
|
|
438
|
+
if (!animate || !(this.opts.animate ?? true)) {
|
|
439
|
+
this.camera = target;
|
|
440
|
+
this.cameraTo = null;
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
this.cameraFrom = { ...this.camera };
|
|
444
|
+
this.cameraTo = target;
|
|
445
|
+
this.cameraStart = now();
|
|
446
|
+
}
|
|
447
|
+
getCamera() {
|
|
448
|
+
return { ...this.camera };
|
|
449
|
+
}
|
|
450
|
+
panBy(dx, dy) {
|
|
451
|
+
this.cameraTo = null;
|
|
452
|
+
this.camera.x += dx / this.camera.scale;
|
|
453
|
+
this.camera.y += dy / this.camera.scale;
|
|
454
|
+
}
|
|
455
|
+
/** Paint the whole map as a heat strip with the viewport, into another canvas. */
|
|
456
|
+
drawMinimap(canvas) {
|
|
457
|
+
const L = this.layoutResult;
|
|
458
|
+
const m = this.model;
|
|
459
|
+
const ctx = canvas.getContext('2d');
|
|
460
|
+
if (!L || !m || !ctx)
|
|
461
|
+
return;
|
|
462
|
+
const dpr = window.devicePixelRatio || 1;
|
|
463
|
+
const w = canvas.clientWidth;
|
|
464
|
+
const h = canvas.clientHeight;
|
|
465
|
+
if (canvas.width !== Math.round(w * dpr)) {
|
|
466
|
+
canvas.width = Math.round(w * dpr);
|
|
467
|
+
canvas.height = Math.round(h * dpr);
|
|
468
|
+
}
|
|
469
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
470
|
+
ctx.clearRect(0, 0, w, h);
|
|
471
|
+
const s = Math.min(w / L.world.w, h / L.world.h);
|
|
472
|
+
const ox = (w - L.world.w * s) / 2;
|
|
473
|
+
const oy = (h - L.world.h * s) / 2;
|
|
474
|
+
const t = this.theme;
|
|
475
|
+
for (const b of L.bands) {
|
|
476
|
+
ctx.fillStyle = b.level === 0 ? t.surface : t.grid;
|
|
477
|
+
ctx.globalAlpha = 0.9;
|
|
478
|
+
ctx.fillRect(ox + b.x * s, oy + b.y * s, b.w * s, b.h * s);
|
|
479
|
+
}
|
|
480
|
+
for (const u of this.units) {
|
|
481
|
+
const r = this.shapes.get(u.id) ?? L.rects.get(u.id);
|
|
482
|
+
if (!r)
|
|
483
|
+
continue;
|
|
484
|
+
const err = this.errorOf(u);
|
|
485
|
+
const heat = err > 0 ? logNorm(err, ERROR_FLOOR, ERROR_CEIL) : 0;
|
|
486
|
+
ctx.globalAlpha = 1;
|
|
487
|
+
ctx.fillStyle = heat > 0 ? t.error : this.lens === 'ownership' ? this.colourOf(u.id) : t.edge;
|
|
488
|
+
ctx.globalAlpha = heat > 0 ? 0.35 + heat * 0.65 : 0.5;
|
|
489
|
+
ctx.fillRect(ox + r.x * s, oy + r.y * s, Math.max(2, r.w * s), Math.max(2, r.h * s));
|
|
490
|
+
}
|
|
491
|
+
ctx.globalAlpha = 1;
|
|
492
|
+
ctx.strokeStyle = t.highlight;
|
|
493
|
+
ctx.lineWidth = 1;
|
|
494
|
+
const vx = ox + this.camera.x * s;
|
|
495
|
+
const vy = oy + this.camera.y * s;
|
|
496
|
+
ctx.strokeRect(vx + 0.5, vy + 0.5, (this.width / this.camera.scale) * s, (this.height / this.camera.scale) * s);
|
|
497
|
+
}
|
|
498
|
+
/** Map a click on the minimap to a camera move. */
|
|
499
|
+
minimapClick(canvas, px, py) {
|
|
500
|
+
const L = this.layoutResult;
|
|
501
|
+
if (!L)
|
|
502
|
+
return;
|
|
503
|
+
const w = canvas.clientWidth;
|
|
504
|
+
const h = canvas.clientHeight;
|
|
505
|
+
const s = Math.min(w / L.world.w, h / L.world.h);
|
|
506
|
+
const ox = (w - L.world.w * s) / 2;
|
|
507
|
+
const oy = (h - L.world.h * s) / 2;
|
|
508
|
+
const wx = (px - ox) / s;
|
|
509
|
+
const wy = (py - oy) / s;
|
|
510
|
+
this.cameraFrom = { ...this.camera };
|
|
511
|
+
this.cameraTo = { x: wx - this.width / 2 / this.camera.scale, y: wy - this.height / 2 / this.camera.scale, scale: this.camera.scale };
|
|
512
|
+
this.cameraStart = now();
|
|
513
|
+
}
|
|
514
|
+
toDataURL(type = 'image/png') {
|
|
515
|
+
return this.canvas.toDataURL(type);
|
|
516
|
+
}
|
|
517
|
+
stats() {
|
|
518
|
+
let p = 0;
|
|
519
|
+
for (const s of this.particles.values())
|
|
520
|
+
p += s.list.length;
|
|
521
|
+
return { units: this.units.length, edges: this.edges.length, leaves: this.model?.leaves.size ?? 0, particles: p };
|
|
522
|
+
}
|
|
523
|
+
destroy() {
|
|
524
|
+
cancelAnimationFrame(this.raf);
|
|
525
|
+
this.resizeObserver?.disconnect();
|
|
526
|
+
this.canvas.remove();
|
|
527
|
+
this.tooltip.remove();
|
|
528
|
+
}
|
|
529
|
+
// --- model → layout -----------------------------------------------------------
|
|
530
|
+
rebuild(morph) {
|
|
531
|
+
const m = this.model;
|
|
532
|
+
if (!m)
|
|
533
|
+
return;
|
|
534
|
+
const { units, expanded } = visibleUnits(m, this.state);
|
|
535
|
+
this.units = units;
|
|
536
|
+
this.unitById = new Map(units.map((u) => [u.id, u]));
|
|
537
|
+
this.expanded = expanded;
|
|
538
|
+
const L = layoutAtlas(m, units, expanded);
|
|
539
|
+
this.layoutResult = L;
|
|
540
|
+
const agg = aggregateEdges(m, units, this.lens === 'kafka' ? (e) => e.async : undefined);
|
|
541
|
+
this.edges = agg.edges;
|
|
542
|
+
this.unitOf = agg.unitOf;
|
|
543
|
+
this.internal = agg.internal;
|
|
544
|
+
this.domainRps = rpsDomain(this.edges.map((e) => e.rps));
|
|
545
|
+
this.health = propagate(m, this.state.killed);
|
|
546
|
+
this.ego = this.state.focus ? neighbourhood(m, this.state.focus, this.state.hops) : null;
|
|
547
|
+
// Shapes: where things are going, and where they come from. A new shape
|
|
548
|
+
// grows out of the nearest thing that existed before it.
|
|
549
|
+
const to = new Map();
|
|
550
|
+
for (const [id, r] of L.rects)
|
|
551
|
+
to.set(id, r);
|
|
552
|
+
for (const b of L.bands)
|
|
553
|
+
to.set(b.id, { x: b.x, y: b.y, w: b.w, h: b.h });
|
|
554
|
+
const from = new Map();
|
|
555
|
+
this.appearing.clear();
|
|
556
|
+
for (const id of to.keys()) {
|
|
557
|
+
const prev = this.shapes.get(id);
|
|
558
|
+
if (prev) {
|
|
559
|
+
from.set(id, prev);
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
from.set(id, this.ancestorShape(id) ?? to.get(id));
|
|
563
|
+
this.appearing.add(id);
|
|
564
|
+
}
|
|
565
|
+
this.shapesFrom = from;
|
|
566
|
+
this.shapesTo = to;
|
|
567
|
+
if (morph && (this.opts.animate ?? true)) {
|
|
568
|
+
this.morphStart = now();
|
|
569
|
+
this.morphing = true;
|
|
570
|
+
}
|
|
571
|
+
else {
|
|
572
|
+
this.shapes = new Map(to);
|
|
573
|
+
this.morphing = false;
|
|
574
|
+
}
|
|
575
|
+
this.fitScale = this.computeFitScale();
|
|
576
|
+
for (const id of [...this.particles.keys()])
|
|
577
|
+
if (!this.edges.some((e) => e.id === id))
|
|
578
|
+
this.particles.delete(id);
|
|
579
|
+
this.opts.onState?.(this.getState());
|
|
580
|
+
}
|
|
581
|
+
ancestorShape(id) {
|
|
582
|
+
const m = this.model;
|
|
583
|
+
id = baseOf(id);
|
|
584
|
+
const path = m.leaves.get(id)?.path ?? (() => {
|
|
585
|
+
const p = [];
|
|
586
|
+
let g = m.groups.get(id);
|
|
587
|
+
while (g?.parent) {
|
|
588
|
+
p.unshift(g.parent);
|
|
589
|
+
g = m.groups.get(g.parent);
|
|
590
|
+
}
|
|
591
|
+
return p;
|
|
592
|
+
})();
|
|
593
|
+
for (let i = path.length - 1; i >= 0; i--) {
|
|
594
|
+
const r = this.shapes.get(path[i]);
|
|
595
|
+
if (r)
|
|
596
|
+
return r;
|
|
597
|
+
}
|
|
598
|
+
// Collapsing: the group's tile grows out of its previous band, which had the same id.
|
|
599
|
+
return null;
|
|
600
|
+
}
|
|
601
|
+
egoRect(id) {
|
|
602
|
+
if (!this.model || !this.ego)
|
|
603
|
+
return null;
|
|
604
|
+
const ids = new Set();
|
|
605
|
+
for (const l of this.ego)
|
|
606
|
+
ids.add(this.unitOf.get(l) ?? l);
|
|
607
|
+
return this.union([...ids].map((u) => this.shapesTo.get(u)));
|
|
608
|
+
}
|
|
609
|
+
union(rects) {
|
|
610
|
+
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
|
611
|
+
for (const r of rects) {
|
|
612
|
+
if (!r)
|
|
613
|
+
continue;
|
|
614
|
+
x0 = Math.min(x0, r.x);
|
|
615
|
+
y0 = Math.min(y0, r.y);
|
|
616
|
+
x1 = Math.max(x1, r.x + r.w);
|
|
617
|
+
y1 = Math.max(y1, r.y + r.h);
|
|
618
|
+
}
|
|
619
|
+
return Number.isFinite(x0) ? { x: x0 - 20, y: y0 - 20, w: x1 - x0 + 40, h: y1 - y0 + 40 } : null;
|
|
620
|
+
}
|
|
621
|
+
// --- frame ----------------------------------------------------------------------
|
|
622
|
+
start() {
|
|
623
|
+
const loop = () => {
|
|
624
|
+
this.raf = requestAnimationFrame(loop);
|
|
625
|
+
const t = now();
|
|
626
|
+
const dt = Math.min(0.05, (t - (this.last || t)) / 1000);
|
|
627
|
+
this.last = t;
|
|
628
|
+
this.step(dt);
|
|
629
|
+
this.draw();
|
|
630
|
+
};
|
|
631
|
+
this.raf = requestAnimationFrame(loop);
|
|
632
|
+
}
|
|
633
|
+
step(dt) {
|
|
634
|
+
if (this.morphing) {
|
|
635
|
+
const p = clamp((now() - this.morphStart) / MORPH_MS, 0, 1);
|
|
636
|
+
const e = ease(p);
|
|
637
|
+
for (const [id, to] of this.shapesTo) {
|
|
638
|
+
const from = this.shapesFrom.get(id) ?? to;
|
|
639
|
+
this.shapes.set(id, { x: from.x + (to.x - from.x) * e, y: from.y + (to.y - from.y) * e, w: from.w + (to.w - from.w) * e, h: from.h + (to.h - from.h) * e });
|
|
640
|
+
}
|
|
641
|
+
for (const id of [...this.shapes.keys()])
|
|
642
|
+
if (!this.shapesTo.has(id))
|
|
643
|
+
this.shapes.delete(id);
|
|
644
|
+
if (p >= 1)
|
|
645
|
+
this.morphing = false;
|
|
646
|
+
}
|
|
647
|
+
if (this.cameraTo && this.cameraFrom) {
|
|
648
|
+
const p = clamp((now() - this.cameraStart) / CAMERA_MS, 0, 1);
|
|
649
|
+
const e = ease(p);
|
|
650
|
+
const f = this.cameraFrom, t = this.cameraTo;
|
|
651
|
+
// Interpolate scale geometrically so zooms feel even.
|
|
652
|
+
const scale = f.scale * Math.pow(t.scale / f.scale, e);
|
|
653
|
+
this.camera = { x: f.x + (t.x - f.x) * e, y: f.y + (t.y - f.y) * e, scale };
|
|
654
|
+
if (p >= 1) {
|
|
655
|
+
this.camera = { ...t };
|
|
656
|
+
this.cameraTo = null;
|
|
657
|
+
}
|
|
658
|
+
this.opts.onCamera?.();
|
|
659
|
+
}
|
|
660
|
+
this.dash += dt * 28;
|
|
661
|
+
this.buildViews();
|
|
662
|
+
this.stepParticles(dt);
|
|
663
|
+
}
|
|
664
|
+
buildViews() {
|
|
665
|
+
// Three routes, chosen by geometry:
|
|
666
|
+
// - target wholly to the right: a horizontal S, the classic layered look;
|
|
667
|
+
// - x ranges overlap (wide tiles, same column, most back-edges): a
|
|
668
|
+
// vertical S between the tiles' facing sides, attached where along
|
|
669
|
+
// the request path the traffic actually leaves and lands. On a
|
|
670
|
+
// collapsed domain that turns its bar into a depth axis: a ribbon
|
|
671
|
+
// leaving at depth 3 leaves at depth 3;
|
|
672
|
+
// - same row and going backwards: an arc over the top.
|
|
673
|
+
// Ports on a side are spread by the other end's position so a busy unit
|
|
674
|
+
// fans in instead of piling onto one point.
|
|
675
|
+
const shown = this.edges.filter((e) => this.showEdge(e));
|
|
676
|
+
const L = this.layoutResult;
|
|
677
|
+
const half = L.metrics.tileW / 2;
|
|
678
|
+
const routeOf = (e) => {
|
|
679
|
+
const A = this.shapes.get(e.from);
|
|
680
|
+
const B = this.shapes.get(e.to);
|
|
681
|
+
if (B.x >= A.x + A.w - 2)
|
|
682
|
+
return 'h';
|
|
683
|
+
const sameRow = Math.abs(A.y - B.y) < 1;
|
|
684
|
+
return sameRow ? 'arc' : 'v';
|
|
685
|
+
};
|
|
686
|
+
const ports = new Map();
|
|
687
|
+
const addPort = (k, e, key) => { ports.set(k, [...(ports.get(k) ?? []), { e, key }]); };
|
|
688
|
+
const routes = new Map();
|
|
689
|
+
for (const e of shown) {
|
|
690
|
+
const A = this.shapes.get(e.from);
|
|
691
|
+
const B = this.shapes.get(e.to);
|
|
692
|
+
if (!A || !B)
|
|
693
|
+
continue;
|
|
694
|
+
const r = routeOf(e);
|
|
695
|
+
routes.set(e.id, r);
|
|
696
|
+
if (r === 'h') {
|
|
697
|
+
addPort(`${e.from}:right`, e, B.y + B.h / 2);
|
|
698
|
+
addPort(`${e.to}:left`, e, A.y + A.h / 2);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
const index = new Map();
|
|
702
|
+
for (const [k, list] of ports) {
|
|
703
|
+
list.sort((a, b) => a.key - b.key);
|
|
704
|
+
list.forEach((p, i) => index.set(`${k}:${p.e.id}`, { i, n: list.length }));
|
|
705
|
+
}
|
|
706
|
+
this.views = [];
|
|
707
|
+
for (const e of shown) {
|
|
708
|
+
const A = this.shapes.get(e.from);
|
|
709
|
+
const B = this.shapes.get(e.to);
|
|
710
|
+
if (!A || !B)
|
|
711
|
+
continue;
|
|
712
|
+
const width = edgeWidth(e.rps, this.domainRps, { min: 0.7, max: 8 });
|
|
713
|
+
const r = routes.get(e.id);
|
|
714
|
+
let a, b, c1, c2;
|
|
715
|
+
if (r === 'h') {
|
|
716
|
+
const po = index.get(`${e.from}:right:${e.id}`);
|
|
717
|
+
const pi = index.get(`${e.to}:left:${e.id}`);
|
|
718
|
+
a = { x: A.x + A.w, y: A.y + (A.h * (po.i + 1)) / (po.n + 1) };
|
|
719
|
+
b = { x: B.x, y: B.y + (B.h * (pi.i + 1)) / (pi.n + 1) };
|
|
720
|
+
const dx = Math.max(40, (b.x - a.x) * 0.5);
|
|
721
|
+
c1 = { x: a.x + dx, y: a.y };
|
|
722
|
+
c2 = { x: b.x - dx, y: b.y };
|
|
723
|
+
}
|
|
724
|
+
else if (r === 'v') {
|
|
725
|
+
const down = B.y > A.y;
|
|
726
|
+
const sx = clamp(L.colX(e.fromDepth) + half, A.x + 10, A.x + A.w - 10);
|
|
727
|
+
const tx = clamp(L.colX(e.toDepth) + half, B.x + 10, B.x + B.w - 10);
|
|
728
|
+
a = { x: sx, y: down ? A.y + A.h : A.y };
|
|
729
|
+
b = { x: tx, y: down ? B.y : B.y + B.h };
|
|
730
|
+
const dy = (b.y - a.y) * 0.5;
|
|
731
|
+
c1 = { x: sx, y: a.y + dy };
|
|
732
|
+
c2 = { x: tx, y: b.y - dy };
|
|
733
|
+
}
|
|
734
|
+
else {
|
|
735
|
+
a = { x: A.x + A.w * 0.75, y: A.y };
|
|
736
|
+
b = { x: B.x + B.w * 0.25, y: B.y };
|
|
737
|
+
const lift = 60 + Math.abs(a.x - b.x) * 0.12;
|
|
738
|
+
c1 = { x: a.x + 30, y: a.y - lift };
|
|
739
|
+
c2 = { x: b.x - 30, y: b.y - lift };
|
|
740
|
+
}
|
|
741
|
+
const length = Math.hypot(b.x - a.x, b.y - a.y) * 1.1;
|
|
742
|
+
this.views.push({ edge: e, a, b, c1, c2, width, length });
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
showEdge(e) {
|
|
746
|
+
return this.lens !== 'kafka' || e.async;
|
|
747
|
+
}
|
|
748
|
+
stepParticles(dt) {
|
|
749
|
+
if (!(this.opts.animate ?? true) || this.lens === 'ownership') {
|
|
750
|
+
this.particles.clear();
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
// Spend particles on the edges that matter: those touching the focus and
|
|
754
|
+
// the busiest at leaf level, under a global cap.
|
|
755
|
+
const eligible = this.views
|
|
756
|
+
.filter((v) => this.isLeafEdge(v.edge) || (this.ego && this.touchesEgo(v.edge)))
|
|
757
|
+
.sort((a, b) => (this.touchesEgo(a.edge) ? 1 : 0) - (this.touchesEgo(b.edge) ? 1 : 0) || b.edge.rps - a.edge.rps)
|
|
758
|
+
.slice(0, PARTICLE_EDGES);
|
|
759
|
+
const live = new Set();
|
|
760
|
+
for (const v of eligible) {
|
|
761
|
+
live.add(v.edge.id);
|
|
762
|
+
let s = this.particles.get(v.edge.id);
|
|
763
|
+
if (!s) {
|
|
764
|
+
s = { list: [], acc: 0 };
|
|
765
|
+
this.particles.set(v.edge.id, s);
|
|
766
|
+
}
|
|
767
|
+
const rate = emissionRate(v.edge.rps, this.domainRps) * (v.edge.async ? 0.35 : 0.55);
|
|
768
|
+
s.acc += rate * dt;
|
|
769
|
+
const speed = particleSpeed(v.edge.latencyMs) / Math.max(60, v.length);
|
|
770
|
+
while (s.acc >= 1 && s.list.length < 60) {
|
|
771
|
+
s.acc -= 1;
|
|
772
|
+
s.list.push({ t: 0, speed: speed * (0.9 + this.rand() * 0.2), err: this.rand() < v.edge.errorRate });
|
|
773
|
+
}
|
|
774
|
+
if (s.acc > 4)
|
|
775
|
+
s.acc = 4;
|
|
776
|
+
for (const p of s.list)
|
|
777
|
+
p.t += p.speed * dt;
|
|
778
|
+
s.list = s.list.filter((p) => p.t < 1);
|
|
779
|
+
}
|
|
780
|
+
for (const id of [...this.particles.keys()])
|
|
781
|
+
if (!live.has(id))
|
|
782
|
+
this.particles.delete(id);
|
|
783
|
+
}
|
|
784
|
+
isLeafEdge(e) {
|
|
785
|
+
return this.unitById.get(e.from)?.kind === 'leaf' || this.unitById.get(e.to)?.kind === 'leaf';
|
|
786
|
+
}
|
|
787
|
+
touchesEgo(e) {
|
|
788
|
+
if (!this.ego)
|
|
789
|
+
return false;
|
|
790
|
+
return e.leafEdges.some((le) => this.ego.has(le.from) && this.ego.has(le.to));
|
|
791
|
+
}
|
|
792
|
+
// --- drawing --------------------------------------------------------------------
|
|
793
|
+
resize() {
|
|
794
|
+
const rect = this.container.getBoundingClientRect();
|
|
795
|
+
this.width = Math.max(1, Math.round(rect.width));
|
|
796
|
+
this.height = Math.max(1, Math.round(rect.height));
|
|
797
|
+
this.dpr = window.devicePixelRatio || 1;
|
|
798
|
+
this.canvas.width = Math.round(this.width * this.dpr);
|
|
799
|
+
this.canvas.height = Math.round(this.height * this.dpr);
|
|
800
|
+
if (this.layoutResult)
|
|
801
|
+
this.fitScale = this.computeFitScale();
|
|
802
|
+
}
|
|
803
|
+
computeFitScale() {
|
|
804
|
+
const L = this.layoutResult;
|
|
805
|
+
const ins = { top: 24, right: 24, bottom: 24, left: 24, ...this.opts.insets };
|
|
806
|
+
return Math.min((this.width - ins.left - ins.right) / Math.max(1, L.world.w), (this.height - ins.top - ins.bottom) / Math.max(1, L.world.h));
|
|
807
|
+
}
|
|
808
|
+
toScreen(x, y) {
|
|
809
|
+
return { x: (x - this.camera.x) * this.camera.scale, y: (y - this.camera.y) * this.camera.scale };
|
|
810
|
+
}
|
|
811
|
+
toWorld(x, y) {
|
|
812
|
+
return { x: x / this.camera.scale + this.camera.x, y: y / this.camera.scale + this.camera.y };
|
|
813
|
+
}
|
|
814
|
+
draw() {
|
|
815
|
+
const { ctx, theme: t } = this;
|
|
816
|
+
ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
|
|
817
|
+
ctx.fillStyle = t.background;
|
|
818
|
+
ctx.fillRect(0, 0, this.width, this.height);
|
|
819
|
+
if (!this.model || !this.layoutResult)
|
|
820
|
+
return;
|
|
821
|
+
const s = this.camera.scale;
|
|
822
|
+
ctx.save();
|
|
823
|
+
ctx.translate(-this.camera.x * s, -this.camera.y * s);
|
|
824
|
+
ctx.scale(s, s);
|
|
825
|
+
this.drawColumns();
|
|
826
|
+
this.drawBands();
|
|
827
|
+
const focusUnit = this.state.focus ? this.unitOf.get(this.state.focus) ?? this.state.focus : null;
|
|
828
|
+
const hoverUnit = this.hovered?.kind === 'unit' ? this.hovered.id : null;
|
|
829
|
+
const hoverEdge = this.hovered?.kind === 'edge' ? this.hovered.id : null;
|
|
830
|
+
// Edges: full treatment for the top N by traffic plus anything the viewer
|
|
831
|
+
// is looking at; hairlines for the long tail.
|
|
832
|
+
// Rank by how close to the viewer's place an edge is (leaf-level first,
|
|
833
|
+
// then finer tiles), then by traffic - so the thing you opened gets its
|
|
834
|
+
// ribbons before the world does.
|
|
835
|
+
const rank = (id) => {
|
|
836
|
+
const u = this.unitById.get(id);
|
|
837
|
+
if (!u)
|
|
838
|
+
return 0;
|
|
839
|
+
if (u.kind === 'leaf')
|
|
840
|
+
return 10;
|
|
841
|
+
if (id.endsWith('#rest'))
|
|
842
|
+
return 5;
|
|
843
|
+
return this.model.groups.get(id)?.level ?? 0;
|
|
844
|
+
};
|
|
845
|
+
const ranked = [...this.views]
|
|
846
|
+
.map((v) => ({ v, p: Math.max(rank(v.edge.from), rank(v.edge.to)) }))
|
|
847
|
+
.sort((a, b) => b.p - a.p || b.v.edge.rps - a.v.edge.rps)
|
|
848
|
+
.map((x) => x.v);
|
|
849
|
+
// The ribbon budget scales with what is on the map: a dozen domains get
|
|
850
|
+
// their dozen-odd strongest flows in full and the rest as hairlines, so
|
|
851
|
+
// the overview is the major routes, not every road. Hover or focus a
|
|
852
|
+
// unit and its own ribbons come up regardless.
|
|
853
|
+
const budget = clamp(Math.round(this.units.length * 1.6), 14, FULL_EDGES);
|
|
854
|
+
const full = new Set(ranked.slice(0, budget).map((v) => v.edge.id));
|
|
855
|
+
for (const v of ranked.reverse()) {
|
|
856
|
+
const e = v.edge;
|
|
857
|
+
const involved = e.from === focusUnit || e.to === focusUnit || e.from === hoverUnit || e.to === hoverUnit || e.id === hoverEdge;
|
|
858
|
+
const inEgo = this.ego ? this.touchesEgo(e) : true;
|
|
859
|
+
const onTrail = this.trailIds ? e.leafEdges.some((le) => this.trailEdges.has(le.id)) : false;
|
|
860
|
+
let alpha = 1;
|
|
861
|
+
if (this.trailIds)
|
|
862
|
+
alpha = onTrail ? 1 : 0.08;
|
|
863
|
+
else if (this.ego)
|
|
864
|
+
alpha = inEgo ? 1 : 0.1;
|
|
865
|
+
else if (hoverUnit || hoverEdge)
|
|
866
|
+
alpha = involved ? 1 : 0.35;
|
|
867
|
+
const hair = !full.has(e.id) && !involved && !onTrail;
|
|
868
|
+
this.drawEdge(v, alpha, hair, involved || onTrail);
|
|
869
|
+
}
|
|
870
|
+
for (const v of this.views)
|
|
871
|
+
if (this.particles.has(v.edge.id))
|
|
872
|
+
this.drawParticles(v);
|
|
873
|
+
for (const u of this.units)
|
|
874
|
+
this.drawUnit(u, focusUnit, hoverUnit);
|
|
875
|
+
ctx.restore();
|
|
876
|
+
this.updateTooltip();
|
|
877
|
+
}
|
|
878
|
+
drawColumns() {
|
|
879
|
+
const { ctx, theme: t } = this;
|
|
880
|
+
const L = this.layoutResult;
|
|
881
|
+
const s = this.camera.scale;
|
|
882
|
+
if (L.metrics.colW * s < 30)
|
|
883
|
+
return;
|
|
884
|
+
ctx.save();
|
|
885
|
+
ctx.strokeStyle = t.grid;
|
|
886
|
+
ctx.lineWidth = 1 / s;
|
|
887
|
+
ctx.fillStyle = t.textMuted;
|
|
888
|
+
ctx.font = `${11 / s}px ui-monospace, Menlo, monospace`;
|
|
889
|
+
ctx.globalAlpha = 0.9;
|
|
890
|
+
const top = this.camera.y;
|
|
891
|
+
for (let d = 0; d <= this.model.maxDepth; d++) {
|
|
892
|
+
const x = L.colX(d) - L.metrics.bandPad - 4;
|
|
893
|
+
ctx.beginPath();
|
|
894
|
+
ctx.moveTo(x, L.world.y);
|
|
895
|
+
ctx.lineTo(x, L.world.h);
|
|
896
|
+
ctx.stroke();
|
|
897
|
+
if (s > 0.35)
|
|
898
|
+
ctx.fillText(d === 0 ? 'ingress' : `depth ${d}`, L.colX(d), top + 14 / s);
|
|
899
|
+
}
|
|
900
|
+
ctx.restore();
|
|
901
|
+
}
|
|
902
|
+
drawBands() {
|
|
903
|
+
const { ctx, theme: t } = this;
|
|
904
|
+
const s = this.camera.scale;
|
|
905
|
+
for (const b of this.layoutResult.bands) {
|
|
906
|
+
const r = this.shapes.get(b.id) ?? b;
|
|
907
|
+
const g = this.model.groups.get(b.id);
|
|
908
|
+
const colour = this.colourOf(b.id);
|
|
909
|
+
ctx.save();
|
|
910
|
+
ctx.globalAlpha = b.level === 0 ? 0.55 : 0.5;
|
|
911
|
+
ctx.fillStyle = b.level === 0 ? t.surface : t.grid;
|
|
912
|
+
roundRect(ctx, r.x, r.y, r.w, r.h, 10);
|
|
913
|
+
ctx.fill();
|
|
914
|
+
ctx.globalAlpha = 1;
|
|
915
|
+
ctx.strokeStyle = t.border;
|
|
916
|
+
ctx.lineWidth = 1 / s;
|
|
917
|
+
ctx.stroke();
|
|
918
|
+
// Ownership stripe on the left edge.
|
|
919
|
+
ctx.fillStyle = colour;
|
|
920
|
+
ctx.globalAlpha = this.lens === 'ownership' ? 0.9 : 0.55;
|
|
921
|
+
roundRect(ctx, r.x, r.y, 4, r.h, 2);
|
|
922
|
+
ctx.fill();
|
|
923
|
+
// Header: the crumb you click to collapse.
|
|
924
|
+
if (r.h * s > 14) {
|
|
925
|
+
ctx.globalAlpha = 1;
|
|
926
|
+
const px = clamp(10.5 / s, 12, 16);
|
|
927
|
+
ctx.font = `600 ${px}px ui-sans-serif, system-ui, sans-serif`;
|
|
928
|
+
ctx.fillStyle = t.text;
|
|
929
|
+
const label = `${g.label}`;
|
|
930
|
+
ctx.fillText(label, r.x + 12, r.y + px + 4);
|
|
931
|
+
const lw = ctx.measureText(label).width;
|
|
932
|
+
ctx.font = `${px * 0.85}px ui-monospace, Menlo, monospace`;
|
|
933
|
+
ctx.fillStyle = t.textMuted;
|
|
934
|
+
const meta = `${this.model.hierarchy[g.level] ?? 'group'} · ${g.all.length} · ${formatRps(g.rps)}`;
|
|
935
|
+
ctx.fillText(meta, r.x + 12 + lw + 10, r.y + px + 4);
|
|
936
|
+
// Collapse affordance.
|
|
937
|
+
ctx.fillStyle = t.textMuted;
|
|
938
|
+
ctx.fillText('−', r.x + r.w - 16, r.y + px + 4);
|
|
939
|
+
}
|
|
940
|
+
ctx.restore();
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
drawEdge(v, alpha, hair, emphasised) {
|
|
944
|
+
const { ctx, theme: t } = this;
|
|
945
|
+
const e = v.edge;
|
|
946
|
+
const s = this.camera.scale;
|
|
947
|
+
const colour = this.edgeColour(e);
|
|
948
|
+
const dead = this.health.dead.has(e.to) || this.health.dead.has(e.from) || e.leafEdges.some((le) => this.health.dead.has(le.to));
|
|
949
|
+
ctx.save();
|
|
950
|
+
ctx.globalAlpha = alpha * (hair ? 0.2 : 0.85);
|
|
951
|
+
ctx.lineCap = 'round';
|
|
952
|
+
ctx.beginPath();
|
|
953
|
+
ctx.moveTo(v.a.x, v.a.y);
|
|
954
|
+
ctx.bezierCurveTo(v.c1.x, v.c1.y, v.c2.x, v.c2.y, v.b.x, v.b.y);
|
|
955
|
+
const w = hair ? Math.max(0.5 / s, 0.5) : Math.max(v.width, 0.7 / s);
|
|
956
|
+
if (this.lens === 'blast' && !dead) {
|
|
957
|
+
// Rigid: solid double line. Breakaway: broken.
|
|
958
|
+
if (e.rigid) {
|
|
959
|
+
ctx.strokeStyle = colour;
|
|
960
|
+
ctx.lineWidth = w + 2;
|
|
961
|
+
ctx.globalAlpha *= 0.35;
|
|
962
|
+
ctx.stroke();
|
|
963
|
+
ctx.globalAlpha = alpha;
|
|
964
|
+
}
|
|
965
|
+
else
|
|
966
|
+
ctx.setLineDash([9, 7]);
|
|
967
|
+
}
|
|
968
|
+
else if (e.async) {
|
|
969
|
+
// Packets: a moving dash. Sync flows are solid and carry particles.
|
|
970
|
+
ctx.setLineDash([Math.max(4, w * 1.6), Math.max(6, w * 2.2)]);
|
|
971
|
+
ctx.lineDashOffset = -this.dash;
|
|
972
|
+
}
|
|
973
|
+
else if (e.mixed) {
|
|
974
|
+
ctx.setLineDash([14, 4]);
|
|
975
|
+
ctx.lineDashOffset = -this.dash;
|
|
976
|
+
}
|
|
977
|
+
if (dead) {
|
|
978
|
+
ctx.strokeStyle = t.error;
|
|
979
|
+
ctx.setLineDash([3, 5]);
|
|
980
|
+
}
|
|
981
|
+
else
|
|
982
|
+
ctx.strokeStyle = emphasised ? this.brighten(colour) : colour;
|
|
983
|
+
ctx.lineWidth = w;
|
|
984
|
+
ctx.stroke();
|
|
985
|
+
// Under-glow so a heavy ribbon reads over the bands.
|
|
986
|
+
if (!hair && w > 3 && !dead) {
|
|
987
|
+
ctx.setLineDash([]);
|
|
988
|
+
ctx.globalAlpha = alpha * 0.12;
|
|
989
|
+
ctx.lineWidth = w + 6;
|
|
990
|
+
ctx.stroke();
|
|
991
|
+
}
|
|
992
|
+
// Consumer-group label on async edges at leaf level, when there is room.
|
|
993
|
+
if (!hair && e.async && s > 0.8 && (this.lens === 'kafka' || emphasised) && e.leafEdges.length === 1) {
|
|
994
|
+
const api = e.leafEdges[0].apis[0];
|
|
995
|
+
if (api && /-cg$|group/i.test(api)) {
|
|
996
|
+
const mid = bez(v, 0.5);
|
|
997
|
+
ctx.setLineDash([]);
|
|
998
|
+
ctx.globalAlpha = alpha;
|
|
999
|
+
ctx.font = `${10 / s}px ui-monospace, Menlo, monospace`;
|
|
1000
|
+
ctx.fillStyle = t.textMuted;
|
|
1001
|
+
ctx.fillText(api, mid.x + 4 / s, mid.y - 4 / s);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
ctx.restore();
|
|
1005
|
+
}
|
|
1006
|
+
drawParticles(v) {
|
|
1007
|
+
const { ctx, theme: t } = this;
|
|
1008
|
+
const s = this.particles.get(v.edge.id);
|
|
1009
|
+
if (!s)
|
|
1010
|
+
return;
|
|
1011
|
+
const scale = this.camera.scale;
|
|
1012
|
+
const r = clamp(2.1 / Math.sqrt(scale), 1.2, 3.4);
|
|
1013
|
+
const colour = this.edgeColour(v.edge);
|
|
1014
|
+
ctx.save();
|
|
1015
|
+
for (const p of s.list) {
|
|
1016
|
+
const pt = bez(v, p.t);
|
|
1017
|
+
ctx.fillStyle = p.err ? t.error : colour;
|
|
1018
|
+
ctx.globalAlpha = 0.95;
|
|
1019
|
+
ctx.beginPath();
|
|
1020
|
+
ctx.arc(pt.x, pt.y, r, 0, Math.PI * 2);
|
|
1021
|
+
ctx.fill();
|
|
1022
|
+
if (p.err) {
|
|
1023
|
+
ctx.strokeStyle = t.error;
|
|
1024
|
+
ctx.lineWidth = 1 / scale;
|
|
1025
|
+
ctx.globalAlpha = 0.6;
|
|
1026
|
+
ctx.beginPath();
|
|
1027
|
+
ctx.arc(pt.x, pt.y, r * 2, 0, Math.PI * 2);
|
|
1028
|
+
ctx.stroke();
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
ctx.restore();
|
|
1032
|
+
}
|
|
1033
|
+
drawUnit(u, focusUnit, hoverUnit) {
|
|
1034
|
+
const { ctx, theme: t } = this;
|
|
1035
|
+
const r = this.shapes.get(u.id);
|
|
1036
|
+
if (!r)
|
|
1037
|
+
return;
|
|
1038
|
+
const s = this.camera.scale;
|
|
1039
|
+
const m = this.model;
|
|
1040
|
+
const leaf = u.kind === 'leaf' ? m.leaves.get(u.id) : undefined;
|
|
1041
|
+
const group = u.kind === 'group' ? m.groups.get(u.id) : undefined;
|
|
1042
|
+
const isTopic = leaf?.kind === 'topic';
|
|
1043
|
+
const dead = u.members.every((id) => this.health.dead.has(id));
|
|
1044
|
+
const deadFrac = u.members.filter((id) => this.health.dead.has(id)).length / u.members.length;
|
|
1045
|
+
const degraded = !dead && u.members.some((id) => this.health.degraded.has(id));
|
|
1046
|
+
const inEgo = !this.ego || u.members.some((id) => this.ego.has(id));
|
|
1047
|
+
const onTrail = this.trailIds ? u.members.some((id) => this.trailIds.has(id)) : true;
|
|
1048
|
+
let alpha = 1;
|
|
1049
|
+
if (this.trailIds)
|
|
1050
|
+
alpha = onTrail ? 1 : 0.25;
|
|
1051
|
+
else if (this.ego)
|
|
1052
|
+
alpha = inEgo ? 1 : 0.35;
|
|
1053
|
+
const appear = this.appearing.has(u.id) && this.morphing ? ease(clamp((now() - this.morphStart) / MORPH_MS, 0, 1)) : 1;
|
|
1054
|
+
alpha *= 0.15 + appear * 0.85;
|
|
1055
|
+
ctx.save();
|
|
1056
|
+
ctx.globalAlpha = alpha;
|
|
1057
|
+
// Error glow: absolute scale, same as the flat renderer.
|
|
1058
|
+
const err = this.errorOf(u);
|
|
1059
|
+
const heat = err > 0 ? logNorm(err, ERROR_FLOOR, ERROR_CEIL) : 0;
|
|
1060
|
+
if (heat > 0 && this.lens !== 'ownership')
|
|
1061
|
+
this.drawGlow(r, heat * (this.lens === 'reliability' ? 1 : 0.8), alpha);
|
|
1062
|
+
const fill = this.fillOf(u, leaf?.kind ?? 'group');
|
|
1063
|
+
if (isTopic)
|
|
1064
|
+
this.drawRail(r, leaf, fill, alpha);
|
|
1065
|
+
else {
|
|
1066
|
+
ctx.fillStyle = fill;
|
|
1067
|
+
ctx.strokeStyle = u.id === focusUnit ? t.highlight : u.id === hoverUnit ? t.text : t.border;
|
|
1068
|
+
ctx.lineWidth = (u.id === focusUnit ? 2 : 1) / Math.max(0.5, Math.min(1, s));
|
|
1069
|
+
roundRect(ctx, r.x, r.y, r.w, r.h, u.kind === 'group' ? 8 : leaf?.kind === 'datastore' || leaf?.kind === 'cache' ? 14 : 6);
|
|
1070
|
+
ctx.fill();
|
|
1071
|
+
ctx.stroke();
|
|
1072
|
+
if (u.kind === 'group') {
|
|
1073
|
+
// A collapsed group: ownership stripe, member count, a small
|
|
1074
|
+
// "contains topics" pipe if it does.
|
|
1075
|
+
ctx.fillStyle = this.colourOf(u.id);
|
|
1076
|
+
ctx.globalAlpha = alpha * 0.9;
|
|
1077
|
+
roundRect(ctx, r.x, r.y, 5, r.h, 2);
|
|
1078
|
+
ctx.fill();
|
|
1079
|
+
ctx.globalAlpha = alpha;
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
if (dead)
|
|
1083
|
+
this.hatch(r, alpha);
|
|
1084
|
+
else if (deadFrac > 0) {
|
|
1085
|
+
ctx.fillStyle = t.error;
|
|
1086
|
+
ctx.globalAlpha = alpha * 0.8;
|
|
1087
|
+
ctx.fillRect(r.x + 6, r.y + r.h - 4, (r.w - 12) * deadFrac, 3);
|
|
1088
|
+
ctx.globalAlpha = alpha;
|
|
1089
|
+
}
|
|
1090
|
+
if (degraded) {
|
|
1091
|
+
ctx.strokeStyle = '#f59e0b';
|
|
1092
|
+
ctx.setLineDash([4, 3]);
|
|
1093
|
+
ctx.lineWidth = 1.5 / Math.max(0.5, s);
|
|
1094
|
+
roundRect(ctx, r.x - 3, r.y - 3, r.w + 6, r.h + 6, 9);
|
|
1095
|
+
ctx.stroke();
|
|
1096
|
+
ctx.setLineDash([]);
|
|
1097
|
+
}
|
|
1098
|
+
// Labels, only when legible.
|
|
1099
|
+
if (r.h * s >= 7) {
|
|
1100
|
+
// Never smaller than ~10 screen px: a label you cannot read is noise.
|
|
1101
|
+
const px = clamp(10.5 / s, 12, r.h * 0.62);
|
|
1102
|
+
ctx.font = `${u.kind === 'group' ? 600 : 500} ${px}px ui-sans-serif, system-ui, sans-serif`;
|
|
1103
|
+
ctx.fillStyle = t.nodeText;
|
|
1104
|
+
ctx.textBaseline = 'middle';
|
|
1105
|
+
const label = u.kind === 'group' ? (group ? group.label : `${u.members.length} more`) : leaf.label;
|
|
1106
|
+
const pad = u.kind === 'group' ? 12 : isTopic ? 10 : 8;
|
|
1107
|
+
const maxW = r.w - pad * 2 - (u.kind === 'group' ? 46 : 0);
|
|
1108
|
+
ctx.fillText(fitText(ctx, label, maxW), r.x + pad, r.y + r.h / 2);
|
|
1109
|
+
if (u.kind === 'group') {
|
|
1110
|
+
ctx.font = `${px * 0.85}px ui-monospace, Menlo, monospace`;
|
|
1111
|
+
ctx.fillStyle = t.textMuted;
|
|
1112
|
+
ctx.textAlign = 'right';
|
|
1113
|
+
const topics = u.members.filter((id) => m.leaves.get(id)?.kind === 'topic').length;
|
|
1114
|
+
if (group)
|
|
1115
|
+
ctx.fillText(`${u.members.length - topics}${topics ? ` ·${topics}⊟` : ''}`, r.x + r.w - 10, r.y + r.h / 2);
|
|
1116
|
+
ctx.textAlign = 'left';
|
|
1117
|
+
}
|
|
1118
|
+
ctx.textBaseline = 'alphabetic';
|
|
1119
|
+
}
|
|
1120
|
+
ctx.restore();
|
|
1121
|
+
}
|
|
1122
|
+
/** A Kafka topic: a pipe, partition ticks, lag filling it from the left. */
|
|
1123
|
+
drawRail(r, leaf, fill, alpha) {
|
|
1124
|
+
const { ctx, theme: t } = this;
|
|
1125
|
+
const s = this.camera.scale;
|
|
1126
|
+
const lag = leaf.attrs.lag ?? 0;
|
|
1127
|
+
const lagT = lag > 0 ? logNorm(lag, 100, 1e6) : 0;
|
|
1128
|
+
ctx.fillStyle = fill;
|
|
1129
|
+
ctx.strokeStyle = t.border;
|
|
1130
|
+
ctx.lineWidth = 1 / Math.max(0.5, Math.min(1, s));
|
|
1131
|
+
roundRect(ctx, r.x, r.y, r.w, r.h, r.h / 2);
|
|
1132
|
+
ctx.fill();
|
|
1133
|
+
ctx.stroke();
|
|
1134
|
+
// Partition ticks.
|
|
1135
|
+
const parts = Math.min(24, leaf.attrs.partitions ?? 6);
|
|
1136
|
+
ctx.strokeStyle = t.border;
|
|
1137
|
+
ctx.globalAlpha = alpha * 0.6;
|
|
1138
|
+
for (let i = 1; i < parts; i++) {
|
|
1139
|
+
const x = r.x + 10 + ((r.w - 20) * i) / parts;
|
|
1140
|
+
ctx.beginPath();
|
|
1141
|
+
ctx.moveTo(x, r.y + 3);
|
|
1142
|
+
ctx.lineTo(x, r.y + r.h - 3);
|
|
1143
|
+
ctx.stroke();
|
|
1144
|
+
}
|
|
1145
|
+
// Lag fill: amber to red as it grows.
|
|
1146
|
+
if (lagT > 0.05) {
|
|
1147
|
+
const w = (r.w - 8) * lagT;
|
|
1148
|
+
const col = lagT < 0.55 ? '#f59e0b' : t.error;
|
|
1149
|
+
ctx.fillStyle = col;
|
|
1150
|
+
ctx.globalAlpha = alpha * (0.35 + lagT * 0.4);
|
|
1151
|
+
roundRect(ctx, r.x + 4, r.y + 4, w, r.h - 8, (r.h - 8) / 2);
|
|
1152
|
+
ctx.fill();
|
|
1153
|
+
// The meniscus: a bright leading edge, so the level reads.
|
|
1154
|
+
ctx.globalAlpha = alpha * 0.9;
|
|
1155
|
+
ctx.fillRect(r.x + 4 + w - 1.5, r.y + 4, 1.5, r.h - 8);
|
|
1156
|
+
}
|
|
1157
|
+
ctx.globalAlpha = alpha;
|
|
1158
|
+
// Pipe end caps.
|
|
1159
|
+
ctx.strokeStyle = t.textMuted;
|
|
1160
|
+
ctx.globalAlpha = alpha * 0.7;
|
|
1161
|
+
ctx.beginPath();
|
|
1162
|
+
ctx.moveTo(r.x + 6, r.y + 2);
|
|
1163
|
+
ctx.lineTo(r.x + 6, r.y + r.h - 2);
|
|
1164
|
+
ctx.moveTo(r.x + r.w - 6, r.y + 2);
|
|
1165
|
+
ctx.lineTo(r.x + r.w - 6, r.y + r.h - 2);
|
|
1166
|
+
ctx.stroke();
|
|
1167
|
+
ctx.globalAlpha = alpha;
|
|
1168
|
+
}
|
|
1169
|
+
drawGlow(r, t, alpha) {
|
|
1170
|
+
const { ctx, theme } = this;
|
|
1171
|
+
const s = this.camera.scale;
|
|
1172
|
+
const breathe = t > 0.5 ? (0.5 + 0.5 * Math.sin(now() / 540 + r.x * 0.013 + r.y * 0.007)) * (t - 0.5) * 2 : 0;
|
|
1173
|
+
const spread = (3 + t * t * 26 + breathe * 5) / Math.max(0.6, Math.min(1, s));
|
|
1174
|
+
const strength = (0.18 + t * 0.62 + breathe * 0.12) * alpha;
|
|
1175
|
+
ctx.save();
|
|
1176
|
+
ctx.fillStyle = theme.error;
|
|
1177
|
+
for (const [k, a] of [[1, 0.22], [0.6, 0.3], [0.3, 0.42]]) {
|
|
1178
|
+
ctx.shadowColor = theme.error;
|
|
1179
|
+
ctx.shadowBlur = spread * k * 2.2 * s;
|
|
1180
|
+
ctx.globalAlpha = strength * a * (theme.name === 'dark' ? 1 : 0.8);
|
|
1181
|
+
const pad = spread * k * 0.5;
|
|
1182
|
+
roundRect(ctx, r.x - pad, r.y - pad, r.w + pad * 2, r.h + pad * 2, 10 + pad);
|
|
1183
|
+
ctx.fill();
|
|
1184
|
+
}
|
|
1185
|
+
ctx.shadowBlur = 0;
|
|
1186
|
+
ctx.strokeStyle = theme.error;
|
|
1187
|
+
ctx.lineWidth = (1 + t * t * 3.5) / Math.max(0.6, Math.min(1, s));
|
|
1188
|
+
ctx.globalAlpha = (0.4 + t * 0.55) * alpha;
|
|
1189
|
+
const rim = 2 + t * t * 5;
|
|
1190
|
+
roundRect(ctx, r.x - rim, r.y - rim, r.w + rim * 2, r.h + rim * 2, 10);
|
|
1191
|
+
ctx.stroke();
|
|
1192
|
+
ctx.restore();
|
|
1193
|
+
}
|
|
1194
|
+
hatch(r, alpha) {
|
|
1195
|
+
const { ctx, theme: t } = this;
|
|
1196
|
+
ctx.save();
|
|
1197
|
+
roundRect(ctx, r.x, r.y, r.w, r.h, 6);
|
|
1198
|
+
ctx.clip();
|
|
1199
|
+
ctx.strokeStyle = t.error;
|
|
1200
|
+
ctx.globalAlpha = alpha * 0.55;
|
|
1201
|
+
ctx.lineWidth = 1.5;
|
|
1202
|
+
for (let x = r.x - r.h; x < r.x + r.w; x += 7) {
|
|
1203
|
+
ctx.beginPath();
|
|
1204
|
+
ctx.moveTo(x, r.y + r.h);
|
|
1205
|
+
ctx.lineTo(x + r.h, r.y);
|
|
1206
|
+
ctx.stroke();
|
|
1207
|
+
}
|
|
1208
|
+
ctx.restore();
|
|
1209
|
+
}
|
|
1210
|
+
// --- styling ------------------------------------------------------------------------
|
|
1211
|
+
rootOf(id) {
|
|
1212
|
+
const m = this.model;
|
|
1213
|
+
id = baseOf(id);
|
|
1214
|
+
const leaf = m.leaves.get(id);
|
|
1215
|
+
if (leaf)
|
|
1216
|
+
return leaf.path[0] ?? id;
|
|
1217
|
+
let g = m.groups.get(id);
|
|
1218
|
+
while (g?.parent)
|
|
1219
|
+
g = m.groups.get(g.parent);
|
|
1220
|
+
return g?.id ?? id;
|
|
1221
|
+
}
|
|
1222
|
+
colourOf(id) {
|
|
1223
|
+
return this.groupColour.get(this.rootOf(id)) ?? this.theme.edge;
|
|
1224
|
+
}
|
|
1225
|
+
fillOf(u, kind) {
|
|
1226
|
+
const t = this.theme;
|
|
1227
|
+
if (this.lens === 'ownership')
|
|
1228
|
+
return mix(this.colourOf(u.id), t.name === 'dark' ? 0.72 : 0.84, t);
|
|
1229
|
+
if (u.kind === 'group')
|
|
1230
|
+
return u.id.endsWith('#rest') ? (t.name === 'dark' ? '#171d26' : '#f3f5f8') : t.name === 'dark' ? '#1d2531' : '#eef2f7';
|
|
1231
|
+
const k = kind;
|
|
1232
|
+
if (kind === 'topic')
|
|
1233
|
+
return t.name === 'dark' ? '#2a2f3d' : '#ecebf7';
|
|
1234
|
+
return t.node[k] ?? t.node.service;
|
|
1235
|
+
}
|
|
1236
|
+
edgeColour(e) {
|
|
1237
|
+
const t = this.theme;
|
|
1238
|
+
switch (this.lens) {
|
|
1239
|
+
case 'ownership':
|
|
1240
|
+
return this.colourOf(e.from);
|
|
1241
|
+
case 'latency': {
|
|
1242
|
+
const h = logNorm(clamp(e.latencyMs, 2, 800), 2, 800);
|
|
1243
|
+
return heat(h, t.name === 'dark');
|
|
1244
|
+
}
|
|
1245
|
+
case 'reliability': {
|
|
1246
|
+
const h = e.errorRate > ERROR_FLOOR ? logNorm(e.errorRate, ERROR_FLOOR, ERROR_CEIL) : 0;
|
|
1247
|
+
return h > 0 ? mixHex(t.edge, t.error, 0.3 + h * 0.7) : t.edge;
|
|
1248
|
+
}
|
|
1249
|
+
case 'blast':
|
|
1250
|
+
return e.rigid ? (t.name === 'dark' ? '#8fa3c0' : '#4b5b73') : t.edgeMuted;
|
|
1251
|
+
default:
|
|
1252
|
+
return e.async ? t.flow.challenge : t.flow.request;
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
brighten(hex) {
|
|
1256
|
+
return mixHex(hex, this.theme.name === 'dark' ? '#ffffff' : '#000000', 0.25);
|
|
1257
|
+
}
|
|
1258
|
+
errorOf(u) {
|
|
1259
|
+
const m = this.model;
|
|
1260
|
+
if (u.kind === 'leaf')
|
|
1261
|
+
return m.leaves.get(u.id).errorRate;
|
|
1262
|
+
// A group's halo is its worst member, not its average: one burning box in
|
|
1263
|
+
// a team of forty is exactly the thing the map exists to show.
|
|
1264
|
+
let worst = 0;
|
|
1265
|
+
for (const id of u.members)
|
|
1266
|
+
worst = Math.max(worst, m.leaves.get(id).errorRate);
|
|
1267
|
+
return worst;
|
|
1268
|
+
}
|
|
1269
|
+
// --- interaction ------------------------------------------------------------------
|
|
1270
|
+
hit(sx, sy) {
|
|
1271
|
+
if (!this.layoutResult)
|
|
1272
|
+
return null;
|
|
1273
|
+
const p = this.toWorld(sx, sy);
|
|
1274
|
+
for (let i = this.units.length - 1; i >= 0; i--) {
|
|
1275
|
+
const u = this.units[i];
|
|
1276
|
+
const r = this.shapes.get(u.id);
|
|
1277
|
+
if (r && p.x >= r.x && p.x <= r.x + r.w && p.y >= r.y && p.y <= r.y + r.h)
|
|
1278
|
+
return { kind: 'unit', id: u.id, x: sx, y: sy };
|
|
1279
|
+
}
|
|
1280
|
+
// Edges: nearest within a few screen pixels.
|
|
1281
|
+
const tol = 5 / this.camera.scale;
|
|
1282
|
+
let best = null;
|
|
1283
|
+
for (const v of this.views) {
|
|
1284
|
+
const bb = { x0: Math.min(v.a.x, v.b.x, v.c1.x, v.c2.x) - tol, x1: Math.max(v.a.x, v.b.x, v.c1.x, v.c2.x) + tol, y0: Math.min(v.a.y, v.b.y, v.c1.y, v.c2.y) - tol, y1: Math.max(v.a.y, v.b.y, v.c1.y, v.c2.y) + tol };
|
|
1285
|
+
if (p.x < bb.x0 || p.x > bb.x1 || p.y < bb.y0 || p.y > bb.y1)
|
|
1286
|
+
continue;
|
|
1287
|
+
for (let k = 0; k <= 24; k++) {
|
|
1288
|
+
const q = bez(v, k / 24);
|
|
1289
|
+
const d = Math.hypot(q.x - p.x, q.y - p.y);
|
|
1290
|
+
if (d < tol + v.width / 2 && (!best || d < best.d))
|
|
1291
|
+
best = { id: v.edge.id, d };
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
if (best)
|
|
1295
|
+
return { kind: 'edge', id: best.id, x: sx, y: sy };
|
|
1296
|
+
// Band headers, innermost first.
|
|
1297
|
+
for (const b of [...this.layoutResult.bands].reverse()) {
|
|
1298
|
+
const r = this.shapes.get(b.id) ?? b;
|
|
1299
|
+
if (p.x >= r.x && p.x <= r.x + r.w && p.y >= r.y && p.y <= r.y + Math.min(r.h, 26))
|
|
1300
|
+
return { kind: 'band', id: b.id, x: sx, y: sy };
|
|
1301
|
+
}
|
|
1302
|
+
return null;
|
|
1303
|
+
}
|
|
1304
|
+
/** Zoom around a screen point, with semantic zoom at the ends of the range. */
|
|
1305
|
+
zoomAt(sx, sy, factor) {
|
|
1306
|
+
this.cameraTo = null;
|
|
1307
|
+
const before = this.toWorld(sx, sy);
|
|
1308
|
+
const minScale = this.fitScale * 0.55;
|
|
1309
|
+
const next = clamp(this.camera.scale * factor, minScale, 3);
|
|
1310
|
+
const t = now();
|
|
1311
|
+
if (factor > 1) {
|
|
1312
|
+
// Zooming in over a collapsed tile that is already large: open it.
|
|
1313
|
+
const h = this.hit(sx, sy);
|
|
1314
|
+
const u = h?.kind === 'unit' ? this.unitById.get(h.id) : undefined;
|
|
1315
|
+
if (u?.kind === 'group') {
|
|
1316
|
+
const r = this.shapes.get(u.id);
|
|
1317
|
+
if (r.w * next > 300 && t - this.lastSemantic > 700) {
|
|
1318
|
+
this.lastSemantic = t;
|
|
1319
|
+
this.expand(u.id);
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
else if (this.camera.scale <= this.fitScale * 0.9 && t - this.lastSemantic > 700) {
|
|
1325
|
+
// Zooming out when the map already fits: fold up the group under the cursor.
|
|
1326
|
+
const p = this.toWorld(sx, sy);
|
|
1327
|
+
const inner = [...this.layoutResult.bands].reverse().find((b) => {
|
|
1328
|
+
const r = this.shapes.get(b.id) ?? b;
|
|
1329
|
+
return p.x >= r.x && p.x <= r.x + r.w && p.y >= r.y && p.y <= r.y + r.h;
|
|
1330
|
+
});
|
|
1331
|
+
if (inner) {
|
|
1332
|
+
this.lastSemantic = t;
|
|
1333
|
+
this.collapse(inner.id);
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
this.camera.scale = next;
|
|
1338
|
+
const after = this.toWorld(sx, sy);
|
|
1339
|
+
this.camera.x += before.x - after.x;
|
|
1340
|
+
this.camera.y += before.y - after.y;
|
|
1341
|
+
this.opts.onCamera?.();
|
|
1342
|
+
}
|
|
1343
|
+
updateTooltip() {
|
|
1344
|
+
const h = this.hovered;
|
|
1345
|
+
if (!h || !this.pointer || !this.model || this.drag) {
|
|
1346
|
+
this.tooltip.style.opacity = '0';
|
|
1347
|
+
return;
|
|
1348
|
+
}
|
|
1349
|
+
const m = this.model;
|
|
1350
|
+
let text = '';
|
|
1351
|
+
if (h.kind === 'unit') {
|
|
1352
|
+
const u = this.unitById.get(h.id);
|
|
1353
|
+
if (u.kind === 'leaf') {
|
|
1354
|
+
const l = m.leaves.get(u.id);
|
|
1355
|
+
const bits = [l.id, `${l.kind} · ${l.path.join(' › ')}`, `in ${formatRps(l.inRps)} out ${formatRps(l.outRps)}`];
|
|
1356
|
+
if (l.errorRate > 0)
|
|
1357
|
+
bits.push(`errors ${(l.errorRate * 100).toFixed(2)}%`);
|
|
1358
|
+
if (l.kind === 'topic')
|
|
1359
|
+
bits.push(`partitions ${l.attrs.partitions ?? '?'} lag ${fmtInt(l.attrs.lag ?? 0)}`);
|
|
1360
|
+
if (l.attrs.instances)
|
|
1361
|
+
bits.push(`instances ${l.attrs.instances}`);
|
|
1362
|
+
if (this.health.dead.has(l.id))
|
|
1363
|
+
bits.push('DEAD');
|
|
1364
|
+
else if (this.health.degraded.has(l.id))
|
|
1365
|
+
bits.push('degraded (a fail-open dependency is dead)');
|
|
1366
|
+
text = bits.join('\n');
|
|
1367
|
+
}
|
|
1368
|
+
else if (!m.groups.has(u.id)) {
|
|
1369
|
+
text = `${u.members.length} more in ${m.groups.get(baseOf(u.id))?.label ?? 'this group'}\nnot in the focus's neighbourhood · click to open all`;
|
|
1370
|
+
}
|
|
1371
|
+
else {
|
|
1372
|
+
const g = m.groups.get(u.id);
|
|
1373
|
+
const topics = g.all.filter((id) => m.leaves.get(id).kind === 'topic').length;
|
|
1374
|
+
const dead = g.all.filter((id) => this.health.dead.has(id)).length;
|
|
1375
|
+
const bits = [g.label, `${m.hierarchy[g.level] ?? 'group'} · ${g.all.length - topics} services${topics ? `, ${topics} topics` : ''}`, `traffic ${formatRps(g.rps)} internal ${formatRps(this.internal.get(g.id) ?? 0)}`];
|
|
1376
|
+
const worst = this.errorOf(u);
|
|
1377
|
+
if (worst > ERROR_FLOOR)
|
|
1378
|
+
bits.push(`worst error rate ${(worst * 100).toFixed(1)}%`);
|
|
1379
|
+
if (g.lag > 0)
|
|
1380
|
+
bits.push(`worst lag ${fmtInt(g.lag)}`);
|
|
1381
|
+
if (dead)
|
|
1382
|
+
bits.push(`${dead} dead`);
|
|
1383
|
+
bits.push('click to open');
|
|
1384
|
+
text = bits.join('\n');
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
else if (h.kind === 'edge') {
|
|
1388
|
+
const e = this.edges.find((x) => x.id === h.id);
|
|
1389
|
+
if (e) {
|
|
1390
|
+
const bits = [`${e.from} → ${e.to}`, `${formatRps(e.rps)} ${formatLatency(e.latencyMs)} ${(e.errorRate * 100).toFixed(2)}% err`, e.async ? 'async (kafka)' : e.mixed ? 'sync + async' : 'sync', e.rigid ? 'fail-closed on every flow' : 'has fail-open flows'];
|
|
1391
|
+
if (e.leafEdges.length > 1)
|
|
1392
|
+
bits.push(`${e.leafEdges.length} flows between ${e.fromLeaves} and ${e.toLeaves} services`);
|
|
1393
|
+
else if (e.leafEdges[0]?.apis.length)
|
|
1394
|
+
bits.push(e.leafEdges[0].apis.slice(0, 6).join(', '));
|
|
1395
|
+
if (e.back)
|
|
1396
|
+
bits.push('against the grain (callback / cycle)');
|
|
1397
|
+
text = bits.join('\n');
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
else {
|
|
1401
|
+
const g = m.groups.get(h.id);
|
|
1402
|
+
text = `${g.label}\nclick header to collapse`;
|
|
1403
|
+
}
|
|
1404
|
+
this.tooltip.textContent = text;
|
|
1405
|
+
this.tooltip.style.opacity = '1';
|
|
1406
|
+
const x = Math.min(this.pointer.x + 14, this.width - this.tooltip.offsetWidth - 8);
|
|
1407
|
+
const y = Math.min(this.pointer.y + 14, this.height - this.tooltip.offsetHeight - 8);
|
|
1408
|
+
this.tooltip.style.transform = `translate(${x}px, ${y}px)`;
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
// --- helpers ---------------------------------------------------------------------------
|
|
1412
|
+
function now() {
|
|
1413
|
+
return typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
1414
|
+
}
|
|
1415
|
+
function ease(t) {
|
|
1416
|
+
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
|
1417
|
+
}
|
|
1418
|
+
function bez(v, t) {
|
|
1419
|
+
const mt = 1 - t;
|
|
1420
|
+
return {
|
|
1421
|
+
x: mt * mt * mt * v.a.x + 3 * mt * mt * t * v.c1.x + 3 * mt * t * t * v.c2.x + t * t * t * v.b.x,
|
|
1422
|
+
y: mt * mt * mt * v.a.y + 3 * mt * mt * t * v.c1.y + 3 * mt * t * t * v.c2.y + t * t * t * v.b.y,
|
|
1423
|
+
};
|
|
1424
|
+
}
|
|
1425
|
+
function roundRect(ctx, x, y, w, h, r) {
|
|
1426
|
+
const rr = Math.min(r, w / 2, h / 2);
|
|
1427
|
+
ctx.beginPath();
|
|
1428
|
+
ctx.moveTo(x + rr, y);
|
|
1429
|
+
ctx.arcTo(x + w, y, x + w, y + h, rr);
|
|
1430
|
+
ctx.arcTo(x + w, y + h, x, y + h, rr);
|
|
1431
|
+
ctx.arcTo(x, y + h, x, y, rr);
|
|
1432
|
+
ctx.arcTo(x, y, x + w, y, rr);
|
|
1433
|
+
ctx.closePath();
|
|
1434
|
+
}
|
|
1435
|
+
function fitText(ctx, text, maxW) {
|
|
1436
|
+
if (ctx.measureText(text).width <= maxW)
|
|
1437
|
+
return text;
|
|
1438
|
+
let s = text;
|
|
1439
|
+
while (s.length > 2 && ctx.measureText(s + '…').width > maxW)
|
|
1440
|
+
s = s.slice(0, -1);
|
|
1441
|
+
return s + '…';
|
|
1442
|
+
}
|
|
1443
|
+
function fmtInt(n) {
|
|
1444
|
+
return n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : `${Math.round(n)}`;
|
|
1445
|
+
}
|
|
1446
|
+
function hexToRgb(hex) {
|
|
1447
|
+
const h = hex.replace('#', '');
|
|
1448
|
+
const n = parseInt(h.length === 3 ? h.split('').map((c) => c + c).join('') : h, 16);
|
|
1449
|
+
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
|
1450
|
+
}
|
|
1451
|
+
function mixHex(a, b, t) {
|
|
1452
|
+
const [r1, g1, b1] = hexToRgb(a);
|
|
1453
|
+
const [r2, g2, b2] = hexToRgb(b);
|
|
1454
|
+
const c = (x, y) => Math.round(x + (y - x) * t).toString(16).padStart(2, '0');
|
|
1455
|
+
return `#${c(r1, r2)}${c(g1, g2)}${c(b1, b2)}`;
|
|
1456
|
+
}
|
|
1457
|
+
function mix(hex, toward, theme) {
|
|
1458
|
+
return mixHex(hex, theme.name === 'dark' ? '#0e1117' : '#ffffff', toward);
|
|
1459
|
+
}
|
|
1460
|
+
/** Cool to warm. */
|
|
1461
|
+
function heat(t, dark) {
|
|
1462
|
+
const stops = dark ? ['#4ad6b8', '#5aa9ff', '#c58bff', '#ffb54d', '#ff6b6b'] : ['#0d9488', '#1f6feb', '#8b5cf6', '#d97706', '#dc2626'];
|
|
1463
|
+
const i = Math.min(stops.length - 2, Math.floor(t * (stops.length - 1)));
|
|
1464
|
+
return mixHex(stops[i], stops[i + 1], t * (stops.length - 1) - i);
|
|
1465
|
+
}
|
|
1466
|
+
//# sourceMappingURL=atlas.js.map
|