@guinetik/gcanvas 1.0.5 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/dist/aizawa.html +27 -0
  2. package/dist/clifford.html +25 -0
  3. package/dist/cmb.html +24 -0
  4. package/dist/dadras.html +26 -0
  5. package/dist/dejong.html +25 -0
  6. package/dist/gcanvas.es.js +5130 -372
  7. package/dist/gcanvas.es.min.js +1 -1
  8. package/dist/gcanvas.umd.js +1 -1
  9. package/dist/gcanvas.umd.min.js +1 -1
  10. package/dist/halvorsen.html +27 -0
  11. package/dist/index.html +96 -48
  12. package/dist/js/aizawa.js +425 -0
  13. package/dist/js/bezier.js +5 -5
  14. package/dist/js/clifford.js +236 -0
  15. package/dist/js/cmb.js +594 -0
  16. package/dist/js/dadras.js +405 -0
  17. package/dist/js/dejong.js +257 -0
  18. package/dist/js/halvorsen.js +405 -0
  19. package/dist/js/isometric.js +34 -46
  20. package/dist/js/lorenz.js +425 -0
  21. package/dist/js/painter.js +8 -8
  22. package/dist/js/rossler.js +480 -0
  23. package/dist/js/schrodinger.js +314 -18
  24. package/dist/js/thomas.js +394 -0
  25. package/dist/lorenz.html +27 -0
  26. package/dist/rossler.html +27 -0
  27. package/dist/scene-interactivity-test.html +220 -0
  28. package/dist/thomas.html +27 -0
  29. package/package.json +1 -1
  30. package/readme.md +30 -22
  31. package/src/game/objects/go.js +7 -0
  32. package/src/game/objects/index.js +2 -0
  33. package/src/game/objects/isometric-scene.js +53 -3
  34. package/src/game/objects/layoutscene.js +57 -0
  35. package/src/game/objects/mask.js +241 -0
  36. package/src/game/objects/scene.js +19 -0
  37. package/src/game/objects/wrapper.js +14 -2
  38. package/src/game/pipeline.js +17 -0
  39. package/src/game/ui/button.js +101 -16
  40. package/src/game/ui/theme.js +0 -6
  41. package/src/game/ui/togglebutton.js +25 -14
  42. package/src/game/ui/tooltip.js +12 -4
  43. package/src/index.js +3 -0
  44. package/src/io/gesture.js +409 -0
  45. package/src/io/index.js +4 -1
  46. package/src/io/keys.js +9 -1
  47. package/src/io/screen.js +476 -0
  48. package/src/math/attractors.js +664 -0
  49. package/src/math/heat.js +106 -0
  50. package/src/math/index.js +1 -0
  51. package/src/mixins/draggable.js +15 -19
  52. package/src/painter/painter.shapes.js +11 -5
  53. package/src/particle/particle-system.js +165 -1
  54. package/src/physics/index.js +26 -0
  55. package/src/physics/physics-updaters.js +333 -0
  56. package/src/physics/physics.js +375 -0
  57. package/src/shapes/image.js +5 -5
  58. package/src/shapes/index.js +2 -0
  59. package/src/shapes/parallelogram.js +147 -0
  60. package/src/shapes/righttriangle.js +115 -0
  61. package/src/shapes/svg.js +281 -100
  62. package/src/shapes/text.js +22 -6
  63. package/src/shapes/transformable.js +5 -0
  64. package/src/sound/effects.js +807 -0
  65. package/src/sound/index.js +13 -0
  66. package/src/webgl/index.js +7 -0
  67. package/src/webgl/shaders/clifford-point-shaders.js +131 -0
  68. package/src/webgl/shaders/dejong-point-shaders.js +131 -0
  69. package/src/webgl/shaders/point-sprite-shaders.js +152 -0
  70. package/src/webgl/webgl-clifford-renderer.js +477 -0
  71. package/src/webgl/webgl-dejong-renderer.js +472 -0
  72. package/src/webgl/webgl-line-renderer.js +391 -0
  73. package/src/webgl/webgl-particle-renderer.js +410 -0
  74. package/types/index.d.ts +30 -2
  75. package/types/io.d.ts +217 -0
  76. package/types/physics.d.ts +299 -0
  77. package/types/shapes.d.ts +8 -0
  78. package/types/webgl.d.ts +188 -109
package/dist/js/cmb.js ADDED
@@ -0,0 +1,594 @@
1
+ /**
2
+ * Cosmic Microwave Background Visualization
3
+ *
4
+ * A 3D visualization of the Cosmic Microwave Background (CMB) radiation,
5
+ * the oldest light in the universe. Uses WebGL particle rendering and
6
+ * Camera3D for 3D projection with physics-based thermal motion.
7
+ *
8
+ * The CMB shows tiny temperature fluctuations (anisotropies) that seeded
9
+ * the formation of galaxies. Temperature is ~2.725K with variations of ~0.00001K.
10
+ *
11
+ * Color mapping: Blue (cold) → White (average) → Red (hot)
12
+ */
13
+
14
+ import {
15
+ Game,
16
+ ParticleSystem,
17
+ Updaters,
18
+ PhysicsUpdaters,
19
+ Camera3D,
20
+ Noise,
21
+ Painter,
22
+ Gesture,
23
+ Tweenetik,
24
+ Easing,
25
+ Screen,
26
+ applyParticleHeatTransfer,
27
+ } from "/gcanvas.es.min.js";
28
+
29
+ // ─────────────────────────────────────────────────────────────────────────────
30
+ // CONFIGURATION
31
+ // ─────────────────────────────────────────────────────────────────────────────
32
+
33
+ /**
34
+ * Get responsive configuration based on screen size
35
+ */
36
+ function getResponsiveConfig() {
37
+ // Sphere radius based on smaller screen dimension (fills ~80% of screen)
38
+ const minDim = Screen.minDimension();
39
+ const sphereRadius = minDim * 0.4;
40
+
41
+ // Particle count scales with screen area (more particles = denser CMB)
42
+ // Mobile: ~1500, Tablet: ~2500, Desktop: ~4000
43
+ const numParticles = Screen.responsive(1500, 2500, 4000);
44
+
45
+ return { sphereRadius, numParticles };
46
+ }
47
+
48
+ const CONFIG = {
49
+ // Particle count and distribution (set dynamically in init)
50
+ numParticles: 3000, // default, overridden by getResponsiveConfig()
51
+ sphereRadius: 400, // default, overridden by getResponsiveConfig()
52
+
53
+ // CMB temperature (Kelvin)
54
+ baseTemperature: 2.725,
55
+ temperatureVariation: 0.0001, // ±100 μK
56
+
57
+ // Visual settings
58
+ particleSize: 3,
59
+ particleSizeVariation: 2,
60
+
61
+ // Camera
62
+ perspective: 600,
63
+ autoRotateSpeed: 0.1,
64
+ cameraDistance: 0,
65
+
66
+ // Zoom (position scaling - fly into the CMB)
67
+ minZoom: 0.5, // zoomed out
68
+ maxZoom: 4.0, // zoomed in (inside the sphere)
69
+ zoomSmoothing: 0.12, // interpolation speed
70
+
71
+ // Big Bang animation
72
+ bigBang: {
73
+ enabled: true,
74
+ initialZoom: 0.05, // start tiny
75
+ targetZoom: 1.0, // expand to full size
76
+ zoomDuration: 1.5, // seconds to expand (faster)
77
+ spawnDelay: 0.0001, // delay between particle spawns (ultra fast burst)
78
+ explosionForce: 800, // outward velocity (stronger)
79
+ flashDuration: 0.3, // white flash duration (quicker fade)
80
+ flashHoldTime: 0.05, // hold at full white (shorter)
81
+ },
82
+
83
+ // Physics - thermal motion
84
+ thermalMotion: 0.2,
85
+ thermalScale: 5,
86
+ sphereRestitution: 0.98,
87
+
88
+ // Heat transfer between particles (disabled for performance)
89
+ heatTransferEnabled: false,
90
+ heatTransferRate: 0.005,
91
+ heatTransferDistance: 30,
92
+
93
+ // Noise for temperature distribution
94
+ noiseScale: 0.015,
95
+ noiseOctaves: 3,
96
+
97
+ // Rendering
98
+ useWebGL: false,
99
+ blendMode: "screen",
100
+ };
101
+
102
+ // ─────────────────────────────────────────────────────────────────────────────
103
+ // HELPER FUNCTIONS
104
+ // ─────────────────────────────────────────────────────────────────────────────
105
+
106
+ /**
107
+ * Generate a random point on a sphere surface
108
+ */
109
+ function randomSpherePoint(radius) {
110
+ // Use spherical coordinates for uniform distribution
111
+ const theta = Math.random() * Math.PI * 2;
112
+ const phi = Math.acos(2 * Math.random() - 1);
113
+
114
+ return {
115
+ x: radius * Math.sin(phi) * Math.cos(theta),
116
+ y: radius * Math.sin(phi) * Math.sin(theta),
117
+ z: radius * Math.cos(phi),
118
+ };
119
+ }
120
+
121
+ /**
122
+ * Generate CMB temperature using 3D noise
123
+ * Returns normalized value from 0 to 1 (for heat.js compatibility)
124
+ */
125
+ function getCMBTemperature(x, y, z) {
126
+ // Use multiple octaves of noise for realistic power spectrum
127
+ let temp = 0;
128
+ let amplitude = 1;
129
+ let frequency = CONFIG.noiseScale;
130
+ let maxValue = 0;
131
+
132
+ for (let i = 0; i < CONFIG.noiseOctaves; i++) {
133
+ // 3D noise sampled at position
134
+ temp += Noise.perlin3(
135
+ x * frequency,
136
+ y * frequency,
137
+ z * frequency
138
+ ) * amplitude;
139
+
140
+ maxValue += amplitude;
141
+ amplitude *= 0.5;
142
+ frequency *= 2;
143
+ }
144
+
145
+ // Normalize to 0 to 1
146
+ return (temp / maxValue + 1) * 0.5;
147
+ }
148
+
149
+ /**
150
+ * Map temperature (0-1) to color
151
+ * Planck CMB palette (based on actual satellite imagery):
152
+ * 0 (cold) = Deep Blue
153
+ * 0.5 = Light tan/cream (neutral)
154
+ * 1 (hot) = Deep Orange/Red-Orange
155
+ */
156
+ function temperatureToColor(t) {
157
+ // Clamp
158
+ t = Math.max(0, Math.min(1, t));
159
+
160
+ let r, g, b;
161
+
162
+ if (t < 0.35) {
163
+ // Cold: Deep Blue to Light Blue
164
+ const s = t / 0.35; // 0 to 1
165
+ r = Math.floor(30 + 100 * s); // 30 → 130
166
+ g = Math.floor(80 + 120 * s); // 80 → 200
167
+ b = Math.floor(200 + 55 * s); // 200 → 255
168
+ } else if (t < 0.65) {
169
+ // Middle: Light Blue to Light Tan/Cream (neutral zone)
170
+ const s = (t - 0.35) / 0.3; // 0 to 1
171
+ r = Math.floor(130 + 100 * s); // 130 → 230
172
+ g = Math.floor(200 - 10 * s); // 200 → 190
173
+ b = Math.floor(255 - 120 * s); // 255 → 135
174
+ } else {
175
+ // Hot: Tan to Deep Orange
176
+ const s = (t - 0.65) / 0.35; // 0 to 1
177
+ r = Math.floor(230 + 25 * s); // 230 → 255
178
+ g = Math.floor(190 - 110 * s); // 190 → 80
179
+ b = Math.floor(135 - 135 * s); // 135 → 0
180
+ }
181
+
182
+ return { r, g, b, a: 0.95 };
183
+ }
184
+
185
+ /**
186
+ * Update particle color based on temperature
187
+ */
188
+ function updateParticleColor(p) {
189
+ const color = temperatureToColor(p.custom.temperature);
190
+ p.color.r = color.r;
191
+ p.color.g = color.g;
192
+ p.color.b = color.b;
193
+ p.color.a = color.a;
194
+ }
195
+
196
+ // ─────────────────────────────────────────────────────────────────────────────
197
+ // CUSTOM UPDATER - Color from temperature
198
+ // ─────────────────────────────────────────────────────────────────────────────
199
+
200
+ const colorFromTemperature = (p, dt) => {
201
+ if (!p.alive || p.custom.temperature === undefined) return;
202
+ updateParticleColor(p);
203
+ };
204
+
205
+ // ─────────────────────────────────────────────────────────────────────────────
206
+ // CMB DEMO CLASS
207
+ // ─────────────────────────────────────────────────────────────────────────────
208
+
209
+ class CMBDemo extends Game {
210
+ constructor(canvas) {
211
+ super(canvas);
212
+ this.backgroundColor = "#000008";
213
+ this.enableFluidSize();
214
+ }
215
+
216
+ init() {
217
+ super.init();
218
+
219
+ // Initialize Screen for responsive sizing
220
+ Screen.init(this);
221
+
222
+ // Apply responsive configuration
223
+ const responsive = getResponsiveConfig();
224
+ CONFIG.sphereRadius = responsive.sphereRadius;
225
+ CONFIG.numParticles = responsive.numParticles;
226
+
227
+ console.log(`Screen: ${Screen.width}x${Screen.height} | ${Screen.isMobile ? 'Mobile' : Screen.isTablet ? 'Tablet' : 'Desktop'}`);
228
+ console.log(`CMB Config: radius=${CONFIG.sphereRadius.toFixed(0)}, particles=${CONFIG.numParticles}`);
229
+
230
+ // Initialize noise
231
+ Noise.seed(42);
232
+
233
+ // Create 3D camera (unclamped for full rotation)
234
+ this.camera = new Camera3D({
235
+ perspective: CONFIG.perspective,
236
+ viewWidth: this.width,
237
+ viewHeight: this.height,
238
+ inertia: true,
239
+ friction: 0.95,
240
+ clampX: false,
241
+ });
242
+
243
+ // Enable mouse/touch controls for rotation
244
+ this.camera.enableMouseControl(this.canvas);
245
+
246
+ // Big Bang state
247
+ const bb = CONFIG.bigBang;
248
+ this.bigBangActive = bb.enabled;
249
+ this.flashOpacity = bb.enabled ? 1.0 : 0;
250
+ this.spawnQueue = [];
251
+ this.spawnTimer = 0;
252
+
253
+ // Zoom state - start tiny for Big Bang
254
+ this.zoom = bb.enabled ? bb.initialZoom : 1.0;
255
+ this.targetZoom = bb.enabled ? bb.initialZoom : 1.0;
256
+
257
+ // Animate zoom expansion for Big Bang
258
+ if (bb.enabled) {
259
+ // Flash fade out after hold time
260
+ setTimeout(() => {
261
+ Tweenetik.to(this, { flashOpacity: 0 }, bb.flashDuration, Easing.easeOutQuad);
262
+ }, bb.flashHoldTime * 1000);
263
+
264
+ // Zoom expansion
265
+ Tweenetik.to(this, { targetZoom: bb.targetZoom }, bb.zoomDuration, Easing.easeOutCubic);
266
+ }
267
+
268
+ // Gesture handler for zoom (wheel + pinch)
269
+ this.gesture = new Gesture(this.canvas, {
270
+ onZoom: (delta) => {
271
+ // delta > 0 = zoom in, delta < 0 = zoom out
272
+ const factor = delta > 0 ? 1.15 : 0.87;
273
+ this.targetZoom = Math.max(
274
+ CONFIG.minZoom,
275
+ Math.min(CONFIG.maxZoom, this.targetZoom * factor)
276
+ );
277
+ },
278
+ });
279
+
280
+ // Initial camera rotation for nice view
281
+ this.camera.rotationX = 0.2;
282
+ this.camera.rotationY = 0;
283
+ this.camera.z = CONFIG.cameraDistance;
284
+
285
+ // Zoom attract updater - particles attracted to their target positions scaled by zoom
286
+ const zoomAttract = (p, dt) => {
287
+ if (!p.alive) return;
288
+ const zoom = this.zoom;
289
+ const strength = 6 * dt;
290
+ const damping = 0.94;
291
+
292
+ // Attract to scaled target position
293
+ const dx = p.custom.targetX * zoom - p.x;
294
+ const dy = p.custom.targetY * zoom - p.y;
295
+ const dz = p.custom.targetZ * zoom - p.z;
296
+
297
+ p.vx = (p.vx + dx * strength) * damping;
298
+ p.vy = (p.vy + dy * strength) * damping;
299
+ p.vz = (p.vz + dz * strength) * damping;
300
+
301
+ p.x += p.vx * dt;
302
+ p.y += p.vy * dt;
303
+ p.z += p.vz * dt;
304
+ };
305
+
306
+ // Create particle system
307
+ this.particles = new ParticleSystem(this, {
308
+ maxParticles: CONFIG.numParticles + 100,
309
+ camera: this.camera,
310
+ depthSort: true,
311
+ useWebGL: CONFIG.useWebGL,
312
+ blendMode: CONFIG.blendMode,
313
+
314
+ // Updaters for particle behavior
315
+ updaters: [
316
+ zoomAttract,
317
+ PhysicsUpdaters.thermal(() => CONFIG.thermalMotion, CONFIG.thermalScale),
318
+ colorFromTemperature,
319
+ ],
320
+ });
321
+
322
+ // Add to pipeline
323
+ this.pipeline.add(this.particles);
324
+
325
+ // Auto-rotation
326
+ this.autoRotate = true;
327
+ this.time = 0;
328
+
329
+ // Create CMB particles - Big Bang spawns from center with delay
330
+ if (CONFIG.bigBang.enabled) {
331
+ this.prepareBigBangParticles();
332
+ } else {
333
+ this.createCMBParticles();
334
+ }
335
+
336
+ console.log(`CMB Demo initialized`);
337
+ }
338
+
339
+ /**
340
+ * Prepare particles for Big Bang - queue them for staggered spawn
341
+ */
342
+ prepareBigBangParticles() {
343
+ for (let i = 0; i < CONFIG.numParticles; i++) {
344
+ // Calculate target position on sphere
345
+ const pos = randomSpherePoint(CONFIG.sphereRadius);
346
+ const temp = getCMBTemperature(pos.x, pos.y, pos.z);
347
+
348
+ // Queue particle data for staggered spawning
349
+ this.spawnQueue.push({
350
+ targetX: pos.x,
351
+ targetY: pos.y,
352
+ targetZ: pos.z,
353
+ temperature: temp,
354
+ spawnTime: i * CONFIG.bigBang.spawnDelay,
355
+ });
356
+ }
357
+ console.log(`Big Bang: ${this.spawnQueue.length} particles queued`);
358
+ }
359
+
360
+ /**
361
+ * Spawn a single Big Bang particle from center
362
+ */
363
+ spawnBigBangParticle(data) {
364
+ if (this.particles.particles.length >= this.particles.maxParticles) return;
365
+
366
+ const p = this.particles.acquire();
367
+ const bb = CONFIG.bigBang;
368
+
369
+ // Target position on sphere surface
370
+ p.custom.targetX = data.targetX;
371
+ p.custom.targetY = data.targetY;
372
+ p.custom.targetZ = data.targetZ;
373
+ p.custom.temperature = data.temperature;
374
+
375
+ // Start at center (the singularity)
376
+ p.x = 0;
377
+ p.y = 0;
378
+ p.z = 0;
379
+
380
+ // Explosion velocity - outward toward target
381
+ const dist = Math.sqrt(data.targetX ** 2 + data.targetY ** 2 + data.targetZ ** 2);
382
+ const nx = data.targetX / dist;
383
+ const ny = data.targetY / dist;
384
+ const nz = data.targetZ / dist;
385
+
386
+ // Random variation in explosion force
387
+ const force = bb.explosionForce * (0.8 + Math.random() * 0.4);
388
+ p.vx = nx * force;
389
+ p.vy = ny * force;
390
+ p.vz = nz * force;
391
+
392
+ // Color from temperature
393
+ updateParticleColor(p);
394
+
395
+ // Size
396
+ p.size = CONFIG.particleSize + data.temperature * CONFIG.particleSizeVariation;
397
+ p.shape = "circle";
398
+
399
+ // Lifecycle
400
+ p.lifetime = 999999;
401
+ p.age = 0;
402
+ p.alive = true;
403
+
404
+ this.particles.particles.push(p);
405
+ }
406
+
407
+ /**
408
+ * Create CMB particles instantly (non-Big Bang mode)
409
+ */
410
+ createCMBParticles() {
411
+ for (let i = 0; i < CONFIG.numParticles; i++) {
412
+ // Check if we've hit max
413
+ if (this.particles.particles.length >= this.particles.maxParticles) {
414
+ console.warn(`Hit max particles at ${i}`);
415
+ break;
416
+ }
417
+
418
+ // Get particle from pool
419
+ const p = this.particles.acquire();
420
+
421
+ // Position on sphere surface (store as target for zoom)
422
+ const pos = randomSpherePoint(CONFIG.sphereRadius);
423
+ p.custom.targetX = pos.x;
424
+ p.custom.targetY = pos.y;
425
+ p.custom.targetZ = pos.z;
426
+
427
+ // Initial position matches target
428
+ p.x = pos.x;
429
+ p.y = pos.y;
430
+ p.z = pos.z;
431
+
432
+ // Start with zero velocity (attract will handle motion)
433
+ p.vx = 0;
434
+ p.vy = 0;
435
+ p.vz = 0;
436
+
437
+ // CMB temperature at this position (0-1 range)
438
+ const temp = getCMBTemperature(pos.x, pos.y, pos.z);
439
+ p.custom.temperature = temp;
440
+
441
+ // Initial color based on temperature
442
+ updateParticleColor(p);
443
+
444
+ // Size varies slightly with temperature (hotter = slightly larger)
445
+ p.size = CONFIG.particleSize + temp * CONFIG.particleSizeVariation;
446
+
447
+ // Shape
448
+ p.shape = "circle";
449
+
450
+ // Long lifetime (effectively permanent)
451
+ p.lifetime = 999999;
452
+ p.age = 0;
453
+ p.alive = true;
454
+
455
+ // Add to active particles array
456
+ this.particles.particles.push(p);
457
+ }
458
+ }
459
+
460
+ update(dt) {
461
+ super.update(dt);
462
+
463
+ this.time += dt;
464
+
465
+ // Update tweens (Big Bang animations)
466
+ Tweenetik.updateAll(dt);
467
+
468
+ // Big Bang particle spawning
469
+ if (this.spawnQueue.length > 0) {
470
+ this.spawnTimer += dt;
471
+
472
+ // Spawn all particles whose time has come
473
+ while (this.spawnQueue.length > 0 && this.spawnQueue[0].spawnTime <= this.spawnTimer) {
474
+ this.spawnBigBangParticle(this.spawnQueue.shift());
475
+ }
476
+ }
477
+
478
+ // Smooth zoom interpolation (fly into the CMB)
479
+ this.zoom += (this.targetZoom - this.zoom) * CONFIG.zoomSmoothing;
480
+
481
+ // Auto-rotate camera when not being dragged
482
+ if (this.autoRotate && !this.camera.isDragging()) {
483
+ this.camera.rotationY += CONFIG.autoRotateSpeed * dt;
484
+ }
485
+
486
+ // Update camera dimensions
487
+ this.camera.viewWidth = this.width;
488
+ this.camera.viewHeight = this.height;
489
+ this.camera.update(dt);
490
+
491
+ // Apply heat transfer between nearby particles (from heat.js)
492
+ if (CONFIG.heatTransferEnabled) {
493
+ applyParticleHeatTransfer(this.particles.particles, {
494
+ maxDistance: CONFIG.heatTransferDistance,
495
+ rate: CONFIG.heatTransferRate,
496
+ falloff: 1,
497
+ temperatureKey: 'temperature',
498
+ filter: (p) => p.alive,
499
+ });
500
+ }
501
+ }
502
+
503
+ render() {
504
+ super.render();
505
+
506
+ // Draw Big Bang flash overlay
507
+ if (this.flashOpacity > 0.01) {
508
+ this.ctx.fillStyle = `rgba(255, 255, 255, ${this.flashOpacity})`;
509
+ this.ctx.fillRect(0, 0, this.width, this.height);
510
+ }
511
+
512
+ // Draw info overlay
513
+ this.drawOverlay();
514
+ }
515
+
516
+ drawOverlay() {
517
+ const ctx = this.ctx;
518
+
519
+ // Temperature scale legend
520
+ const legendX = 20;
521
+ const legendY = this.height - 80;
522
+ const legendWidth = 150;
523
+ const legendHeight = 15;
524
+
525
+ // Draw gradient bar (matches temperatureToColor - Planck CMB palette)
526
+ const gradient = ctx.createLinearGradient(legendX, legendY, legendX + legendWidth, legendY);
527
+ gradient.addColorStop(0, 'rgb(30, 80, 200)'); // Deep blue
528
+ gradient.addColorStop(0.35, 'rgb(130, 200, 255)'); // Light blue
529
+ gradient.addColorStop(0.5, 'rgb(230, 190, 135)'); // Light tan/cream
530
+ gradient.addColorStop(1, 'rgb(255, 80, 0)'); // Deep orange
531
+
532
+ ctx.fillStyle = gradient;
533
+ ctx.fillRect(legendX, legendY, legendWidth, legendHeight);
534
+
535
+ // Border
536
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)';
537
+ ctx.lineWidth = 1;
538
+ ctx.strokeRect(legendX, legendY, legendWidth, legendHeight);
539
+
540
+ // Labels
541
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
542
+ ctx.font = '10px monospace';
543
+ ctx.textAlign = 'center';
544
+ ctx.fillText('Cold', legendX + 20, legendY + legendHeight + 12);
545
+ ctx.fillText('Hot', legendX + legendWidth - 20, legendY + legendHeight + 12);
546
+
547
+ // Title
548
+ ctx.textAlign = 'left';
549
+ ctx.fillText('Temperature Anisotropy', legendX, legendY - 5);
550
+
551
+ // Stats
552
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.6)';
553
+ ctx.font = '11px monospace';
554
+ const statsY = this.height - 120;
555
+ ctx.fillText(`Particles: ${this.particles.particleCount}`, legendX, statsY);
556
+ ctx.fillText(`T₀ = ${CONFIG.baseTemperature} K`, legendX, statsY - 15);
557
+ ctx.fillText(`δT ≈ ±${(CONFIG.baseTemperature * CONFIG.temperatureVariation * 1e6).toFixed(0)} μK`, legendX, statsY - 30);
558
+
559
+ // Zoom level
560
+ ctx.fillText(`Zoom: ${this.zoom.toFixed(1)}x`, legendX, statsY - 45);
561
+
562
+ // Controls hint
563
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.4)';
564
+ ctx.font = '10px monospace';
565
+ ctx.fillText('Drag to rotate • Scroll to zoom', legendX, this.height - 15);
566
+ }
567
+
568
+ stop() {
569
+ super.stop();
570
+ if (this.gesture) {
571
+ this.gesture.destroy();
572
+ }
573
+ if (this.particles) {
574
+ this.particles.destroy();
575
+ }
576
+ }
577
+
578
+ onResize() {
579
+ if (this.camera) {
580
+ this.camera.viewWidth = this.width;
581
+ this.camera.viewHeight = this.height;
582
+ }
583
+ }
584
+ }
585
+
586
+ // ─────────────────────────────────────────────────────────────────────────────
587
+ // INITIALIZATION
588
+ // ─────────────────────────────────────────────────────────────────────────────
589
+
590
+ window.addEventListener("load", () => {
591
+ const canvas = document.getElementById("game");
592
+ const demo = new CMBDemo(canvas);
593
+ demo.start();
594
+ });