@naniteninja/trait-visual 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2020 @@
1
+ import { CommonModule } from '@angular/common';
2
+ import * as i0 from '@angular/core';
3
+ import { Input, ViewChild, Component } from '@angular/core';
4
+ import * as THREE from 'three';
5
+ import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
6
+ import * as PCAImport from 'ml-pca';
7
+
8
+ class Node extends THREE.Object3D {
9
+ options;
10
+ velocity;
11
+ isSun;
12
+ attributes;
13
+ preferences;
14
+ preference = 0;
15
+ mesh;
16
+ swap = null;
17
+ static attributeWeights = [];
18
+ static preferenceWeights = [];
19
+ static normalizeWeights(weights, len) {
20
+ const list = Array.isArray(weights) ? weights.slice(0, len) : [];
21
+ const extended = list.slice();
22
+ while (extended.length < len)
23
+ extended.push(1);
24
+ return extended.map((w) => (Number.isFinite(w) && w >= 0 ? w : 0));
25
+ }
26
+ sunBaseScale = 3;
27
+ halo;
28
+ coreSprite;
29
+ haloSprite;
30
+ baseSphereRadius = 0.05; // geometry radius
31
+ clock = new THREE.Clock();
32
+ constructor(data, options) {
33
+ super();
34
+ this.options = options;
35
+ this.position.fromArray(data.initialPosition);
36
+ this.velocity = new THREE.Vector3(0, 0, 0);
37
+ this.userData = { ...data };
38
+ this.isSun = data.isSun;
39
+ this.attributes = data.attributes
40
+ ? [...Object.values(data.attributes)]
41
+ : Array.from({ length: 10 }, () => Math.floor(Math.random() * 99));
42
+ this.preferences = data.preferences
43
+ ? [...Object.values(data.preferences)]
44
+ : Array.from({ length: 10 }, () => Math.floor(Math.random() * 99));
45
+ // Create glowing core using additive Fresnel-like shader (center-bright)
46
+ const geom = new THREE.SphereGeometry(this.baseSphereRadius, 48, 48);
47
+ const coreMat = makeCoreGlowMaterial(this.isSun ? new THREE.Color('#C300FF') : new THREE.Color('#FF3366'), this.isSun ? 0.9 : 0.7, 3.5, this.isSun ? 0.6 : 0.35 // base floor so planets stay red from all angles
48
+ );
49
+ this.mesh = new THREE.Mesh(geom, coreMat);
50
+ this.add(this.mesh);
51
+ // Soft additive rim glow (BackSide Fresnel)
52
+ const haloMat = makeRimGlowMaterial(this.isSun ? new THREE.Color('#C300FF') : new THREE.Color('#FF3366'), this.isSun ? 0.22 : 0.16, 0.2, 2.2);
53
+ this.halo = new THREE.Mesh(geom.clone(), haloMat);
54
+ this.halo.scale.setScalar(this.isSun ? 1.8 : 1.35);
55
+ this.add(this.halo);
56
+ // Camera-facing luminous core billboard to ensure visibility from all angles
57
+ this.coreSprite = this.createBillboardGlow(this.isSun ? new THREE.Color('#C300FF') : new THREE.Color('#FF3366'), this.isSun ? 0.35 : 0.25, // reduced to avoid washing out particles
58
+ this.isSun ? 1.4 : 1.05);
59
+ this.coreSprite.visible = false; // keep glow sprites hidden for clearer particles
60
+ this.add(this.coreSprite);
61
+ // Softer, larger halo billboard (always-on rim feel)
62
+ this.halo.visible = false; // keep mesh halo hidden
63
+ this.haloSprite = this.createBillboardGlow(this.isSun ? new THREE.Color('#C300FF') : new THREE.Color('#FF3366'), this.isSun ? 0.08 : 0.06, // much softer halo
64
+ this.isSun ? 2.2 : 1.6);
65
+ this.haloSprite.renderOrder = 18;
66
+ this.haloSprite.visible = false; // hide outer halo to ensure particles remain readable
67
+ this.add(this.haloSprite);
68
+ }
69
+ updatePhysics(nodes, central, deltaTime) {
70
+ if (!nodes || nodes.length === 0)
71
+ return;
72
+ const forces = nodes.map(() => new THREE.Vector3());
73
+ // --- Attraction: from each non-sun node to central (sun) ---
74
+ if (central) {
75
+ for (let i = 0; i < nodes.length; i++) {
76
+ const node = nodes[i];
77
+ if (node === central)
78
+ continue;
79
+ const d = central.position.clone().sub(node.position);
80
+ const distanceSq = d.lengthSq() + 1e-4;
81
+ const compatibility = node.calculatePreferredCompatibility(central);
82
+ let force = d
83
+ .normalize()
84
+ .multiplyScalar((this.options.sun.attraction * compatibility * 2.0) /
85
+ (distanceSq + 0.5));
86
+ // Clamp runaway attraction
87
+ force.clampLength(0, 0.08);
88
+ forces[i].add(force);
89
+ }
90
+ }
91
+ // ---Outer ↔ Outer interactions (blend of attraction and repulsion) ---
92
+ for (let i = 0; i < nodes.length; i++) {
93
+ const a = nodes[i];
94
+ if (a === central)
95
+ continue;
96
+ for (let j = i + 1; j < nodes.length; j++) {
97
+ const b = nodes[j];
98
+ if (b === central)
99
+ continue;
100
+ const dir = b.position.clone().sub(a.position);
101
+ const distSq = dir.lengthSq() + 1e-6;
102
+ const dist = Math.sqrt(distSq);
103
+ if (dist < 1e-8)
104
+ continue;
105
+ const similarity = a.calculateAttributeCompatibility(b);
106
+ const dissimilarity = 1 - similarity;
107
+ const radiusA = a.baseSphereRadius * a.mesh.scale.x;
108
+ const radiusB = b.baseSphereRadius * b.mesh.scale.x;
109
+ const minDist = (radiusA + radiusB) * 1;
110
+ if (dist < minDist) {
111
+ const repel = dir
112
+ .normalize()
113
+ .multiplyScalar(this.options.planet.repulsion / (distSq + 0.01));
114
+ repel.clampLength(0, 0.25);
115
+ forces[i].sub(repel);
116
+ forces[j].add(repel);
117
+ continue;
118
+ }
119
+ const attractMag = (30 * similarity * 3.0) / (distSq + 0.5);
120
+ const repelMag = 90 * dissimilarity / (distSq + 0.5);
121
+ const netMag = attractMag - repelMag;
122
+ if (Math.abs(netMag) < 1e-4)
123
+ continue;
124
+ const f = dir.normalize().multiplyScalar(netMag).clampLength(-0.9, 0.9);
125
+ forces[i].add(f);
126
+ forces[j].add(f.clone().multiplyScalar(-1));
127
+ }
128
+ }
129
+ // ---Extra Repulsion (outer nodes only) ---
130
+ for (let i = 0; i < nodes.length; i++) {
131
+ const a = nodes[i];
132
+ if (a === central)
133
+ continue;
134
+ for (let j = i + 1; j < nodes.length; j++) {
135
+ const b = nodes[j];
136
+ if (b === central)
137
+ continue;
138
+ const dir = a.position.clone().sub(b.position);
139
+ const distSq = dir.lengthSq() + 1e-4;
140
+ const similarity = a.calculateAttributeCompatibility(b);
141
+ const mag = 20 * (1 - similarity) / (distSq + 0.5);
142
+ const f = dir.normalize().multiplyScalar(mag).clampLength(0, 0.9);
143
+ forces[i].add(f);
144
+ forces[j].add(f.clone().multiplyScalar(-1));
145
+ }
146
+ }
147
+ // --- Apply forces → velocities → positions ---
148
+ for (let i = 0; i < nodes.length; i++) {
149
+ const node = nodes[i];
150
+ if (node == central)
151
+ continue;
152
+ if (!(node.velocity instanceof THREE.Vector3)) {
153
+ node.velocity = new THREE.Vector3(0, 0, 0);
154
+ }
155
+ const effectiveDt = Math.min(deltaTime, 0.95);
156
+ const acceleration = forces[i];
157
+ // apply acceleration
158
+ node.velocity.addScaledVector(acceleration, effectiveDt);
159
+ // --- jitter suppression ---
160
+ // add a small deadzone to prevent micro-oscillation
161
+ if (node.velocity.lengthSq() < 1e-10) {
162
+ node.velocity.set(0, 0, 0);
163
+ continue;
164
+ }
165
+ // soft low-pass filter to smooth small fluctuations
166
+ node.velocity.lerp(node.velocity.clone(), 0.95);
167
+ // damping
168
+ // const dampingFactor = 1 - 0.8 * 0.9;
169
+ const dampingFactor = 0.181;
170
+ node.velocity.multiplyScalar(dampingFactor);
171
+ // clamp to prevent small high-frequency shake
172
+ node.velocity.clampLength(0, 0.15);
173
+ // integrate motion
174
+ node.position.addScaledVector(node.velocity, effectiveDt);
175
+ // --- Multi-node jitter suppression (group stabilizer) ---
176
+ if (nodes.length > 2) {
177
+ let nearCount = 0;
178
+ for (let j = 0; j < nodes.length; j++) {
179
+ if (j === i || nodes[j] === central)
180
+ continue;
181
+ const d = node.position.distanceTo(nodes[j].position);
182
+ if (d < 0.35)
183
+ nearCount++; // consider nodes within a small neighborhood
184
+ }
185
+ // if surrounded by 2+ near neighbors and moving very slowly, freeze micro motion
186
+ if (nearCount >= 2 && node.velocity.lengthSq() < 1e-5) {
187
+ node.velocity.multiplyScalar(0.1); // heavy dampening
188
+ if (node.velocity.lengthSq() < 1e-10)
189
+ node.velocity.set(0, 0, 0);
190
+ }
191
+ }
192
+ }
193
+ // --- Final stabilization: prevent overlap among similar clusters ---
194
+ const separationRadius = 0.12;
195
+ const stiffness = 0.1;
196
+ for (let i = 0; i < nodes.length; i++) {
197
+ const a = nodes[i];
198
+ if (a === central)
199
+ continue;
200
+ for (let j = i + 1; j < nodes.length; j++) {
201
+ const b = nodes[j];
202
+ if (b === central)
203
+ continue;
204
+ const dir = a.position.clone().sub(b.position);
205
+ const dist = dir.length();
206
+ if (dist < separationRadius && dist > 0.01) {
207
+ // very small corrective displacement only, not a new force
208
+ const overlap = separationRadius - dist;
209
+ const correction = dir.normalize().multiplyScalar(overlap * stiffness);
210
+ a.position.add(correction);
211
+ b.position.sub(correction);
212
+ }
213
+ }
214
+ }
215
+ }
216
+ setSun(state = !this.isSun, preference) {
217
+ this.isSun = state;
218
+ if (state) {
219
+ // Switch to neon purple core; keep sprites hidden for clarity
220
+ const core = this.mesh.material;
221
+ core.uniforms['glowColor'].value = new THREE.Color('#C300FF');
222
+ core.uniforms['opacity'].value = 0.9;
223
+ core.uniforms['base'].value = 0.6;
224
+ const halo = this.halo.material;
225
+ halo.uniforms['glowColor'].value = new THREE.Color('#C300FF');
226
+ halo.uniforms['opacity'].value = 0.22;
227
+ this.halo.scale.setScalar(1.8);
228
+ // Billboard core (hidden)
229
+ this.coreSprite.material.color =
230
+ new THREE.Color('#C300FF');
231
+ this.coreSprite.material.opacity = 0.35;
232
+ const dSun = this.baseSphereRadius * 2 * 1.8;
233
+ this.coreSprite.scale.set(dSun, dSun, 1);
234
+ // Billboard halo (hidden)
235
+ this.haloSprite.material.color =
236
+ new THREE.Color('#C300FF');
237
+ this.haloSprite.material.opacity = 0.08;
238
+ const dSunHalo = this.baseSphereRadius * 2 * 3.0;
239
+ this.haloSprite.scale.set(dSunHalo, dSunHalo, 1);
240
+ }
241
+ else {
242
+ // Switch to pink-red core; keep sprites hidden for clarity
243
+ const core = this.mesh.material;
244
+ core.uniforms['glowColor'].value = new THREE.Color('#FF3366');
245
+ core.uniforms['opacity'].value = 0.7;
246
+ core.uniforms['base'].value = 0.35;
247
+ const halo = this.halo.material;
248
+ halo.uniforms['glowColor'].value = new THREE.Color('#FF3366');
249
+ halo.uniforms['opacity'].value = 0.16;
250
+ this.halo.scale.setScalar(1.35);
251
+ // Billboard core (hidden)
252
+ this.coreSprite.material.color =
253
+ new THREE.Color('#FF3366');
254
+ this.coreSprite.material.opacity = 0.25;
255
+ const d = this.baseSphereRadius * 2 * 1.3;
256
+ this.coreSprite.scale.set(d, d, 1);
257
+ // Billboard halo (hidden)
258
+ this.haloSprite.material.color =
259
+ new THREE.Color('#FF3366');
260
+ this.haloSprite.material.opacity = 0.06;
261
+ const dHalo = this.baseSphereRadius * 2 * 2.0;
262
+ this.haloSprite.scale.set(dHalo, dHalo, 1);
263
+ }
264
+ if (preference !== undefined) {
265
+ this.preference = preference;
266
+ }
267
+ }
268
+ calculatePreferredCompatibility(sun) {
269
+ const sunPreferences = sun.preferences;
270
+ const planetAttributes = this.attributes;
271
+ const len = Math.min(sunPreferences.length, planetAttributes.length);
272
+ const weights = Node.normalizeWeights(Node.preferenceWeights, len);
273
+ let diffSum = 0;
274
+ let weightSum = 0;
275
+ for (let i = 0; i < len; i++) {
276
+ const w = weights[i];
277
+ diffSum += w * Math.abs(sunPreferences[i] - planetAttributes[i]);
278
+ weightSum += w;
279
+ }
280
+ const maxDiff = Math.max(1e-6, weightSum * 100);
281
+ return 1 - diffSum / maxDiff;
282
+ }
283
+ calculateAttributeCompatibility(other) {
284
+ const attributesA = this.attributes;
285
+ const attributesB = other.attributes;
286
+ const len = Math.min(attributesA.length, attributesB.length);
287
+ const weights = Node.normalizeWeights(Node.attributeWeights, len);
288
+ let diffSum = 0;
289
+ let weightSum = 0;
290
+ for (let i = 0; i < len; i++) {
291
+ const w = weights[i];
292
+ diffSum += w * Math.abs(attributesA[i] - attributesB[i]);
293
+ weightSum += w;
294
+ }
295
+ const maxDiff = Math.max(1e-6, weightSum * 100);
296
+ return 1 - diffSum / maxDiff;
297
+ }
298
+ update(nodes, cursorPosition, scene, camera) {
299
+ if (this.swap) {
300
+ this.handleSwapAnimation();
301
+ return;
302
+ }
303
+ // Sun: keep steady size and glow (disable pulsation)
304
+ if (this.isSun) {
305
+ const targetScale = this.sunBaseScale;
306
+ this.mesh.scale.lerp(new THREE.Vector3(targetScale, targetScale, targetScale), 0.2);
307
+ const core = this.mesh.material;
308
+ core.uniforms['opacity'].value = 0.75;
309
+ const haloMat = this.halo.material;
310
+ haloMat.uniforms['opacity'].value = 0.14;
311
+ const haloScale = 1.8;
312
+ this.halo.scale.lerp(new THREE.Vector3(haloScale, haloScale, haloScale), 0.2);
313
+ // Sprites remain hidden/steady
314
+ return;
315
+ }
316
+ let totalForce = new THREE.Vector3();
317
+ const sun = nodes.find((n) => n.isSun) ?? null;
318
+ if (sun) {
319
+ totalForce.add(this.calculateSunForce(sun));
320
+ // Update planet glow based on compatibility with sun
321
+ const compat = this.calculatePreferredCompatibility(sun); // 0..1
322
+ const core = this.mesh.material;
323
+ core.uniforms['opacity'].value = 0.55 + compat * 0.4; // brighter core with compatibility
324
+ // Planet additive halo tightly around mesh
325
+ const haloMat = this.halo.material;
326
+ const baseOpacity = 0.12 + compat * 0.25; // 0.12..0.37
327
+ let proximityBoost = 0;
328
+ if (cursorPosition) {
329
+ const d = this.position.distanceTo(cursorPosition);
330
+ proximityBoost = Math.max(0, 1 - d / 1.5) * 0.04; // very subtle
331
+ }
332
+ haloMat.uniforms['opacity'].value = Math.min(0.5, baseOpacity + proximityBoost);
333
+ this.mesh.scale.set(1.9, 1.9, 1.9);
334
+ const s = 1.35 * (1 + compat * 0.15);
335
+ this.halo.scale.lerp(new THREE.Vector3(s, s, s), 0.25);
336
+ // Billboard tuning for planets
337
+ const coreMat = this.coreSprite.material;
338
+ coreMat.opacity = 0.45 + compat * 0.35 + proximityBoost * 0.2;
339
+ const d2 = this.baseSphereRadius * 2 * (1.3 + compat * 0.2) * this.mesh.scale.x;
340
+ this.coreSprite.scale.lerp(new THREE.Vector3(d2, d2, 1), 0.2);
341
+ const haloMatS = this.haloSprite.material;
342
+ haloMatS.opacity = 0.16 + compat * 0.18 + proximityBoost * 0.1;
343
+ const d2h = this.baseSphereRadius * 2 * (2.0 + compat * 0.5) * this.mesh.scale.x;
344
+ this.haloSprite.scale.lerp(new THREE.Vector3(d2h, d2h, 1), 0.2);
345
+ }
346
+ totalForce.add(this.calculatePlanetRepulsion(nodes));
347
+ totalForce.add(this.calculatePlanetAttraction(nodes));
348
+ totalForce.multiplyScalar(this.options.velocityDamping);
349
+ const deltaTime = this.clock.getDelta();
350
+ this.updatePhysics(nodes, sun, deltaTime);
351
+ this.applyForces(totalForce);
352
+ }
353
+ // (Sprite-based glow removed in favor of additive core + halo meshes)
354
+ createBillboardGlow(color, opacity, diameterMultiplier) {
355
+ const size = 128;
356
+ const canvas = document.createElement('canvas');
357
+ canvas.width = size;
358
+ canvas.height = size;
359
+ const ctx = canvas.getContext('2d');
360
+ const g = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
361
+ g.addColorStop(0.0, 'rgba(255,255,255,1)');
362
+ g.addColorStop(0.5, 'rgba(255,255,255,0.6)');
363
+ g.addColorStop(1.0, 'rgba(255,255,255,0)');
364
+ ctx.fillStyle = g;
365
+ ctx.fillRect(0, 0, size, size);
366
+ const tex = new THREE.CanvasTexture(canvas);
367
+ tex.minFilter = THREE.LinearFilter;
368
+ tex.magFilter = THREE.LinearFilter;
369
+ const material = new THREE.SpriteMaterial({
370
+ map: tex,
371
+ color,
372
+ transparent: true,
373
+ opacity,
374
+ depthWrite: false,
375
+ depthTest: false,
376
+ blending: THREE.AdditiveBlending,
377
+ });
378
+ const sprite = new THREE.Sprite(material);
379
+ const diameter = this.baseSphereRadius * 2 * diameterMultiplier;
380
+ sprite.scale.set(diameter, diameter, 1);
381
+ sprite.renderOrder = 20;
382
+ return sprite;
383
+ }
384
+ calculateSunForce(sun) {
385
+ let force = new THREE.Vector3();
386
+ const compatibility = this.calculatePreferredCompatibility(sun);
387
+ const desiredDistance = 0.25 + (1 - compatibility) * 4.0;
388
+ const currentDistance = sun.position.distanceTo(this.position);
389
+ const error = currentDistance - desiredDistance;
390
+ const directionToSun = new THREE.Vector3()
391
+ .subVectors(sun.position, this.position)
392
+ .normalize();
393
+ if (currentDistance < desiredDistance) {
394
+ const repulsionForce = -this.options.sun.repulsion *
395
+ (desiredDistance - currentDistance) *
396
+ compatibility;
397
+ force.add(directionToSun.multiplyScalar(repulsionForce));
398
+ }
399
+ else {
400
+ const attractionForce = 2 * this.options.sun.attraction * error * compatibility + 0.001;
401
+ force.add(directionToSun.multiplyScalar(attractionForce));
402
+ }
403
+ return force;
404
+ }
405
+ calculatePlanetAttraction(nodes) {
406
+ let attractionForce = new THREE.Vector3();
407
+ const attractionConstant = 0.001;
408
+ nodes.forEach((other) => {
409
+ if (other !== this && !other.isSun) {
410
+ const compatibility = this.calculateAttributeCompatibility(other);
411
+ const forceMagnitude = attractionConstant * compatibility - 0.001;
412
+ const attractionDirection = new THREE.Vector3()
413
+ .subVectors(other.position, this.position)
414
+ .normalize();
415
+ attractionForce.add(attractionDirection.multiplyScalar(forceMagnitude));
416
+ }
417
+ });
418
+ return attractionForce;
419
+ }
420
+ calculatePlanetRepulsion(nodes) {
421
+ let repulsionForce = new THREE.Vector3();
422
+ nodes.forEach((other) => {
423
+ if (other !== this && !other.isSun) {
424
+ const distance = this.position.distanceTo(other.position);
425
+ if (distance < this.options.planet.repulsionInitializationThreshold) {
426
+ const compatibility = this.calculateAttributeCompatibility(other);
427
+ const repulsion = this.options.planet.repulsion *
428
+ (this.options.planet.repulsionInitializationThreshold -
429
+ distance) *
430
+ (1 - compatibility) +
431
+ 0.001;
432
+ const repulsionDirection = new THREE.Vector3()
433
+ .subVectors(this.position, other.position)
434
+ .normalize();
435
+ repulsionForce.add(repulsionDirection.multiplyScalar(repulsion));
436
+ }
437
+ }
438
+ });
439
+ return repulsionForce;
440
+ }
441
+ applyForces(force) {
442
+ this.velocity.add(force);
443
+ if (this.velocity.length() > this.options.maxVelocity) {
444
+ this.velocity.setLength(this.options.maxVelocity);
445
+ }
446
+ this.velocity.multiplyScalar(this.options.velocityDamping);
447
+ this.position.add(this.velocity);
448
+ }
449
+ handleSwapAnimation() {
450
+ if (!this.swap)
451
+ return;
452
+ const currentSwap = this.swap;
453
+ const currentTime = performance.now();
454
+ let progress = (currentTime - currentSwap.startTime) / currentSwap.duration;
455
+ if (progress >= 1) {
456
+ progress = 1;
457
+ this.velocity.set(0, 0, 0);
458
+ this.swap = null;
459
+ }
460
+ this.position.copy(currentSwap.start.clone().lerp(currentSwap.end, progress));
461
+ }
462
+ }
463
+ function makeCoreGlowMaterial(color, opacity = 0.8, power = 3.5, base = 0.1) {
464
+ const uniforms = {
465
+ glowColor: { value: color },
466
+ opacity: { value: opacity },
467
+ p: { value: power },
468
+ base: { value: base },
469
+ };
470
+ const vertex = `
471
+ varying vec3 vNormal;
472
+ varying vec3 vWorldPosition;
473
+ void main() {
474
+ vNormal = normalize(normalMatrix * normal);
475
+ vec4 worldPosition = modelMatrix * vec4(position, 1.0);
476
+ vWorldPosition = worldPosition.xyz;
477
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
478
+ }
479
+ `;
480
+ const fragment = `
481
+ uniform vec3 glowColor;
482
+ uniform float opacity;
483
+ uniform float p;
484
+ uniform float base;
485
+ varying vec3 vNormal;
486
+ varying vec3 vWorldPosition;
487
+ void main() {
488
+ vec3 viewDir = normalize(cameraPosition - vWorldPosition);
489
+ // Center-bright with a base floor so sides never go black
490
+ float ndv = clamp(dot(vNormal, viewDir), 0.0, 1.0);
491
+ float intensity = base + (1.0 - base) * pow(ndv, p);
492
+ gl_FragColor = vec4(glowColor * intensity, intensity * opacity);
493
+ }
494
+ `;
495
+ return new THREE.ShaderMaterial({
496
+ uniforms,
497
+ vertexShader: vertex,
498
+ fragmentShader: fragment,
499
+ transparent: true,
500
+ depthWrite: false,
501
+ blending: THREE.AdditiveBlending,
502
+ side: THREE.FrontSide,
503
+ });
504
+ }
505
+ function makeRimGlowMaterial(color, opacity = 0.2, c = 0.2, power = 2.2) {
506
+ const uniforms = {
507
+ glowColor: { value: color },
508
+ opacity: { value: opacity },
509
+ c: { value: c },
510
+ p: { value: power },
511
+ };
512
+ const vertex = `
513
+ varying vec3 vNormal;
514
+ varying vec3 vWorldPosition;
515
+ void main() {
516
+ vNormal = normalize(normalMatrix * normal);
517
+ vec4 worldPosition = modelMatrix * vec4(position, 1.0);
518
+ vWorldPosition = worldPosition.xyz;
519
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
520
+ }
521
+ `;
522
+ const fragment = `
523
+ uniform vec3 glowColor;
524
+ uniform float opacity;
525
+ uniform float c;
526
+ uniform float p;
527
+ varying vec3 vNormal;
528
+ varying vec3 vWorldPosition;
529
+ void main() {
530
+ vec3 viewDir = normalize(cameraPosition - vWorldPosition);
531
+ float intensity = pow(max(0.0, c - dot(vNormal, viewDir)), p);
532
+ gl_FragColor = vec4(glowColor, intensity * opacity);
533
+ }
534
+ `;
535
+ return new THREE.ShaderMaterial({
536
+ uniforms,
537
+ vertexShader: vertex,
538
+ fragmentShader: fragment,
539
+ transparent: true,
540
+ depthWrite: false,
541
+ blending: THREE.AdditiveBlending,
542
+ side: THREE.BackSide,
543
+ });
544
+ }
545
+ Node.prototype.makeCoreGlowMaterial = makeCoreGlowMaterial;
546
+ Node.prototype.makeRimGlowMaterial = makeRimGlowMaterial;
547
+
548
+ class Cluster extends THREE.Object3D {
549
+ options;
550
+ nodes;
551
+ constructor(nodeData, options) {
552
+ super();
553
+ this.options = {
554
+ sun: {
555
+ attraction: 1,
556
+ repulsion: 1,
557
+ repulsionInitializationThreshold: 0.4,
558
+ },
559
+ planet: {
560
+ attraction: 1,
561
+ repulsion: 1,
562
+ repulsionInitializationThreshold: 0.2,
563
+ },
564
+ maxVelocity: 0.02,
565
+ velocityDamping: 0.8,
566
+ minAttributeValue: 0,
567
+ minPreferenceValue: 0,
568
+ maxAttributeValue: 100,
569
+ maxPreferenceValue: 100,
570
+ ...options,
571
+ };
572
+ this.nodes = [];
573
+ this.setUp(nodeData);
574
+ }
575
+ setUp(nodeData) {
576
+ nodeData.forEach((data) => {
577
+ const node = new Node(data, this.options);
578
+ this.nodes.push(node);
579
+ this.add(node);
580
+ });
581
+ }
582
+ update(cursorPosition, scene, camera) {
583
+ this.nodes.forEach((node) => node.update(this.nodes, cursorPosition, scene, camera));
584
+ // Bound the position within (-8, -8, -8) and (8, 8, 8)
585
+ const BOUND = 8;
586
+ this.position.x = THREE.MathUtils.clamp(this.position.x, -BOUND, BOUND);
587
+ this.position.y = THREE.MathUtils.clamp(this.position.y, -BOUND, BOUND);
588
+ this.position.z = THREE.MathUtils.clamp(this.position.z, -BOUND, BOUND);
589
+ }
590
+ }
591
+
592
+ class BlackHoleParticleField {
593
+ // Private properties
594
+ scene;
595
+ particles = [];
596
+ particleSystem = null;
597
+ // Natural particle count for bubble-like effect
598
+ particleCount = 200;
599
+ // Visual parameters (scaled by coreRadius)
600
+ coreRadius = 0.39; // defaults; overridden per-node via options
601
+ FIELD_RADIUS_FACTOR = 0.9; // relative scale
602
+ NEON_PURPLE = new THREE.Color(0xc300ff);
603
+ PINK_RED = new THREE.Color(0xff3366);
604
+ palette = 'planet';
605
+ // Use even surface distribution (no view alignment by default)
606
+ useSurface = true;
607
+ seedCounter = 0; // Fibonacci seed index
608
+ speedScale = 1; // instance speed scaler (planets slower)
609
+ // Animation params (slower, smoother orbits)
610
+ MIN_SPEED = 0.4; // radians/sec
611
+ MAX_SPEED = 1.2;
612
+ get FRONT_MIN() {
613
+ return this.coreRadius * 0.06;
614
+ }
615
+ // Physical speed coupling (omega ≈ k * |v| / r) and clamps
616
+ OMEGA_K = 1.0;
617
+ OMEGA_MAX_RIM = 0.9; // rad/s
618
+ OMEGA_MAX_CAP = 0.7; // rad/s
619
+ // Surface mode coupling (gentler, avoids insect jitter)
620
+ OMEGA_SURFACE_K = 0.8; // coupling from |vProj|/r → rad/s
621
+ OMEGA_SURFACE_MAX = 0.8; // clamp for surface mode
622
+ OMEGA_SURFACE_BASE_MIN = 0.12; // intrinsic base speed
623
+ OMEGA_SURFACE_BASE_MAX = 0.28;
624
+ SPEED_EPS = 1e-5;
625
+ SHIMMER_FRAC = 0.02; // 2% at rest
626
+ // Debugging: color bands and periodically log counts
627
+ debugBands = false;
628
+ lastDebugLogMs = 0;
629
+ bandCounts = {
630
+ topCurve: 0,
631
+ bottomCurve: 0,
632
+ orbit: 0,
633
+ topCap: 0,
634
+ bottomCap: 0,
635
+ surface: 0,
636
+ };
637
+ MIN_LIFETIME = 3.0;
638
+ MAX_LIFETIME = 6.0;
639
+ // --- anchor tracking + flow alignment ---
640
+ targetAnchor = new THREE.Vector3(); // last received from update()
641
+ anchor = new THREE.Vector3(); // smoothed, z=0
642
+ anchorLerp = 1.0; // fully attached to the cursor position
643
+ lastRight = new THREE.Vector3(1, 0, 0); // cached flow-aligned right axis
644
+ flowSpeedScale = 1.0;
645
+ prevTargetAnchor = new THREE.Vector3(); // for anchor-delta velocity
646
+ worldUp = new THREE.Vector3(0, 1, 0);
647
+ constructor(scene, opts) {
648
+ this.scene = scene;
649
+ if (opts?.coreRadius && Number.isFinite(opts.coreRadius)) {
650
+ this.coreRadius = Math.max(0.01, opts.coreRadius);
651
+ }
652
+ if (opts?.particleCount && Number.isFinite(opts.particleCount)) {
653
+ this.particleCount = Math.max(8, Math.floor(opts.particleCount));
654
+ }
655
+ if (opts?.palette) {
656
+ this.palette = opts.palette;
657
+ }
658
+ if (opts?.distribution) {
659
+ this.useSurface = opts.distribution === 'surface';
660
+ }
661
+ if (opts?.speedScale && Number.isFinite(opts.speedScale)) {
662
+ this.speedScale = Math.max(0.05, opts.speedScale);
663
+ }
664
+ // Enable debug via query param ?debugBands=1
665
+ try {
666
+ const search = window?.location?.search ?? '';
667
+ this.debugBands = /(?:^|[?&])debugBands=1(?:&|$)/.test(search);
668
+ }
669
+ catch { }
670
+ this.initializeParticleField();
671
+ }
672
+ // Public methods
673
+ /**
674
+ * Update particle field anchored at `anchorPosition`, advected by `flow`.
675
+ */
676
+ update(anchorPosition, flow, deltaTime, camera) {
677
+ if (!this.particleSystem)
678
+ return;
679
+ // 1) Accept input, use full 3D coordinates
680
+ const x = anchorPosition?.x;
681
+ const y = anchorPosition?.y;
682
+ const z = anchorPosition?.z;
683
+ if (Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z)) {
684
+ // Anchor exactly at the target position (no world-Z offset)
685
+ this.targetAnchor.set(x, y, z);
686
+ }
687
+ // Smooth follow to avoid jitter (and hide any one-frame glitches)
688
+ this.anchor.lerp(this.targetAnchor, this.anchorLerp);
689
+ // Move the ENTIRE particle system to the anchor.
690
+ // Geometry stays in LOCAL rim-space.
691
+ this.particleSystem.position.copy(this.anchor);
692
+ // 2) Delta time guard
693
+ if (!Number.isFinite(deltaTime) || deltaTime <= 0)
694
+ deltaTime = 1 / 60;
695
+ const geom = this.particleSystem.geometry;
696
+ const positions = geom.getAttribute('position').array;
697
+ const colors = geom.getAttribute('color').array;
698
+ // (sizes/opacities kept for shader)
699
+ const sizes = geom.getAttribute('size').array;
700
+ // 3) Build a WORLD-coupled plane (flow + worldUp) and fuse flow with anchor-delta
701
+ const camForward = new THREE.Vector3(0, 0, 1);
702
+ const camUp = new THREE.Vector3(0, 1, 0);
703
+ if (camera) {
704
+ camera.getWorldDirection(camForward);
705
+ camUp.copy(camera.up).normalize();
706
+ }
707
+ // Compute anchor-delta velocity (works during drag/swap)
708
+ // Prevent first-frame spike
709
+ if (this.prevTargetAnchor.lengthSq() === 0) {
710
+ this.prevTargetAnchor.copy(this.targetAnchor);
711
+ }
712
+ const anchorDelta = new THREE.Vector3().subVectors(this.targetAnchor, this.prevTargetAnchor);
713
+ const vAnchor = anchorDelta
714
+ .clone()
715
+ .multiplyScalar(1 / Math.max(1e-6, deltaTime));
716
+ const vNode = (flow || new THREE.Vector3()).clone();
717
+ const vNodeMag = vNode.length();
718
+ const vAncMag = vAnchor.length();
719
+ const w = vAncMag / Math.max(1e-6, vAncMag + vNodeMag); // prefer anchor when node velocity is small
720
+ const vFlow = vNode.multiplyScalar(1 - w).add(vAnchor.multiplyScalar(w));
721
+ // Project flow onto world-up plane
722
+ const proj = vFlow
723
+ .clone()
724
+ .sub(this.worldUp.clone().multiplyScalar(vFlow.dot(this.worldUp)));
725
+ const projLen = proj.length();
726
+ // Adaptive smoothing: snappier when faster
727
+ const speedHigh = 0.02; // typical maxVelocity
728
+ const alpha = THREE.MathUtils.clamp(0.4 - 0.3 * Math.min(1, vFlow.length() / speedHigh), 0.1, 0.45);
729
+ if (projLen > 1e-6) {
730
+ const instRight = proj.clone().normalize();
731
+ this.lastRight.lerp(instRight, alpha).normalize();
732
+ }
733
+ // If invalid, fall back to camera plane right
734
+ if (!Number.isFinite(this.lastRight.x + this.lastRight.y + this.lastRight.z) ||
735
+ this.lastRight.lengthSq() < 1e-6) {
736
+ const rightCam = camForward.clone().cross(camUp).normalize();
737
+ if (rightCam.lengthSq() > 0)
738
+ this.lastRight.copy(rightCam);
739
+ else
740
+ this.lastRight.set(1, 0, 0);
741
+ }
742
+ let right = this.lastRight.clone();
743
+ const up = this.worldUp.clone();
744
+ let forward = new THREE.Vector3().crossVectors(right, up).normalize();
745
+ // Hybrid plane: bias normal toward camera to avoid "edge-only" look
746
+ const speedNorm = THREE.MathUtils.clamp(vFlow.length() / 0.02, 0, 1);
747
+ const cameraBias = THREE.MathUtils.clamp(0.35 - 0.2 * speedNorm, 0.1, 0.35);
748
+ forward = forward.clone().lerp(camForward, cameraBias).normalize();
749
+ // Recompute right to stay orthonormal after blending
750
+ right = up.clone().cross(forward).normalize();
751
+ // For sign decisions, use instantaneous (unsmoothed) projected flow when available
752
+ const instRight = projLen > 1e-6 ? proj.clone().normalize() : this.lastRight.clone();
753
+ if (right.dot(instRight) < 0)
754
+ right.multiplyScalar(-1);
755
+ const flowSpeed = vFlow.length();
756
+ this.flowSpeedScale = THREE.MathUtils.clamp(flowSpeed * 100, 0, 3); // not used for omega anymore but kept for wiggles
757
+ for (let i = 0; i < this.particles.length; i++) {
758
+ let p = this.particles[i];
759
+ p.age += deltaTime;
760
+ const twoPi = Math.PI * 2;
761
+ // Angular wander: scale by flow for banded mode; always small for surface mode
762
+ const speedFactor = this.useSurface
763
+ ? 0.5
764
+ : THREE.MathUtils.clamp(flowSpeed / 0.02, 0, 1);
765
+ const wander = p.angleWanderAmp *
766
+ speedFactor *
767
+ Math.sin(2 * Math.PI * p.angleWanderFreq * p.age + p.angleWanderPhase);
768
+ if (this.useSurface && p.type === 'surface' && p.u && p.v) {
769
+ // ALWAYS use dynamic direction based on node velocity, never fixed dirSign
770
+ const camRight = camForward.clone().cross(camUp).normalize();
771
+ // Calculate direction based on world velocity + anchor movement
772
+ const totalFlow = vFlow.clone();
773
+ const flowMagnitude = totalFlow.length();
774
+ // Determine rotation direction from velocity:
775
+ // - Node moving right (+X) → particles rotate clockwise (+1)
776
+ // - Node moving left (-X) → particles rotate counter-clockwise (-1)
777
+ let dir = 1; // default when stationary
778
+ if (flowMagnitude > 1e-5) {
779
+ const flowRightComponent = totalFlow.dot(camRight);
780
+ dir = Math.sign(flowRightComponent) || 1;
781
+ }
782
+ else {
783
+ // When stationary, use natural rotation based on particle's orbit plane
784
+ // This prevents systematic patterns that cause ring formation
785
+ const orbitNormal = new THREE.Vector3().crossVectors(p.u, p.v);
786
+ dir = Math.sign(orbitNormal.y) || 1; // Use Y component for consistent but varied direction
787
+ }
788
+ // Project node flow into the particle's plane and derive omega
789
+ const n = new THREE.Vector3().crossVectors(p.u, p.v).normalize();
790
+ const vProjLen = vFlow
791
+ .clone()
792
+ .sub(n.clone().multiplyScalar(vFlow.dot(n)))
793
+ .length();
794
+ const baseR = Math.max(1e-4, p.radius);
795
+ const omegaFlow = this.OMEGA_SURFACE_K * (vProjLen / baseR);
796
+ const omegaBase = p.baseOmega ?? this.OMEGA_SURFACE_BASE_MIN;
797
+ const omega = Math.min(this.OMEGA_SURFACE_MAX * this.speedScale, (omegaBase + omegaFlow) * this.speedScale);
798
+ // Tiny wander for natural look (very small)
799
+ p.angle += dir * (omega + wander) * deltaTime;
800
+ if (p.angle > Math.PI)
801
+ p.angle -= twoPi;
802
+ if (p.angle < -Math.PI)
803
+ p.angle += twoPi;
804
+ // Precession disabled for now
805
+ const local = this.getLocalOrbitPos(p, p.age, right, up, forward, camForward);
806
+ positions[i * 3 + 0] = local.x;
807
+ positions[i * 3 + 1] = local.y;
808
+ positions[i * 3 + 2] = local.z;
809
+ }
810
+ else {
811
+ // Legacy banded motion aligned with flow
812
+ const margin = p.thetaMargin ?? 0;
813
+ const baseR = Math.max(1e-4, p.radius);
814
+ const omegaRaw = this.OMEGA_K * (flowSpeed / baseR);
815
+ const omegaClamp = p.type === 'topCap' || p.type === 'bottomCap'
816
+ ? this.OMEGA_MAX_CAP
817
+ : this.OMEGA_MAX_RIM;
818
+ const omega = Math.min(omegaRaw, omegaClamp);
819
+ const angSign = Math.sign(vFlow.dot(instRight)) || 1;
820
+ p.angle += angSign * (omega + wander) * deltaTime;
821
+ if (p.angle > Math.PI)
822
+ p.angle -= twoPi;
823
+ if (p.angle < -Math.PI)
824
+ p.angle += twoPi;
825
+ const local = this.getLocalOrbitPos(p, p.age, right, up, forward, camForward);
826
+ positions[i * 3 + 0] = local.x;
827
+ positions[i * 3 + 1] = local.y;
828
+ positions[i * 3 + 2] = local.z;
829
+ }
830
+ if (p.age >= p.lifetime) {
831
+ // Retain particle; just reset time and vary wiggle phase
832
+ p.age = 0;
833
+ p.lifetime =
834
+ this.MIN_LIFETIME +
835
+ Math.random() * (this.MAX_LIFETIME - this.MIN_LIFETIME);
836
+ p.wigglePhase = Math.random() * Math.PI * 2;
837
+ }
838
+ }
839
+ // Periodic debug counts log
840
+ if (this.debugBands) {
841
+ const now = performance.now();
842
+ if (now - this.lastDebugLogMs > 2000) {
843
+ this.lastDebugLogMs = now;
844
+ // Recompute counts once (particles are fixed types)
845
+ const counts = {};
846
+ for (const p of this.particles)
847
+ counts[p.type] = (counts[p.type] || 0) + 1;
848
+ console.info('[ParticleBands]', counts);
849
+ }
850
+ }
851
+ // 4) Push updates
852
+ geom.getAttribute('position').needsUpdate = true;
853
+ geom.getAttribute('color').needsUpdate = true;
854
+ geom.computeBoundingSphere?.();
855
+ // Keep last target anchor for next-frame delta
856
+ this.prevTargetAnchor.copy(this.targetAnchor);
857
+ }
858
+ /**
859
+ * Resize the field to a new `coreRadius` while preserving particle distribution.
860
+ * Scales all radius-like parameters so visuals stay consistent.
861
+ */
862
+ resizeCoreRadius(newRadius) {
863
+ if (!Number.isFinite(newRadius) || newRadius <= 0)
864
+ return;
865
+ const old = this.coreRadius;
866
+ if (!old || Math.abs(newRadius - old) < 1e-6)
867
+ return;
868
+ const s = newRadius / old;
869
+ // Update core scalar used in shaders/limits
870
+ this.coreRadius = newRadius;
871
+ // Proportionally scale particle radii and related amplitudes/biases
872
+ for (let i = 0; i < this.particles.length; i++) {
873
+ const p = this.particles[i];
874
+ p.radius *= s;
875
+ p.radialJitterAmp *= s;
876
+ p.upBias *= s;
877
+ p.depthBias *= s;
878
+ if (p.capRadBias)
879
+ p.capRadBias *= s;
880
+ p.arcUpAmp *= s;
881
+ p.arcDepthAmp *= s;
882
+ p.arcRadAmp *= s;
883
+ }
884
+ }
885
+ // Public toggle for debug coloring
886
+ setDebugBands(enabled) {
887
+ if (this.debugBands === enabled)
888
+ return;
889
+ this.debugBands = enabled;
890
+ if (!this.particleSystem)
891
+ return;
892
+ const colors = this.particleSystem.geometry.getAttribute('color');
893
+ for (let i = 0; i < this.particles.length; i++) {
894
+ const c = this.debugBands
895
+ ? this.getDebugColorForType(this.particles[i].type)
896
+ : this.particles[i].color;
897
+ colors.setX(i, c.r);
898
+ colors.setY(i, c.g);
899
+ colors.setZ(i, c.b);
900
+ }
901
+ colors.needsUpdate = true;
902
+ }
903
+ toggleDebugBands() {
904
+ this.setDebugBands(!this.debugBands);
905
+ return this.debugBands;
906
+ }
907
+ // Switch palette at runtime (e.g., when a node becomes or ceases to be the sun)
908
+ setPalette(newPalette) {
909
+ if (this.palette === newPalette)
910
+ return;
911
+ this.palette = newPalette;
912
+ if (!this.particleSystem)
913
+ return;
914
+ const geom = this.particleSystem.geometry;
915
+ const colorsAttr = geom.getAttribute('color');
916
+ const base = this.palette === 'sun' ? this.NEON_PURPLE : this.PINK_RED;
917
+ for (let i = 0; i < this.particles.length; i++) {
918
+ const jitter = THREE.MathUtils.lerp(0.9, 1.1, Math.random());
919
+ const c = base.clone().multiplyScalar(jitter);
920
+ c.r = Math.min(1, Math.max(0, c.r));
921
+ c.g = Math.min(1, Math.max(0, c.g));
922
+ c.b = Math.min(1, Math.max(0, c.b));
923
+ this.particles[i].color.copy(c);
924
+ if (!this.debugBands) {
925
+ colorsAttr.setX(i, c.r);
926
+ colorsAttr.setY(i, c.g);
927
+ colorsAttr.setZ(i, c.b);
928
+ }
929
+ }
930
+ colorsAttr.needsUpdate = true;
931
+ }
932
+ dispose() {
933
+ if (this.particleSystem) {
934
+ this.scene.remove(this.particleSystem);
935
+ this.particleSystem.geometry.dispose();
936
+ this.particleSystem.material.dispose();
937
+ this.particleSystem = null;
938
+ }
939
+ this.particles.length = 0;
940
+ }
941
+ // Private methods
942
+ // --------------------------- init ---------------------------
943
+ initializeParticleField() {
944
+ this.generateParticles();
945
+ this.createParticleSystem();
946
+ }
947
+ generateParticles() {
948
+ this.particles.length = 0;
949
+ // reset counts
950
+ Object.keys(this.bandCounts).forEach((k) => (this.bandCounts[k] = 0));
951
+ for (let i = 0; i < this.particleCount; i++) {
952
+ const p = this.createNewParticle();
953
+ this.particles.push(p);
954
+ this.bandCounts[p.type]++;
955
+ }
956
+ }
957
+ createNewParticle() {
958
+ // New mode: evenly spread particles around the surface using per-particle orbit planes
959
+ if (this.useSurface) {
960
+ const type = 'surface';
961
+ // Fibonacci sphere to pick an initial position direction (uniform on sphere)
962
+ const i = this.seedCounter++;
963
+ const n = Math.max(1, this.particleCount);
964
+ const golden = Math.PI * (3 - Math.sqrt(5));
965
+ const offset = 2 / n;
966
+ const y = i * offset - 1 + offset / 2; // uniform in [-1, 1]
967
+ const rxy = Math.sqrt(Math.max(0, 1 - y * y));
968
+ const theta = golden * i;
969
+ const ux = Math.cos(theta) * rxy;
970
+ const uz = Math.sin(theta) * rxy;
971
+ const u = new THREE.Vector3(ux, y, uz).normalize(); // direction to initial point
972
+ // Build an orbit plane containing u: choose a random axis perpendicular to u
973
+ let rand = new THREE.Vector3(Math.random(), Math.random(), Math.random()).normalize();
974
+ if (Math.abs(rand.dot(u)) > 0.95)
975
+ rand = new THREE.Vector3(1, 0, 0); // avoid parallel
976
+ const axis = new THREE.Vector3().crossVectors(u, rand).normalize(); // plane normal
977
+ const v = new THREE.Vector3().crossVectors(axis, u).normalize(); // completes basis
978
+ // Small random rotation around the plane to avoid grid-like bands
979
+ const angle = THREE.MathUtils.randFloatSpread(Math.PI * 2);
980
+ // Tight shell: 1.002R .. 1.008R
981
+ const radius = this.coreRadius *
982
+ (1 + THREE.MathUtils.lerp(0.002, 0.008, Math.random()));
983
+ // Speed is derived from node flow at runtime; initialize small values to avoid spikes
984
+ const angularSpeed = 0;
985
+ const radialJitterAmp = this.coreRadius * 0.002; // very subtle breathing
986
+ const radialJitterFreq = 0.2 + Math.random() * 0.3;
987
+ // Color from palette with small jitter
988
+ const base = this.palette === 'sun' ? this.NEON_PURPLE : this.PINK_RED;
989
+ const cjit = THREE.MathUtils.lerp(0.9, 1.1, Math.random());
990
+ const color = base.clone().multiplyScalar(cjit);
991
+ color.r = Math.min(1, Math.max(0, color.r));
992
+ color.g = Math.min(1, Math.max(0, color.g));
993
+ color.b = Math.min(1, Math.max(0, color.b));
994
+ const precessAxis = new THREE.Vector3(0, 0, 1); // disabled precession
995
+ const precessSpeed = 0;
996
+ // Intrinsic orbit speed so particles revolve even when node is stationary
997
+ const baseOmega = THREE.MathUtils.lerp(this.OMEGA_SURFACE_BASE_MIN, this.OMEGA_SURFACE_BASE_MAX, Math.random()) * this.speedScale;
998
+ return {
999
+ type,
1000
+ radius,
1001
+ angle,
1002
+ angularSpeed,
1003
+ radialJitterAmp,
1004
+ radialJitterFreq,
1005
+ size: 0.8 + Math.random() * 1.4,
1006
+ color,
1007
+ opacity: 0.85,
1008
+ lifetime: this.MIN_LIFETIME +
1009
+ Math.random() * (this.MAX_LIFETIME - this.MIN_LIFETIME),
1010
+ age: Math.random() * 0.5,
1011
+ ellipse: 1,
1012
+ upBias: 0,
1013
+ depthBias: 0,
1014
+ wigglePhase: Math.random() * Math.PI * 2,
1015
+ wiggleFreqUp: 0.25 + Math.random() * 0.4,
1016
+ wiggleFreqDepth: 0.25 + Math.random() * 0.4,
1017
+ capRadBias: 0,
1018
+ tiltUF: undefined,
1019
+ arcUpAmp: 0,
1020
+ arcDepthAmp: 0,
1021
+ arcRadAmp: this.coreRadius * 0.004,
1022
+ arcFreq: 1,
1023
+ angleWanderAmp: THREE.MathUtils.lerp(0.01, 0.03, Math.random()),
1024
+ angleWanderFreq: 0.15 + Math.random() * 0.3,
1025
+ angleWanderPhase: Math.random() * Math.PI * 2,
1026
+ verticalOffset: 0,
1027
+ phi: undefined,
1028
+ thetaMargin: 0,
1029
+ frontCurveAngle: undefined,
1030
+ phiAmp: 0,
1031
+ axis,
1032
+ u,
1033
+ v,
1034
+ dirSign: 1, // Unused - direction calculated dynamically
1035
+ precessAxis,
1036
+ precessSpeed,
1037
+ baseOmega,
1038
+ };
1039
+ }
1040
+ // Legacy banded distribution (kept for reference and fallback)
1041
+ // Distribution for full wrap with clear center:
1042
+ // topCurve ~22%, bottomCurve ~22% (rim ~44%), topCap ~28%, bottomCap ~28% (caps ~56%)
1043
+ const rpick = Math.random();
1044
+ const type = rpick < 0.22
1045
+ ? 'topCurve'
1046
+ : rpick < 0.44
1047
+ ? 'bottomCurve'
1048
+ : rpick < 0.72
1049
+ ? 'topCap'
1050
+ : 'bottomCap';
1051
+ // Keep particles hugging the node surface
1052
+ const rim = this.coreRadius * 1.005; // very tight
1053
+ const rimBand = 0.01; // thin band width
1054
+ const capSurfaceR = THREE.MathUtils.lerp(this.coreRadius * 1.01, this.coreRadius * 1.03, Math.random());
1055
+ // Per-particle shape and offsets to avoid identical tracks
1056
+ const ellipse = 0.96 + Math.random() * 0.08; // very subtle, 0.96–1.04
1057
+ const upScale = this.coreRadius;
1058
+ let upBias = 0;
1059
+ switch (type) {
1060
+ case 'topCurve':
1061
+ upBias = THREE.MathUtils.lerp(0.006, 0.018, Math.random()) * upScale;
1062
+ break;
1063
+ case 'bottomCurve':
1064
+ upBias = -THREE.MathUtils.lerp(0.006, 0.018, Math.random()) * upScale;
1065
+ break;
1066
+ default:
1067
+ upBias = 0;
1068
+ }
1069
+ const depthBias = THREE.MathUtils.lerp(-0.012, 0.012, Math.random()) * this.coreRadius;
1070
+ const capRadBias = type === 'topCap' || type === 'bottomCap'
1071
+ ? THREE.MathUtils.lerp(-0.004, 0.004, Math.random()) * this.coreRadius
1072
+ : 0;
1073
+ // Optional tilt for mid bands: 1 = use up (vertical), 0 = use forward (horizontal)
1074
+ // Center bands disabled; keep tiltUF unused/default
1075
+ const tiltUF = undefined;
1076
+ // Compute mid-band plane radius if needed
1077
+ // Center bands disabled; mid-plane values unused
1078
+ const midOffset = 0;
1079
+ const midSurfR = 0;
1080
+ const midPlaneRadius = 0;
1081
+ let radius = 0;
1082
+ if (type === 'topCap' || type === 'bottomCap') {
1083
+ radius = Math.max(0.0001, capSurfaceR + capRadBias);
1084
+ }
1085
+ else {
1086
+ // Rim bands, allow very tight to surface
1087
+ radius = rim + Math.random() * rimBand;
1088
+ // Occasionally let bottom dip slightly for occlusion hug
1089
+ if (type === 'bottomCurve' && Math.random() < 0.2) {
1090
+ radius =
1091
+ this.coreRadius * THREE.MathUtils.lerp(0.98, 1.0, Math.random());
1092
+ }
1093
+ }
1094
+ // Define spherical latitude (phi) per band to control top gap filling
1095
+ // phi: 0 (top pole), PI/2 (equator/rim), PI (bottom pole)
1096
+ const rimPhi = Math.PI * 0.5; // 90° (equator)
1097
+ // Caps sit close to rim so center stays clear, but still on surface
1098
+ const topPhiMin = rimPhi - 0.22; // ~12.6° above rim
1099
+ const topPhiMax = rimPhi - 0.12; // ~6.9° above rim
1100
+ const botPhiMin = rimPhi + 0.12; // ~6.9° below rim
1101
+ const botPhiMax = rimPhi + 0.22; // ~12.6° below rim
1102
+ const biasTowardB = (a, b, pow = 2.0) => a + (b - a) * Math.pow(Math.random(), 1 / pow);
1103
+ const biasTowardA = (a, b, pow = 2.0) => a + (b - a) * (1 - Math.pow(Math.random(), 1 / pow));
1104
+ let phi;
1105
+ // Set phi only for caps and mids to place them on inner surfaces
1106
+ switch (type) {
1107
+ case 'topCap':
1108
+ phi = THREE.MathUtils.clamp(THREE.MathUtils.lerp(topPhiMin, topPhiMax, Math.random()), 0.02, rimPhi - 0.02);
1109
+ break;
1110
+ case 'bottomCap':
1111
+ phi = THREE.MathUtils.clamp(THREE.MathUtils.lerp(botPhiMin, botPhiMax, Math.random()), rimPhi + 0.02, Math.PI - 0.02);
1112
+ break;
1113
+ default:
1114
+ // Rim arcs: slight above/below equator
1115
+ phi =
1116
+ type === 'topCurve'
1117
+ ? rimPhi - THREE.MathUtils.lerp(0.02, 0.04, Math.random())
1118
+ : type === 'bottomCurve'
1119
+ ? rimPhi + THREE.MathUtils.lerp(0.02, 0.04, Math.random())
1120
+ : rimPhi;
1121
+ }
1122
+ // Random initial angle per group to avoid trains
1123
+ let angle = 0;
1124
+ if (type === 'topCap' || type === 'bottomCap') {
1125
+ // Start near left edge of front half
1126
+ angle = -Math.PI * 0.95 + Math.random() * 0.1;
1127
+ }
1128
+ else {
1129
+ // Rim arcs: anywhere around
1130
+ angle = THREE.MathUtils.lerp(-Math.PI, Math.PI, Math.random());
1131
+ }
1132
+ // small variance
1133
+ angle += (Math.random() - 0.5) * 0.05;
1134
+ // Per-group base speeds retained but will be overridden by physical mapping
1135
+ const baseSpeed = THREE.MathUtils.lerp(this.MIN_SPEED, this.MAX_SPEED, Math.random());
1136
+ const speedMul = type === 'topCurve' || type === 'bottomCurve'
1137
+ ? 1.1
1138
+ : type === 'topCap' || type === 'bottomCap'
1139
+ ? 0.95
1140
+ : 0.8; // orbit
1141
+ const angularSpeed = baseSpeed * speedMul;
1142
+ // Subtle breathing; even smaller so it hugs the surface and avoids clipping
1143
+ const radialJitterAmp = this.coreRadius *
1144
+ (type === 'topCap' || type === 'bottomCap' ? 0.012 : this.SHIMMER_FRAC);
1145
+ const radialJitterFreq = 0.3 + Math.random() * 0.5; // Hz (gentle)
1146
+ // Color selection based on palette: sun = purple family, planet = pink-red
1147
+ const base = this.palette === 'sun' ? this.NEON_PURPLE : this.PINK_RED;
1148
+ // subtle per-particle variation within same family (lighten/darken slightly)
1149
+ const jitter = THREE.MathUtils.lerp(0.9, 1.1, Math.random());
1150
+ const color = base.clone().multiplyScalar(jitter);
1151
+ color.r = Math.min(1, Math.max(0, color.r));
1152
+ color.g = Math.min(1, Math.max(0, color.g));
1153
+ color.b = Math.min(1, Math.max(0, color.b));
1154
+ // Wiggles for extra micro-variation
1155
+ const wigglePhase = Math.random() * Math.PI * 2;
1156
+ const wiggleFreqUp = 0.3 + Math.random() * 0.5; // 0.3–0.8 Hz
1157
+ const wiggleFreqDepth = 0.25 + Math.random() * 0.4; // 0.25–0.65 Hz
1158
+ // Angle-correlated drift and angular wander
1159
+ const arcFreq = Math.floor(1 + Math.random() * 3); // 1..3
1160
+ const arcUpAmp = type === 'topCap' || type === 'bottomCap'
1161
+ ? upScale * THREE.MathUtils.lerp(0.001, 0.004, Math.random())
1162
+ : upScale * THREE.MathUtils.lerp(0.002, 0.008, Math.random());
1163
+ const arcDepthAmp = type === 'topCap' || type === 'bottomCap'
1164
+ ? this.coreRadius * THREE.MathUtils.lerp(0.001, 0.004, Math.random())
1165
+ : this.coreRadius * THREE.MathUtils.lerp(0.002, 0.006, Math.random());
1166
+ const arcRadAmp = type === 'topCap' || type === 'bottomCap'
1167
+ ? radius * THREE.MathUtils.lerp(0.004, 0.01, Math.random())
1168
+ : radius * THREE.MathUtils.lerp(0.006, 0.015, Math.random());
1169
+ const angleWanderAmp = THREE.MathUtils.lerp(0.03, 0.1, Math.random()); // rad/s (scaled by speed later)
1170
+ const angleWanderFreq = 0.3 + Math.random() * 0.5; // Hz
1171
+ const angleWanderPhase = Math.random() * Math.PI * 2;
1172
+ return {
1173
+ type,
1174
+ radius,
1175
+ angle,
1176
+ angularSpeed,
1177
+ radialJitterAmp,
1178
+ radialJitterFreq,
1179
+ size: 0.8 + Math.random() * 1.4, // smaller discs (0.8..2.2)
1180
+ color,
1181
+ opacity: 0.85,
1182
+ lifetime: this.MIN_LIFETIME +
1183
+ Math.random() * (this.MAX_LIFETIME - this.MIN_LIFETIME),
1184
+ age: Math.random() * 0.5,
1185
+ ellipse,
1186
+ upBias,
1187
+ depthBias,
1188
+ wigglePhase,
1189
+ wiggleFreqUp,
1190
+ wiggleFreqDepth,
1191
+ capRadBias,
1192
+ tiltUF,
1193
+ arcUpAmp,
1194
+ arcDepthAmp,
1195
+ arcRadAmp,
1196
+ arcFreq,
1197
+ angleWanderAmp,
1198
+ angleWanderFreq,
1199
+ angleWanderPhase,
1200
+ verticalOffset: 0,
1201
+ phi,
1202
+ thetaMargin: type === 'topCap' || type === 'bottomCap' ? Math.PI * 0.18 : 0,
1203
+ frontCurveAngle: undefined,
1204
+ phiAmp: type === 'topCap' || type === 'bottomCap'
1205
+ ? THREE.MathUtils.lerp(0.04, 0.1, Math.random())
1206
+ : THREE.MathUtils.lerp(0.0, 0.03, Math.random()),
1207
+ };
1208
+ }
1209
+ // (Removed spline generation in favor of analytic 3D orbits)
1210
+ // --------------------- THREE objects -----------------------
1211
+ createParticleSystem() {
1212
+ const geometry = new THREE.BufferGeometry();
1213
+ const positions = new Float32Array(this.particleCount * 3);
1214
+ const colors = new Float32Array(this.particleCount * 3);
1215
+ // (Kept for future shader; PointsMaterial won't use them per-vertex)
1216
+ const sizes = new Float32Array(this.particleCount);
1217
+ const opacities = new Float32Array(this.particleCount);
1218
+ // Initialize LOCAL positions (do NOT offset by anchor here)
1219
+ for (let i = 0; i < this.particleCount; i++) {
1220
+ const p = this.particles[i];
1221
+ const xy = this.getLocalOrbitPos(p, 0, new THREE.Vector3(1, 0, 0), new THREE.Vector3(0, 1, 0), new THREE.Vector3(0, 0, 1), new THREE.Vector3(0, 0, 1)); // initial position
1222
+ positions[i * 3 + 0] = xy.x;
1223
+ positions[i * 3 + 1] = xy.y;
1224
+ positions[i * 3 + 2] = xy.z;
1225
+ if (this.debugBands) {
1226
+ const c = this.getDebugColorForType(p.type);
1227
+ colors[i * 3 + 0] = c.r;
1228
+ colors[i * 3 + 1] = c.g;
1229
+ colors[i * 3 + 2] = c.b;
1230
+ }
1231
+ else {
1232
+ colors[i * 3 + 0] = p.color.r;
1233
+ colors[i * 3 + 1] = p.color.g;
1234
+ colors[i * 3 + 2] = p.color.b;
1235
+ }
1236
+ sizes[i] = p.size;
1237
+ opacities[i] = p.opacity;
1238
+ }
1239
+ geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
1240
+ geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
1241
+ geometry.setAttribute('size', new THREE.BufferAttribute(sizes, 1));
1242
+ geometry.setAttribute('opacity', new THREE.BufferAttribute(opacities, 1));
1243
+ // Mark frequently-updated attributes
1244
+ // No need for setUsage in the current version of THREE.js
1245
+ // Just use needsUpdate to tell Three.js to re-upload the buffers each frame
1246
+ geometry.getAttribute('position').needsUpdate = true;
1247
+ geometry.getAttribute('color').needsUpdate = true;
1248
+ // Custom bubble shader for perfect circular bubbles with dramatic lighting
1249
+ const vertexShader = `
1250
+ attribute float size;
1251
+ attribute float opacity;
1252
+ varying vec3 vColor;
1253
+ varying float vOpacity;
1254
+ varying float vSize;
1255
+
1256
+ void main() {
1257
+ vColor = color;
1258
+ vOpacity = opacity;
1259
+ vSize = size;
1260
+
1261
+ vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
1262
+
1263
+ // Smaller bokeh-style circles
1264
+ gl_PointSize = size * 4.0;
1265
+
1266
+ gl_Position = projectionMatrix * mvPosition;
1267
+ }
1268
+ `;
1269
+ const fragmentShader = `
1270
+ varying vec3 vColor;
1271
+ varying float vOpacity;
1272
+ varying float vSize;
1273
+
1274
+ void main() {
1275
+ // Create circular bubble coordinate system
1276
+ vec2 center = gl_PointCoord - 0.5;
1277
+ float distance = length(center);
1278
+
1279
+ // Perfect circular mask - discard pixels outside circle
1280
+ if (distance > 0.5) discard;
1281
+
1282
+ // Dramatic bubble lighting effects
1283
+ float rimDistance = distance * 2.0; // 0.0 at center, 1.0 at edge
1284
+
1285
+ // Bright center fading to edge (soap bubble effect)
1286
+ float centerGlow = 1.0 - smoothstep(0.0, 0.35, rimDistance);
1287
+
1288
+ // Rim lighting - bright edges
1289
+ float rimGlow = smoothstep(0.65, 1.0, rimDistance) * 2.0;
1290
+
1291
+ // Glass-like inner reflection
1292
+ float bubble = smoothstep(0.85, 0.25, rimDistance);
1293
+
1294
+ // Combine lighting effects
1295
+ float totalGlow = centerGlow * 0.7 + rimGlow * 1.6 + bubble * 0.7;
1296
+
1297
+ // Final bubble color with dramatic lighting
1298
+ vec3 finalColor = vColor * totalGlow * 1.9;
1299
+
1300
+ // Smooth alpha falloff for glass-like appearance
1301
+ float alpha = (1.0 - smoothstep(0.35, 0.52, distance)) * vOpacity;
1302
+
1303
+ gl_FragColor = vec4(finalColor, alpha);
1304
+ }
1305
+ `;
1306
+ const material = new THREE.ShaderMaterial({
1307
+ vertexShader,
1308
+ fragmentShader,
1309
+ transparent: true,
1310
+ blending: THREE.AdditiveBlending,
1311
+ depthWrite: false,
1312
+ vertexColors: true,
1313
+ });
1314
+ this.particleSystem = new THREE.Points(geometry, material);
1315
+ this.particleSystem.frustumCulled = false;
1316
+ // Initial anchor placement at origin; will follow in update()
1317
+ this.particleSystem.position.copy(this.anchor);
1318
+ this.scene.add(this.particleSystem);
1319
+ }
1320
+ // Compute local position on the orbit for a particle at its current angle,
1321
+ // adding a subtle radial breathing.
1322
+ getLocalOrbitPos(p, age, right, up, forward, camForward) {
1323
+ if (this.useSurface && p.type === 'surface' && p.u && p.v) {
1324
+ // Surface-spread: use per-particle plane basis (u, v); allow full wrap
1325
+ const jitter = p.radialJitterAmp * Math.sin(age * 2 * Math.PI * p.radialJitterFreq);
1326
+ const arcRad = p.arcRadAmp * Math.sin(p.arcFreq * p.angle + (p.wigglePhase || 0));
1327
+ let r = Math.max(0.001, p.radius + jitter + arcRad);
1328
+ const cosA = Math.cos(p.angle);
1329
+ const sinA = Math.sin(p.angle);
1330
+ const out = new THREE.Vector3();
1331
+ out.add(p.u.clone().multiplyScalar(cosA * r));
1332
+ out.add(p.v.clone().multiplyScalar(sinA * r));
1333
+ // Tiny axis wiggle for thickness
1334
+ if (p.axis) {
1335
+ const axWiggle = 0.002 *
1336
+ this.coreRadius *
1337
+ Math.sin(age * 2 * Math.PI * (p.wiggleFreqUp || 0.3) + (p.wigglePhase || 0));
1338
+ out.add(p.axis.clone().multiplyScalar(axWiggle));
1339
+ }
1340
+ // Hard clamp to tight shell near exact surface
1341
+ const minR = this.coreRadius * 1.0;
1342
+ const maxR = this.coreRadius * 1.01;
1343
+ const len = out.length();
1344
+ if (len < minR)
1345
+ out.setLength(minR);
1346
+ else if (len > maxR)
1347
+ out.setLength(maxR);
1348
+ return out;
1349
+ }
1350
+ const jitter = p.radialJitterAmp * Math.sin(age * 2 * Math.PI * p.radialJitterFreq);
1351
+ // Angle-correlated radial modulation prevents strict rings
1352
+ const arcRad = p.arcRadAmp * Math.sin(p.arcFreq * p.angle + p.wigglePhase * 0.71);
1353
+ let r = Math.max(0.001, p.radius + jitter + arcRad);
1354
+ const cosA = Math.cos(p.angle);
1355
+ const sinA = Math.sin(p.angle);
1356
+ const out = new THREE.Vector3();
1357
+ const upWiggle = 0.004 *
1358
+ this.coreRadius *
1359
+ Math.sin(age * 2 * Math.PI * p.wiggleFreqUp + p.wigglePhase);
1360
+ const depthWiggle = 0.004 *
1361
+ this.coreRadius *
1362
+ Math.cos(age * 2 * Math.PI * p.wiggleFreqDepth + p.wigglePhase * 0.37);
1363
+ const arcUp = p.arcUpAmp * Math.sin(p.arcFreq * p.angle + p.wigglePhase);
1364
+ const arcDepth = p.arcDepthAmp * Math.cos(p.arcFreq * p.angle + p.wigglePhase * 0.53);
1365
+ // Unified spherical placement (true wrap) for rim + caps
1366
+ // Vary phi along theta for cap crescents; keep rim near equator
1367
+ let phi = p.phi ?? Math.PI * 0.5;
1368
+ const isCap = p.type === 'topCap' || p.type === 'bottomCap';
1369
+ if (isCap && p.phiAmp) {
1370
+ const sign = p.type === 'topCap' ? -1 : 1; // pull toward rim at arc center
1371
+ phi = THREE.MathUtils.clamp(phi + sign * p.phiAmp * Math.sin(p.angle), 0.02, Math.PI - 0.02);
1372
+ }
1373
+ const rp = r * Math.sin(phi);
1374
+ const y = r * Math.cos(phi);
1375
+ const x = rp * cosA * p.ellipse;
1376
+ const z = rp * sinA;
1377
+ out.copy(right).multiplyScalar(x);
1378
+ out.add(up.clone().multiplyScalar(y));
1379
+ out.add(forward.clone().multiplyScalar(z));
1380
+ // Layer subtle extras
1381
+ out.add(up.clone().multiplyScalar(p.upBias + upWiggle + arcUp));
1382
+ out.add(forward.clone().multiplyScalar(p.depthBias + depthWiggle + arcDepth));
1383
+ // Different penetration limits for different particle types
1384
+ const minShell = p.type === 'topCurve' || p.type === 'bottomCurve'
1385
+ ? this.coreRadius * 0.7 // Rim particles: 30% inside surface
1386
+ : this.coreRadius * 1.0; // Cap particles: stay at surface level
1387
+ const len = out.length();
1388
+ if (len < minShell) {
1389
+ if (len < 1e-6) {
1390
+ out.set(minShell, 0, 0);
1391
+ }
1392
+ else {
1393
+ out.multiplyScalar(minShell / len);
1394
+ }
1395
+ }
1396
+ // For banded mode, keep a slight front-bias so arcs read clearly
1397
+ if (!(this.useSurface && p.type === 'surface')) {
1398
+ const fCam = out.dot(camForward);
1399
+ if (fCam > -this.FRONT_MIN) {
1400
+ out.add(camForward.clone().multiplyScalar(-this.FRONT_MIN - fCam));
1401
+ }
1402
+ }
1403
+ return out;
1404
+ }
1405
+ // Debug color palette per band
1406
+ getDebugColorForType(type) {
1407
+ switch (type) {
1408
+ case 'topCurve':
1409
+ return new THREE.Color(0x00ffff); // cyan
1410
+ case 'bottomCurve':
1411
+ return new THREE.Color(0xff8800); // orange
1412
+ case 'topCap':
1413
+ return new THREE.Color(0xff00ff); // magenta
1414
+ case 'bottomCap':
1415
+ return new THREE.Color(0xffff00); // yellow
1416
+ case 'orbit':
1417
+ return new THREE.Color(0xffffff); // white
1418
+ case 'surface':
1419
+ return new THREE.Color(0x66ff66); // green for debug
1420
+ }
1421
+ }
1422
+ }
1423
+
1424
+ class TraitVisualComponent {
1425
+ renderer2;
1426
+ ngZone;
1427
+ canvasRef;
1428
+ // Input properties for configuration
1429
+ nodeData = [];
1430
+ attributeWeights = [];
1431
+ preferenceWeights = [];
1432
+ attributeCount;
1433
+ preferenceCount;
1434
+ // Three.js scene properties
1435
+ scene;
1436
+ camera;
1437
+ renderer;
1438
+ controls;
1439
+ raycaster = new THREE.Raycaster();
1440
+ mouse = new THREE.Vector2();
1441
+ cluster;
1442
+ selectedNode = null;
1443
+ draggingNode = null;
1444
+ dragPlane = new THREE.Plane();
1445
+ dragOffset = new THREE.Vector3();
1446
+ newNodeCounter = 1;
1447
+ isCameraLocked = false;
1448
+ // Private properties
1449
+ nodeAuras = new Map();
1450
+ starFieldNear;
1451
+ starFieldFar;
1452
+ starTexture = null;
1453
+ dustField = null;
1454
+ pointerPitchAxis = new THREE.Vector3(1, 0, 0);
1455
+ pointerYawAxis = new THREE.Vector3(0, 1, 0);
1456
+ pointerPitchCurrent = 0;
1457
+ pointerPitchTarget = 0;
1458
+ pointerYawCurrent = 0;
1459
+ pointerYawTarget = 0;
1460
+ pointerYawAccumulated = 0;
1461
+ pointerOffset = new THREE.Vector3();
1462
+ pointerPitchMax = THREE.MathUtils.degToRad(1.8);
1463
+ pointerYawMax = THREE.MathUtils.degToRad(0.8);
1464
+ pointerYawSpeed = THREE.MathUtils.degToRad(0.15);
1465
+ pointerPitchDamping = 4;
1466
+ pointerYawDamping = 4;
1467
+ constructor(renderer2, ngZone) {
1468
+ this.renderer2 = renderer2;
1469
+ this.ngZone = ngZone;
1470
+ }
1471
+ ngOnInit() {
1472
+ console.log('TraitVisualComponent: ngOnInit - nodeData length:', this.nodeData.length);
1473
+ console.log('TraitVisualComponent: attributeWeights length:', this.attributeWeights.length);
1474
+ console.log('TraitVisualComponent: preferenceWeights length:', this.preferenceWeights.length);
1475
+ this.initScene();
1476
+ if (this.nodeData.length > 0) {
1477
+ this.loadNodes();
1478
+ this.syncNodeWeightGlobals();
1479
+ }
1480
+ else {
1481
+ console.warn('TraitVisualComponent: No nodeData provided!');
1482
+ }
1483
+ }
1484
+ ngOnChanges(changes) {
1485
+ if (changes['nodeData'] && !changes['nodeData'].firstChange && this.scene) {
1486
+ // Reload nodes when nodeData changes
1487
+ if (this.cluster) {
1488
+ this.scene.remove(this.cluster);
1489
+ this.nodeAuras.forEach((a) => a.dispose());
1490
+ this.nodeAuras.clear();
1491
+ }
1492
+ if (this.nodeData.length > 0) {
1493
+ this.loadNodes();
1494
+ this.syncNodeWeightGlobals();
1495
+ }
1496
+ }
1497
+ if ((changes['attributeWeights'] || changes['preferenceWeights']) && !changes['attributeWeights']?.firstChange && !changes['preferenceWeights']?.firstChange) {
1498
+ this.syncNodeWeightGlobals();
1499
+ }
1500
+ }
1501
+ ngAfterViewInit() {
1502
+ this.animate();
1503
+ this.renderer2.listen(this.canvasRef.nativeElement, 'contextmenu', (event) => this.onRightClick(event));
1504
+ window.addEventListener('resize', () => this.onWindowResize());
1505
+ const canvas = this.canvasRef.nativeElement;
1506
+ this.renderer2.listen('window', 'mousemove', (event) => this.onMouseMove(event));
1507
+ this.renderer2.listen(canvas, 'mousedown', (event) => this.onDragStart(event));
1508
+ this.renderer2.listen(canvas, 'mousemove', (event) => this.onDragMove(event));
1509
+ this.renderer2.listen(canvas, 'mouseup', (event) => this.onDragEnd(event));
1510
+ this.renderer2.listen(canvas, 'mouseleave', (event) => {
1511
+ this.onDragEnd(event);
1512
+ this.onCanvasLeave();
1513
+ });
1514
+ }
1515
+ ngOnDestroy() {
1516
+ // Dispose all auras
1517
+ this.nodeAuras.forEach((a) => a.dispose());
1518
+ this.nodeAuras.clear();
1519
+ this.dustField?.dispose();
1520
+ }
1521
+ currentCentral() {
1522
+ if (!this.cluster)
1523
+ return null;
1524
+ return this.cluster.nodes.find((node) => node.isSun) || null;
1525
+ }
1526
+ nonSuns() {
1527
+ if (!this.cluster)
1528
+ return [];
1529
+ return this.cluster.nodes.filter((node) => !node.isSun);
1530
+ }
1531
+ syncNodeWeightGlobals() {
1532
+ if (this.attributeWeights.length > 0) {
1533
+ Node.attributeWeights = this.attributeWeights.slice();
1534
+ }
1535
+ if (this.preferenceWeights.length > 0) {
1536
+ Node.preferenceWeights = this.preferenceWeights.slice();
1537
+ }
1538
+ }
1539
+ initScene() {
1540
+ this.scene = new THREE.Scene();
1541
+ this.camera = new THREE.PerspectiveCamera(55, window.innerWidth / window.innerHeight, 0.1, 1000);
1542
+ this.camera.up.set(0, 1, 0);
1543
+ this.camera.position.set(0, 0, 60);
1544
+ const initialTilt = THREE.MathUtils.degToRad(-45);
1545
+ this.camera.position.applyAxisAngle(new THREE.Vector3(1, 0, 0), -initialTilt);
1546
+ this.camera.lookAt(0, 0, 0);
1547
+ this.renderer = new THREE.WebGLRenderer({
1548
+ canvas: this.canvasRef.nativeElement,
1549
+ antialias: true,
1550
+ alpha: true,
1551
+ });
1552
+ this.renderer.setClearColor(0x000000, 0);
1553
+ this.renderer.setSize(window.innerWidth, window.innerHeight);
1554
+ this.controls = new OrbitControls(this.camera, this.renderer.domElement);
1555
+ this.controls.target.set(0, 0, 0);
1556
+ this.controls.enableDamping = true;
1557
+ this.controls.enableRotate = true;
1558
+ this.controls.screenSpacePanning = true;
1559
+ this.controls.enablePan = true;
1560
+ this.controls.enableZoom = true;
1561
+ this.scene.add(new THREE.AmbientLight(0xbbbbbb, 1));
1562
+ this.scene.add(new THREE.DirectionalLight(0xffffff, 1));
1563
+ // Add immersive 3D starfields
1564
+ this.starFieldFar = this.createStarField({
1565
+ count: 1200,
1566
+ innerRadius: 120,
1567
+ outerRadius: 170,
1568
+ size: 1,
1569
+ opacity: 0.6,
1570
+ });
1571
+ this.starFieldFar.renderOrder = -2;
1572
+ this.scene.add(this.starFieldFar);
1573
+ this.starFieldNear = this.createStarField({
1574
+ count: 200,
1575
+ innerRadius: 60,
1576
+ outerRadius: 110,
1577
+ size: 1.2,
1578
+ opacity: 0.75,
1579
+ });
1580
+ this.starFieldNear.renderOrder = -1;
1581
+ this.scene.add(this.starFieldNear);
1582
+ }
1583
+ loadNodes() {
1584
+ if (this.nodeData.length === 0)
1585
+ return;
1586
+ this.cluster = new Cluster(this.nodeData);
1587
+ this.scene.add(this.cluster);
1588
+ const initialCentral = this.cluster.nodes.find((node) => node.isSun) || this.cluster.nodes[0];
1589
+ initialCentral.setSun(true, 5);
1590
+ initialCentral.mesh.scale.set(3, 3, 3);
1591
+ this.ensureNodeAuras();
1592
+ const sun = this.currentCentral();
1593
+ if (sun) {
1594
+ const geom = sun.mesh.geometry;
1595
+ const bs = geom.boundingSphere ?? new THREE.Sphere(new THREE.Vector3(), 0.05);
1596
+ const coreRadius = (bs.radius || 0.05) * sun.mesh.scale.x;
1597
+ let dustOuter = 18.0;
1598
+ let dustThickness = 0.0;
1599
+ let dustMidCount = 160000;
1600
+ let dustBokehCount = 24000;
1601
+ let dustNoise = 0.035;
1602
+ let dustMinW = 0.02;
1603
+ let dustMaxW = 0.08;
1604
+ let dustMode = 'disk';
1605
+ let searchParams;
1606
+ try {
1607
+ const rawSearch = window?.location?.search ?? '';
1608
+ searchParams = new URLSearchParams(rawSearch);
1609
+ }
1610
+ catch {
1611
+ searchParams = undefined;
1612
+ }
1613
+ const parseParam = (key, fallback) => {
1614
+ if (!searchParams)
1615
+ return fallback;
1616
+ const raw = searchParams.get(key);
1617
+ if (raw === null)
1618
+ return fallback;
1619
+ const value = parseFloat(raw);
1620
+ return Number.isFinite(value) ? value : fallback;
1621
+ };
1622
+ dustOuter = Math.max(coreRadius * 1.05, parseParam('dustOuter', dustOuter));
1623
+ dustThickness = Math.max(0, parseParam('dustThick', dustThickness));
1624
+ dustMidCount = Math.max(0, Math.round(parseParam('dustCount', dustMidCount)));
1625
+ dustBokehCount = Math.max(0, Math.round(parseParam('dustBokeh', dustBokehCount)));
1626
+ dustNoise = Math.max(0, parseParam('dustNoise', dustNoise));
1627
+ dustMinW = Math.max(0.001, parseParam('dustMinW', dustMinW));
1628
+ dustMaxW = Math.max(dustMinW + 0.001, parseParam('dustMaxW', dustMaxW));
1629
+ const dustModeParam = searchParams?.get('dustMode');
1630
+ if (dustModeParam === 'disk') {
1631
+ dustMode = 'disk';
1632
+ }
1633
+ }
1634
+ }
1635
+ onWindowResize() {
1636
+ this.camera.aspect = window.innerWidth / window.innerHeight;
1637
+ this.camera.updateProjectionMatrix();
1638
+ this.renderer.setSize(window.innerWidth, window.innerHeight);
1639
+ this.dustField?.setViewportHeight(window.innerHeight);
1640
+ }
1641
+ onRightClick(event) {
1642
+ event.preventDefault();
1643
+ // Simplified right-click handling - just select node
1644
+ this.raycaster.setFromCamera(this.mouse, this.camera);
1645
+ const intersects = this.raycaster.intersectObjects(this.cluster?.children || [], true);
1646
+ if (intersects.length > 0) {
1647
+ this.selectedNode = intersects[0].object.parent;
1648
+ }
1649
+ }
1650
+ onMouseMove(event) {
1651
+ if (this.draggingNode)
1652
+ return;
1653
+ this.mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
1654
+ this.mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
1655
+ this.pointerPitchTarget = 0;
1656
+ this.pointerYawTarget = 0;
1657
+ this.pointerYawAccumulated = 0;
1658
+ return;
1659
+ }
1660
+ onDragStart(event) {
1661
+ event.preventDefault();
1662
+ const canvas = this.canvasRef.nativeElement;
1663
+ const rect = canvas.getBoundingClientRect();
1664
+ const mouse = new THREE.Vector2(((event.clientX - rect.left) / rect.width) * 2 - 1, -((event.clientY - rect.top) / rect.height) * 2 + 1);
1665
+ this.raycaster.setFromCamera(mouse, this.camera);
1666
+ const intersects = this.raycaster.intersectObjects(this.cluster?.children || [], true);
1667
+ if (intersects.length > 0) {
1668
+ this.draggingNode = intersects[0].object.parent;
1669
+ this.controls.enabled = false;
1670
+ const planeNormal = this.camera
1671
+ .getWorldDirection(new THREE.Vector3())
1672
+ .clone()
1673
+ .negate();
1674
+ this.dragPlane.setFromNormalAndCoplanarPoint(planeNormal, intersects[0].point);
1675
+ this.dragOffset.copy(intersects[0].point).sub(this.draggingNode.position);
1676
+ }
1677
+ }
1678
+ onDragMove(event) {
1679
+ if (!this.draggingNode)
1680
+ return;
1681
+ event.preventDefault();
1682
+ const canvas = this.canvasRef.nativeElement;
1683
+ const rect = canvas.getBoundingClientRect();
1684
+ const mouse = new THREE.Vector2(((event.clientX - rect.left) / rect.width) * 2 - 1, -((event.clientY - rect.top) / rect.height) * 2 + 1);
1685
+ this.raycaster.setFromCamera(mouse, this.camera);
1686
+ const intersection = new THREE.Vector3();
1687
+ if (this.raycaster.ray.intersectPlane(this.dragPlane, intersection)) {
1688
+ this.draggingNode.position.copy(intersection.sub(this.dragOffset));
1689
+ }
1690
+ }
1691
+ onDragEnd(event) {
1692
+ if (!this.draggingNode)
1693
+ return;
1694
+ event.preventDefault();
1695
+ this.draggingNode = null;
1696
+ this.controls.enabled = true;
1697
+ }
1698
+ onCanvasLeave() {
1699
+ this.resetPointerPitch();
1700
+ }
1701
+ resetPointerPitch(instant = false) {
1702
+ this.pointerPitchTarget = 0;
1703
+ this.pointerYawTarget = 0;
1704
+ this.pointerYawAccumulated = 0;
1705
+ if (!instant)
1706
+ return;
1707
+ if (!this.camera || !this.controls) {
1708
+ this.pointerPitchCurrent = 0;
1709
+ this.pointerYawCurrent = 0;
1710
+ return;
1711
+ }
1712
+ const target = this.controls.target;
1713
+ const offset = this.pointerOffset;
1714
+ offset.copy(this.camera.position).sub(target);
1715
+ if (this.pointerPitchCurrent !== 0) {
1716
+ offset.applyAxisAngle(this.pointerPitchAxis, -this.pointerPitchCurrent);
1717
+ }
1718
+ if (this.pointerYawCurrent !== 0) {
1719
+ offset.applyAxisAngle(this.pointerYawAxis, -this.pointerYawCurrent);
1720
+ }
1721
+ this.camera.position.copy(target).add(offset);
1722
+ this.camera.lookAt(target);
1723
+ this.pointerPitchCurrent = 0;
1724
+ this.pointerYawCurrent = 0;
1725
+ }
1726
+ updateCameraOrbitFromPointer(deltaTime) {
1727
+ if (!this.camera || !this.controls)
1728
+ return;
1729
+ if (this.isCameraLocked)
1730
+ return;
1731
+ const target = this.controls.target;
1732
+ const offset = this.pointerOffset;
1733
+ offset.copy(this.camera.position).sub(target);
1734
+ if (this.pointerPitchCurrent !== 0) {
1735
+ offset.applyAxisAngle(this.pointerPitchAxis, -this.pointerPitchCurrent);
1736
+ }
1737
+ if (this.pointerYawCurrent !== 0) {
1738
+ offset.applyAxisAngle(this.pointerYawAxis, -this.pointerYawCurrent);
1739
+ }
1740
+ const dt = Math.max(deltaTime, 0);
1741
+ const dampingPitch = 1 - Math.exp(-this.pointerPitchDamping * dt);
1742
+ const dampingYaw = 1 - Math.exp(-this.pointerYawDamping * dt);
1743
+ const lerpFactorPitch = THREE.MathUtils.clamp(Number.isFinite(dampingPitch) ? dampingPitch : 0, 0, 1);
1744
+ const lerpFactorYaw = THREE.MathUtils.clamp(Number.isFinite(dampingYaw) ? dampingYaw : 0, 0, 1);
1745
+ this.pointerPitchCurrent = THREE.MathUtils.lerp(this.pointerPitchCurrent, this.pointerPitchTarget, lerpFactorPitch);
1746
+ this.pointerYawCurrent = THREE.MathUtils.lerp(this.pointerYawCurrent, this.pointerYawTarget, lerpFactorYaw);
1747
+ if (Math.abs(this.pointerPitchCurrent) < 1e-4 &&
1748
+ Math.abs(this.pointerPitchTarget) < 1e-4) {
1749
+ this.pointerPitchCurrent = 0;
1750
+ }
1751
+ if (Math.abs(this.pointerYawCurrent) < 1e-4 &&
1752
+ Math.abs(this.pointerYawTarget) < 1e-4) {
1753
+ this.pointerYawCurrent = 0;
1754
+ }
1755
+ if (this.pointerPitchCurrent !== 0) {
1756
+ offset.applyAxisAngle(this.pointerPitchAxis, this.pointerPitchCurrent);
1757
+ }
1758
+ if (this.pointerYawCurrent !== 0) {
1759
+ offset.applyAxisAngle(this.pointerYawAxis, this.pointerYawCurrent);
1760
+ }
1761
+ this.camera.position.copy(target).add(offset);
1762
+ this.camera.lookAt(target);
1763
+ }
1764
+ animate() {
1765
+ this.ngZone.runOutsideAngular(() => {
1766
+ let lastTime = 0;
1767
+ const loop = (currentTime) => {
1768
+ requestAnimationFrame(loop);
1769
+ const deltaTime = (currentTime - lastTime) / 1000;
1770
+ lastTime = currentTime;
1771
+ this.controls.update();
1772
+ this.updateCameraOrbitFromPointer(deltaTime);
1773
+ if (this.cluster)
1774
+ this.cluster.update(undefined, this.scene, this.camera);
1775
+ if (this.cluster) {
1776
+ for (const node of this.cluster.nodes) {
1777
+ const aura = this.nodeAuras.get(node);
1778
+ if (aura) {
1779
+ const geom = node.mesh.geometry;
1780
+ const bs = geom.boundingSphere ??
1781
+ new THREE.Sphere(new THREE.Vector3(), 0.05);
1782
+ const newCoreRadius = (bs.radius || 0.05) * node.mesh.scale.x;
1783
+ const ud = node.userData;
1784
+ const prevCoreRadius = ud._auraCoreRadius;
1785
+ const needsResize = !prevCoreRadius ||
1786
+ Math.abs(newCoreRadius - prevCoreRadius) / prevCoreRadius >
1787
+ 0.005;
1788
+ if (needsResize) {
1789
+ aura.resizeCoreRadius(newCoreRadius);
1790
+ const occ = ud._occluder;
1791
+ if (occ && prevCoreRadius) {
1792
+ const scaleFactor = newCoreRadius / prevCoreRadius;
1793
+ occ.scale.multiplyScalar(scaleFactor);
1794
+ }
1795
+ ud._auraCoreRadius = newCoreRadius;
1796
+ }
1797
+ aura.update(node.position, node.velocity, deltaTime, this.camera);
1798
+ }
1799
+ }
1800
+ }
1801
+ const center = this.currentCentral()?.position ?? new THREE.Vector3();
1802
+ if (this.dustField && this.cluster) {
1803
+ const atts = this.cluster.nodes
1804
+ .filter((n) => !n.isSun)
1805
+ .map((n) => {
1806
+ const geom = n.mesh.geometry;
1807
+ const bs = geom.boundingSphere ??
1808
+ new THREE.Sphere(new THREE.Vector3(), 0.05);
1809
+ const radius = (bs.radius || 0.05) * n.mesh.scale.x;
1810
+ return { position: n.position.clone(), radius };
1811
+ });
1812
+ this.dustField.setAttractorsWorld(atts);
1813
+ }
1814
+ this.dustField?.update(deltaTime, center);
1815
+ if (this.starFieldNear && this.starFieldFar) {
1816
+ this.starFieldNear.rotation.y += 0.00025;
1817
+ this.starFieldFar.rotation.y += 0.0001;
1818
+ }
1819
+ this.renderer.render(this.scene, this.camera);
1820
+ };
1821
+ loop(0);
1822
+ });
1823
+ }
1824
+ ensureNodeAuras() {
1825
+ if (!this.cluster)
1826
+ return;
1827
+ for (const node of this.cluster.nodes) {
1828
+ if (!this.nodeAuras.has(node)) {
1829
+ const geom = node.mesh.geometry;
1830
+ const bs = geom.boundingSphere ?? new THREE.Sphere(new THREE.Vector3(), 0.05);
1831
+ const coreRadius = (bs.radius || 0.05) * node.mesh.scale.x;
1832
+ const particleCount = node.isSun ? 600 : 200;
1833
+ const aura = new BlackHoleParticleField(this.scene, {
1834
+ coreRadius,
1835
+ particleCount,
1836
+ palette: node.isSun ? 'sun' : 'planet',
1837
+ distribution: 'surface',
1838
+ speedScale: 0.3,
1839
+ });
1840
+ this.nodeAuras.set(node, aura);
1841
+ const occGeom = new THREE.SphereGeometry(coreRadius * 0.99, 24, 24);
1842
+ const occMat = new THREE.MeshBasicMaterial({
1843
+ color: 0x000000,
1844
+ depthWrite: true,
1845
+ });
1846
+ occMat.colorWrite = false;
1847
+ const occluder = new THREE.Mesh(occGeom, occMat);
1848
+ occluder.renderOrder = -0.5;
1849
+ node.add(occluder);
1850
+ node.userData._auraCoreRadius = coreRadius;
1851
+ node.userData._occluder = occluder;
1852
+ }
1853
+ }
1854
+ }
1855
+ createStarField(opts) {
1856
+ const geometry = new THREE.BufferGeometry();
1857
+ const positions = new Float32Array(opts.count * 3);
1858
+ const colors = new Float32Array(opts.count * 3);
1859
+ for (let i = 0; i < opts.count; i++) {
1860
+ const dir = new THREE.Vector3(Math.random() * 2 - 1, Math.random() * 2 - 1, Math.random() * 2 - 1).normalize();
1861
+ const r = THREE.MathUtils.lerp(opts.innerRadius, opts.outerRadius, Math.random());
1862
+ const pos = dir.multiplyScalar(r);
1863
+ positions[i * 3 + 0] = pos.x;
1864
+ positions[i * 3 + 1] = pos.y;
1865
+ positions[i * 3 + 2] = pos.z;
1866
+ const warm = Math.random() < 0.45;
1867
+ const c = warm
1868
+ ? new THREE.Color(1.0, 0.92 + Math.random() * 0.05, 0.98)
1869
+ : new THREE.Color(0.92, 0.95 + Math.random() * 0.04, 1.0);
1870
+ colors[i * 3 + 0] = c.r;
1871
+ colors[i * 3 + 1] = c.g;
1872
+ colors[i * 3 + 2] = c.b;
1873
+ }
1874
+ geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
1875
+ geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
1876
+ const material = new THREE.PointsMaterial({
1877
+ size: opts.size,
1878
+ sizeAttenuation: true,
1879
+ transparent: true,
1880
+ opacity: opts.opacity,
1881
+ depthWrite: false,
1882
+ vertexColors: true,
1883
+ blending: THREE.AdditiveBlending,
1884
+ map: this.getStarTexture(),
1885
+ alphaMap: this.getStarTexture(),
1886
+ });
1887
+ material.alphaTest = 0.15;
1888
+ const points = new THREE.Points(geometry, material);
1889
+ points.frustumCulled = false;
1890
+ return points;
1891
+ }
1892
+ getStarTexture() {
1893
+ if (this.starTexture)
1894
+ return this.starTexture;
1895
+ const size = 128;
1896
+ const canvas = document.createElement('canvas');
1897
+ canvas.width = size;
1898
+ canvas.height = size;
1899
+ const ctx = canvas.getContext('2d');
1900
+ const g = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
1901
+ g.addColorStop(0.0, 'rgba(255,255,255,1)');
1902
+ g.addColorStop(0.4, 'rgba(255,255,255,0.9)');
1903
+ g.addColorStop(0.7, 'rgba(255,255,255,0.4)');
1904
+ g.addColorStop(1.0, 'rgba(255,255,255,0)');
1905
+ ctx.fillStyle = g;
1906
+ ctx.fillRect(0, 0, size, size);
1907
+ const texture = new THREE.CanvasTexture(canvas);
1908
+ texture.minFilter = THREE.LinearFilter;
1909
+ texture.magFilter = THREE.LinearFilter;
1910
+ texture.anisotropy = 2;
1911
+ texture.needsUpdate = true;
1912
+ this.starTexture = texture;
1913
+ return texture;
1914
+ }
1915
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.2", ngImport: i0, type: TraitVisualComponent, deps: [{ token: i0.Renderer2 }, { token: i0.NgZone }], target: i0.ɵɵFactoryTarget.Component });
1916
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "19.2.2", type: TraitVisualComponent, isStandalone: true, selector: "tv-trait-visual", inputs: { nodeData: "nodeData", attributeWeights: "attributeWeights", preferenceWeights: "preferenceWeights", attributeCount: "attributeCount", preferenceCount: "preferenceCount" }, viewQueries: [{ propertyName: "canvasRef", first: true, predicate: ["rendererCanvas"], descendants: true, static: true }], usesOnChanges: true, ngImport: i0, template: "<div class=\"app-wrapper\">\r\n <canvas #rendererCanvas></canvas>\r\n</div>\r\n\r\n", styles: [".app-wrapper{display:flex;width:100vw;position:relative;height:100vh;background:#000}canvas{display:flex;width:100vw;height:100vh;overflow:hidden;position:relative;z-index:1;background:transparent!important}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }] });
1917
+ }
1918
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.2", ngImport: i0, type: TraitVisualComponent, decorators: [{
1919
+ type: Component,
1920
+ args: [{ selector: 'tv-trait-visual', standalone: true, imports: [CommonModule], template: "<div class=\"app-wrapper\">\r\n <canvas #rendererCanvas></canvas>\r\n</div>\r\n\r\n", styles: [".app-wrapper{display:flex;width:100vw;position:relative;height:100vh;background:#000}canvas{display:flex;width:100vw;height:100vh;overflow:hidden;position:relative;z-index:1;background:transparent!important}\n"] }]
1921
+ }], ctorParameters: () => [{ type: i0.Renderer2 }, { type: i0.NgZone }], propDecorators: { canvasRef: [{
1922
+ type: ViewChild,
1923
+ args: ['rendererCanvas', { static: true }]
1924
+ }], nodeData: [{
1925
+ type: Input
1926
+ }], attributeWeights: [{
1927
+ type: Input
1928
+ }], preferenceWeights: [{
1929
+ type: Input
1930
+ }], attributeCount: [{
1931
+ type: Input
1932
+ }], preferenceCount: [{
1933
+ type: Input
1934
+ }] } });
1935
+
1936
+ // Dynamically resolve the PCA constructor for different build types
1937
+ const PCAClass = PCAImport.default || PCAImport.PCA || PCAImport;
1938
+ let dynamicCounts = {
1939
+ attributes: 5,
1940
+ preferences: 5
1941
+ };
1942
+ function updateCounts(attrCount, prefCount) {
1943
+ dynamicCounts.attributes = attrCount;
1944
+ dynamicCounts.preferences = prefCount;
1945
+ }
1946
+ /**
1947
+ * Performs PCA on a list of attribute arrays to map them into 3D space.
1948
+ * Returns:
1949
+ * - positions: normalized [x, y, z] tuples
1950
+ * - weights: per-attribute magnitude across PCA components
1951
+ */
1952
+ function computePCA3D(attributesList) {
1953
+ const pca = new PCAClass(attributesList, { center: true, scale: true });
1954
+ const reduced = pca.predict(attributesList, { nComponents: 3 }).to2DArray();
1955
+ // --- Compute per-attribute weights ---
1956
+ const loadings = pca.getLoadings().to2DArray();
1957
+ const weights = loadings.map((arr) => Math.sqrt(arr[0] ** 2 + arr[1] ** 2 + arr[2] ** 2));
1958
+ const maxAbs = Math.max(...reduced.flatMap((arr) => arr.map((v) => Math.abs(v))));
1959
+ const positions = reduced.map((arr) => [
1960
+ arr[0] / maxAbs,
1961
+ arr[1] / maxAbs,
1962
+ arr[2] / maxAbs
1963
+ ]);
1964
+ return { positions, weights };
1965
+ }
1966
+ function generateAttributes(count, base = 0) {
1967
+ const attrs = {};
1968
+ for (let i = 0; i < count; i++)
1969
+ attrs[`attr${i + 1}`] = (base + i * 20) % 101;
1970
+ return attrs;
1971
+ }
1972
+ const colors = ['#C300FF', '#FF3366'];
1973
+ const baseNodes = [
1974
+ {
1975
+ id: 1,
1976
+ name: 'James',
1977
+ initialPosition: [0, 0, 0],
1978
+ isSun: true,
1979
+ color: '#C300FF',
1980
+ attributes: generateAttributes(dynamicCounts.attributes, 0),
1981
+ preferences: generateAttributes(dynamicCounts.preferences, 100),
1982
+ },
1983
+ ...[...Array(5)].map((_, i) => {
1984
+ const id = i + 2;
1985
+ const names = ['John', 'Alice', 'Robert', 'Emma', 'Michael'];
1986
+ return {
1987
+ id,
1988
+ name: names[i],
1989
+ initialPosition: [0, 0, 0],
1990
+ isSun: false,
1991
+ color: colors[i % 2],
1992
+ attributes: generateAttributes(dynamicCounts.attributes, i * 10),
1993
+ preferences: generateAttributes(dynamicCounts.preferences, 0),
1994
+ };
1995
+ }),
1996
+ ];
1997
+ const attributesList = baseNodes.map((n) => Object.values(n.attributes));
1998
+ const { positions, weights } = computePCA3D(attributesList);
1999
+ const nodeData = baseNodes.map((node, i) => {
2000
+ if (node.isSun)
2001
+ return { ...node, initialPosition: [0, 0, 0] };
2002
+ const [x, y, z] = positions[i];
2003
+ return {
2004
+ ...node,
2005
+ initialPosition: [x * 5, y * 5, z * 5],
2006
+ };
2007
+ });
2008
+ const attributeWeights = weights;
2009
+
2010
+ /*
2011
+ * Public API Surface of trait-visual
2012
+ */
2013
+ // Main component
2014
+
2015
+ /**
2016
+ * Generated bundle index. Do not edit.
2017
+ */
2018
+
2019
+ export { TraitVisualComponent, attributeWeights, dynamicCounts, nodeData, updateCounts };
2020
+ //# sourceMappingURL=naniteninja-trait-visual.mjs.map