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.
- icycache/__init__.py +32 -0
- icycache/__main__.py +9 -0
- icycache/cli.py +171 -0
- icycache/config/icycache.gateway.json +111 -0
- icycache/config/icycache.json +149 -0
- icycache/config/normalization_rules.json +5832 -0
- icycache/config/semantic_cache.json +8 -0
- icycache/config/tools_registry.json +10 -0
- icycache/src/icy_cache_store.py +217 -0
- icycache/src/icy_core.py +380 -0
- icycache/src/icy_normalize.py +197 -0
- icycache/src/icy_paths.py +76 -0
- icycache/src/icy_semantic.py +197 -0
- icycache/src/mcp_gateway.py +360 -0
- icycache/src/mcp_server.py +1319 -0
- icycache/src/mcp_stdio_bridge.py +83 -0
- icycache/src/mcp_stdio_entry.py +23 -0
- icycache/src/prefix_proxy.py +336 -0
- icycache/src/server.py +1142 -0
- icycache/src/tool_proxy.py +655 -0
- icycache/src/upstream_client.py +98 -0
- icycache/src/upstream_utils.py +135 -0
- icycache/static/dashboard.html +2385 -0
- icycache/static/icywebgl.js +2190 -0
- icycache/static/logo.svg +58 -0
- icycache/static/three.min.js +6 -0
- icycache-0.3.2.dist-info/METADATA +422 -0
- icycache-0.3.2.dist-info/RECORD +31 -0
- icycache-0.3.2.dist-info/WHEEL +4 -0
- icycache-0.3.2.dist-info/entry_points.txt +2 -0
- icycache-0.3.2.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
})();
|