icycache 0.3.2__py3-none-any.whl

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.
@@ -0,0 +1,2190 @@
1
+ /* ============================================================================
2
+ ICYCACHE WEBGL — moteur 3D temps réel (Three.js r128)
3
+ ----------------------------------------------------------------------------
4
+ Trois modules, tous pilotés par la VRAIE donnée du cache IcyCache :
5
+
6
+ A. HumanNeuron (#globe) — une VRAIE cellule neurale en 3D :
7
+ soma organique, noyau, dendrites branchees,
8
+ epines, axone myelinise (noeuds de Ranvier),
9
+ terminaux axoniques. Chaque entree de cache
10
+ est un noeud connecte au soma (synapse) et
11
+ a ses voisins : TOUT est relie.
12
+
13
+ B. AgentLoop (#ring3d) — le pipeline agent (PROMPT→NORMALIZE→KEY→
14
+ CACHE→SERVE) sur une lemniscate de Gerono
15
+ (figure-8) en 3D, avec stations, tete de
16
+ charge, eclair, mode vol.
17
+
18
+ C. Chart3D (#bars3d…) — histogramme 3D extrude empile (hits / misses /
19
+ bypass) + ruban 3D des tokens economises.
20
+
21
+ Chaque module expose une API de controle complete. Les proprietes sont
22
+ definies via des accesseurs : ecrire `core.autoSpin = false` suffit a
23
+ declencher le recalcul, sans appel de methode.
24
+
25
+ Convention de couleurs (alignee sur le CSS du dashboard) :
26
+ cyan #00ffff → service depuis le cache (hit)
27
+ fire #ff6400 → generation payante (miss)
28
+ violet #50b4ff → bypass
29
+ ember #ffaa00 → tokens economises
30
+ steel #6b7a99 → depense / neutre
31
+ ========================================================================== */
32
+ (function () {
33
+ 'use strict';
34
+
35
+ if (typeof THREE === 'undefined') {
36
+ console.warn('[IcyWebGL] Three.js absent — les modules 3D sont desactives.');
37
+ return;
38
+ }
39
+
40
+ /* ---------------------------------------------------------------- palette
41
+ Chaque role a SA couleur, alignee sur le CSS du dashboard :
42
+ vert #3dffa0 → service depuis le cache (hit) = economie
43
+ rouge #ff4d6d → generation payante (miss) = cout
44
+ bleu #4d9dff → bypass
45
+ jaune #ffd24a → tokens economises
46
+ La regle : une couleur = une information. Trois nuances de cyan
47
+ ne permettaient pas de distinguer un hit d'un miss d'un bypass. */
48
+ const C = {
49
+ HIT: 0x3dffa0,
50
+ MISS: 0xff4d6d,
51
+ BYPASS: 0x4d9dff,
52
+ SAVED: 0xffd24a,
53
+ SPENT: 0x5a6a85,
54
+ SHELL: 0x1d6b8c,
55
+ GRID: 0x2f6f8f,
56
+ MEMBRANE: 0x1b5e77,
57
+ NEURITE: 0x2a9dbd,
58
+ MYELIN: 0xbfe9ff,
59
+ NUCLEUS: 0x7dffcf,
60
+ AXON: 0x3fd0ff
61
+ };
62
+
63
+ const MODEL_COLORS = [
64
+ 0x3dffa0, 0xff4d6d, 0x4d9dff, 0xffd24a,
65
+ 0xff5fd2, 0xa98bff, 0x00e5ff, 0x2fe0c8
66
+ ];
67
+
68
+ const STAGES = ['PROMPT', 'NORMALIZE', 'KEY', 'CACHE', 'SERVE'];
69
+ const SHAPES = ['loop', 'torus', 'helix', 'trefoil'];
70
+
71
+ /* ---------------------------------------------------------------- helpers */
72
+ const DPR = () => Math.min(window.devicePixelRatio || 1, 2);
73
+ const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
74
+ const lerp = (a, b, t) => a + (b - a) * t;
75
+
76
+ /* PRNG deterministe : le neurone doit avoir la MEME forme a chaque
77
+ rechargement, sinon la silhouette change au reload (tres desorientant). */
78
+ function mulberry32(a) {
79
+ return function () {
80
+ a |= 0; a = (a + 0x6D2B79F5) | 0;
81
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
82
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
83
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
84
+ };
85
+ }
86
+
87
+ /* Bruit lisse (somme de sinus) — deformation organique du soma. */
88
+ function vnoise(x, y, z, seed) {
89
+ return Math.sin(x * 1.73 + seed) * Math.cos(y * 1.91 - seed * 0.7) +
90
+ Math.sin(z * 2.37 + seed * 1.3) * Math.cos(x * 1.11 + seed * 0.4) * 0.6 +
91
+ Math.sin((x + y + z) * 2.9 + seed * 2.1) * 0.25;
92
+ }
93
+
94
+ /* Texture de halo radiale (partagee par tous les modules). */
95
+ let _haloTex = null;
96
+ function haloTexture() {
97
+ if (_haloTex) return _haloTex;
98
+ const s = 128, cv = document.createElement('canvas');
99
+ cv.width = cv.height = s;
100
+ const g = cv.getContext('2d');
101
+ const grd = g.createRadialGradient(s / 2, s / 2, 0, s / 2, s / 2, s / 2);
102
+ grd.addColorStop(0.00, 'rgba(255,255,255,1)');
103
+ grd.addColorStop(0.18, 'rgba(255,255,255,.55)');
104
+ grd.addColorStop(0.45, 'rgba(255,255,255,.16)');
105
+ grd.addColorStop(1.00, 'rgba(255,255,255,0)');
106
+ g.fillStyle = grd;
107
+ g.fillRect(0, 0, s, s);
108
+ _haloTex = new THREE.CanvasTexture(cv);
109
+ return _haloTex;
110
+ }
111
+
112
+ /* Etiquette texte -> sprite. La taille est pilotee en pixels ecran par
113
+ `fitLabelScale`, donc le texte reste lisible a TOUTE distance. */
114
+ function labelSprite(text, color, scale) {
115
+ const pad = 8, fs = 26;
116
+ const cv = document.createElement('canvas');
117
+ const g = cv.getContext('2d');
118
+ g.font = '600 ' + fs + 'px "Segoe UI", system-ui, sans-serif';
119
+ const w = Math.ceil(g.measureText(text).width) + pad * 2;
120
+ cv.width = w; cv.height = fs + pad * 2;
121
+ const g2 = cv.getContext('2d');
122
+ g2.font = '600 ' + fs + 'px "Segoe UI", system-ui, sans-serif';
123
+ g2.textBaseline = 'middle';
124
+ g2.shadowColor = 'rgba(0,0,0,.85)';
125
+ g2.shadowBlur = 6;
126
+ g2.fillStyle = '#' + new THREE.Color(color).getHexString();
127
+ g2.fillText(text, pad, cv.height / 2);
128
+ const tex = new THREE.CanvasTexture(cv);
129
+ tex.needsUpdate = true;
130
+ const mat = new THREE.SpriteMaterial({
131
+ map: tex, transparent: true, depthTest: false, depthWrite: false
132
+ });
133
+ const sp = new THREE.Sprite(mat);
134
+ sp.scale.set((cv.width / cv.height) * scale, scale, 1);
135
+ sp.raycast = function () {}; // les labels ne capturent pas le picking
136
+ sp.userData.isLabel = true;
137
+ sp.userData.aspect = cv.width / cv.height;
138
+ sp.userData.px = fs + pad * 2; // hauteur du sprite en pixels source
139
+ return sp;
140
+ }
141
+
142
+ /* Echantillonne une polyligne en u \in [0,1] (longueur proportionnelle). */
143
+ function samplePath(pts, u) {
144
+ if (!pts || pts.length === 0) return new THREE.Vector3();
145
+ if (pts.length === 1) return pts[0].clone();
146
+ let total = 0;
147
+ const segs = [];
148
+ for (let i = 1; i < pts.length; i++) {
149
+ const d = pts[i].distanceTo(pts[i - 1]);
150
+ segs.push(d);
151
+ total += d;
152
+ }
153
+ if (total <= 1e-9) return pts[0].clone();
154
+ let target = ((u % 1) + 1) % 1 * total;
155
+ for (let i = 0; i < segs.length; i++) {
156
+ if (target <= segs[i] || i === segs.length - 1) {
157
+ const f = segs[i] > 1e-9 ? target / segs[i] : 0;
158
+ return pts[i].clone().lerp(pts[i + 1], clamp(f, 0, 1));
159
+ }
160
+ target -= segs[i];
161
+ }
162
+ return pts[pts.length - 1].clone();
163
+ }
164
+
165
+ /* ==========================================================================
166
+ SAFE CURVE — parametrisation par abscisse curviligne maison.
167
+ Three.js CatmullRomCurve3.getPointAt() peut renvoyer NaN quand deux
168
+ echantillons consecutifs sont identiques (segment de longueur nulle).
169
+ On recalcule donc nous-memes la table de longueurs : division protegee,
170
+ indices bornes -> aucun NaN possible.
171
+ ========================================================================== */
172
+ function SafeCurve(fn, samples) {
173
+ this.fn = fn;
174
+ this.samples = samples || 900;
175
+ this._built = false;
176
+ }
177
+ SafeCurve.prototype._build = function () {
178
+ const n = this.samples;
179
+ const pts = new Array(n + 1);
180
+ const cum = new Float32Array(n + 1);
181
+ let len = 0;
182
+ for (let i = 0; i <= n; i++) {
183
+ const p = this.fn(i / n);
184
+ pts[i] = p;
185
+ if (i > 0) {
186
+ const d = p.distanceTo(pts[i - 1]);
187
+ len += (isFinite(d) && d > 0) ? d : 0;
188
+ }
189
+ cum[i] = len;
190
+ }
191
+ this._pts = pts;
192
+ this._cum = cum;
193
+ this._len = len || 1;
194
+ this._built = true;
195
+ };
196
+ SafeCurve.prototype.pointAt = function (u) {
197
+ if (!this._built) this._build();
198
+ u = ((u % 1) + 1) % 1;
199
+ const target = u * this._len;
200
+ const cum = this._cum;
201
+ let lo = 0, hi = this.samples;
202
+ while (lo < hi) {
203
+ const mid = (lo + hi) >> 1;
204
+ if (cum[mid] < target) lo = mid + 1; else hi = mid;
205
+ }
206
+ let i = lo;
207
+ if (i <= 0) return this._pts[0].clone();
208
+ if (i > this.samples) i = this.samples;
209
+ const l0 = cum[i - 1], l1 = cum[i];
210
+ const seg = l1 - l0;
211
+ const f = seg > 1e-12 ? (target - l0) / seg : 0;
212
+ return this._pts[i - 1].clone().lerp(this._pts[i], f);
213
+ };
214
+ SafeCurve.prototype.tangentAt = function (u) {
215
+ const a = this.pointAt(u - 0.004);
216
+ const b = this.pointAt(u + 0.004);
217
+ const t = new THREE.Vector3().subVectors(b, a);
218
+ if (t.lengthSq() < 1e-12) return new THREE.Vector3(0, 1, 0);
219
+ return t.normalize();
220
+ };
221
+
222
+ /* ==========================================================================
223
+ TUBE A TAPER — tube de rayon variable le long d'une polyligne.
224
+ (TubeGeometry de Three.js a un rayon constant ; on veut des dendrites
225
+ qui s'effilent, un axone qui grossit vers le soma, etc.)
226
+ ========================================================================== */
227
+ function taperedTube(points, radiusAt, radialSeg, material) {
228
+ const n = points.length;
229
+ if (n < 2) return null;
230
+ const T = [];
231
+ for (let i = 0; i < n; i++) {
232
+ const a = points[Math.max(0, i - 1)], b = points[Math.min(n - 1, i + 1)];
233
+ const t = new THREE.Vector3().subVectors(b, a);
234
+ if (t.lengthSq() < 1e-12) t.set(0, 1, 0);
235
+ T.push(t.normalize());
236
+ }
237
+ /* Transport parallele : on fait "rouler" une normale le long de la courbe
238
+ pour eviter la torsion de Frenet (qui vrille les tubes). */
239
+ let N = new THREE.Vector3(0, 1, 0);
240
+ if (Math.abs(N.dot(T[0])) > 0.9) N.set(1, 0, 0);
241
+ N.crossVectors(T[0], N).normalize();
242
+
243
+ const frames = [];
244
+ for (let i = 0; i < n; i++) {
245
+ if (i > 0) {
246
+ const axis = new THREE.Vector3().crossVectors(T[i - 1], T[i]);
247
+ const l = axis.length();
248
+ if (l > 1e-8) {
249
+ axis.divideScalar(l);
250
+ const ang = Math.atan2(l, T[i - 1].dot(T[i]));
251
+ N.applyAxisAngle(axis, ang);
252
+ }
253
+ N.addScaledVector(T[i], -N.dot(T[i])).normalize();
254
+ }
255
+ frames.push({ n: N.clone(), b: new THREE.Vector3().crossVectors(T[i], N) });
256
+ }
257
+
258
+ const verts = [], norms = [], uvs = [], idx = [];
259
+ for (let i = 0; i < n; i++) {
260
+ const r = radiusAt(i / (n - 1));
261
+ const f = frames[i], p = points[i];
262
+ for (let j = 0; j <= radialSeg; j++) {
263
+ const th = (j / radialSeg) * Math.PI * 2;
264
+ const cx = Math.cos(th), sy = Math.sin(th);
265
+ const nx = f.n.x * cx + f.b.x * sy;
266
+ const ny = f.n.y * cx + f.b.y * sy;
267
+ const nz = f.n.z * cx + f.b.z * sy;
268
+ verts.push(p.x + nx * r, p.y + ny * r, p.z + nz * r);
269
+ norms.push(nx, ny, nz);
270
+ uvs.push(j / radialSeg, i / (n - 1));
271
+ }
272
+ }
273
+ for (let i = 0; i < n - 1; i++) {
274
+ for (let j = 0; j < radialSeg; j++) {
275
+ const a = i * (radialSeg + 1) + j;
276
+ const b = a + radialSeg + 1;
277
+ idx.push(a, b, a + 1, b, b + 1, a + 1);
278
+ }
279
+ }
280
+ const geo = new THREE.BufferGeometry();
281
+ geo.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3));
282
+ geo.setAttribute('normal', new THREE.Float32BufferAttribute(norms, 3));
283
+ geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
284
+ geo.setIndex(idx);
285
+ return new THREE.Mesh(geo, material);
286
+ }
287
+
288
+ /* ==========================================================================
289
+ ORBITE — rotation (drag), panoramique (shift+drag), zoom (molette),
290
+ focus anime sur un point, auto-rotation.
291
+ ========================================================================== */
292
+ function makeOrbit(canvas, camera, dom) {
293
+ const th0 = dom.theta === undefined ? 0.55 : dom.theta;
294
+ const ph0 = dom.phi === undefined ? 0.22 : dom.phi;
295
+ const st = {
296
+ theta: th0, phi: ph0, dist: dom.dist,
297
+ tTheta: th0, tPhi: ph0, tDist: dom.dist,
298
+ target: new THREE.Vector3(0, 0, 0),
299
+ tTarget: new THREE.Vector3(0, 0, 0),
300
+ dragging: false, panning: false, lx: 0, ly: 0,
301
+ auto: true, manual: false
302
+ };
303
+ const onDown = e => {
304
+ st.dragging = true;
305
+ st.manual = true; st.auto = false;
306
+ st.panning = e.shiftKey || e.button === 1;
307
+ st.lx = e.clientX; st.ly = e.clientY;
308
+ canvas.style.cursor = st.panning ? 'move' : 'grabbing';
309
+ e.preventDefault();
310
+ };
311
+ const onUp = () => {
312
+ st.dragging = false; st.panning = false;
313
+ canvas.style.cursor = 'grab';
314
+ };
315
+ const onMove = e => {
316
+ if (!st.dragging) return;
317
+ const dx = e.clientX - st.lx, dy = e.clientY - st.ly;
318
+ st.lx = e.clientX; st.ly = e.clientY;
319
+ if (st.panning) {
320
+ const right = new THREE.Vector3().setFromMatrixColumn(camera.matrix, 0);
321
+ const up = new THREE.Vector3().setFromMatrixColumn(camera.matrix, 1);
322
+ const k = st.dist * 0.0016;
323
+ st.tTarget.addScaledVector(right, -dx * k).addScaledVector(up, dy * k);
324
+ } else {
325
+ st.tTheta -= dx * 0.008;
326
+ st.tPhi = clamp(st.tPhi + dy * 0.008, -Math.PI / 2.15, Math.PI / 2.15);
327
+ }
328
+ };
329
+ const onWheel = e => {
330
+ e.preventDefault();
331
+ st.tDist = clamp(st.tDist * (1 + e.deltaY * 0.0012), 6, 220);
332
+ };
333
+ canvas.addEventListener('pointerdown', onDown);
334
+ window.addEventListener('pointerup', onUp);
335
+ window.addEventListener('pointermove', onMove);
336
+ canvas.addEventListener('wheel', onWheel, { passive: false });
337
+ canvas.style.cursor = 'grab';
338
+
339
+ return {
340
+ state: st,
341
+ reset: () => {
342
+ st.tTheta = th0; st.tPhi = ph0; st.tDist = dom.dist;
343
+ st.tTarget.set(0, 0, 0);
344
+ st.manual = false; st.auto = true;
345
+ },
346
+ focus: (v, dist) => {
347
+ st.tTarget.copy(v);
348
+ if (dist) st.tDist = clamp(dist, 6, 220);
349
+ st.manual = true; st.auto = false;
350
+ },
351
+ /* Recadre exactement la camera sur un ensemble de POINTS de contenu.
352
+ Plus fin qu'une boite englobante : pour une rangee de barres, les
353
+ coins de la boite surestiment l'encombrement reel (l'axe se tilt et
354
+ la boite "fantome" repousse la camera trop loin). */
355
+ framePoints: (points, margin) => {
356
+ if (!points || !points.length) return;
357
+ const b = new THREE.Box3();
358
+ points.forEach(p => b.expandByPoint(p));
359
+ const center = b.getCenter(new THREE.Vector3());
360
+ const cp = Math.cos(st.phi), sp = Math.sin(st.phi);
361
+ const fwd = new THREE.Vector3(-cp * Math.sin(st.theta), -sp, -cp * Math.cos(st.theta));
362
+ let right = new THREE.Vector3().crossVectors(fwd, new THREE.Vector3(0, 1, 0));
363
+ if (right.lengthSq() < 1e-8) right.set(1, 0, 0);
364
+ right.normalize();
365
+ const up = new THREE.Vector3().crossVectors(right, fwd).normalize();
366
+ let mx = 0, my = 0, mz = 0;
367
+ points.forEach(p => {
368
+ const v = p.clone().sub(center);
369
+ mx = Math.max(mx, Math.abs(v.dot(right)));
370
+ my = Math.max(my, Math.abs(v.dot(up)));
371
+ mz = Math.max(mz, Math.abs(v.dot(fwd)));
372
+ });
373
+ const tanV = Math.tan(camera.fov * Math.PI / 360);
374
+ const tanH = tanV * (camera.aspect || 1);
375
+ const d = Math.max(my / tanV, mx / tanH) + mz;
376
+ st.tTarget.copy(center);
377
+ st.target.copy(center);
378
+ st.tDist = clamp(d * (margin || 1.08), 12, 220);
379
+ st.dist = st.tDist;
380
+ },
381
+ frameBox: (box, margin) => {
382
+ const center = box.getCenter(new THREE.Vector3());
383
+ const pts = [];
384
+ for (const x of [box.min.x, box.max.x])
385
+ for (const y of [box.min.y, box.max.y])
386
+ for (const z of [box.min.z, box.max.z]) pts.push(new THREE.Vector3(x, y, z));
387
+ const cp = Math.cos(st.phi), sp = Math.sin(st.phi);
388
+ const fwd = new THREE.Vector3(-cp * Math.sin(st.theta), -sp, -cp * Math.cos(st.theta));
389
+ let right = new THREE.Vector3().crossVectors(fwd, new THREE.Vector3(0, 1, 0));
390
+ if (right.lengthSq() < 1e-8) right.set(1, 0, 0);
391
+ right.normalize();
392
+ const up = new THREE.Vector3().crossVectors(right, fwd).normalize();
393
+ let mx = 0, my = 0, mz = 0;
394
+ pts.forEach(p => {
395
+ const v = p.clone().sub(center);
396
+ mx = Math.max(mx, Math.abs(v.dot(right)));
397
+ my = Math.max(my, Math.abs(v.dot(up)));
398
+ mz = Math.max(mz, Math.abs(v.dot(fwd)));
399
+ });
400
+ const tanV = Math.tan(camera.fov * Math.PI / 360);
401
+ const tanH = tanV * (camera.aspect || 1);
402
+ const d = Math.max(my / tanV, mx / tanH) + mz;
403
+ st.tTarget.copy(center);
404
+ st.target.copy(center);
405
+ st.tDist = clamp(d * (margin || 1.12), 12, 220);
406
+ st.dist = st.tDist;
407
+ },
408
+ update: (dt, autoSpeed) => {
409
+ if (st.auto && !st.manual && !st.dragging) {
410
+ if (dom.sway) {
411
+ /* Balancement autour de l'angle de repos : la lemniscate reste
412
+ lisible comme un "8" au lieu de tourner de profil et de
413
+ disparaitre une fois sur deux. */
414
+ st._clock = (st._clock || 0) + dt;
415
+ st.tTheta = th0 + Math.sin(st._clock * (dom.swaySpeed || 0.32)) * (dom.swayAmp || 0.42);
416
+ } else {
417
+ st.tTheta += dt * (autoSpeed || 0.12);
418
+ }
419
+ }
420
+ st.theta = lerp(st.theta, st.tTheta, 0.12);
421
+ st.phi = lerp(st.phi, st.tPhi, 0.12);
422
+ st.dist = lerp(st.dist, st.tDist, 0.12);
423
+ st.target.lerp(st.tTarget, 0.12);
424
+ const cp = Math.cos(st.phi), sp = Math.sin(st.phi);
425
+ camera.position.set(
426
+ cp * Math.sin(st.theta) * st.dist,
427
+ sp * st.dist,
428
+ cp * Math.cos(st.theta) * st.dist
429
+ ).add(st.target);
430
+ camera.lookAt(st.target);
431
+ },
432
+ dispose: () => {
433
+ canvas.removeEventListener('pointerdown', onDown);
434
+ window.removeEventListener('pointerup', onUp);
435
+ window.removeEventListener('pointermove', onMove);
436
+ canvas.removeEventListener('wheel', onWheel);
437
+ }
438
+ };
439
+ }
440
+
441
+ /* Redimensionnement automatique : les canvas vivent dans des onglets qui
442
+ peuvent etre masques au demarrage (clientWidth = 0). Un ResizeObserver
443
+ les re-sync quand ils deviennent visibles. */
444
+ function autoResize(obj) {
445
+ const cv = obj.canvas;
446
+ if (typeof ResizeObserver === 'undefined') return;
447
+ const ro = new ResizeObserver(() => {
448
+ const w = cv.clientWidth, h = cv.clientHeight;
449
+ if (w > 0 && h > 0 && (w !== obj._w || h !== obj._h)) obj.resize();
450
+ });
451
+ ro.observe(cv);
452
+ obj._ro = ro;
453
+ }
454
+
455
+ /* Raf loop qui se met en pause quand le canvas n'est plus visible. */
456
+ function startLoop(fn) {
457
+ let raf = 0, last = performance.now(), alive = true;
458
+ function tick(now) {
459
+ if (!alive) return;
460
+ raf = requestAnimationFrame(tick);
461
+ const dt = Math.min(0.05, (now - last) / 1000);
462
+ last = now;
463
+ const cv = fn.canvas;
464
+ if (!cv) return;
465
+ /* offsetParent === null => onglet/carte masque(e) : on ne rend pas. */
466
+ if (cv.offsetParent === null && cv.style.position !== 'fixed') return;
467
+ fn.step(dt, now);
468
+ }
469
+ raf = requestAnimationFrame(tick);
470
+ return { stop: () => { alive = false; cancelAnimationFrame(raf); } };
471
+ }
472
+
473
+ /* Taille d'etiquette calee sur des PIXELS ECRAN : le texte garde la meme
474
+ taille lisible quelle que soit la distance de camera. */
475
+ function fitLabelScale(camera, canvas, sprites, px, target) {
476
+ if (!sprites || !sprites.length) return;
477
+ const h = canvas.clientHeight || 1;
478
+ const dist = camera.position.distanceTo(target || camera.position) || 1;
479
+ const fov = camera.fov * Math.PI / 180;
480
+ const viewH = 2 * dist * Math.tan(fov / 2);
481
+ const perPx = viewH / h;
482
+ const s = perPx * (px || 14);
483
+ for (const sp of sprites) {
484
+ sp.scale.set((sp.userData.aspect || 1) * s, s, 1);
485
+ }
486
+ }
487
+
488
+ /* ==========================================================================
489
+ A. HUMAN NEURON — une cellule neurale complete en 3D
490
+ ========================================================================== */
491
+ function HumanNeuron(canvas) {
492
+ const self = this;
493
+ this.canvas = canvas;
494
+ this.renderer = new THREE.WebGLRenderer({ canvas: canvas, antialias: true, alpha: true });
495
+ this.renderer.setPixelRatio(DPR());
496
+ this.scene = new THREE.Scene();
497
+ this.camera = new THREE.PerspectiveCamera(42, 1, 0.1, 1000);
498
+ this.camera.position.set(0, 10, 62);
499
+ this.orbit = makeOrbit(canvas, this.camera, { dist: 62 });
500
+
501
+ /* Lumieres : luisance du soma, reflet sur l'axone. */
502
+ this.scene.add(new THREE.AmbientLight(0x2a4a5e, 1.25));
503
+ const key = new THREE.PointLight(0x8fe8ff, 1.7, 220);
504
+ key.position.set(14, 18, 22);
505
+ this.scene.add(key);
506
+ const rim = new THREE.PointLight(0xff7a2a, 1.0, 200);
507
+ rim.position.set(-20, -10, -18);
508
+ this.scene.add(rim);
509
+
510
+ this.group = new THREE.Group();
511
+ this.scene.add(this.group);
512
+ this.nodeGroup = new THREE.Group();
513
+ this.group.add(this.nodeGroup);
514
+
515
+ this.nodes = [];
516
+ this.nodeMeshes = [];
517
+ this.labels = [];
518
+ this.synapses = [];
519
+ this._items = [];
520
+ this._models = [];
521
+ this._pickCb = null;
522
+ this._hoverCb = null;
523
+
524
+ this._autoSpin = true;
525
+ this._showLabels = true;
526
+ this._showSynapses = true;
527
+ this._showSpines = true;
528
+ this._showMyelin = true;
529
+ this._zoom = 1;
530
+ this.nodeCount = 0;
531
+ this.edgeCount = 0;
532
+ this.legend = [];
533
+
534
+ /* Vitalite : la cellule GRANDIT avec le contenu du cache. C'est la
535
+ metrique la plus parlante du panneau — plus d'entrees, plus la
536
+ cellule est developpee. */
537
+ this._vitality = 1;
538
+ this._vitalityTarget = 1;
539
+
540
+ /* Potentiels d'action : une sphere lumineuse qui descend l'axone. */
541
+ this.spikes = [];
542
+ this._replayQueue = [];
543
+ this._replayTimer = 0;
544
+
545
+ this.raycaster = new THREE.Raycaster();
546
+ this.pointer = new THREE.Vector2(-10, -10);
547
+ this.hovered = null;
548
+
549
+ canvas.addEventListener('pointermove', e => {
550
+ const r = canvas.getBoundingClientRect();
551
+ self.pointer.x = ((e.clientX - r.left) / r.width) * 2 - 1;
552
+ self.pointer.y = -((e.clientY - r.top) / r.height) * 2 + 1;
553
+ });
554
+ canvas.addEventListener('pointerleave', () => {
555
+ self.pointer.set(-10, -10);
556
+ self._setHover(null);
557
+ });
558
+ canvas.addEventListener('click', e => {
559
+ const r = canvas.getBoundingClientRect();
560
+ self.pointer.x = ((e.clientX - r.left) / r.width) * 2 - 1;
561
+ self.pointer.y = -((e.clientY - r.top) / r.height) * 2 + 1;
562
+ self.raycaster.setFromCamera(self.pointer, self.camera);
563
+ const hits = self.raycaster.intersectObjects(self.nodeMeshes, false);
564
+ if (self._pickCb) self._pickCb(hits.length ? hits[0].object.userData.item : null);
565
+ });
566
+
567
+ this._buildAnatomy();
568
+ this._frameAnatomy();
569
+
570
+ /* --- API publique ---------------------------------------------- */
571
+ this.setGraph = function (items) { self._setGraph(items || []); };
572
+ this.setOptions = function (o) {
573
+ if (!o) return;
574
+ if (o.autoSpin !== undefined) self.autoSpin = o.autoSpin;
575
+ if (o.showLabels !== undefined) self.showLabels = o.showLabels;
576
+ if (o.showSynapses !== undefined) self.showSynapses = o.showSynapses;
577
+ if (o.showSpines !== undefined) self.showSpines = o.showSpines;
578
+ if (o.showMyelin !== undefined) self.showMyelin = o.showMyelin;
579
+ };
580
+ this.onPick = function (cb) { self._pickCb = cb; };
581
+ this.onHover = function (cb) { self._hoverCb = cb; };
582
+ this.resetOrbit = function () { self.orbit.reset(); self._frameAnatomy(); };
583
+ this.focusNode = function (item) {
584
+ const m = self.nodeMeshes.find(x => x.userData.item === item);
585
+ if (m) self.orbit.focus(m.position.clone(), Math.max(14, m.position.length() * 0.9));
586
+ };
587
+ this.resetLayout = function () { self._rebuildNodes(); };
588
+ this.pulse = function (color) { self._pulse(color || C.HIT); };
589
+ this.fireSoma = function () { self._fireSoma(); };
590
+ /* Vitalite : la cellule se developpe avec la taille du cache. */
591
+ this.setVitality = function (v) {
592
+ self._vitalityTarget = clamp(+v || 1, 0.55, 1.9);
593
+ };
594
+ /* Rejoue un flux de requetes : chaque HIT traverse la cellule en vert
595
+ (servi depuis le cache), chaque MISS en rouge (paye au modèle). */
596
+ this.replay = function (events) {
597
+ self._replayQueue = (events || []).slice(-24).reverse();
598
+ self._replayTimer = 0;
599
+ };
600
+ this.stopReplay = function () { self._replayQueue = []; };
601
+ this.resize = function () { self._resize(); };
602
+
603
+ Object.defineProperty(this, 'autoSpin', {
604
+ get: () => self._autoSpin,
605
+ set: v => { self._autoSpin = !!v; self.orbit.state.auto = !!v; }
606
+ });
607
+ Object.defineProperty(this, 'showLabels', {
608
+ get: () => self._showLabels,
609
+ set: v => {
610
+ self._showLabels = !!v;
611
+ self.labels.forEach(l => { l.visible = self._showLabels; });
612
+ }
613
+ });
614
+ Object.defineProperty(this, 'showSynapses', {
615
+ get: () => self._showSynapses,
616
+ set: v => {
617
+ self._showSynapses = !!v;
618
+ self.synapses.forEach(s => { s.visible = self._showSynapses; });
619
+ if (self.synGroup) self.synGroup.visible = self._showSynapses;
620
+ }
621
+ });
622
+ Object.defineProperty(this, 'showSpines', {
623
+ get: () => self._showSpines,
624
+ set: v => {
625
+ self._showSpines = !!v;
626
+ if (self.spineGroup) self.spineGroup.visible = self._showSpines;
627
+ }
628
+ });
629
+ Object.defineProperty(this, 'showMyelin', {
630
+ get: () => self._showMyelin,
631
+ set: v => {
632
+ self._showMyelin = !!v;
633
+ if (self.myelinGroup) self.myelinGroup.visible = self._showMyelin;
634
+ }
635
+ });
636
+ Object.defineProperty(this, 'zoom', {
637
+ get: () => self._zoom,
638
+ set: v => { self._zoom = clamp(+v || 1, 0.45, 2.6); }
639
+ });
640
+
641
+ this._resize();
642
+ this._frameAnatomy();
643
+ autoResize(this);
644
+ this._loop = startLoop(this);
645
+ }
646
+
647
+ HumanNeuron.prototype._resize = function () {
648
+ const cv = this.canvas;
649
+ const w = cv.clientWidth, h = cv.clientHeight;
650
+ if (!w || !h) return; // canvas masque : on garde la derniere taille
651
+ this._w = w; this._h = h;
652
+ this.renderer.setSize(w, h, false);
653
+ this.camera.aspect = w / h;
654
+ this.camera.updateProjectionMatrix();
655
+ /* Premiere mise a la taille reelle : on recadre une fois, pour que la
656
+ cellule tienne dans le cadre (l'aspect change la distance requise). */
657
+ if (!this._framed) { this._framed = true; this._frameAnatomy(); }
658
+ };
659
+
660
+ /* ------------------------------------------------------------------
661
+ ANATOMIE : soma, noyau, dendrites, epines, axone, myeline, terminaux
662
+ ------------------------------------------------------------------ */
663
+ HumanNeuron.prototype._buildAnatomy = function () {
664
+ const rng = mulberry32(20261024);
665
+ const G = this.group;
666
+ this.anatomy = new THREE.Group();
667
+ G.add(this.anatomy);
668
+
669
+ const R = 4.2; // rayon du soma
670
+ this.somaRadius = R;
671
+
672
+ /* --- SOMA : membrane irreguliere (bruit de surface) --------------
673
+ SphereGeometry (indexee) et non IcosahedronGeometry : ce dernier est
674
+ non-indexe, donc computeVertexNormals() produit des normales
675
+ facettees et la membrane devient facettée au lieu d'organique. */
676
+ const geo = new THREE.SphereGeometry(R, 72, 48);
677
+ const pos = geo.attributes.position;
678
+ const v = new THREE.Vector3();
679
+ const seeds = [];
680
+ for (let i = 0; i < 5; i++) seeds.push(new THREE.Vector3(rng() - 0.5, rng() - 0.5, rng() - 0.5).normalize());
681
+ for (let i = 0; i < pos.count; i++) {
682
+ v.fromBufferAttribute(pos, i);
683
+ const n = v.clone().normalize();
684
+ let d = 0;
685
+ for (const s of seeds) d += Math.sin(n.dot(s) * 3.3 + s.x * 9) * 0.052;
686
+ d += Math.sin(n.y * 5.5 + 1.3) * 0.028;
687
+ v.copy(n).multiplyScalar(R * (1 + d));
688
+ pos.setXYZ(i, v.x, v.y, v.z);
689
+ }
690
+ geo.computeVertexNormals();
691
+ const soma = new THREE.Mesh(geo, new THREE.MeshPhongMaterial({
692
+ color: 0x1b6d88, emissive: 0x062633, emissiveIntensity: 1,
693
+ shininess: 140, specular: 0xe6fbff, transparent: true, opacity: 0.3, depthWrite: false
694
+ }));
695
+ this.soma = soma;
696
+ this.anatomy.add(soma);
697
+
698
+ /* Halo membrane (rim light) */
699
+ const rimShell = new THREE.Mesh(
700
+ new THREE.IcosahedronGeometry(R * 1.045, 3),
701
+ new THREE.MeshBasicMaterial({
702
+ color: 0x4fd8ff, transparent: true, opacity: 0.14,
703
+ blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.BackSide
704
+ })
705
+ );
706
+ rimShell.raycast = function () {};
707
+ this.anatomy.add(rimShell);
708
+ this.rimShell = rimShell;
709
+
710
+ /* --- NOYAU + nucleole -------------------------------------------- */
711
+ const nucleus = new THREE.Mesh(
712
+ new THREE.SphereGeometry(R * 0.44, 28, 20),
713
+ new THREE.MeshBasicMaterial({ color: C.NUCLEUS, transparent: true, opacity: 0.85, blending: THREE.AdditiveBlending, depthWrite: false })
714
+ );
715
+ nucleus.position.set(0.4, 0.2, 0.3);
716
+ this.anatomy.add(nucleus);
717
+ this.nucleus = nucleus;
718
+
719
+ const nucleolus = new THREE.Mesh(
720
+ new THREE.SphereGeometry(R * 0.17, 18, 14),
721
+ new THREE.MeshBasicMaterial({ color: 0xffffff })
722
+ );
723
+ nucleolus.position.copy(nucleus.position).add(new THREE.Vector3(0.25, 0.15, 0.2));
724
+ this.anatomy.add(nucleolus);
725
+
726
+ const nHalo = new THREE.Sprite(new THREE.SpriteMaterial({
727
+ map: haloTexture(), color: C.NUCLEUS, transparent: true,
728
+ blending: THREE.AdditiveBlending, depthWrite: false, opacity: 0.75
729
+ }));
730
+ nHalo.position.copy(nucleus.position);
731
+ nHalo.scale.setScalar(R * 2.6);
732
+ nHalo.raycast = function () {};
733
+ this.anatomy.add(nHalo);
734
+ this.nucleusHalo = nHalo;
735
+
736
+ /* --- DENDRITES : arbres branches ---------------------------------- */
737
+ const neuriteMat = new THREE.MeshPhongMaterial({
738
+ color: C.NEURITE, emissive: 0x07303d, shininess: 70,
739
+ specular: 0x9fe8ff, transparent: true, opacity: 0.55, depthWrite: false
740
+ });
741
+ const spineGroup = new THREE.Group();
742
+ const myelinGroup = new THREE.Group();
743
+ this.anatomy.add(spineGroup);
744
+ this.anatomy.add(myelinGroup);
745
+ this.spineGroup = spineGroup;
746
+ this.myelinGroup = myelinGroup;
747
+
748
+ const branches = [];
749
+ /* `root` identifie le TRONC de dendrite d'origine. Sans ca, les indices
750
+ debranches suivent l'ordre de parcours en profondeur (troncon, puis
751
+ ses enfants) et tous les noeuds se retrouvent sur le meme tronc. */
752
+ const grow = (origin, dir, len, rad, depth, rootId) => {
753
+ const pts = [origin.clone()];
754
+ const p = origin.clone();
755
+ const d = dir.clone().normalize();
756
+ const segs = 7;
757
+ for (let i = 0; i < segs; i++) {
758
+ d.x += (rng() - 0.5) * 0.62;
759
+ d.y += (rng() - 0.5) * 0.62;
760
+ d.z += (rng() - 0.5) * 0.62;
761
+ d.y += 0.02; // tres leger biais vers le haut
762
+ d.normalize();
763
+ p.addScaledVector(d, len / segs);
764
+ pts.push(p.clone());
765
+ }
766
+ branches.push({ pts: pts, r0: rad, r1: rad * 0.55, kind: 'dendrite', root: rootId });
767
+ /* epines dendritiques */
768
+ for (let s = 1; s < pts.length; s++) {
769
+ if (rng() < 0.55) {
770
+ const sp = new THREE.Mesh(
771
+ new THREE.SphereGeometry(rad * 0.34, 7, 6),
772
+ new THREE.MeshBasicMaterial({ color: 0x9fe8ff, transparent: true, opacity: 0.85 })
773
+ );
774
+ sp.position.copy(pts[s]).add(new THREE.Vector3(
775
+ (rng() - 0.5) * 0.7, (rng() - 0.5) * 0.7, (rng() - 0.5) * 0.7));
776
+ sp.raycast = function () {};
777
+ spineGroup.add(sp);
778
+ }
779
+ }
780
+ if (depth > 0) {
781
+ const nB = rng() < 0.62 ? 2 : 1;
782
+ for (let b = 0; b < nB; b++) {
783
+ const idx = 2 + Math.floor(rng() * (segs - 2));
784
+ const from = pts[idx];
785
+ const nd = pts[idx + 1].clone().sub(from).normalize();
786
+ const axis = new THREE.Vector3(rng() - 0.5, rng() - 0.5, rng() - 0.5).normalize();
787
+ nd.applyAxisAngle(axis, (rng() - 0.5) * 1.9);
788
+ grow(from, nd, len * 0.63, rad * 0.58, depth - 1, rootId);
789
+ }
790
+ }
791
+ };
792
+
793
+ const nDend = 7;
794
+ for (let i = 0; i < nDend; i++) {
795
+ const th = (i / nDend) * Math.PI * 2 + rng() * 0.5;
796
+ /* Les troncs repartis sur toute la sphere (un peu plus haut que bas) :
797
+ une distribution purement verticale donnerait un "sapin" colle au
798
+ soma au lieu d'une cellule. */
799
+ const ph = rng() * 1.45 - 0.42;
800
+ const dir = new THREE.Vector3(
801
+ Math.cos(th) * Math.cos(ph),
802
+ Math.sin(ph) * 0.9,
803
+ Math.sin(th) * Math.cos(ph)
804
+ );
805
+ const origin = dir.clone().multiplyScalar(R * 0.86);
806
+ grow(origin, dir, 7.6 + rng() * 3.4, 0.62, 2, i);
807
+ }
808
+
809
+ /* --- AXONE + MYELINE + TERMINAUX ---------------------------------
810
+ Longueur volontairement contenue : l'axone doit rester dans le meme
811
+ gabarit que l'arbre dendritique, sinon il sort du cadre et la
812
+ cellule n'est plus lisible. */
813
+ const axDir = new THREE.Vector3(0.46, -0.74, 0.50).normalize();
814
+ const axPts = [axDir.clone().multiplyScalar(R * 0.82)];
815
+ let ap = axPts[0].clone();
816
+ let ad = axDir.clone();
817
+ for (let i = 0; i < 6; i++) {
818
+ ad.x += (rng() - 0.5) * 0.2;
819
+ ad.y += (rng() - 0.5) * 0.2;
820
+ ad.z += (rng() - 0.5) * 0.2;
821
+ ad.normalize();
822
+ ap.addScaledVector(ad, 3.0);
823
+ axPts.push(ap.clone());
824
+ }
825
+ branches.push({ pts: axPts, r0: 0.95, r1: 0.30, kind: 'axon', root: 100 });
826
+
827
+ /* Gaine de myeline : segments elipsoidaux separes par les noeuds de Ranvier */
828
+ const myelinMat = new THREE.MeshPhongMaterial({
829
+ color: C.MYELIN, emissive: 0x143848, shininess: 120,
830
+ specular: 0xffffff, transparent: true, opacity: 0.55
831
+ });
832
+ for (let i = 1; i < axPts.length - 1; i++) {
833
+ const p = axPts[i], q = axPts[i + 1];
834
+ const mid = p.clone().lerp(q, 0.5);
835
+ const len = p.distanceTo(q);
836
+ const g = new THREE.SphereGeometry(1, 12, 9);
837
+ const m = new THREE.Mesh(g, myelinMat);
838
+ m.position.copy(mid);
839
+ m.scale.set(0.78, 0.78, len * 0.34);
840
+ /* oriente le myelin selon l'axe */
841
+ const dirq = q.clone().sub(p).normalize();
842
+ m.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), dirq);
843
+ m.raycast = function () {};
844
+ myelinGroup.add(m);
845
+ /* noeud de Ranvier : petit anneau lumineux */
846
+ const rn = new THREE.Mesh(
847
+ new THREE.TorusGeometry(0.42, 0.07, 6, 14),
848
+ new THREE.MeshBasicMaterial({ color: C.SAVED, transparent: true, opacity: 0.85 })
849
+ );
850
+ rn.position.copy(p);
851
+ rn.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), dirq);
852
+ rn.raycast = function () {};
853
+ myelinGroup.add(rn);
854
+ }
855
+
856
+ /* Arborisation terminale + boutons synaptiques */
857
+ const boutonMat = new THREE.MeshBasicMaterial({ color: 0xffd76b });
858
+ const end = axPts[axPts.length - 1];
859
+ const terminalTips = [];
860
+ for (let b = 0; b < 5; b++) {
861
+ const th = (b / 5) * Math.PI * 2 + rng();
862
+ const bd = new THREE.Vector3(Math.cos(th) * 0.8, (rng() - 0.4) * 0.8, Math.sin(th) * 0.8).normalize();
863
+ const tp = [end.clone()];
864
+ let pp = end.clone(), dd = bd;
865
+ for (let i = 0; i < 3; i++) {
866
+ dd.x += (rng() - 0.5) * 0.5;
867
+ dd.y += (rng() - 0.5) * 0.5;
868
+ dd.z += (rng() - 0.5) * 0.5;
869
+ dd.normalize();
870
+ pp.addScaledVector(dd, 2.0);
871
+ tp.push(pp.clone());
872
+ }
873
+ branches.push({ pts: tp, r0: 0.30, r1: 0.16, kind: 'terminal', root: 200 + b });
874
+ const bouton = new THREE.Mesh(new THREE.SphereGeometry(0.52, 14, 10), boutonMat);
875
+ bouton.position.copy(tp[tp.length - 1]);
876
+ bouton.raycast = function () {};
877
+ this.anatomy.add(bouton);
878
+ const bh = new THREE.Sprite(new THREE.SpriteMaterial({
879
+ map: haloTexture(), color: 0xffd76b, transparent: true,
880
+ blending: THREE.AdditiveBlending, depthWrite: false, opacity: 0.7
881
+ }));
882
+ bh.position.copy(bouton.position);
883
+ bh.scale.setScalar(3.2);
884
+ bh.raycast = function () {};
885
+ this.anatomy.add(bh);
886
+ terminalTips.push(tp[tp.length - 1].clone());
887
+ }
888
+
889
+ /* Construction des tubes */
890
+ this._branches = branches;
891
+ for (const b of branches) {
892
+ const mat = b.kind === 'axon'
893
+ ? new THREE.MeshPhongMaterial({
894
+ color: C.AXON, emissive: 0x0a3b4d, shininess: 90,
895
+ specular: 0xd6f6ff, transparent: true, opacity: 0.6, depthWrite: false
896
+ })
897
+ : neuriteMat;
898
+ const t = taperedTube(b.pts, u => lerp(b.r0, b.r1, u), 8, mat);
899
+ if (!t) continue;
900
+ t.raycast = function () {};
901
+ this.anatomy.add(t);
902
+ }
903
+
904
+ /* Points d'ancrage : ou peuvent se poser les noeuds de cache.
905
+ On memorise la branche d'appartenance pour repartir les noeuds sur des
906
+ branches DIFFERENTES (sinon ils s'empilent tous sur la meme). */
907
+ this.anchors = [];
908
+ branches.forEach(b => {
909
+ for (let i = 1; i < b.pts.length; i++) {
910
+ /* On garde la polyligne de la branche : les noeuds y circulent
911
+ ensuite (ce ne sont pas des points figes). */
912
+ this.anchors.push({ p: b.pts[i], kind: b.kind, branch: b.root, path: b.pts });
913
+ }
914
+ });
915
+ for (const t of terminalTips) this.anchors.push({ p: t, kind: 'bouton', branch: 300, path: null });
916
+ /* L'axone, pour les potentials d'action. */
917
+ this.axonPath = axPts;
918
+
919
+ /* Fiche anatomique : c'est la metrique detaillee affichee dans le
920
+ panneau « Human Neuron » (le nombre de dendrites, d'epines, de
921
+ segments de myeline, de noeuds de Ranvier...). */
922
+ this.stats = {
923
+ somaDiameter: +(R * 2).toFixed(1),
924
+ dendrites: nDend,
925
+ branches: branches.length,
926
+ spines: spineGroup.children.length,
927
+ myelin: Math.floor(myelinGroup.children.length / 2),
928
+ terminals: terminalTips.length,
929
+ axonNodes: axPts.length,
930
+ totalNodes: 0
931
+ };
932
+ };
933
+
934
+ /* Recadre la camera sur la cellule : le cadrage suit la geometrie reelle,
935
+ quelle que soit la longueur de l'axone ou des dendrites. */
936
+ HumanNeuron.prototype._frameAnatomy = function () {
937
+ if (!this.anatomy) return;
938
+ const box = new THREE.Box3().setFromObject(this.anatomy);
939
+ if (box.isEmpty()) return;
940
+ this.orbit.frameBox(box, 1.02); this.orbit.state.tDist = Math.max(14, this.orbit.state.tDist * 0.55); this.orbit.state.dist = this.orbit.state.tDist;
941
+ const center = box.getCenter(new THREE.Vector3());
942
+ const r = box.getBoundingSphere(new THREE.Sphere()).radius;
943
+ this.camera.position.set(
944
+ Math.sin(this.orbit.state.theta) * r * 1.5,
945
+ r * 0.55,
946
+ Math.cos(this.orbit.state.theta) * r * 1.5
947
+ ).add(center);
948
+ this.camera.lookAt(center);
949
+ };
950
+
951
+ /* ------------------------------------------------------------------
952
+ NŒUDS DE CACHE + SYNAPSES
953
+ ------------------------------------------------------------------ */
954
+ HumanNeuron.prototype._setGraph = function (items) {
955
+ const list = items.slice();
956
+ const models = [];
957
+ list.forEach(it => { if (models.indexOf(it.model) < 0) models.push(it.model); });
958
+ list.forEach(it => {
959
+ it._modelIdx = Math.max(0, models.indexOf(it.model || ''));
960
+ it._color = MODEL_COLORS[it._modelIdx % MODEL_COLORS.length];
961
+ });
962
+
963
+ this.legend = models.map((m, i) => {
964
+ const hex = MODEL_COLORS[i % MODEL_COLORS.length];
965
+ return {
966
+ model: m, hex: hex,
967
+ col: [(hex >> 16) & 255, (hex >> 8) & 255, hex & 255],
968
+ n: list.filter(x => x.model === m).length
969
+ };
970
+ });
971
+
972
+ this._items = list;
973
+ this._models = models;
974
+ this._rebuildNodes();
975
+ };
976
+
977
+ HumanNeuron.prototype._rebuildNodes = function () {
978
+ const self = this;
979
+ const items = this._items;
980
+
981
+ /* purge */
982
+ this.nodeMeshes.forEach(m => {
983
+ this.nodeGroup.remove(m);
984
+ if (m.geometry) m.geometry.dispose();
985
+ if (m.material) m.material.dispose();
986
+ if (m.userData.halo) this.nodeGroup.remove(m.userData.halo);
987
+ });
988
+ this.labels.forEach(l => {
989
+ this.nodeGroup.remove(l);
990
+ if (l.material.map) l.material.map.dispose();
991
+ if (l.material) l.material.dispose();
992
+ });
993
+ if (this.synGroup) {
994
+ this.group.remove(this.synGroup);
995
+ this.synGroup.traverse(o => {
996
+ if (o.geometry) o.geometry.dispose();
997
+ if (o.material) o.material.dispose();
998
+ });
999
+ }
1000
+ this.nodeMeshes = [];
1001
+ this.labels = [];
1002
+ this.synapses = [];
1003
+
1004
+ const synGroup = new THREE.Group();
1005
+ this.synGroup = synGroup;
1006
+ this.group.add(synGroup);
1007
+
1008
+ const anchors = this.anchors || [];
1009
+ const maxHits = Math.max(1, ...items.map(i => i.hits || 0));
1010
+ this._linkSeen = new Set();
1011
+ const fract = x => x - Math.floor(x);
1012
+
1013
+ /* Repartition round-robin sur les branches : le noeud i part sur la
1014
+ branche (i % nbBranches), ce qui evite l'empilement de tous les noeuds
1015
+ sur un seul dendrite et la superposition de leurs etiquettes. */
1016
+ const groups = [];
1017
+ const gmap = new Map();
1018
+ anchors.forEach(a => {
1019
+ if (!gmap.has(a.branch)) { gmap.set(a.branch, []); groups.push(gmap.get(a.branch)); }
1020
+ gmap.get(a.branch).push(a);
1021
+ });
1022
+ const pickAnchor = idx => {
1023
+ if (!groups.length) return { p: new THREE.Vector3(0, 0, 0), kind: 'dendrite' };
1024
+ const g = groups[idx % groups.length];
1025
+ const a = g[Math.floor(idx / groups.length) % g.length];
1026
+ return a;
1027
+ };
1028
+
1029
+ /* Placement : les noeuds se repartissent sur l'arbre dendritique et les
1030
+ terminaux, pour qu'ils semblent faire partie de la cellule. */
1031
+ items.forEach((it, idx) => {
1032
+ const a = pickAnchor(idx);
1033
+ const jitter = new THREE.Vector3(
1034
+ fract(Math.sin(idx * 12.9898) * 43758.5453) - 0.5,
1035
+ fract(Math.sin(idx * 78.233) * 12345.6789) - 0.5,
1036
+ fract(Math.sin(idx * 39.346) * 87654.321) - 0.5
1037
+ );
1038
+ /* Le noeud vit sur sa branche : il y derive en permanence, mais
1039
+ autour de SA position (sinon tous les noeuds finissent par
1040
+ traverser le soma et leurs etiquettes s'empilent). */
1041
+ it._path = a.path || null;
1042
+ it._u0 = fract(Math.sin(idx * 45.164) * 9871.233);
1043
+ it._drift = 0.09 + (idx % 3) * 0.03;
1044
+ it._speed = 0.25 + (idx % 5) * 0.06;
1045
+ const pos = a.p.clone().add(jitter.multiplyScalar(0.55));
1046
+ it._pos = pos;
1047
+ it._anchorKind = a.kind;
1048
+
1049
+ const scale = 0.34 + 0.62 * Math.sqrt((it.hits || 0) / maxHits);
1050
+ const mesh = new THREE.Mesh(
1051
+ new THREE.SphereGeometry(scale, 16, 12),
1052
+ new THREE.MeshBasicMaterial({ color: it._color })
1053
+ );
1054
+ mesh.position.copy(pos);
1055
+ mesh.userData = { item: it, scale: scale, base: scale };
1056
+ this.nodeGroup.add(mesh);
1057
+ this.nodeMeshes.push(mesh);
1058
+
1059
+ const halo = new THREE.Sprite(new THREE.SpriteMaterial({
1060
+ map: haloTexture(), color: it._color, transparent: true,
1061
+ blending: THREE.AdditiveBlending, depthWrite: false, opacity: 0.6
1062
+ }));
1063
+ halo.scale.setScalar(scale * 8);
1064
+ halo.position.copy(pos);
1065
+ halo.raycast = function () {};
1066
+ this.nodeGroup.add(halo);
1067
+ mesh.userData.halo = halo;
1068
+ });
1069
+
1070
+ /* --- CONNEXIONS -------------------------------------------------
1071
+ 1. chaque noeud est relie au soma par une synapse courbe (le "cable")
1072
+ 2. chaque noeud est relie a ses 2 voisins les plus proches
1073
+ => l'ensemble forme un reseau continu : rien n'est isole. */
1074
+ const somaPos = new THREE.Vector3(0, 0, 0);
1075
+ const linkMat = new THREE.LineBasicMaterial({
1076
+ vertexColors: true, transparent: true, opacity: 0.55,
1077
+ blending: THREE.AdditiveBlending, depthWrite: false
1078
+ });
1079
+ const linkPos = [], linkCol = [];
1080
+ const c = new THREE.Color();
1081
+
1082
+ /* Courbe quadratique de Bezier : le noeud -> le soma, avec un arc. */
1083
+ const addLink = (a, b, colA, colB, bow) => {
1084
+ const SEG = 16;
1085
+ const mid = a.clone().lerp(b, 0.5);
1086
+ const dir = b.clone().sub(a);
1087
+ let perp = dir.clone().cross(new THREE.Vector3(0, 1, 0));
1088
+ if (perp.lengthSq() < 1e-8) perp = dir.clone().cross(new THREE.Vector3(1, 0, 0));
1089
+ perp.normalize();
1090
+ const ctrl = mid.clone()
1091
+ .addScaledVector(perp, bow)
1092
+ .add(new THREE.Vector3(0, bow * 0.3, 0));
1093
+ let prev = a.clone();
1094
+ for (let i = 1; i <= SEG; i++) {
1095
+ const t = i / SEG, it = 1 - t;
1096
+ const cur = a.clone().multiplyScalar(it * it)
1097
+ .addScaledVector(ctrl, 2 * it * t)
1098
+ .addScaledVector(b, t * t);
1099
+ linkPos.push(prev.x, prev.y, prev.z, cur.x, cur.y, cur.z);
1100
+ c.set(colA).multiplyScalar(0.85); linkCol.push(c.r, c.g, c.b);
1101
+ c.set(colB).multiplyScalar(0.85); linkCol.push(c.r, c.g, c.b);
1102
+ prev = cur;
1103
+ }
1104
+ };
1105
+
1106
+ items.forEach((it, i) => {
1107
+ addLink(it._pos, somaPos, it._color, C.AXON, 1.6 + (i % 5) * 0.35);
1108
+ });
1109
+
1110
+ /* voisins les plus proches */
1111
+ for (let i = 0; i < items.length; i++) {
1112
+ const a = items[i]._pos;
1113
+ const d = [];
1114
+ for (let j = 0; j < items.length; j++) {
1115
+ if (i === j) continue;
1116
+ d.push([a.distanceTo(items[j]._pos), j]);
1117
+ }
1118
+ d.sort((x, y) => x[0] - y[0]);
1119
+ for (let k = 0; k < Math.min(2, d.length); k++) {
1120
+ const j = d[k][1];
1121
+ const key = i < j ? i + ':' + j : j + ':' + i;
1122
+ if (this._linkSeen.has(key)) continue;
1123
+ this._linkSeen.add(key);
1124
+ addLink(items[i]._pos, items[j]._pos, items[i]._color, items[j]._color, 0.9);
1125
+ }
1126
+ }
1127
+
1128
+ if (linkPos.length) {
1129
+ const lg = new THREE.BufferGeometry();
1130
+ lg.setAttribute('position', new THREE.Float32BufferAttribute(linkPos, 3));
1131
+ lg.setAttribute('color', new THREE.Float32BufferAttribute(linkCol, 3));
1132
+ const lines = new THREE.LineSegments(lg, linkMat);
1133
+ lines.frustumCulled = false;
1134
+ lines.visible = this._showSynapses;
1135
+ synGroup.add(lines);
1136
+ this.synapses.push(lines);
1137
+ }
1138
+
1139
+ /* Impulsions qui parcourent les synapses */
1140
+ const PULSE = Math.min(90, Math.max(18, items.length * 2));
1141
+ const pgeo = new THREE.BufferGeometry();
1142
+ pgeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(PULSE * 3), 3));
1143
+ this.pulseCount = PULSE;
1144
+ this.pulseGeo = pgeo;
1145
+ this.pulses = new THREE.Points(pgeo, new THREE.PointsMaterial({
1146
+ color: 0xffffff, size: 0.7, transparent: true, opacity: 0.9,
1147
+ blending: THREE.AdditiveBlending, depthWrite: false, sizeAttenuation: true
1148
+ }));
1149
+ this.pulses.frustumCulled = false;
1150
+ synGroup.add(this.pulses);
1151
+
1152
+ /* Etiquettes : uniquement les noeuds les plus consultes (lisibilite).
1153
+ Elles sont decalees en spirale autour du noeud, sinon elles se
1154
+ superposent quand plusieurs noeuds sont proches. Le detail complet
1155
+ d'un noeud est de toute facon dans l'inspecteur (survol). */
1156
+ const top = items.slice().sort((a, b) => (b.hits || 0) - (a.hits || 0)).slice(0, 4);
1157
+ top.forEach((it, li) => {
1158
+ const ang = li * 2.399963; // angle d'or : repartition reguliere
1159
+ const off = new THREE.Vector3(
1160
+ Math.cos(ang) * 3.6,
1161
+ 2.4 + (li % 3) * 1.1,
1162
+ Math.sin(ang) * 3.6
1163
+ );
1164
+ const lbl = labelSprite((it.label || it.model || '').slice(0, 22), it._color, 0.9);
1165
+ lbl.position.copy(it._pos).add(off);
1166
+ lbl.userData.item = it;
1167
+ lbl.userData.off = off.clone();
1168
+ lbl.visible = this._showLabels;
1169
+ this.nodeGroup.add(lbl);
1170
+ this.labels.push(lbl);
1171
+ /* Le noeud et son etiquette sont lies : l'etiquette suit le noeud. */
1172
+ const owner = this.nodeMeshes.find(x => x.userData.item === it);
1173
+ if (owner) owner.userData.label = lbl;
1174
+ });
1175
+
1176
+ this.nodeCount = items.length;
1177
+ this.edgeCount = this._linkSeen.size + items.length;
1178
+ if (this.stats) this.stats.totalNodes = items.length;
1179
+ };
1180
+
1181
+ HumanNeuron.prototype._pulse = function (color) {
1182
+ if (!this.nodeMeshes.length) return;
1183
+ const m = this.nodeMeshes[Math.floor(Math.random() * this.nodeMeshes.length)];
1184
+ m.userData.pulse = 1;
1185
+ m.material.color.setHex(color);
1186
+ if (m.userData.halo) m.userData.halo.material.color.setHex(color);
1187
+ };
1188
+
1189
+ HumanNeuron.prototype._fireSoma = function () {
1190
+ if (!this.nodeMeshes.length) return;
1191
+ for (let i = 0; i < 8; i++) {
1192
+ const m = this.nodeMeshes[Math.floor(Math.random() * this.nodeMeshes.length)];
1193
+ m.userData.pulse = 1;
1194
+ }
1195
+ this.somaFlash = 1;
1196
+ };
1197
+
1198
+ /* Un potentiel d'action : une lueur qui part du soma et descend l'axone
1199
+ jusqu'aux terminaux. C'est la traduction visuelle d'une requete qui
1200
+ traverse reellement le proxy. */
1201
+ HumanNeuron.prototype._fireSpike = function (color, speed) {
1202
+ const path = this.axonPath;
1203
+ if (!path || path.length < 2) return null;
1204
+ const mesh = new THREE.Mesh(
1205
+ new THREE.SphereGeometry(0.55, 14, 10),
1206
+ new THREE.MeshBasicMaterial({ color: color, transparent: true, opacity: 1 })
1207
+ );
1208
+ const halo = new THREE.Sprite(new THREE.SpriteMaterial({
1209
+ map: haloTexture(), color: color, transparent: true,
1210
+ blending: THREE.AdditiveBlending, depthWrite: false, opacity: 0.9
1211
+ }));
1212
+ halo.scale.setScalar(5.5);
1213
+ mesh.add(halo);
1214
+ this.group.add(mesh);
1215
+ const sp = { mesh: mesh, t: 0, speed: speed || 0.55, color: color };
1216
+ this.spikes.push(sp);
1217
+ return sp;
1218
+ };
1219
+
1220
+ /* Rejeu d'un flux : on enchaine les potentiels d'action, un par requete. */
1221
+ HumanNeuron.prototype._stepReplay = function (dt) {
1222
+ if (!this._replayQueue.length) return;
1223
+ this._replayTimer -= dt;
1224
+ if (this._replayTimer > 0) return;
1225
+ const ev = this._replayQueue.shift();
1226
+ if (!ev) return;
1227
+ const kind = String(ev.kind || ev.type || '').toUpperCase();
1228
+ const isHit = kind.includes('HIT');
1229
+ const color = isHit ? C.HIT : C.MISS;
1230
+ this._fireSpike(color, isHit ? 0.85 : 0.4);
1231
+ this.somaFlash = 1;
1232
+ this._replayTimer = 0.28;
1233
+ };
1234
+
1235
+ HumanNeuron.prototype._setHover = function (mesh) {
1236
+ if (this.hovered === mesh) return;
1237
+ if (this.hovered) {
1238
+ const it = this.hovered.userData.item;
1239
+ this.hovered.material.color.setHex(it._color);
1240
+ this.hovered.scale.setScalar(this.hovered.userData.base);
1241
+ }
1242
+ this.hovered = mesh;
1243
+ if (mesh) {
1244
+ mesh.material.color.setHex(0xffffff);
1245
+ mesh.scale.setScalar(mesh.userData.base * 1.55);
1246
+ }
1247
+ if (this._hoverCb) {
1248
+ const it = mesh ? mesh.userData.item : null;
1249
+ this._hoverCb(it ? {
1250
+ label: it.label || '', model: it.model || 'unknown',
1251
+ hits: it.hits || 0, color: it._color, pos: it._pos
1252
+ } : null);
1253
+ }
1254
+ };
1255
+
1256
+ HumanNeuron.prototype.step = function (dt, now) {
1257
+ const t = now * 0.001;
1258
+ this.orbit.update(dt, 0.10);
1259
+ if (this._zoom !== 1) {
1260
+ const d = this.camera.position.distanceTo(this.orbit.state.target);
1261
+ this.camera.position.sub(this.orbit.state.target)
1262
+ .multiplyScalar(1 + (this._zoom - 1) * 0.02)
1263
+ .add(this.orbit.state.target);
1264
+ }
1265
+
1266
+ /* respiration du soma + flash */
1267
+ const breathe = 1 + Math.sin(t * 0.9) * 0.018;
1268
+ this.soma.scale.setScalar(breathe);
1269
+ this.rimShell.scale.setScalar(breathe * 1.01);
1270
+ this.nucleus.rotation.y += dt * 0.25;
1271
+ this.nucleus.rotation.x += dt * 0.12;
1272
+ if (this.somaFlash > 0) {
1273
+ this.somaFlash = Math.max(0, this.somaFlash - dt * 1.4);
1274
+ this.rimShell.material.opacity = 0.14 + this.somaFlash * 0.4;
1275
+ }
1276
+ this.nucleusHalo.material.opacity = 0.6 + Math.sin(t * 1.7) * 0.18;
1277
+
1278
+ /* croissance : la cellule se developpe avec le cache, en douceur */
1279
+ if (Math.abs(this._vitality - this._vitalityTarget) > 0.001) {
1280
+ this._vitality += (this._vitalityTarget - this._vitality) * Math.min(1, dt * 0.6);
1281
+ this.anatomy.scale.setScalar(this._vitality);
1282
+ }
1283
+
1284
+ /* rejeu du flux + potentials d'action */
1285
+ this._stepReplay(dt);
1286
+ for (let i = this.spikes.length - 1; i >= 0; i--) {
1287
+ const sp = this.spikes[i];
1288
+ sp.t += dt * sp.speed;
1289
+ if (sp.t >= 1) {
1290
+ this.group.remove(sp.mesh);
1291
+ sp.mesh.geometry.dispose();
1292
+ sp.mesh.material.dispose();
1293
+ this.spikes.splice(i, 1);
1294
+ continue;
1295
+ }
1296
+ const p = samplePath(this.axonPath, sp.t);
1297
+ sp.mesh.position.copy(p);
1298
+ sp.mesh.material.opacity = 1 - Math.pow(sp.t, 3);
1299
+ sp.mesh.scale.setScalar(1 + Math.sin(sp.t * Math.PI) * 0.5);
1300
+ }
1301
+
1302
+ /* noeuds : circulation le long des neurites + respiration + impulsion */
1303
+ this.nodeMeshes.forEach((m, i) => {
1304
+ const it = m.userData.item;
1305
+ const base = m.userData.base;
1306
+
1307
+ /* 1. le noeud derive le long de sa branche */
1308
+ if (it._path && it._path.length > 1) {
1309
+ const u = it._u0 + Math.sin(t * it._speed + i) * it._drift;
1310
+ const p = samplePath(it._path, u);
1311
+ it._pos.copy(p);
1312
+ m.position.copy(p);
1313
+ if (m.userData.halo) m.userData.halo.position.copy(p);
1314
+ if (m.userData.label) {
1315
+ const off = m.userData.label.userData.off;
1316
+ m.userData.label.position.copy(p).add(off);
1317
+ }
1318
+ }
1319
+
1320
+ /* 2. respiration + flash */
1321
+ if (m.userData.pulse > 0) {
1322
+ m.userData.pulse -= dt * 2.0;
1323
+ const p = Math.max(0, m.userData.pulse);
1324
+ m.scale.setScalar(base * (1 + p * 1.1));
1325
+ if (m.userData.halo) m.userData.halo.material.opacity = 0.6 + p * 0.5;
1326
+ if (p <= 0 && m.userData.halo) m.userData.halo.material.opacity = 0.6;
1327
+ } else {
1328
+ const b = 1 + Math.sin(t * 1.6 + i * 0.7) * 0.09;
1329
+ m.scale.setScalar(base * b);
1330
+ if (m.userData.halo) m.userData.halo.material.opacity = 0.42 + Math.sin(t * 1.6 + i * 0.7) * 0.16;
1331
+ }
1332
+ if (m.userData.pulse <= 0 && m.material.color.getHex() !== it._color && this.hovered !== m) {
1333
+ m.material.color.setHex(it._color);
1334
+ }
1335
+ });
1336
+
1337
+ /* impulsions le long des synapses */
1338
+ if (this.pulses && this._items.length) {
1339
+ const arr = this.pulseGeo.attributes.position.array;
1340
+ const n = this.pulseCount;
1341
+ for (let i = 0; i < n; i++) {
1342
+ const it = this._items[(i * 13) % this._items.length];
1343
+ if (!it || !it._pos) { arr[i * 3] = 0; arr[i * 3 + 1] = 0; arr[i * 3 + 2] = 0; continue; }
1344
+ const u = ((t * 0.42 + i * 0.137) % 1);
1345
+ const p = it._pos.clone().multiplyScalar(1 - u);
1346
+ p.y += Math.sin(Math.PI * u) * 1.6;
1347
+ arr[i * 3] = p.x; arr[i * 3 + 1] = p.y; arr[i * 3 + 2] = p.z;
1348
+ }
1349
+ this.pulseGeo.attributes.position.needsUpdate = true;
1350
+ }
1351
+
1352
+ /* picking */
1353
+ this.raycaster.setFromCamera(this.pointer, this.camera);
1354
+ const hits = this.raycaster.intersectObjects(this.nodeMeshes, false);
1355
+ this._setHover(hits.length ? hits[0].object : null);
1356
+
1357
+ fitLabelScale(this.camera, this.canvas, this.labels, 14, this.orbit.state.target);
1358
+ this.renderer.render(this.scene, this.camera);
1359
+ };
1360
+
1361
+ /* ==========================================================================
1362
+ B. AGENT LOOP — lemniscate de Gerono (figure-8) en 3D
1363
+ ========================================================================== */
1364
+ function AgentLoop(canvas) {
1365
+ const self = this;
1366
+ this.canvas = canvas;
1367
+ this.renderer = new THREE.WebGLRenderer({ canvas: canvas, antialias: true, alpha: true });
1368
+ this.renderer.setPixelRatio(DPR());
1369
+ this.scene = new THREE.Scene();
1370
+ this.camera = new THREE.PerspectiveCamera(45, 1, 0.1, 1000);
1371
+ this.camera.position.set(0, 4, 40);
1372
+ /* theta = 0 : la camera regarde l'axe +Z, donc la lemniscate se lit
1373
+ comme un vrai "8" a l'ecran. phi faible = leger plongeon 3D.
1374
+ sway : balancement doux plutot qu'une rotation continue (une rotation
1375
+ continue ferait passer la courbe de profil une fois sur deux). */
1376
+ this.orbit = makeOrbit(canvas, this.camera, {
1377
+ dist: 40, theta: 0, phi: 0.10, sway: true, swayAmp: 0.40, swaySpeed: 0.30
1378
+ });
1379
+
1380
+ this.scene.add(new THREE.AmbientLight(0x24465c, 1.4));
1381
+ const l1 = new THREE.PointLight(0x7fe6ff, 1.5, 160); l1.position.set(10, 14, 14);
1382
+ const l2 = new THREE.PointLight(0xff9a3c, 1.2, 160); l2.position.set(-12, -8, -10);
1383
+ this.scene.add(l1); this.scene.add(l2);
1384
+
1385
+ this.group = new THREE.Group();
1386
+ this.scene.add(this.group);
1387
+
1388
+ this._shape = 'loop';
1389
+ this._flight = false;
1390
+ this._boltLevel = 2;
1391
+ this._thick = 1;
1392
+ this._speed = 1;
1393
+ this._autoSpin = true;
1394
+ this._manual = false;
1395
+ this._activeStage = 0;
1396
+ this._stageCb = null;
1397
+ this._head = 0;
1398
+ this._zoom = 1;
1399
+
1400
+ this._buildCurve();
1401
+
1402
+ /* Tete de charge + halo */
1403
+ this.head = new THREE.Mesh(
1404
+ new THREE.SphereGeometry(0.72, 20, 14),
1405
+ new THREE.MeshBasicMaterial({ color: 0xffffff })
1406
+ );
1407
+ this.group.add(this.head);
1408
+ this.headHalo = new THREE.Sprite(new THREE.SpriteMaterial({
1409
+ map: haloTexture(), color: C.HIT, transparent: true,
1410
+ blending: THREE.AdditiveBlending, depthWrite: false
1411
+ }));
1412
+ this.headHalo.scale.setScalar(7);
1413
+ this.group.add(this.headHalo);
1414
+
1415
+ /* Trainee de la tete */
1416
+ const TRAIL = 60;
1417
+ const tgeo = new THREE.BufferGeometry();
1418
+ tgeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(TRAIL * 3), 3));
1419
+ this.trailCount = TRAIL;
1420
+ this.trailGeo = tgeo;
1421
+ this.trail = new THREE.Line(tgeo, new THREE.LineBasicMaterial({
1422
+ color: 0x9fefff, transparent: true, opacity: 0.75,
1423
+ blending: THREE.AdditiveBlending, depthWrite: false
1424
+ }));
1425
+ this.trail.frustumCulled = false;
1426
+ this.group.add(this.trail);
1427
+
1428
+ /* Particules qui parcourent la courbe */
1429
+ const N = 420;
1430
+ const pgeo = new THREE.BufferGeometry();
1431
+ pgeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(N * 3), 3));
1432
+ this.particleCount = N;
1433
+ this.particleGeo = pgeo;
1434
+ this.particles = new THREE.Points(pgeo, new THREE.PointsMaterial({
1435
+ color: C.HIT, size: 0.34, transparent: true, opacity: 0.95,
1436
+ blending: THREE.AdditiveBlending, depthWrite: false, sizeAttenuation: true
1437
+ }));
1438
+ this.particles.frustumCulled = false;
1439
+ this.group.add(this.particles);
1440
+
1441
+ /* Les stations du pipeline sont creees par _buildCurve() (appele plus haut) :
1442
+ on ne les recree pas ici, sinon on laisserait un groupe orphelin. */
1443
+
1444
+ /* Etape active : anneau lumineux sur la courbe */
1445
+ this.stageRing = new THREE.Mesh(
1446
+ new THREE.TorusGeometry(2.1, 0.14, 8, 36),
1447
+ new THREE.MeshBasicMaterial({ color: C.SAVED, transparent: true, opacity: 0.9 })
1448
+ );
1449
+ this.stageRing.raycast = function () {};
1450
+ this.group.add(this.stageRing);
1451
+
1452
+ /* --- API publique ---------------------------------------------- */
1453
+ this.setShape = function (s) { self._setShape(s); };
1454
+ this.setFlight = function (b) { self._flight = !!b; };
1455
+ this.setBolts = function (n) { self._setBolts(n); };
1456
+ this.setThick = function (t) { self._setThick(t); };
1457
+ this.setSpeed = function (s) { self._speed = clamp(+s || 1, 0.2, 4); };
1458
+ this.jumpToStage = function (i) {
1459
+ self._head = (clamp(i | 0, 0, STAGES.length - 1) + 0.08) / STAGES.length;
1460
+ };
1461
+ this.onStage = function (cb) { self._stageCb = cb; };
1462
+ this.resetOrbit = function () { self.orbit.reset(); self._frameCurve(); };
1463
+ /* Une passe complete du pipeline : la tete de charge visite les cinq
1464
+ etapes dans l'ordre (PROMPT -> NORMALIZE -> KEY -> CACHE -> SERVE),
1465
+ avec une pause sur chacune. C'est le workflow du proxy, anime. */
1466
+ this.runPipeline = function () {
1467
+ self._runStage = 0;
1468
+ self._runHold = 0;
1469
+ self._running = true;
1470
+ };
1471
+ this.isRunning = function () { return !!self._running; };
1472
+ this.resize = function () { self._resize(); };
1473
+
1474
+ Object.defineProperty(this, 'shape', { get: () => self._shape });
1475
+ Object.defineProperty(this, 'flight', { get: () => self._flight, set: v => { self._flight = !!v; } });
1476
+ Object.defineProperty(this, 'boltLevel', { get: () => self._boltLevel });
1477
+ Object.defineProperty(this, 'thick', { get: () => self._thick });
1478
+ Object.defineProperty(this, 'speed', { get: () => self._speed });
1479
+ Object.defineProperty(this, 'autoSpin', {
1480
+ get: () => self._autoSpin,
1481
+ set: v => {
1482
+ self._autoSpin = !!v; self._manual = !v;
1483
+ self.orbit.state.auto = !!v; self.orbit.state.manual = !v;
1484
+ }
1485
+ });
1486
+ Object.defineProperty(this, 'manual', {
1487
+ get: () => self._manual,
1488
+ set: v => { self._manual = !!v; self.orbit.state.manual = !!v; }
1489
+ });
1490
+ Object.defineProperty(this, 'activeStage', { get: () => self._activeStage });
1491
+ Object.defineProperty(this, 'zoom', {
1492
+ get: () => self._zoom,
1493
+ set: v => { self._zoom = clamp(+v || 1, 0.5, 2.4); }
1494
+ });
1495
+
1496
+ this._resize();
1497
+ this._frameCurve();
1498
+ autoResize(this);
1499
+ this._loop = startLoop(this);
1500
+ }
1501
+
1502
+ AgentLoop.prototype._resize = function () {
1503
+ const cv = this.canvas;
1504
+ const w = cv.clientWidth, h = cv.clientHeight;
1505
+ if (!w || !h) return; // canvas masque : on garde la derniere taille
1506
+ this._w = w; this._h = h;
1507
+ this.renderer.setSize(w, h, false);
1508
+ this.camera.aspect = w / h;
1509
+ this.camera.updateProjectionMatrix();
1510
+ if (!this._framed) { this._framed = true; this._frameCurve(); }
1511
+ };
1512
+
1513
+ /* Recadre la camera sur la courbe : le "8" remplit le cadre sans etre
1514
+ coupe, quelle que soit la forme choisie (lemniscate, tore, helice...). */
1515
+ AgentLoop.prototype._frameCurve = function () {
1516
+ if (!this.curve) return;
1517
+ const box = new THREE.Box3();
1518
+ const N = 180;
1519
+ for (let i = 0; i <= N; i++) box.expandByPoint(this.curve.pointAt(i / N));
1520
+ /* marge pour les stations (anneaux) et les etiquettes d'etape */
1521
+ box.min.y -= 2.4; box.max.y += 4.8;
1522
+ box.min.x -= 3.0; box.max.x += 3.0;
1523
+ box.min.z -= 2.2; box.max.z += 2.2;
1524
+ this.orbit.frameBox(box, 1.1);
1525
+ };
1526
+
1527
+ /* Formes disponibles pour le pipeline. */
1528
+ AgentLoop.prototype._shapeFn = function (shape) {
1529
+ if (shape === 'torus') {
1530
+ return t => new THREE.Vector3(
1531
+ 16 * Math.cos(t * Math.PI * 2),
1532
+ 4.2 * Math.sin(t * Math.PI * 2 * 2),
1533
+ 16 * Math.sin(t * Math.PI * 2)
1534
+ );
1535
+ }
1536
+ if (shape === 'helix') {
1537
+ return t => {
1538
+ const a = t * Math.PI * 2 * 2;
1539
+ return new THREE.Vector3(
1540
+ 9 * Math.cos(a),
1541
+ (t - 0.5) * 22,
1542
+ 9 * Math.sin(a) + (Math.floor(t * 4) % 2 ? 7 : -7)
1543
+ );
1544
+ };
1545
+ }
1546
+ if (shape === 'trefoil') {
1547
+ return t => {
1548
+ const a = t * Math.PI * 2;
1549
+ return new THREE.Vector3(
1550
+ (Math.sin(a) + 2 * Math.sin(2 * a)) * 6.5,
1551
+ (Math.cos(a) - 2 * Math.cos(2 * a)) * 6.5,
1552
+ Math.sin(3 * a) * 6.5
1553
+ );
1554
+ };
1555
+ }
1556
+ /* Lemniscate de Gerono (figure-8) : x = A.cos t, y = B.sin t.cos t.
1557
+ Unlike the rational Bernoulli form, its derivative never vanishes, so
1558
+ the arc-length table stays perfectly conditioned.
1559
+ Proportions ~1.3:1 pour que le 8 se lise dans un cadre panoramique. */
1560
+ return t => new THREE.Vector3(
1561
+ 13 * Math.cos(t * Math.PI * 2),
1562
+ 20 * Math.sin(t * Math.PI * 2) * Math.cos(t * Math.PI * 2),
1563
+ 3.6 * Math.sin(t * Math.PI * 4)
1564
+ );
1565
+ };
1566
+
1567
+ AgentLoop.prototype._buildCurve = function () {
1568
+ if (this.tube) {
1569
+ this.group.remove(this.tube);
1570
+ this.tube.geometry.dispose();
1571
+ this.tube.material.dispose();
1572
+ this.tube = null;
1573
+ }
1574
+ this.curve = new SafeCurve(this._shapeFn(this._shape), 1200);
1575
+ this._rebuildTube();
1576
+ this._buildStations();
1577
+ this._buildBolts();
1578
+ };
1579
+
1580
+ AgentLoop.prototype._rebuildTube = function () {
1581
+ const radius = 0.42 * this._thick;
1582
+ /* On echantillonne la courbe (SafeCurve) et on construit un tube a
1583
+ section constante : pas de TubeGeometry (evite tout NaN). */
1584
+ const pts = [];
1585
+ const SEG = 260;
1586
+ for (let i = 0; i <= SEG; i++) pts.push(this.curve.pointAt(i / SEG));
1587
+ const mat = new THREE.MeshBasicMaterial({
1588
+ color: C.HIT, transparent: true, opacity: 0.26,
1589
+ blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide
1590
+ });
1591
+ this.tube = taperedTube(pts, () => radius, 10, mat);
1592
+ if (this.tube) {
1593
+ this.tube.frustumCulled = false;
1594
+ this.group.add(this.tube);
1595
+ }
1596
+
1597
+ /* Contour net : deux lignes, une par lobe, pour lire la figure-8. */
1598
+ if (!this.lines) this.lines = [];
1599
+ this.lines.forEach(l => { this.group.remove(l); l.geometry.dispose(); l.material.dispose(); });
1600
+ this.lines = [];
1601
+ const lobes = [
1602
+ { from: 0.0, to: 0.5, color: 0x8ff4ff },
1603
+ { from: 0.5, to: 1.0, color: 0xffb066 }
1604
+ ];
1605
+ lobes.forEach(lb => {
1606
+ const lp = [];
1607
+ for (let i = 0; i <= 180; i++) lp.push(this.curve.pointAt(lb.from + (lb.to - lb.from) * (i / 180)));
1608
+ const lg = new THREE.BufferGeometry().setFromPoints(lp);
1609
+ const line = new THREE.Line(lg, new THREE.LineBasicMaterial({
1610
+ color: lb.color, transparent: true, opacity: 0.9,
1611
+ blending: THREE.AdditiveBlending, depthWrite: false
1612
+ }));
1613
+ line.frustumCulled = false;
1614
+ this.group.add(line);
1615
+ this.lines.push(line);
1616
+ });
1617
+ };
1618
+
1619
+ /* Stations du pipeline : anneau + faisceau + etiquette a chaque etape. */
1620
+ AgentLoop.prototype._buildStations = function () {
1621
+ if (this.stageGroup) {
1622
+ this.group.remove(this.stageGroup);
1623
+ this.stageGroup.traverse(o => {
1624
+ if (o.geometry) o.geometry.dispose();
1625
+ if (o.material) { if (o.material.map) o.material.map.dispose(); o.material.dispose(); }
1626
+ });
1627
+ }
1628
+ this.stageGroup = new THREE.Group();
1629
+ this.group.add(this.stageGroup);
1630
+ this.stageLabels = [];
1631
+ this.stageGroup.userData = { rings: [] };
1632
+
1633
+ STAGES.forEach((name, i) => {
1634
+ const u = (i + 0.5) / STAGES.length;
1635
+ const p = this.curve.pointAt(u);
1636
+ const ring = new THREE.Mesh(
1637
+ new THREE.TorusGeometry(1.55, 0.1, 8, 32),
1638
+ new THREE.MeshBasicMaterial({ color: 0x7fe6ff, transparent: true, opacity: 0.55 })
1639
+ );
1640
+ ring.position.copy(p);
1641
+ ring.lookAt(this.camera.position);
1642
+ ring.raycast = function () {};
1643
+ this.stageGroup.add(ring);
1644
+ this.stageGroup.userData.rings.push(ring);
1645
+
1646
+ const beam = new THREE.Mesh(
1647
+ new THREE.CylinderGeometry(0.09, 0.09, 3.2, 8),
1648
+ new THREE.MeshBasicMaterial({
1649
+ color: 0x7fe6ff, transparent: true, opacity: 0.4,
1650
+ blending: THREE.AdditiveBlending, depthWrite: false
1651
+ })
1652
+ );
1653
+ beam.position.copy(p).add(new THREE.Vector3(0, 1.6, 0));
1654
+ beam.raycast = function () {};
1655
+ this.stageGroup.add(beam);
1656
+
1657
+ const lbl = labelSprite(name, 0x9fefff, 0.9);
1658
+ lbl.position.copy(p).add(new THREE.Vector3(0, 3.6, 0));
1659
+ this.stageGroup.add(lbl);
1660
+ this.stageLabels.push(lbl);
1661
+ });
1662
+ };
1663
+
1664
+ /* Eclairs : lignes brisees entre deux points voisins de la courbe. */
1665
+ AgentLoop.prototype._buildBolts = function () {
1666
+ if (this.bolts) {
1667
+ this.bolts.forEach(b => { this.group.remove(b); b.geometry.dispose(); b.material.dispose(); });
1668
+ this.bolts = [];
1669
+ }
1670
+ this.bolts = [];
1671
+ if (this._boltLevel <= 0) return;
1672
+ const count = this._boltLevel;
1673
+ for (let i = 0; i < count; i++) {
1674
+ const base = 0.12 + i * 0.31;
1675
+ const pts = [];
1676
+ const segs = 10;
1677
+ for (let s = 0; s <= segs; s++) {
1678
+ const u = (base + s / segs * 0.16) % 1;
1679
+ const p = this.curve.pointAt(u);
1680
+ const jitter = (s === 0 || s === segs) ? 0 : (Math.random() - 0.5) * 3.2;
1681
+ pts.push(new THREE.Vector3(p.x + jitter, p.y + jitter * 0.6, p.z + jitter * 0.4));
1682
+ }
1683
+ const g = new THREE.BufferGeometry().setFromPoints(pts);
1684
+ const l = new THREE.Line(g, new THREE.LineBasicMaterial({
1685
+ color: C.SAVED, transparent: true, opacity: 0.95,
1686
+ blending: THREE.AdditiveBlending, depthWrite: false
1687
+ }));
1688
+ l.frustumCulled = false;
1689
+ this.group.add(l);
1690
+ this.bolts.push(l);
1691
+ }
1692
+ };
1693
+
1694
+ AgentLoop.prototype._setShape = function (s) {
1695
+ if (s === this._shape) return;
1696
+ this._shape = s;
1697
+ this._buildCurve();
1698
+ this._frameCurve(); // chaque forme a son propre gabarit
1699
+ };
1700
+
1701
+ AgentLoop.prototype._setBolts = function (n) {
1702
+ n = clamp(Math.round(n), 0, 3);
1703
+ this._boltLevel = n;
1704
+ this._buildBolts();
1705
+ };
1706
+
1707
+ AgentLoop.prototype._setThick = function (t) {
1708
+ this._thick = clamp(+t || 1, 0.5, 2.2);
1709
+ if (this.curve) this._rebuildTube();
1710
+ };
1711
+
1712
+ AgentLoop.prototype.step = function (dt, now) {
1713
+ const t = now * 0.001;
1714
+ this.orbit.update(dt, this._flight ? 0.45 : 0.16);
1715
+ if (this._zoom !== 1) {
1716
+ this.camera.position.sub(this.orbit.state.target)
1717
+ .multiplyScalar(1 + (this._zoom - 1) * 0.03)
1718
+ .add(this.orbit.state.target);
1719
+ }
1720
+
1721
+ const speed = (this._flight ? 0.30 : 0.085) * this._speed;
1722
+ if (this._running) {
1723
+ /* Mode pipeline : la tete avance et s'arrete sur chaque etape. */
1724
+ this._runHold -= dt;
1725
+ if (this._runHold <= 0) {
1726
+ this._runStage = (this._runStage + 1) % (STAGES.length + 1);
1727
+ if (this._runStage >= STAGES.length) {
1728
+ this._running = false; // tour complet -> retour au libre
1729
+ } else {
1730
+ this._runHold = 0.85; // pause sur l'etape
1731
+ this._head = (this._runStage + 0.5) / STAGES.length;
1732
+ if (this._stageCb) this._stageCb(this._runStage, STAGES[this._runStage]);
1733
+ }
1734
+ }
1735
+ } else {
1736
+ this._head = (this._head + dt * speed) % 1;
1737
+ }
1738
+
1739
+ const pos = this.curve.pointAt(this._head);
1740
+ this.head.position.copy(pos);
1741
+ this.headHalo.position.copy(pos);
1742
+
1743
+ /* etape active du pipeline */
1744
+ const st = Math.min(STAGES.length - 1, Math.floor(this._head * STAGES.length));
1745
+ if (st !== this._activeStage) {
1746
+ this._activeStage = st;
1747
+ if (this._stageCb) this._stageCb(st, STAGES[st]);
1748
+ }
1749
+ const sp = this.curve.pointAt((this._activeStage + 0.5) / STAGES.length);
1750
+ this.stageRing.position.copy(sp);
1751
+ this.stageRing.lookAt(this.camera.position);
1752
+ this.stageRing.material.opacity = 0.55 + Math.sin(t * 4) * 0.3;
1753
+
1754
+ /* stations : celle active pulse */
1755
+ const rings = this.stageGroup && this.stageGroup.userData && this.stageGroup.userData.rings;
1756
+ if (rings) {
1757
+ rings.forEach((r, i) => {
1758
+ const on = i === this._activeStage;
1759
+ r.material.opacity = on ? 0.95 : 0.32;
1760
+ r.material.color.setHex(on ? C.SAVED : 0x7fe6ff);
1761
+ r.scale.setScalar(on ? 1.25 + Math.sin(t * 5) * 0.12 : 1);
1762
+ r.lookAt(this.camera.position);
1763
+ });
1764
+ }
1765
+
1766
+ /* trainee */
1767
+ const tarr = this.trailGeo.attributes.position.array;
1768
+ for (let i = 0; i < this.trailCount; i++) {
1769
+ const u = (this._head - i * 0.0032 + 1) % 1;
1770
+ const p = this.curve.pointAt(u);
1771
+ tarr[i * 3] = p.x; tarr[i * 3 + 1] = p.y; tarr[i * 3 + 2] = p.z;
1772
+ }
1773
+ this.trailGeo.attributes.position.needsUpdate = true;
1774
+
1775
+ /* particules */
1776
+ const arr = this.particleGeo.attributes.position.array;
1777
+ const N = this.particleCount;
1778
+ const flow = this._flight ? 0.55 : 0.16;
1779
+ for (let i = 0; i < N; i++) {
1780
+ const u = (i / N + t * flow) % 1;
1781
+ const p = this.curve.pointAt(u);
1782
+ const w = (t * 0.8 + i * 0.13) % 1;
1783
+ arr[i * 3] = p.x + Math.sin(w * 12 + i) * 0.3;
1784
+ arr[i * 3 + 1] = p.y + Math.cos(w * 9 + i) * 0.3;
1785
+ arr[i * 3 + 2] = p.z + Math.sin(w * 11 + i * 0.7) * 0.3;
1786
+ }
1787
+ this.particleGeo.attributes.position.needsUpdate = true;
1788
+
1789
+ /* la couleur des particules suit le lobe : froid en haut, chaud en bas */
1790
+ this.particles.material.color.setHex(this._head < 0.5 ? 0x66f0ff : 0xffa552);
1791
+
1792
+ /* eclairs qui clignotent */
1793
+ if (this.bolts) {
1794
+ this.bolts.forEach((b, i) => {
1795
+ b.material.opacity = 0.3 + 0.7 * Math.abs(Math.sin(t * (3 + i) + i));
1796
+ });
1797
+ }
1798
+
1799
+ fitLabelScale(this.camera, this.canvas, this.stageLabels, 12, this.orbit.state.target);
1800
+ this.renderer.render(this.scene, this.camera);
1801
+ };
1802
+
1803
+ /* ==========================================================================
1804
+ C. CHART3D — histogramme 3D extrude empile + ruban des tokens economises
1805
+ ========================================================================== */
1806
+ function Chart3D(canvas, opts) {
1807
+ const self = this;
1808
+ this.canvas = canvas;
1809
+ this.opts = Object.assign({
1810
+ maxBars: 40, showFloor: true, savedLine: true, labelMode: 'time',
1811
+ maxBarW: 0.28, autoSpin: false, showLabels: true, showGrid: true,
1812
+ segNames: ['hits', 'misses', 'bypass'],
1813
+ segLabels: ['Cache hits', 'Misses (paid)', 'Bypass'],
1814
+ colorMap: { hits: C.HIT, misses: C.MISS, bypass: C.BYPASS },
1815
+ savedColor: C.SAVED
1816
+ }, opts || {});
1817
+ if (opts && opts.colorMap) {
1818
+ this.opts.colorMap = Object.assign(
1819
+ { hits: C.HIT, misses: C.MISS, bypass: C.BYPASS }, opts.colorMap);
1820
+ }
1821
+
1822
+ this.renderer = new THREE.WebGLRenderer({ canvas: canvas, antialias: true, alpha: true });
1823
+ this.renderer.setPixelRatio(DPR());
1824
+ this.scene = new THREE.Scene();
1825
+ this.camera = new THREE.PerspectiveCamera(42, 1, 0.1, 1000);
1826
+ /* Angle classique d'un histogramme 3D : legere vue de trois-quarts.
1827
+ sway : balancement doux plutot qu'une rotation continue — une rotation
1828
+ continue ferait tourner l'axe des abscisses sous les yeux. */
1829
+ this.orbit = makeOrbit(canvas, this.camera, {
1830
+ dist: 46, theta: 0.45, phi: 0.24, sway: true, swayAmp: 0.26, swaySpeed: 0.16
1831
+ });
1832
+ /* Un graphique ne tourne pas tout seul (autoSpin = false par defaut). */
1833
+ this.orbit.state.auto = !!this.opts.autoSpin;
1834
+
1835
+ this.scene.add(new THREE.AmbientLight(0x2b4a60, 1.5));
1836
+ const l1 = new THREE.PointLight(0x9fefff, 1.4, 200); l1.position.set(20, 30, 25);
1837
+ const l2 = new THREE.PointLight(0xff9a4a, 0.9, 200); l2.position.set(-25, 10, -20);
1838
+ this.scene.add(l1); this.scene.add(l2);
1839
+
1840
+ this.group = new THREE.Group();
1841
+ this.scene.add(this.group);
1842
+ this.barsGroup = new THREE.Group();
1843
+ this.floorGroup = new THREE.Group();
1844
+ this.labelsGroup = new THREE.Group();
1845
+ this.group.add(this.barsGroup);
1846
+ this.group.add(this.floorGroup);
1847
+ this.group.add(this.labelsGroup);
1848
+
1849
+ this._points = [];
1850
+ this._hoverCb = null;
1851
+ this._hoverIndex = -1;
1852
+ this._zoom = 1;
1853
+
1854
+ this.raycaster = new THREE.Raycaster();
1855
+ this.pointer = new THREE.Vector2(-10, -10);
1856
+ this.pickables = [];
1857
+
1858
+ canvas.addEventListener('pointermove', e => {
1859
+ const r = canvas.getBoundingClientRect();
1860
+ self.pointer.x = ((e.clientX - r.left) / r.width) * 2 - 1;
1861
+ self.pointer.y = -((e.clientY - r.top) / r.height) * 2 + 1;
1862
+ });
1863
+ canvas.addEventListener('pointerleave', () => { self.pointer.set(-10, -10); });
1864
+
1865
+ this.setData = function (pts) { self._setData(pts || []); };
1866
+ this.onHover = function (cb) { self._hoverCb = cb; };
1867
+ this.resize = function () { self._resize(); };
1868
+ this.resetOrbit = function () { self.orbit.reset(); self._frameData(); };
1869
+ /* Amene la camera sur le baton le plus haut : on lit le pic sans
1870
+ avoir a parcourir toute la serie. */
1871
+ this.focusPeak = function () {
1872
+ let best = null, bestVal = -1;
1873
+ self.pickables.forEach(m => {
1874
+ const tot = (m.userData.point.hits || 0) + (m.userData.point.misses || 0) + (m.userData.point.bypass || 0);
1875
+ if (tot > bestVal) { bestVal = tot; best = m; }
1876
+ });
1877
+ if (!best) return null;
1878
+ self.orbit.focus(best.position.clone(), Math.max(16, self.orbit.state.dist * 0.45));
1879
+ return best.userData.point;
1880
+ };
1881
+ this.setOptions = function (o) { Object.assign(self.opts, o || {}); self._setData(self._points); };
1882
+
1883
+ Object.defineProperty(this, 'autoSpin', {
1884
+ get: () => self.opts.autoSpin,
1885
+ set: v => { self.opts.autoSpin = !!v; self.orbit.state.auto = !!v; }
1886
+ });
1887
+ Object.defineProperty(this, 'showLabels', {
1888
+ get: () => self.opts.showLabels,
1889
+ set: v => { self.opts.showLabels = !!v; self.labelsGroup.visible = !!v; }
1890
+ });
1891
+ Object.defineProperty(this, 'showGrid', {
1892
+ get: () => self.opts.showGrid,
1893
+ set: v => { self.opts.showGrid = !!v; self.floorGroup.visible = !!v; }
1894
+ });
1895
+ Object.defineProperty(this, 'savedLine', {
1896
+ get: () => self.opts.savedLine,
1897
+ set: v => { self.opts.savedLine = !!v; if (self.savedRibbon) self.savedRibbon.visible = !!v; }
1898
+ });
1899
+ Object.defineProperty(this, 'zoom', {
1900
+ get: () => self._zoom,
1901
+ set: v => { self._zoom = clamp(+v || 1, 0.5, 2.4); }
1902
+ });
1903
+
1904
+ this._resize();
1905
+ autoResize(this);
1906
+ this._loop = startLoop(this);
1907
+ }
1908
+
1909
+ Chart3D.prototype._resize = function () {
1910
+ const cv = this.canvas;
1911
+ const w = cv.clientWidth, h = cv.clientHeight;
1912
+ if (!w || !h) return; // canvas masque : on garde la derniere taille
1913
+ this._w = w; this._h = h;
1914
+ this.renderer.setSize(w, h, false);
1915
+ this.camera.aspect = w / h;
1916
+ this.camera.updateProjectionMatrix();
1917
+ if (!this._framed) { this._framed = true; this._frameData(); }
1918
+ };
1919
+
1920
+ /* Barrique a gradient vertical : sombre en bas, saturé en haut. */
1921
+ Chart3D.prototype._barGeometry = function (bw, h) {
1922
+ const g = new THREE.BoxGeometry(bw, h, bw);
1923
+ const pos = g.attributes.position;
1924
+ const colors = new Float32Array(pos.count * 3);
1925
+ for (let i = 0; i < pos.count; i++) {
1926
+ const y = pos.getY(i);
1927
+ const f = clamp((y + h / 2) / (h || 1), 0, 1);
1928
+ colors[i * 3] = f; colors[i * 3 + 1] = f; colors[i * 3 + 2] = f;
1929
+ }
1930
+ g.setAttribute('color', new THREE.BufferAttribute(colors, 3));
1931
+ return g;
1932
+ };
1933
+
1934
+ /* Etendue horizontale de la scene. Avec peu de barres on resserre la
1935
+ scene (sinon une barre unique flotte au milieu d'un sol immense) ; avec
1936
+ beaucoup de barres on garde le pas regulier. */
1937
+ Chart3D.prototype._spanFor = function (n) {
1938
+ if (n <= 4) return Math.max(3.2, n * 2.4);
1939
+ return Math.max(8, n * 1.4);
1940
+ };
1941
+
1942
+ Chart3D.prototype._setData = function (pts) {
1943
+ this._points = pts;
1944
+ this.pickables = [];
1945
+
1946
+ const purge = (grp) => {
1947
+ while (grp.children.length) {
1948
+ const o = grp.children[0];
1949
+ grp.remove(o);
1950
+ if (o.geometry) o.geometry.dispose();
1951
+ if (o.material) { if (o.material.map) o.material.map.dispose(); o.material.dispose(); }
1952
+ }
1953
+ };
1954
+ purge(this.barsGroup);
1955
+ purge(this.floorGroup);
1956
+ purge(this.labelsGroup);
1957
+
1958
+ const data = pts.slice(-this.opts.maxBars);
1959
+ const n = data.length;
1960
+ if (!n) { this._maxVal = 1; this._maxSaved = 1; return; }
1961
+
1962
+ let maxVal = 0, maxSaved = 0;
1963
+ data.forEach(p => {
1964
+ const tot = (p.hits || 0) + (p.misses || 0) + (p.bypass || 0);
1965
+ if (tot > maxVal) maxVal = tot;
1966
+ if ((p.saved || 0) > maxSaved) maxSaved = p.saved || 0;
1967
+ });
1968
+ this._maxVal = Math.max(1, maxVal);
1969
+ this._maxSaved = Math.max(1, maxSaved);
1970
+
1971
+ const span = this._spanFor(n);
1972
+ const step = span / n;
1973
+ const bw = Math.min(this.opts.maxBarW * 2.6, step * 0.72);
1974
+ const H = 11;
1975
+
1976
+ const segKeys = this.opts.segNames;
1977
+ const segColors = segKeys.map(k => this.opts.colorMap[k]);
1978
+ const savedPts = [];
1979
+
1980
+ data.forEach((p, i) => {
1981
+ const x = -span / 2 + step * (i + 0.5);
1982
+ let y = 0;
1983
+ segKeys.forEach((k, si) => {
1984
+ const v = p[k] || 0;
1985
+ if (v <= 0) return;
1986
+ const h = Math.max(0.08, (v / this._maxVal) * H);
1987
+ const base = new THREE.Color(segColors[si]);
1988
+ const mat = new THREE.MeshPhongMaterial({
1989
+ color: base, emissive: base.clone().multiplyScalar(0.35),
1990
+ shininess: 90, specular: 0xffffff,
1991
+ vertexColors: true, transparent: true, opacity: 0.95
1992
+ });
1993
+ const m = new THREE.Mesh(this._barGeometry(bw, h), mat);
1994
+ m.position.set(x, y + h / 2, 0);
1995
+ m.userData = { index: i, point: p, seg: k, top: y + h };
1996
+ this.barsGroup.add(m);
1997
+ this.pickables.push(m);
1998
+ y += h;
1999
+ });
2000
+
2001
+ /* Capuchon lumineux au sommet du baton */
2002
+ const cap = new THREE.Mesh(
2003
+ new THREE.BoxGeometry(bw * 1.14, 0.09, bw * 1.14),
2004
+ new THREE.MeshBasicMaterial({
2005
+ color: 0xffffff, transparent: true, opacity: 0.75,
2006
+ blending: THREE.AdditiveBlending, depthWrite: false
2007
+ })
2008
+ );
2009
+ cap.position.set(x, y + 0.05, 0);
2010
+ cap.raycast = function () {};
2011
+ this.barsGroup.add(cap);
2012
+
2013
+ if (this.opts.savedLine) {
2014
+ const sh = (p.saved || 0) / this._maxSaved * H * 0.92;
2015
+ savedPts.push(new THREE.Vector3(x, sh, 0));
2016
+ }
2017
+ });
2018
+
2019
+ /* Ruban 3D des tokens economises */
2020
+ if (this.savedRibbon) {
2021
+ this.group.remove(this.savedRibbon);
2022
+ this.savedRibbon.geometry.dispose();
2023
+ this.savedRibbon.material.dispose();
2024
+ this.savedRibbon = null;
2025
+ }
2026
+ if (this.savedDots) {
2027
+ this.group.remove(this.savedDots);
2028
+ this.savedDots.geometry.dispose();
2029
+ this.savedDots.material.dispose();
2030
+ this.savedDots = null;
2031
+ }
2032
+ if (this.opts.savedLine && savedPts.length > 1) {
2033
+ const mat = new THREE.MeshBasicMaterial({
2034
+ color: this.opts.savedColor, transparent: true, opacity: 0.75,
2035
+ blending: THREE.AdditiveBlending, depthWrite: false
2036
+ });
2037
+ const rib = taperedTube(savedPts, () => 0.14, 6, mat);
2038
+ if (rib) {
2039
+ rib.frustumCulled = false;
2040
+ this.group.add(rib);
2041
+ this.savedRibbon = rib;
2042
+ }
2043
+ /* points lumineux le long du ruban */
2044
+ const sg = new THREE.BufferGeometry().setFromPoints(savedPts);
2045
+ const dots = new THREE.Points(sg, new THREE.PointsMaterial({
2046
+ color: this.opts.savedColor, size: 0.42, transparent: true, opacity: 0.95,
2047
+ blending: THREE.AdditiveBlending, depthWrite: false, sizeAttenuation: true
2048
+ }));
2049
+ dots.frustumCulled = false;
2050
+ this.group.add(dots);
2051
+ this.savedDots = dots;
2052
+ }
2053
+
2054
+ /* Sol + grille */
2055
+ if (this.opts.showFloor) {
2056
+ const plane = new THREE.Mesh(
2057
+ new THREE.PlaneGeometry(span + 8, span * 0.6),
2058
+ new THREE.MeshBasicMaterial({
2059
+ color: 0x061019, transparent: true, opacity: 0.72, side: THREE.DoubleSide
2060
+ })
2061
+ );
2062
+ plane.rotation.x = -Math.PI / 2;
2063
+ plane.position.y = -0.04;
2064
+ this.floorGroup.add(plane);
2065
+
2066
+ const stepG = span / 12;
2067
+ for (let i = 0; i <= 12; i++) {
2068
+ const g = new THREE.BufferGeometry().setFromPoints([
2069
+ new THREE.Vector3(-span / 2 + i * stepG, 0, -span * 0.28),
2070
+ new THREE.Vector3(-span / 2 + i * stepG, 0, span * 0.28)
2071
+ ]);
2072
+ this.floorGroup.add(new THREE.Line(g, new THREE.LineBasicMaterial({
2073
+ color: C.GRID, transparent: true, opacity: 0.34
2074
+ })));
2075
+ }
2076
+ for (let j = 0; j <= 5; j++) {
2077
+ const z = -span * 0.28 + (j / 5) * span * 0.56;
2078
+ const g = new THREE.BufferGeometry().setFromPoints([
2079
+ new THREE.Vector3(-span / 2, 0, z), new THREE.Vector3(span / 2, 0, z)
2080
+ ]);
2081
+ this.floorGroup.add(new THREE.Line(g, new THREE.LineBasicMaterial({
2082
+ color: C.GRID, transparent: true, opacity: 0.2
2083
+ })));
2084
+ }
2085
+ }
2086
+
2087
+ /* Etiquettes de l'axe X (temps ou modele) */
2088
+ const labelEvery = Math.max(1, Math.ceil(n / 9));
2089
+ data.forEach((p, i) => {
2090
+ if (i % labelEvery !== 0 && i !== n - 1) return;
2091
+ const raw = String(p.t || '');
2092
+ const txt = this.opts.labelMode === 'raw' ? raw.slice(0, 22) : raw.slice(5, 16);
2093
+ if (!txt) return;
2094
+ const sp = labelSprite(txt, 0x9fefff, 0.9);
2095
+ sp.position.set(-span / 2 + step * (i + 0.5), -1.6, span * 0.22);
2096
+ this.labelsGroup.add(sp);
2097
+ });
2098
+
2099
+ /* Valeurs affichees au sommet des batons les plus hauts : on lit le
2100
+ chiffre sans avoir a survoler. */
2101
+ const ranked = data.map((p, i) => {
2102
+ let tot = 0;
2103
+ this.opts.segNames.forEach(k => { tot += (p[k] || 0); });
2104
+ return { i: i, tot: tot };
2105
+ }).filter(r => r.tot > 0).sort((a, b) => b.tot - a.tot);
2106
+ ranked.slice(0, 4).forEach(r => {
2107
+ const x = -span / 2 + step * (r.i + 0.5);
2108
+ let yy = 0;
2109
+ data[r.i] && this.opts.segNames.forEach(k => {
2110
+ const v = data[r.i][k] || 0;
2111
+ if (v > 0) yy += (v / this._maxVal) * H;
2112
+ });
2113
+ const sp = labelSprite(String(r.tot), 0xffffff, 0.9);
2114
+ sp.position.set(x, yy + 1.6, 0);
2115
+ sp.userData.isValue = true;
2116
+ this.labelsGroup.add(sp);
2117
+ });
2118
+
2119
+ /* Axe des valeurs (graduations a gauche du sol). */
2120
+ for (let k = 0; k <= 2; k++) {
2121
+ const v = Math.round((this._maxVal * (2 - k)) / 2);
2122
+ const sp = labelSprite(String(v), 0x7fb4d8, 0.9);
2123
+ sp.position.set(-span / 2 - 1.6, (H * (2 - k)) / 2, -span * 0.3);
2124
+ this.labelsGroup.add(sp);
2125
+ }
2126
+
2127
+ /* cadrage : on encadre exactement le volume occupe par les donnees */
2128
+ this._frameData();
2129
+ };
2130
+
2131
+ /* Recadre la camera sur le volume des barres : le graphique remplit le
2132
+ canvas au lieu de flotter au centre d'un grand vide. */
2133
+ Chart3D.prototype._frameData = function () {
2134
+ const data = this._points.slice(-this.opts.maxBars);
2135
+ const n = data.length;
2136
+ if (!n) return;
2137
+ const span = this._spanFor(n);
2138
+ const step = span / n;
2139
+ const H = 11;
2140
+ const pts = [];
2141
+ data.forEach((p, i) => {
2142
+ const x = -span / 2 + step * (i + 0.5);
2143
+ let tot = 0;
2144
+ this.opts.segNames.forEach(k => { tot += (p[k] || 0); });
2145
+ const h = (tot / this._maxVal) * H;
2146
+ pts.push(new THREE.Vector3(x, -2.0, 0)); // ligne d'etiquette
2147
+ pts.push(new THREE.Vector3(x, Math.max(h, 0) + 1.4, 0)); // sommet du baton
2148
+ if (this.opts.savedLine && (p.saved || 0)) {
2149
+ pts.push(new THREE.Vector3(x, (p.saved / this._maxSaved) * H * 0.92 + 1.2, 0));
2150
+ }
2151
+ });
2152
+ this.orbit.framePoints(pts, 1.06);
2153
+ };
2154
+
2155
+ Chart3D.prototype.step = function (dt, now) {
2156
+ this.orbit.update(dt, 0.05);
2157
+ if (this._zoom !== 1) {
2158
+ this.camera.position.sub(this.orbit.state.target)
2159
+ .multiplyScalar(1 + (this._zoom - 1) * 0.02)
2160
+ .add(this.orbit.state.target);
2161
+ }
2162
+
2163
+ /* survol */
2164
+ this.raycaster.setFromCamera(this.pointer, this.camera);
2165
+ const hits = this.raycaster.intersectObjects(this.pickables, false);
2166
+ const idx = hits.length ? hits[0].object.userData.index : -1;
2167
+ if (idx !== this._hoverIndex) {
2168
+ this._hoverIndex = idx;
2169
+ if (this._hoverCb) {
2170
+ const m = idx >= 0 ? this.pickables.find(x => x.userData.index === idx) : null;
2171
+ this._hoverCb(m ? m.userData.point : null);
2172
+ }
2173
+ }
2174
+
2175
+ fitLabelScale(this.camera, this.canvas, this.labelsGroup.children, 11, this.orbit.state.target);
2176
+ this.renderer.render(this.scene, this.camera);
2177
+ };
2178
+
2179
+ /* ------------------------------------------------------------- export */
2180
+ window.IcyWebGL = {
2181
+ version: '3.0',
2182
+ COLORS: C,
2183
+ MODEL_COLORS: MODEL_COLORS,
2184
+ STAGES: STAGES,
2185
+ SHAPES: SHAPES,
2186
+ neuron: function (canvas) { return new HumanNeuron(canvas); },
2187
+ loop: function (canvas) { return new AgentLoop(canvas); },
2188
+ chart: function (canvas, opts) { return new Chart3D(canvas, opts); }
2189
+ };
2190
+ })();