Repository navigation
Expand file tree
/
Copy pathlive3d.js
More file actions
399 lines (378 loc) · 25 KB
/
Copy pathlive3d.js
File metadata and controls
399 lines (378 loc) · 25 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
/* 3D view of a lab: routers on tiers, physical links, BGP sessions as arcs, an SSH "executor" that beams configuration to the
router being configured. Three.js (vendor/three.min.js) + OrbitControls. Data: GET /api/labs/<id>/graph.
const v = Live3D.create(container, { onSelect(name) {} });
v.setGraph(graph) rebuild the scene v.update(graph) refresh state (rebuilds only if the topology changed)
v.pulse(name, kind) router is being configured v.beam(name) an SSH command travels from the executor to a router
v.select(name) v.setAutoRotate(bool) v.resetCamera() v.setLabels(bool) v.dispose()
v.highlight(names, tag) routers a scenario configures (Learn tab) v.tracePath(names, {captions, onHop}) a packet follows a path
v.setTheme('dark'|'light') the scene only (never the page); Live3D.preferred() / Live3D.setPreferred(name) remember the choice in this browser */
(() => {
const TIER = { customer: 0, content: 0, provider: 0, subsidiary: 0, branch: 1, edge: 1, pe: 1, internal: 1, leaf: 1,
"route-reflector-client": 1, core: 2, "route-reflector": 2 };
const TIER_NAME = ["Customers / outside", "Edge", "Core"];
const ROLE_COLOR = { customer: 0x3fb950, content: 0x3fb950, provider: 0xd29922, subsidiary: 0x3fb950, branch: 0x4aa3ff, edge: 0x4aa3ff, pe: 0x4aa3ff,
internal: 0x8b9bb0, leaf: 0x8b9bb0, "route-reflector-client": 0x8b9bb0, core: 0xa371f7, "route-reflector": 0xa371f7 };
const SESSION_COLOR = { ibgp: 0x4aa3ff, ebgp: 0xf0883e, vpnv4: 0xe879f9, vrf: 0x22d3ee };
const THEMES = {
dark: { bg: 0x0b1017, fogNear: 26, fogFar: 60, amb: 0x8fa4bd, ambI: 0.85, sunI: 0.9, grid: [0x22303d, 0x16212b], link: 0x4b5a6a, body: 0x1b2632, plate: 0.05,
label: "#e6edf3", sub: "#8b9bb0", stroke: "rgba(11,15,20,.92)", hl: "#fbbf24", hlHex: 0xfbbf24, cap: "#fff3c4", pkt: 0xfff3c4,
tipBg: "rgba(15,20,26,.94)", tipBd: "#2b3947", tipFg: "#e6edf3", css: "#0b1017" },
light: { bg: 0xeef2f7, fogNear: 30, fogFar: 70, amb: 0xffffff, ambI: 1.0, sunI: 0.7, grid: [0xb7c3d0, 0xdbe3ec], link: 0x8595a6, body: 0x5c6d80, plate: 0.12,
label: "#1b2733", sub: "#546578", stroke: "rgba(238,242,247,.95)", hl: "#b45309", hlHex: 0xd97706, cap: "#7c2d12", pkt: 0xb45309,
tipBg: "rgba(255,255,255,.97)", tipBd: "#c5cfda", tipFg: "#1b2733", css: "#eef2f7" },
};
const KEY = "bgp3dTheme";
function preferred() { try { const v = localStorage.getItem(KEY); return v in THEMES ? v : "dark"; } catch (e) { return "dark"; } }
function setPreferred(v) { try { localStorage.setItem(KEY, v in THEMES ? v : "dark"); } catch (e) { /* storage blocked: the choice lasts for this view only */ } }
const UP = 0x3fb950, DOWN = 0xf85149, WAIT = 0xd29922, IDLE = 0x5b6b7c;
function supported() {
try { const c = document.createElement("canvas"); return !!(window.WebGLRenderingContext && (c.getContext("webgl") || c.getContext("experimental-webgl"))); }
catch (e) { return false; }
}
function textSprite(T, lines, opts = {}) {
const c = document.createElement("canvas"), g = c.getContext("2d"), w = opts.w || 320, h = opts.h || 96;
c.width = w; c.height = h;
g.font = `600 ${opts.size || 40}px -apple-system, Segoe UI, Roboto, sans-serif`; g.textAlign = "center"; g.textBaseline = "middle";
const th = opts.th || THEMES.dark;
g.lineWidth = 6; g.strokeStyle = th.stroke; g.fillStyle = th[opts.role || "label"];
g.strokeText(lines[0], w / 2, lines[1] ? h * 0.36 : h / 2); g.fillText(lines[0], w / 2, lines[1] ? h * 0.36 : h / 2);
if (lines[1]) {
g.font = `500 ${Math.round((opts.size || 40) * 0.62)}px -apple-system, Segoe UI, Roboto, sans-serif`; g.fillStyle = th.sub;
g.strokeText(lines[1], w / 2, h * 0.76); g.fillText(lines[1], w / 2, h * 0.76);
}
const tex = new T.CanvasTexture(c); tex.minFilter = T.LinearFilter;
const sp = new T.Sprite(new T.SpriteMaterial({ map: tex, transparent: true, depthWrite: false, depthTest: false }));
sp.scale.set((opts.scale || 3.2) * (w / 320), (opts.scale || 3.2) * (h / 320), 1);
sp.renderOrder = 10;
return sp;
}
function create(container, handlers = {}) {
const T = window.THREE;
if (!T || !supported()) return null;
let TH = THEMES[handlers.theme] || THEMES[preferred()], themeName = TH === THEMES.light ? "light" : "dark";
const spr = (lines, o) => textSprite(T, lines, { ...o, th: TH });
const renderer = new T.WebGLRenderer({ antialias: true, alpha: false, preserveDrawingBuffer: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setClearColor(TH.bg, 1);
container.appendChild(renderer.domElement); container.dataset.t3d = themeName;
renderer.domElement.style.cssText = "display:block;width:100%;height:100%;outline:none";
const scene = new T.Scene();
scene.fog = new T.Fog(TH.bg, TH.fogNear, TH.fogFar);
const camera = new T.PerspectiveCamera(48, 1, 0.1, 200);
const controls = new T.OrbitControls(camera, renderer.domElement);
controls.enableDamping = true; controls.dampingFactor = 0.08; controls.maxPolarAngle = Math.PI * 0.49; controls.minDistance = 4; controls.maxDistance = 45;
controls.autoRotate = true; controls.autoRotateSpeed = 0.5;
controls.addEventListener("start", () => { controls.autoRotate = false; state.rotate = false; handlers.onRotate && handlers.onRotate(false); });
const amb = new T.AmbientLight(TH.amb, TH.ambI); scene.add(amb);
const sun = new T.DirectionalLight(0xffffff, TH.sunI); sun.position.set(6, 14, 8); scene.add(sun);
const world = new T.Group(); scene.add(world);
const tip = document.createElement("div");
tip.style.cssText = "position:absolute;pointer-events:none;display:none;border-radius:6px;padding:6px 9px;font:12px/1.45 -apple-system,Segoe UI,sans-serif;z-index:5;max-width:260px";
const styleTip = () => { tip.style.background = TH.tipBg; tip.style.border = "1px solid " + TH.tipBd; tip.style.color = TH.tipFg; };
styleTip();
container.style.position = container.style.position || "relative"; container.appendChild(tip);
const state = { hl: {}, hlArgs: null, traceArgs: null, trace: null, graph: null, sig: "", rotate: true, labels: true, nodes: {}, sessions: [], pulses: [], beams: [], selected: null, extent: 10, center: new T.Vector3(), exec: null };
const clock = new T.Clock();
const ray = new T.Raycaster(), mouse = new T.Vector2();
let raf = 0, disposed = false, hoverName = null;
const roleTier = r => (r in TIER ? TIER[r] : 1);
const nodeColor = r => ROLE_COLOR[r] ?? 0x8b9bb0;
function clearWorld() {
world.traverse(o => { if (o.geometry) o.geometry.dispose(); if (o.material) { (o.material.map) && o.material.map.dispose(); o.material.dispose && o.material.dispose(); } });
while (world.children.length) world.remove(world.children[0]);
state.nodes = {}; state.sessions = []; state.pulses = []; state.beams = []; state.selected = null; state.hl = {}; state.trace = null;
}
function layout(graph) {
const xs = graph.nodes.map(n => n.x), ys = graph.nodes.map(n => n.y);
const minX = Math.min(...xs), maxX = Math.max(...xs), minY = Math.min(...ys), maxY = Math.max(...ys);
const span = Math.max(maxX - minX, maxY - minY, 200), k = 21 / span;
const pos = {};
for (const n of graph.nodes) pos[n.name] = new T.Vector3((n.x - (minX + maxX) / 2) * k, roleTier(n.role) * 2.1, (n.y - (minY + maxY) / 2) * k);
return { pos, w: (maxX - minX) * k, d: (maxY - minY) * k };
}
function cylBetween(a, b, r, mat) {
const d = new T.Vector3().subVectors(b, a), len = d.length();
const m = new T.Mesh(new T.CylinderGeometry(r, r, len, 8), mat);
m.position.copy(a).addScaledVector(d, 0.5);
m.quaternion.setFromUnitVectors(new T.Vector3(0, 1, 0), d.clone().normalize());
return m;
}
function build(graph) {
clearWorld();
const { pos, w, d } = layout(graph);
state.extent = Math.max(w, d, 8);
if (handlers.compact) state.center.set(0, 1.4, 0);
const usedTiers = [...new Set(graph.nodes.map(n => roleTier(n.role)))].sort();
// floor and tier plates
const grid = new T.GridHelper(Math.max(w, d) + 14, 24, TH.grid[0], TH.grid[1]); grid.position.y = -0.3; world.add(grid);
for (const t of usedTiers) {
const plate = new T.Mesh(new T.PlaneGeometry(w + 6, d + 5), new T.MeshBasicMaterial({ color: [0x3fb950, 0x4aa3ff, 0xa371f7][t] || 0x4aa3ff, transparent: true, opacity: TH.plate, side: T.DoubleSide, depthWrite: false }));
plate.rotation.x = -Math.PI / 2; plate.position.y = t * 2.1 - 0.28; world.add(plate);
const lab = spr([TIER_NAME[t] || "Tier"], { w: 420, h: 70, size: 34, role: "sub", scale: 3.0 });
lab.position.set(-(w + 6) / 2 - 2.3, t * 2.1 - 0.05, 0); world.add(lab); lab.userData.tier = true;
}
// physical links
const linkMat = new T.MeshStandardMaterial({ color: TH.link, roughness: 0.6, metalness: 0.2 });
for (const lk of graph.links) {
if (!pos[lk.a] || !pos[lk.b]) continue;
const m = cylBetween(pos[lk.a], pos[lk.b], 0.045, linkMat); m.userData = { link: lk }; world.add(m);
}
// routers
for (const n of graph.nodes) {
const g = new T.Group(); g.position.copy(pos[n.name]);
const col = nodeColor(n.role);
const body = new T.Mesh(new T.CylinderGeometry(0.62, 0.7, 0.34, 6), new T.MeshStandardMaterial({ color: TH.body, roughness: 0.45, metalness: 0.5 }));
const cap = new T.Mesh(new T.CylinderGeometry(0.5, 0.5, 0.06, 6), new T.MeshStandardMaterial({ color: col, emissive: col, emissiveIntensity: 0.55 }));
cap.position.y = 0.2;
const ring = new T.Mesh(new T.RingGeometry(0.82, 0.92, 40), new T.MeshBasicMaterial({ color: IDLE, transparent: true, opacity: 0.9, side: T.DoubleSide }));
ring.rotation.x = -Math.PI / 2; ring.position.y = -0.16;
const sel = new T.Mesh(new T.RingGeometry(1.0, 1.08, 48), new T.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0, side: T.DoubleSide }));
sel.rotation.x = -Math.PI / 2; sel.position.y = -0.15;
const label = spr([n.name, `AS ${n.asn}`], { size: 44, scale: 3.0 }); label.position.y = 1.05;
g.add(body, cap, ring, sel, label);
g.userData = { name: n.name, node: n, cap, ring, sel, label, body, col };
world.add(g); state.nodes[n.name] = g;
body.userData.router = n.name; cap.userData.router = n.name; ring.userData.router = n.name;
}
// BGP sessions as arcs
const seen = {};
for (const s of graph.sessions) {
const a = pos[s.a], b = pos[s.b];
if (!a || !b) continue;
const pair = [s.a, s.b].sort().join("|"), idx = seen[pair] = (seen[pair] || 0) + 1;
const dist = a.distanceTo(b), mid = a.clone().add(b).multiplyScalar(0.5);
const side = new T.Vector3().subVectors(b, a).cross(new T.Vector3(0, 1, 0)).normalize().multiplyScalar(0.35 * (idx - 1));
mid.add(side); mid.y += 0.9 + 0.13 * dist + 0.2 * (idx - 1);
const curve = new T.QuadraticBezierCurve3(a.clone().add(new T.Vector3(0, 0.3, 0)), mid, b.clone().add(new T.Vector3(0, 0.3, 0)));
const col = SESSION_COLOR[s.kind] || 0x4aa3ff;
const mat = new T.MeshBasicMaterial({ color: col, transparent: true, opacity: 0.85 });
const tube = new T.Mesh(new T.TubeGeometry(curve, 28, 0.045, 6, false), mat);
tube.userData = { session: s };
world.add(tube);
const dots = [0, 0.5].map(o => { const dm = new T.Mesh(new T.SphereGeometry(0.1, 10, 10), new T.MeshBasicMaterial({ color: 0xffffff })); world.add(dm); return { mesh: dm, off: o }; });
state.sessions.push({ s, curve, mat, tube, dots, col, boost: 0 });
}
// the SSH executor: where every configuration command comes from
const ex = new T.Group(); ex.visible = !handlers.compact;
const box = new T.Mesh(new T.OctahedronGeometry(0.5), new T.MeshStandardMaterial({ color: 0x22d3ee, emissive: 0x22d3ee, emissiveIntensity: 0.6 }));
ex.add(box);
const exl = spr(["SSH executor", "ssh lab@192.168.99.x"], { size: 40, scale: 2.5 }); exl.position.y = 1.0; ex.add(exl);
ex.position.set(0, 0.9, d / 2 + 4.6); world.add(ex);
state.exec = ex;
const ctr = new T.Vector3(0, 1, 0);
state.center.copy(ctr);
applyState(graph);
resetCamera();
}
function applyState(graph) {
const live = graph.lab.live, statuses = {};
for (const n of graph.nodes) statuses[n.name] = n;
for (const [name, g] of Object.entries(state.nodes)) {
const n = statuses[name] || {};
let ring = IDLE, dim = 0.35;
if (live) {
if (n.status === "stopped") { ring = IDLE; dim = 0.2; }
else if (n.reachable === true) { ring = UP; dim = 0.75; }
else if (n.reachable === false) { ring = DOWN; dim = 0.5; }
else { ring = WAIT; dim = 0.55; }
} else if (n.status && n.status !== "stopped" && n.status !== "unknown") { ring = WAIT; dim = 0.5; }
g.userData.ring.material.color.setHex(ring);
g.userData.cap.material.emissiveIntensity = dim;
g.userData.node = n; g.userData.status = live ? (n.reachable === true ? "reachable" : n.reachable === false ? "not answering" : n.status) : "preview (lab not running)";
}
for (const ss of state.sessions) {
const s = graph.sessions.find(x => x.a === ss.s.a && x.b === ss.s.b && x.kind === ss.s.kind) || ss.s;
ss.s = s;
let col = ss.col, op = 0.55;
if (live && s.established === true) { op = 0.95; }
else if (live && s.established === false) { col = DOWN; op = 0.9; }
else if (live) { op = 0.5; }
ss.mat.color.setHex(col); ss.mat.opacity = op;
ss.up = live && s.established === true;
ss.dots.forEach(d => { d.mesh.visible = ss.up || (!live); d.mesh.material.color.setHex(0xffffff); });
}
}
function resetCamera() {
const e = state.extent;
const k = handlers.compact ? 0.86 : 1; // embedded views (Learn tab) sit closer to the routers
camera.position.set(e * 0.3 * k, e * 0.48 * k + 1.2, e * 0.72 * k + 1.5);
controls.target.copy(state.center); controls.update();
}
function setGraph(graph) {
state.graph = graph;
state.sig = sigOf(graph);
build(graph);
}
const sigOf = g => g.lab.id + "|" + g.nodes.map(n => n.name).join(",") + "|" + g.links.length + "|" + g.sessions.length;
function update(graph) {
if (sigOf(graph) !== state.sig) return setGraph(graph);
state.graph = graph; applyState(graph);
}
// ---- effects
function pulse(name, kind) {
const g = state.nodes[name]; if (!g) return;
const col = kind === "err" ? DOWN : 0x22d3ee;
const ring = new T.Mesh(new T.RingGeometry(0.8, 0.9, 48), new T.MeshBasicMaterial({ color: col, transparent: true, opacity: 0.9, side: T.DoubleSide, depthWrite: false }));
ring.rotation.x = -Math.PI / 2; ring.position.copy(g.position); ring.position.y += 0.05; world.add(ring);
state.pulses.push({ mesh: ring, t0: clock.getElapsedTime(), life: 1.4 });
g.userData.flash = clock.getElapsedTime();
state.sessions.forEach(ss => { if (ss.s.a === name || ss.s.b === name) ss.boost = clock.getElapsedTime() + 6; });
}
function beam(name) {
const g = state.nodes[name]; if (!g || !state.exec) return;
const a = state.exec.position.clone(), b = g.position.clone().add(new T.Vector3(0, 0.3, 0));
const mid = a.clone().add(b).multiplyScalar(0.5); mid.y += 1.6;
const curve = new T.QuadraticBezierCurve3(a, mid, b);
const mat = new T.MeshBasicMaterial({ color: 0x22d3ee, transparent: true, opacity: 0.55 });
const tube = new T.Mesh(new T.TubeGeometry(curve, 24, 0.03, 5, false), mat);
const pk = new T.Mesh(new T.SphereGeometry(0.13, 10, 10), new T.MeshBasicMaterial({ color: 0xffffff }));
world.add(tube, pk);
state.beams.push({ tube, pk, curve, t0: clock.getElapsedTime(), life: 1.6 });
}
function select(name) {
state.selected = name;
for (const [n, g] of Object.entries(state.nodes)) g.userData.sel.material.opacity = n === name ? 0.95 : 0;
}
// ---- teaching aids (Learn tab): routers a scenario configures, and a packet that follows a path
function highlight(names, tag) {
state.hlArgs = [names, tag];
for (const m of Object.values(state.hl)) { world.remove(m.ring, m.tag); m.ring.geometry.dispose(); m.ring.material.dispose(); m.tag.material.map.dispose(); m.tag.material.dispose(); }
state.hl = {};
for (const name of names || []) {
const g = state.nodes[name]; if (!g) continue;
const ring = new T.Mesh(new T.RingGeometry(1.12, 1.26, 48), new T.MeshBasicMaterial({ color: TH.hlHex, transparent: true, opacity: 0.9, side: T.DoubleSide, depthWrite: false }));
ring.rotation.x = -Math.PI / 2; ring.position.copy(g.position); ring.position.y -= 0.12;
const label = spr([tag || "configured here"], { w: 360, h: 64, size: 30, role: "hl", scale: 2.2 });
label.position.copy(g.position); label.position.y += 1.75;
world.add(ring, label); state.hl[name] = { ring, tag: label };
}
}
function tracePath(names, opts = {}) {
stopTrace();
state.traceArgs = [names, opts];
const pts = (names || []).map(n => state.nodes[n]).filter(Boolean).map(g => g.position.clone().add(new T.Vector3(0, 0.55, 0)));
if (pts.length < 2) return false;
const curve = new T.CatmullRomCurve3(pts, false, "catmullrom", 0.15);
const path = new T.Mesh(new T.TubeGeometry(curve, 12 * pts.length, 0.045, 6, false), new T.MeshBasicMaterial({ color: TH.hlHex, transparent: true, opacity: 0.32 }));
const pkt = new T.Mesh(new T.SphereGeometry(0.2, 14, 14), new T.MeshBasicMaterial({ color: TH.pkt }));
const halo = new T.Mesh(new T.SphereGeometry(0.34, 14, 14), new T.MeshBasicMaterial({ color: TH.hlHex, transparent: true, opacity: 0.35, depthWrite: false }));
pkt.add(halo);
const dists = [0]; for (let i = 1; i < pts.length; i++) dists.push(dists[i - 1] + pts[i].distanceTo(pts[i - 1]));
const u = dists.map(d => d / dists[dists.length - 1]);
const caps = (opts.captions || []).map(c => { const sp = spr([c], { w: 520, h: 64, size: 30, role: "cap", scale: 3.4 }); sp.visible = false; world.add(sp); return sp; });
world.add(path, pkt);
state.trace = { curve, path, pkt, u, caps, speed: opts.speed || 0.07, onHop: opts.onHop, last: -1, names };
return true;
}
function stopTrace(keep) {
if (!keep) state.traceArgs = null;
const t = state.trace; if (!t) return;
world.remove(t.path, t.pkt, ...t.caps); t.path.geometry.dispose(); t.pkt.geometry.dispose();
t.caps.forEach(c => { c.material.map.dispose(); c.material.dispose(); }); state.trace = null;
}
// ---- theme: light or dark scene (the page around it is not touched)
function setTheme(name) {
if (!(name in THEMES) || name === themeName) return;
TH = THEMES[name]; themeName = name; container.dataset.t3d = name;
renderer.setClearColor(TH.bg, 1); scene.fog.color.setHex(TH.bg); scene.fog.near = TH.fogNear; scene.fog.far = TH.fogFar;
amb.color.setHex(TH.amb); amb.intensity = TH.ambI; sun.intensity = TH.sunI; styleTip();
if (!state.graph) return;
const sel = state.selected, hl = state.hlArgs, tr = state.traceArgs; // build() clears these: draw them again in the new colors
build(state.graph);
if (hl) highlight(hl[0], hl[1]);
if (tr) tracePath(tr[0], tr[1]);
if (sel) select(sel);
}
// ---- interaction
function pick(ev) {
const r = renderer.domElement.getBoundingClientRect();
mouse.set(((ev.clientX - r.left) / r.width) * 2 - 1, -((ev.clientY - r.top) / r.height) * 2 + 1);
ray.setFromCamera(mouse, camera);
const targets = Object.values(state.nodes).flatMap(g => [g.userData.body, g.userData.cap, g.userData.ring]);
const hit = ray.intersectObjects(targets, false)[0];
if (hit) return { router: hit.object.userData.router };
const sh = ray.intersectObjects(state.sessions.map(s => s.tube), false)[0];
return sh ? { session: sh.object.userData.session } : null;
}
renderer.domElement.addEventListener("pointermove", ev => {
const h = pick(ev);
if (!h) { tip.style.display = "none"; renderer.domElement.style.cursor = "default"; return; }
renderer.domElement.style.cursor = h.router ? "pointer" : "default";
const r = container.getBoundingClientRect();
if (h.router) {
const g = state.nodes[h.router].userData, n = g.node;
tip.innerHTML = `<b>${h.router}</b> <span style="color:${TH.sub}">${n.role}</span><br>AS ${n.asn} · ${n.mgmt_ip}<br><span style="color:${TH.sub}">${g.status}</span>`;
} else {
const s = h.session;
tip.innerHTML = `<b>${s.a} ↔ ${s.b}</b><br>${({ ibgp: "iBGP", ebgp: "eBGP", vpnv4: "MP-BGP VPNv4", vrf: "eBGP in VRF (PE-CE)" })[s.kind] || s.kind}<br><span style="color:${s.established === true ? "#3fb950" : s.established === false ? "#f85149" : TH.sub}">${s.state}${s.established ? " · " + s.prefixes + " prefixes" : ""}</span>`;
}
tip.style.display = "block"; tip.style.left = Math.min(ev.clientX - r.left + 14, r.width - 270) + "px"; tip.style.top = (ev.clientY - r.top + 14) + "px";
});
renderer.domElement.addEventListener("pointerleave", () => { tip.style.display = "none"; });
let down = null;
renderer.domElement.addEventListener("pointerdown", ev => { down = [ev.clientX, ev.clientY]; });
renderer.domElement.addEventListener("pointerup", ev => {
if (!down || Math.hypot(ev.clientX - down[0], ev.clientY - down[1]) > 4) return;
const h = pick(ev);
if (h && h.router) { select(h.router); handlers.onSelect && handlers.onSelect(h.router); }
});
// ---- loop
function resize() {
const w = container.clientWidth || 600, h = container.clientHeight || 400;
renderer.setSize(w, h, false); camera.aspect = w / h; camera.updateProjectionMatrix();
}
const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(resize) : null;
ro && ro.observe(container);
resize();
function frame() {
if (disposed) return;
raf = requestAnimationFrame(frame);
if (document.hidden || container.offsetParent === null) return;
const t = clock.getElapsedTime();
controls.autoRotate = state.rotate; controls.update();
for (const g of Object.values(state.nodes)) {
const f = g.userData.flash ? Math.max(0, 1 - (t - g.userData.flash) / 1.2) : 0;
if (f > 0) g.userData.cap.material.emissiveIntensity = 0.75 + f * 1.4;
g.userData.label.visible = state.labels;
g.userData.label.material.opacity = 1;
g.userData.ring.rotation.z = t * 0.3;
}
if (state.exec) { state.exec.children[0].rotation.y = t * 0.8; state.exec.children[1].visible = state.labels; }
state.pulses = state.pulses.filter(p => {
const k = (t - p.t0) / p.life;
if (k >= 1) { world.remove(p.mesh); p.mesh.geometry.dispose(); p.mesh.material.dispose(); return false; }
p.mesh.scale.setScalar(1 + k * 2.2); p.mesh.material.opacity = 0.9 * (1 - k); return true;
});
state.beams = state.beams.filter(b => {
const k = (t - b.t0) / b.life;
if (k >= 1) { world.remove(b.tube, b.pk); b.tube.geometry.dispose(); b.pk.geometry.dispose(); return false; }
b.pk.position.copy(b.curve.getPoint(Math.min(1, k * 1.15))); b.tube.material.opacity = 0.6 * (1 - k * 0.6); return true;
});
for (const [name, m] of Object.entries(state.hl)) {
const k = 1 + 0.08 * Math.sin(t * 3); m.ring.scale.setScalar(k); m.ring.material.opacity = 0.65 + 0.3 * Math.sin(t * 3); m.tag.visible = state.labels;
}
if (state.trace) {
const tr = state.trace, p = (t * tr.speed) % 1;
tr.pkt.position.copy(tr.curve.getPointAt(p)); tr.pkt.children[0].scale.setScalar(1 + 0.25 * Math.sin(t * 8));
let i = 0; while (i + 1 < tr.u.length && tr.u[i + 1] <= p) i++;
if (i !== tr.last) { tr.last = i; tr.caps.forEach((c, k) => { c.visible = k === i; }); tr.onHop && tr.onHop(i, tr.names[i]); }
const c = tr.caps[i]; if (c) c.position.copy(tr.pkt.position).add(new T.Vector3(0, 0.95, 0));
}
for (const ss of state.sessions) {
const fast = ss.boost > t ? 2.6 : 1;
ss.dots.forEach(d => { if (!d.mesh.visible) return; const u = ((t * 0.16 * fast + d.off) % 1); d.mesh.position.copy(ss.curve.getPoint(u)); });
if (ss.boost > t) ss.mat.opacity = 0.75 + 0.25 * Math.sin(t * 9);
}
renderer.render(scene, camera);
}
frame();
return {
setGraph, update, pulse, beam, select, resize, resetCamera, highlight, tracePath, stopTrace, setTheme, theme: () => themeName,
setAutoRotate(v) { state.rotate = !!v; controls.autoRotate = !!v; },
setLabels(v) { state.labels = !!v; },
snapshot() { renderer.render(scene, camera); return renderer.domElement.toDataURL("image/png"); },
dispose() { disposed = true; cancelAnimationFrame(raf); ro && ro.disconnect(); clearWorld(); renderer.dispose(); renderer.domElement.remove(); tip.remove(); },
};
}
window.Live3D = { create, supported, preferred, setPreferred };
})();