@diabolic/borealis 1.0.2 → 1.0.4

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.
@@ -1,1243 +1,1257 @@
1
1
  /**
2
2
  * Borealis - Interactive Animated Background
3
- * @version 1.0.2
3
+ * @version 1.0.4
4
4
  * @license MIT
5
5
  */
6
- /**
7
- * Borealis - Interactive Animated Background
8
- * A canvas-based particle animation system with noise patterns and effects
9
- *
10
- * @author Borealis
11
- * @version 1.0.0
12
- */
13
-
14
- class SimplexNoise {
15
- constructor(seed = Math.random()) {
16
- this.p = new Uint8Array(256);
17
- for (let i = 0; i < 256; i++) this.p[i] = i;
18
-
19
- for (let i = 255; i > 0; i--) {
20
- seed = (seed * 16807) % 2147483647;
21
- const j = seed % (i + 1);
22
- [this.p[i], this.p[j]] = [this.p[j], this.p[i]];
23
- }
24
-
25
- this.perm = new Uint8Array(512);
26
- for (let i = 0; i < 512; i++) this.perm[i] = this.p[i & 255];
27
- }
28
-
29
- noise2D(x, y) {
30
- const F2 = 0.5 * (Math.sqrt(3) - 1);
31
- const G2 = (3 - Math.sqrt(3)) / 6;
32
-
33
- const s = (x + y) * F2;
34
- const i = Math.floor(x + s);
35
- const j = Math.floor(y + s);
36
-
37
- const t = (i + j) * G2;
38
- const X0 = i - t;
39
- const Y0 = j - t;
40
- const x0 = x - X0;
41
- const y0 = y - Y0;
42
-
43
- const i1 = x0 > y0 ? 1 : 0;
44
- const j1 = x0 > y0 ? 0 : 1;
45
-
46
- const x1 = x0 - i1 + G2;
47
- const y1 = y0 - j1 + G2;
48
- const x2 = x0 - 1 + 2 * G2;
49
- const y2 = y0 - 1 + 2 * G2;
50
-
51
- const ii = i & 255;
52
- const jj = j & 255;
53
-
54
- const grad = (hash, x, y) => {
55
- const h = hash & 7;
56
- const u = h < 4 ? x : y;
57
- const v = h < 4 ? y : x;
58
- return ((h & 1) ? -u : u) + ((h & 2) ? -2 * v : 2 * v);
59
- };
60
-
61
- let n0 = 0, n1 = 0, n2 = 0;
62
-
63
- let t0 = 0.5 - x0 * x0 - y0 * y0;
64
- if (t0 >= 0) {
65
- t0 *= t0;
66
- n0 = t0 * t0 * grad(this.perm[ii + this.perm[jj]], x0, y0);
67
- }
68
-
69
- let t1 = 0.5 - x1 * x1 - y1 * y1;
70
- if (t1 >= 0) {
71
- t1 *= t1;
72
- n1 = t1 * t1 * grad(this.perm[ii + i1 + this.perm[jj + j1]], x1, y1);
73
- }
74
-
75
- let t2 = 0.5 - x2 * x2 - y2 * y2;
76
- if (t2 >= 0) {
77
- t2 *= t2;
78
- n2 = t2 * t2 * grad(this.perm[ii + 1 + this.perm[jj + 1]], x2, y2);
79
- }
80
-
81
- return 70 * (n0 + n1 + n2);
82
- }
83
- }
84
-
85
- class Borealis {
86
- /**
87
- * Default options for Borealis
88
- */
89
- static get defaultOptions() {
90
- return {
91
- // Container & Size
92
- container: document.body,
93
- width: null, // Canvas width (null = auto from container/window)
94
- height: null, // Canvas height (null = auto from container/window)
95
- fullscreen: true, // If true, uses fixed positioning to cover viewport
96
-
97
- // Grid settings
98
- density: 50, // Grid density (10-100)
99
- dotSize: 5, // Dot size (0-10, 0=smallest)
100
- solidPattern: false, // Solid pattern without gaps/circles
101
- densityMinCell: 2, // Cell size at max density
102
- densityMaxCell: 8, // Cell size at min density
103
- densityMinGap: 1, // Gap at max density
104
- densityMaxGap: 4, // Gap at min density
105
-
106
- // Pattern settings
107
- patternScale: 0.001, // Noise scale (smaller = larger patterns)
108
- patternAurora: false, // Use aurora colors for pattern
109
- warpScale: 0.5, // Domain warp frequency multiplier
110
- warpAmount: 20, // Domain warp intensity
111
- animationSpeed: 0.00002, // Animation speed multiplier
112
- ridgePower: 2, // Ridge sharpness (higher = sharper lines)
113
- minOpacity: 0, // Minimum opacity (0-1)
114
- maxOpacity: 1, // Maximum opacity (0-1)
115
- waveFrequency: 3, // Wave oscillation frequency
116
- waveAmplitude: 0.5, // Wave intensity (0-1)
117
-
118
- // Effect settings (unified structure)
119
- effect: {
120
- type: 'wave', // 'none', 'wave', 'twinkle'
121
- aurora: false, // Use aurora colors for effect
122
- deadzone: 20, // Center dead zone size (0-100)
123
- // Wave-specific options
124
- speed: 0.0008, // Diagonal line speed
125
- width: 120, // Width of the wave band
126
- chance: 0.08, // Chance of a cell sparkling (0-1)
127
- intensity: 1, // Max brightness
128
- delayMin: 1000, // Min delay between sweeps (ms)
129
- delayMax: 3000, // Max delay between sweeps (ms)
130
- combineSparkle: false, // Add sparkles that get boosted by wave
131
- sparkleBaseOpacity: 0, // Sparkle base opacity when wave not passing (0-100)
132
- // Twinkle-specific options
133
- mode: 'sparkle', // 'sparkle' (random) or 'wave' (flowing waves)
134
- combined: false, // Combine sparkle with wave (sparkles boosted by wave)
135
- baseOpacity: 30, // Base opacity when wave is not passing (0-100)
136
- twinkleSpeed: 50, // Twinkle animation speed (10-100)
137
- size: 50, // Pattern size (10-100)
138
- density: 50, // Star density (0-100)
139
- },
140
-
141
- // Aurora colors
142
- auroraColor1: [0, 255, 128], // Cyan-green
143
- auroraColor2: [148, 0, 211], // Violet
144
- colorScale: 0.003, // Color variation scale
145
-
146
- // Collapse settings
147
- collapseSpeed: 0.1, // Collapse animation speed
148
- collapseWaveWidth: 0.4, // Width of the collapse transition
149
-
150
- // Animation
151
- autoStart: true, // Start animation automatically
152
-
153
- // Callbacks
154
- onShow: null, // Called when show animation completes
155
- onHide: null, // Called when hide animation completes
156
- };
157
- }
158
-
159
- /**
160
- * Create a new Borealis instance
161
- * @param {Object} options - Configuration options
162
- */
163
- constructor(options = {}) {
164
- // Deep merge for effect object
165
- const defaultEffect = Borealis.defaultOptions.effect;
166
- const userEffect = options.effect || {};
167
-
168
- this.options = {
169
- ...Borealis.defaultOptions,
170
- ...options,
171
- effect: { ...defaultEffect, ...userEffect }
172
- };
173
- this._init();
174
- }
175
-
176
- /**
177
- * Initialize the Borealis instance
178
- * @private
179
- */
180
- _init() {
181
- // Create canvas
182
- this.canvas = document.createElement('canvas');
183
-
184
- // Set canvas styles based on mode
185
- if (this.options.fullscreen) {
186
- this.canvas.style.cssText = `
187
- position: fixed;
188
- top: 0;
189
- left: 0;
190
- width: 100%;
191
- height: 100%;
192
- pointer-events: none;
193
- z-index: 0;
194
- `;
195
- } else {
196
- this.canvas.style.cssText = `
197
- position: absolute;
198
- top: 0;
199
- left: 0;
200
- width: 100%;
201
- height: 100%;
202
- pointer-events: none;
203
- `;
204
- }
205
-
206
- // Add to container
207
- const container = this.options.container;
208
- if (container === document.body && this.options.fullscreen) {
209
- document.body.insertBefore(this.canvas, document.body.firstChild);
210
- } else {
211
- // Ensure container has position for absolute positioning
212
- const containerStyle = window.getComputedStyle(container);
213
- if (containerStyle.position === 'static') {
214
- container.style.position = 'relative';
215
- }
216
- container.appendChild(this.canvas);
217
- }
218
-
219
- this.ctx = this.canvas.getContext('2d');
220
- this.noise = new SimplexNoise(Math.random() * 10000);
221
- this.randomOffset = Math.random() * 1000;
222
-
223
- // Internal state
224
- this._cellSize = 4;
225
- this._gap = 2;
226
- this._gridSize = 6;
227
- this._sparkleMap = {};
228
- this._animTime = 0;
229
- this._twinkleTime = 0;
230
- this._lastFrameTime = 0;
231
- this._sparkleWaiting = false;
232
- this._sparkleWaitUntil = 0;
233
- this._diagPos = 0;
234
- this._isCollapsing = false;
235
- this._collapseProgress = 0;
236
- this._isRunning = false;
237
- this._animationId = null;
238
-
239
- // Computed twinkle values
240
- this._twinkleThreshold = 0.8;
241
- this._twinkleSpeedValue = 3;
242
- this._twinkleScaleValue = 0.01;
243
- this._deadzoneValue = 0.2;
244
-
245
- // Apply initial options
246
- this._updateDensity(this.options.density);
247
- this._updateTwinkleSettings();
248
- this._updateDeadzone();
249
-
250
- // Bind methods
251
- this._draw = this._draw.bind(this);
252
- this._resize = this._resize.bind(this);
253
-
254
- // Setup event listeners
255
- window.addEventListener('resize', this._resize);
256
-
257
- // Initial resize
258
- this._resize();
259
-
260
- // Auto start
261
- if (this.options.autoStart) {
262
- this.start();
263
- }
264
- }
265
-
266
- /**
267
- * Update density settings
268
- * @private
269
- */
270
- _updateDensity(value) {
271
- const t = (100 - value) / 90;
272
- const baseCell = this.options.densityMinCell + t * (this.options.densityMaxCell - this.options.densityMinCell);
273
- // Apply dotSize multiplier (0 = 0.3x, 5 = 1x, 10 = 2x)
274
- const sizeMultiplier = 0.3 + (this.options.dotSize / 10) * 1.7;
275
- this._cellSize = baseCell * sizeMultiplier;
276
- this._gap = this.options.densityMinGap + t * (this.options.densityMaxGap - this.options.densityMinGap);
277
- this._gridSize = this._cellSize + this._gap;
278
- }
279
-
280
- /**
281
- * Update twinkle settings from options
282
- * @private
283
- */
284
- _updateTwinkleSettings() {
285
- const effect = this.options.effect;
286
- // Speed: 10-100 maps to 1-6
287
- this._twinkleSpeedValue = 1 + (effect.twinkleSpeed - 10) / 90 * 5;
288
- // Size: 10-100 maps to 0.5-0.001 (inverted, much wider range)
289
- this._twinkleScaleValue = 0.5 - (effect.size - 10) / 90 * 0.499;
290
- // Density: 0-100 maps to threshold 1.0-0.1
291
- this._twinkleThreshold = 1 - effect.density / 100 * 0.9;
292
- }
293
-
294
- /**
295
- * Update deadzone setting (applies to all effects)
296
- * @private
297
- */
298
- _updateDeadzone() {
299
- // Deadzone: 0-100 maps to 0-1 (percentage of diagonal distance from center to corner)
300
- this._deadzoneValue = this.options.effect.deadzone / 100;
301
- }
302
-
303
- /**
304
- * Generate sparkle map
305
- * @private
306
- */
307
- _generateSparkles(cols, rows) {
308
- this._sparkleMap = {};
309
- for (let y = 0; y < rows; y++) {
310
- for (let x = 0; x < cols; x++) {
311
- if (Math.random() < this.options.effect.chance) {
312
- this._sparkleMap[`${x},${y}`] = Math.random();
313
- }
314
- }
315
- }
316
- }
317
-
318
- /**
319
- * Resize handler
320
- * @private
321
- */
322
- _resize() {
323
- // Determine dimensions
324
- let width, height;
325
-
326
- if (this.options.width !== null && this.options.height !== null) {
327
- // Use explicit dimensions
328
- width = this.options.width;
329
- height = this.options.height;
330
- } else if (this.options.fullscreen) {
331
- // Use window dimensions
332
- width = window.innerWidth;
333
- height = window.innerHeight;
334
- } else {
335
- // Use container dimensions
336
- const container = this.options.container;
337
- width = this.options.width !== null ? this.options.width : container.clientWidth;
338
- height = this.options.height !== null ? this.options.height : container.clientHeight;
339
- }
340
-
341
- this.canvas.width = width;
342
- this.canvas.height = height;
343
- const cols = Math.ceil(this.canvas.width / this._gridSize);
344
- const rows = Math.ceil(this.canvas.height / this._gridSize);
345
- this._generateSparkles(cols, rows);
346
- // Clear offscreen canvas cache on resize
347
- this._offscreenCanvas = null;
348
- this._offscreenCtx = null;
349
- }
350
-
351
- /**
352
- * Main draw loop
353
- * @private
354
- */
355
- _draw(time) {
356
- if (!this._isRunning) return;
357
-
358
- const delta = time - this._lastFrameTime;
359
-
360
- this._animTime += delta * this.options.animationSpeed;
361
- this._twinkleTime += delta * 0.001;
362
-
363
- // Handle wave timing
364
- const effect = this.options.effect;
365
- if (!this._sparkleWaiting) {
366
- this._diagPos += delta * effect.speed * 100;
367
-
368
- const cols = Math.ceil(this.canvas.width / this._gridSize);
369
- const rows = Math.ceil(this.canvas.height / this._gridSize);
370
- const maxDiag = cols + rows;
371
-
372
- if (this._diagPos > maxDiag + effect.width) {
373
- this._sparkleWaiting = true;
374
- const delay = effect.delayMin + Math.random() * (effect.delayMax - effect.delayMin);
375
- this._sparkleWaitUntil = time + delay;
376
- this._generateSparkles(cols, rows);
377
- }
378
- } else {
379
- if (time >= this._sparkleWaitUntil) {
380
- this._sparkleWaiting = false;
381
- this._diagPos = -effect.width;
382
- }
383
- }
384
-
385
- this._lastFrameTime = time;
386
- this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
387
-
388
- const cols = Math.ceil(this.canvas.width / this._gridSize);
389
- const rows = Math.ceil(this.canvas.height / this._gridSize);
390
-
391
- // For solid pattern, use offscreen canvas for pixel-perfect base pattern
392
- if (this.options.solidPattern) {
393
- // Create or reuse offscreen canvas at grid resolution
394
- if (!this._offscreenCanvas || this._offscreenCanvas.width !== cols || this._offscreenCanvas.height !== rows) {
395
- this._offscreenCanvas = document.createElement('canvas');
396
- this._offscreenCanvas.width = cols;
397
- this._offscreenCanvas.height = rows;
398
- this._offscreenCtx = this._offscreenCanvas.getContext('2d');
399
- }
400
-
401
- const offCtx = this._offscreenCtx;
402
- const imageData = offCtx.createImageData(cols, rows);
403
- const data = imageData.data;
404
-
405
- // Draw only base pattern to ImageData (no effects)
406
- for (let y = 0; y < rows; y++) {
407
- for (let x = 0; x < cols; x++) {
408
- const cellData = this._calculateCellData(x, y, cols, rows);
409
-
410
- const idx = (y * cols + x) * 4;
411
- data[idx] = cellData.r;
412
- data[idx + 1] = cellData.g;
413
- data[idx + 2] = cellData.b;
414
- data[idx + 3] = Math.round(cellData.opacity * 255);
415
- }
416
- }
417
-
418
- offCtx.putImageData(imageData, 0, 0);
419
-
420
- // Scale up to full canvas size with smooth interpolation
421
- this.ctx.imageSmoothingEnabled = true;
422
- this.ctx.imageSmoothingQuality = 'high';
423
- this.ctx.drawImage(this._offscreenCanvas, 0, 0, this.canvas.width, this.canvas.height);
424
-
425
- // Draw effects on top using regular canvas API (crisp circles)
426
- if (this.options.effect.type !== 'none') {
427
- for (let y = 0; y < rows; y++) {
428
- for (let x = 0; x < cols; x++) {
429
- this._drawEffect(x, y, cols, rows);
430
- }
431
- }
432
- }
433
- } else {
434
- for (let y = 0; y < rows; y++) {
435
- for (let x = 0; x < cols; x++) {
436
- this._drawCell(x, y, cols, rows);
437
- }
438
- }
439
- }
440
-
441
- // Update collapse
442
- this._updateCollapse();
443
-
444
- this._animationId = requestAnimationFrame(this._draw);
445
- }
446
-
447
- /**
448
- * Calculate cell data for solid pattern (used for ImageData rendering)
449
- * @private
450
- */
451
- _calculateCellData(x, y, cols, rows) {
452
- const { options, noise, randomOffset, _animTime } = this;
453
-
454
- // Oscillating wave effect
455
- const wave1 = Math.sin(_animTime * options.waveFrequency + x * options.patternScale * 10) * options.waveAmplitude;
456
- const wave2 = Math.cos(_animTime * options.waveFrequency * 0.7 + y * options.patternScale * 10) * options.waveAmplitude;
457
-
458
- // Domain warping
459
- const warpX = noise.noise2D(x * options.patternScale * options.warpScale + wave1 + randomOffset, y * options.patternScale * options.warpScale + _animTime + randomOffset) * options.warpAmount;
460
- const warpY = noise.noise2D(x * options.patternScale * options.warpScale + 100 + randomOffset, y * options.patternScale * options.warpScale + _animTime + wave2 + randomOffset) * options.warpAmount;
461
-
462
- const noiseVal = noise.noise2D(
463
- (x + warpX) * options.patternScale + wave2 * 0.5 + randomOffset,
464
- (y + warpY) * options.patternScale + wave1 * 0.5 + randomOffset
465
- );
466
-
467
- // Ridge noise
468
- const ridge = 1 - Math.abs(noiseVal);
469
- const rawOpacity = Math.pow(ridge, options.ridgePower);
470
- let opacity = options.minOpacity + rawOpacity * (options.maxOpacity - options.minOpacity);
471
-
472
- // Pattern color (no effects in solid pattern base - effects drawn separately)
473
- let r, g, b;
474
- if (options.patternAurora) {
475
- const colorNoise = noise.noise2D(x * options.colorScale + randomOffset * 0.5, y * options.colorScale + _animTime * 0.5 + randomOffset * 0.5);
476
- const colorBlend = (colorNoise + 1) / 2;
477
- r = Math.round(options.auroraColor1[0] + (options.auroraColor2[0] - options.auroraColor1[0]) * colorBlend);
478
- g = Math.round(options.auroraColor1[1] + (options.auroraColor2[1] - options.auroraColor1[1]) * colorBlend);
479
- b = Math.round(options.auroraColor1[2] + (options.auroraColor2[2] - options.auroraColor1[2]) * colorBlend);
480
- } else {
481
- r = g = b = 255;
482
- }
483
-
484
- // Apply collapse (only base pattern, no effect)
485
- if (this._collapseProgress > 0) {
486
- const collapseResult = this._applyCollapse(x, y, cols, rows, opacity, 0);
487
- opacity = collapseResult.opacity;
488
- }
489
-
490
- return { r, g, b, opacity };
491
- }
492
-
493
- /**
494
- * Draw only effect for a cell (used in solid pattern mode)
495
- * @private
496
- */
497
- _drawEffect(x, y, cols, rows) {
498
- const effect = this.options.effect;
499
-
500
- let effectColor = [255, 255, 255];
501
- let effectOpacity = 0;
502
-
503
- // Wave effect
504
- if (effect.type === 'wave' && !this._sparkleWaiting) {
505
- const result = this._calculateWaveEffect(x, y, cols, rows);
506
- effectColor = result.color;
507
- effectOpacity = result.opacity;
508
- }
509
-
510
- // Twinkle effect
511
- if (effect.type === 'twinkle') {
512
- const result = this._calculateTwinkleEffect(x, y, cols, rows);
513
- effectColor = result.color;
514
- effectOpacity = result.opacity;
515
- }
516
-
517
- // Apply collapse
518
- if (this._collapseProgress > 0) {
519
- const collapseResult = this._applyCollapse(x, y, cols, rows, 0, effectOpacity);
520
- effectOpacity = collapseResult.effectOpacity;
521
- }
522
-
523
- // Draw effect circle if visible
524
- if (effectOpacity > 0) {
525
- this.ctx.fillStyle = `rgba(${effectColor[0]}, ${effectColor[1]}, ${effectColor[2]}, ${effectOpacity})`;
526
- this.ctx.beginPath();
527
- this.ctx.arc(x * this._gridSize + this._cellSize / 2, y * this._gridSize + this._cellSize / 2, this._cellSize / 2, 0, Math.PI * 2);
528
- this.ctx.fill();
529
- }
530
- }
531
-
532
- /**
533
- * Draw a single cell
534
- * @private
535
- */
536
- _drawCell(x, y, cols, rows) {
537
- const { options, noise, randomOffset, _animTime, _twinkleTime } = this;
538
-
539
- // Oscillating wave effect
540
- const wave1 = Math.sin(_animTime * options.waveFrequency + x * options.patternScale * 10) * options.waveAmplitude;
541
- const wave2 = Math.cos(_animTime * options.waveFrequency * 0.7 + y * options.patternScale * 10) * options.waveAmplitude;
542
-
543
- // Domain warping
544
- const warpX = noise.noise2D(x * options.patternScale * options.warpScale + wave1 + randomOffset, y * options.patternScale * options.warpScale + _animTime + randomOffset) * options.warpAmount;
545
- const warpY = noise.noise2D(x * options.patternScale * options.warpScale + 100 + randomOffset, y * options.patternScale * options.warpScale + _animTime + wave2 + randomOffset) * options.warpAmount;
546
-
547
- const noiseVal = noise.noise2D(
548
- (x + warpX) * options.patternScale + wave2 * 0.5 + randomOffset,
549
- (y + warpY) * options.patternScale + wave1 * 0.5 + randomOffset
550
- );
551
-
552
- // Ridge noise
553
- const ridge = 1 - Math.abs(noiseVal);
554
- const rawOpacity = Math.pow(ridge, options.ridgePower);
555
- let opacity = options.minOpacity + rawOpacity * (options.maxOpacity - options.minOpacity);
556
-
557
- // Effect variables
558
- let effectColor = [255, 255, 255];
559
- let effectOpacity = 0;
560
-
561
- // Wave effect
562
- if (options.effect.type === 'wave' && !this._sparkleWaiting) {
563
- const result = this._calculateWaveEffect(x, y, cols, rows);
564
- effectColor = result.color;
565
- effectOpacity = result.opacity;
566
- }
567
-
568
- // Twinkle effect
569
- if (options.effect.type === 'twinkle') {
570
- const result = this._calculateTwinkleEffect(x, y, cols, rows);
571
- effectColor = result.color;
572
- effectOpacity = result.opacity;
573
- }
574
-
575
- // Pattern color
576
- let r, g, b;
577
- if (options.patternAurora) {
578
- const colorNoise = noise.noise2D(x * options.colorScale + randomOffset * 0.5, y * options.colorScale + _animTime * 0.5 + randomOffset * 0.5);
579
- const colorBlend = (colorNoise + 1) / 2;
580
- r = Math.round(options.auroraColor1[0] + (options.auroraColor2[0] - options.auroraColor1[0]) * colorBlend);
581
- g = Math.round(options.auroraColor1[1] + (options.auroraColor2[1] - options.auroraColor1[1]) * colorBlend);
582
- b = Math.round(options.auroraColor1[2] + (options.auroraColor2[2] - options.auroraColor1[2]) * colorBlend);
583
- } else {
584
- r = g = b = 255;
585
- }
586
-
587
- // Apply collapse
588
- if (this._collapseProgress > 0) {
589
- const collapseResult = this._applyCollapse(x, y, cols, rows, opacity, effectOpacity);
590
- opacity = collapseResult.opacity;
591
- effectOpacity = collapseResult.effectOpacity;
592
- }
593
-
594
- // Skip rendering if both opacities are 0 (performance optimization)
595
- if (opacity <= 0 && effectOpacity <= 0) {
596
- return;
597
- }
598
-
599
- // Draw base pattern
600
- this.ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${opacity})`;
601
- if (this.options.solidPattern) {
602
- // Solid mode: fill entire cell without gaps (add 0.5px overlap to prevent gaps)
603
- const px = Math.floor(x * this._gridSize);
604
- const py = Math.floor(y * this._gridSize);
605
- this.ctx.fillRect(px, py, Math.ceil(this._gridSize) + 1, Math.ceil(this._gridSize) + 1);
606
- } else {
607
- // Circle mode
608
- this.ctx.beginPath();
609
- this.ctx.arc(x * this._gridSize + this._cellSize / 2, y * this._gridSize + this._cellSize / 2, this._cellSize / 2, 0, Math.PI * 2);
610
- this.ctx.fill();
611
- }
612
-
613
- // Draw effect on top (always circles)
614
- if (effectOpacity > 0) {
615
- this.ctx.fillStyle = `rgba(${effectColor[0]}, ${effectColor[1]}, ${effectColor[2]}, ${effectOpacity})`;
616
- this.ctx.beginPath();
617
- this.ctx.arc(x * this._gridSize + this._cellSize / 2, y * this._gridSize + this._cellSize / 2, this._cellSize / 2, 0, Math.PI * 2);
618
- this.ctx.fill();
619
- }
620
- }
621
-
622
- /**
623
- * Calculate wave effect
624
- * @private
625
- */
626
- _calculateWaveEffect(x, y, cols, rows) {
627
- const { options, noise, randomOffset, _animTime, _twinkleTime } = this;
628
- const effect = options.effect;
629
- let color = [255, 255, 255];
630
- let opacity = 0;
631
-
632
- // Dead zone calculation (using diagonal distance to corner)
633
- const centerX = cols / 2;
634
- const centerY = rows / 2;
635
- const distFromCenter = Math.sqrt((x - centerX) ** 2 + (y - centerY) ** 2);
636
- const maxDist = Math.sqrt(centerX ** 2 + centerY ** 2); // Distance from center to corner
637
- const maxRadius = maxDist * this._deadzoneValue;
638
- const fadeZone = maxRadius * 0.3;
639
-
640
- let centerFade = 1;
641
- if (distFromCenter < maxRadius) {
642
- centerFade = 0;
643
- } else if (distFromCenter < maxRadius + fadeZone) {
644
- const t = (distFromCenter - maxRadius) / fadeZone;
645
- centerFade = t * t * (3 - 2 * t);
646
- }
647
-
648
- // Combined sparkle mode - sparkles that get boosted by wave
649
- if (effect.combineSparkle && centerFade > 0) {
650
- // Calculate wave proximity (0-1, 1 = wave is here)
651
- const cellDiag = x + y;
652
- const distFromLine = Math.abs(cellDiag - this._diagPos);
653
- // Narrower wave effect zone for more dramatic boost
654
- const waveProximity = Math.max(0, 1 - distFromLine / effect.width);
655
- // Sharper falloff - wave effect drops quickly
656
- const smoothWaveProximity = Math.pow(waveProximity, 0.5);
657
-
658
- // Calculate sparkle
659
- const hash1 = Math.sin(x * 12.9898 + y * 78.233 + randomOffset) * 43758.5453;
660
- const rand1 = hash1 - Math.floor(hash1);
661
- const hash2 = Math.sin(x * 93.9898 + y * 67.345 + randomOffset * 2) * 23421.6312;
662
- const rand2 = hash2 - Math.floor(hash2);
663
-
664
- // Use twinkle density for sparkle distribution
665
- const sparkleThreshold = 1 - effect.density / 100 * 0.9;
666
-
667
- if (rand1 > sparkleThreshold) {
668
- const phase = rand2 * Math.PI * 2;
669
- const sparkleSpeed = 0.1 + (effect.twinkleSpeed / 100) * 0.4;
670
- const twinkleWave = Math.sin(_twinkleTime * sparkleSpeed + phase);
671
- const sparkle = Math.max(0, twinkleWave);
672
-
673
- // Base opacity is limited, wave boosts it to full
674
- const baseOpacity = effect.sparkleBaseOpacity / 100;
675
- const maxBoost = 1 - baseOpacity;
676
- const finalOpacity = baseOpacity + (maxBoost * smoothWaveProximity);
677
-
678
- opacity = sparkle * finalOpacity * centerFade;
679
-
680
- if (effect.aurora) {
681
- const colorRand = Math.sin(x * 45.123 + y * 89.456 + randomOffset) * 12345.6789;
682
- const colorBlend = colorRand - Math.floor(colorRand);
683
- color = [
684
- Math.round(options.auroraColor1[0] + (options.auroraColor2[0] - options.auroraColor1[0]) * colorBlend),
685
- Math.round(options.auroraColor1[1] + (options.auroraColor2[1] - options.auroraColor1[1]) * colorBlend),
686
- Math.round(options.auroraColor1[2] + (options.auroraColor2[2] - options.auroraColor1[2]) * colorBlend)
687
- ];
688
- }
689
- }
690
-
691
- return { color, opacity };
692
- }
693
-
694
- const cellDiag = x + y;
695
- const distFromLine = Math.abs(cellDiag - this._diagPos);
696
-
697
- if (distFromLine < effect.width && this._sparkleMap[`${x},${y}`] !== undefined) {
698
- const normalizedDist = distFromLine / effect.width;
699
- const sparkle = Math.cos(normalizedDist * Math.PI * 0.5) * effect.intensity;
700
-
701
- // Cylinder effect
702
- const fullDiagonalLength = Math.min(cols, rows);
703
- const diagStartX = Math.max(0, Math.floor(this._diagPos) - (rows - 1));
704
- const diagEndX = Math.min(cols - 1, Math.floor(this._diagPos));
705
- const currentLineLength = Math.max(1, diagEndX - diagStartX + 1);
706
-
707
- let cylinderFade = 1;
708
- if (currentLineLength >= fullDiagonalLength && currentLineLength > 1) {
709
- const posAlongLine = (x - diagStartX) / (currentLineLength - 1);
710
- const clampedPos = Math.max(0, Math.min(1, posAlongLine));
711
- cylinderFade = 0.3 + 0.7 * Math.sin(clampedPos * Math.PI);
712
- } else if (currentLineLength > 1) {
713
- const completeness = currentLineLength / fullDiagonalLength;
714
- const posAlongLine = (x - diagStartX) / (currentLineLength - 1);
715
- const clampedPos = Math.max(0, Math.min(1, posAlongLine));
716
- const baseFade = Math.sin(clampedPos * Math.PI);
717
- cylinderFade = Math.max(0.3, 1 - (1 - baseFade) * completeness * 0.7);
718
- }
719
-
720
- opacity = sparkle * this._sparkleMap[`${x},${y}`] * Math.max(0, cylinderFade) * centerFade;
721
-
722
- // Color
723
- if (effect.aurora) {
724
- const colorNoise = noise.noise2D(x * options.colorScale * 2 + randomOffset, y * options.colorScale * 2 + _animTime + randomOffset);
725
- const colorBlend = (colorNoise + 1) / 2;
726
- color = [
727
- Math.round(options.auroraColor1[0] + (options.auroraColor2[0] - options.auroraColor1[0]) * colorBlend),
728
- Math.round(options.auroraColor1[1] + (options.auroraColor2[1] - options.auroraColor1[1]) * colorBlend),
729
- Math.round(options.auroraColor1[2] + (options.auroraColor2[2] - options.auroraColor1[2]) * colorBlend)
730
- ];
731
- }
732
- }
733
-
734
- return { color, opacity };
735
- }
736
-
737
- /**
738
- * Calculate twinkle effect
739
- * @private
740
- */
741
- _calculateTwinkleEffect(x, y, cols, rows) {
742
- const { options, noise, randomOffset, _twinkleTime } = this;
743
- const effect = options.effect;
744
- let color = [255, 255, 255];
745
- let opacity = 0;
746
-
747
- // Dead zone calculation (using diagonal distance to corner)
748
- const centerX = cols / 2;
749
- const centerY = rows / 2;
750
- const distFromCenter = Math.sqrt((x - centerX) ** 2 + (y - centerY) ** 2);
751
- const maxDist = Math.sqrt(centerX ** 2 + centerY ** 2); // Distance from center to corner
752
- const maxRadius = maxDist * this._deadzoneValue;
753
- const fadeZone = maxRadius * 0.3;
754
-
755
- let centerFade = 1;
756
- if (distFromCenter < maxRadius) {
757
- centerFade = 0;
758
- } else if (distFromCenter < maxRadius + fadeZone) {
759
- const t = (distFromCenter - maxRadius) / fadeZone;
760
- centerFade = t * t * (3 - 2 * t);
761
- }
762
-
763
- if (centerFade > 0) {
764
- // Combined mode - sparkles that get boosted by passing waves
765
- if (effect.combined) {
766
- // Calculate wave intensity first
767
- const baseScale = 0.0005 + (1 - this._twinkleScaleValue) * 0.003;
768
- const waveSpeed = this._twinkleSpeedValue * 0.15;
769
-
770
- const wave1 = noise.noise2D(
771
- x * baseScale + _twinkleTime * waveSpeed,
772
- y * baseScale + _twinkleTime * waveSpeed * 0.5 + randomOffset
773
- );
774
- const wave2 = noise.noise2D(
775
- x * baseScale * 0.5 + _twinkleTime * waveSpeed * 0.3 + 50,
776
- y * baseScale * 0.7 - _twinkleTime * waveSpeed * 0.2 + randomOffset + 50
777
- );
778
- const wave3 = noise.noise2D(
779
- (x + y * 0.5) * baseScale * 0.8 + _twinkleTime * waveSpeed * 0.4,
780
- (y - x * 0.3) * baseScale * 0.8 + randomOffset + 100
781
- );
782
-
783
- const combined = (wave1 * 0.5 + wave2 * 0.3 + wave3 * 0.2);
784
- const smoothWave = (Math.sin(combined * Math.PI * 2) + 1) / 2;
785
- const waveIntensity = Math.pow(smoothWave, 0.5); // Smoother wave
786
-
787
- // Calculate sparkle
788
- const hash1 = Math.sin(x * 12.9898 + y * 78.233 + randomOffset) * 43758.5453;
789
- const rand1 = hash1 - Math.floor(hash1);
790
- const hash2 = Math.sin(x * 93.9898 + y * 67.345 + randomOffset * 2) * 23421.6312;
791
- const rand2 = hash2 - Math.floor(hash2);
792
-
793
- if (rand1 > this._twinkleThreshold) {
794
- const phase = rand2 * Math.PI * 2;
795
- const twinkleWave = Math.sin(_twinkleTime * this._twinkleSpeedValue * 2 + phase);
796
- const sparkle = Math.max(0, twinkleWave);
797
-
798
- // Base opacity is limited, wave boosts it to full
799
- const baseOpacity = effect.baseOpacity / 100;
800
- const maxBoost = 1 - baseOpacity;
801
- const finalOpacity = baseOpacity + (maxBoost * waveIntensity);
802
-
803
- opacity = sparkle * finalOpacity * effect.intensity * centerFade;
804
-
805
- if (effect.aurora) {
806
- const colorRand = Math.sin(x * 45.123 + y * 89.456 + randomOffset) * 12345.6789;
807
- const colorBlend = colorRand - Math.floor(colorRand);
808
- color = [
809
- Math.round(options.auroraColor1[0] + (options.auroraColor2[0] - options.auroraColor1[0]) * colorBlend),
810
- Math.round(options.auroraColor1[1] + (options.auroraColor2[1] - options.auroraColor1[1]) * colorBlend),
811
- Math.round(options.auroraColor1[2] + (options.auroraColor2[2] - options.auroraColor1[2]) * colorBlend)
812
- ];
813
- }
814
- }
815
- }
816
- // Wave mode - flowing waves that boost opacity to 100%
817
- else if (effect.mode === 'wave') {
818
- // Create smooth, wide flowing light bands
819
- // Size controls the width of the bands
820
- const baseScale = 0.0005 + (1 - this._twinkleScaleValue) * 0.003;
821
- const waveSpeed = this._twinkleSpeedValue * 0.15;
822
-
823
- // Slow, smooth primary wave - creates wide bands
824
- const wave1 = noise.noise2D(
825
- x * baseScale + _twinkleTime * waveSpeed,
826
- y * baseScale + _twinkleTime * waveSpeed * 0.5 + randomOffset
827
- );
828
-
829
- // Very slow secondary wave for organic variation
830
- const wave2 = noise.noise2D(
831
- x * baseScale * 0.5 + _twinkleTime * waveSpeed * 0.3 + 50,
832
- y * baseScale * 0.7 - _twinkleTime * waveSpeed * 0.2 + randomOffset + 50
833
- );
834
-
835
- // Third wave for extra organic feel
836
- const wave3 = noise.noise2D(
837
- (x + y * 0.5) * baseScale * 0.8 + _twinkleTime * waveSpeed * 0.4,
838
- (y - x * 0.3) * baseScale * 0.8 + randomOffset + 100
839
- );
840
-
841
- // Combine waves smoothly
842
- const combined = (wave1 * 0.5 + wave2 * 0.3 + wave3 * 0.2);
843
-
844
- // Smooth sine-based intensity (no harsh ridges)
845
- const smoothWave = (Math.sin(combined * Math.PI * 2) + 1) / 2;
846
-
847
- // Apply density as band width control
848
- const densityFactor = 0.3 + this._twinkleThreshold * 0.7;
849
- const intensity = Math.pow(smoothWave, 1 / densityFactor);
850
-
851
- // Smooth the final output
852
- opacity = intensity * effect.intensity * centerFade;
853
-
854
- // Aurora colors for wave mode
855
- if (effect.aurora && opacity > 0) {
856
- const colorWave = noise.noise2D(
857
- x * baseScale * 0.3 + _twinkleTime * waveSpeed * 0.1 + randomOffset,
858
- y * baseScale * 0.3 + randomOffset
859
- );
860
- const colorBlend = (colorWave + 1) / 2;
861
- color = [
862
- Math.round(options.auroraColor1[0] + (options.auroraColor2[0] - options.auroraColor1[0]) * colorBlend),
863
- Math.round(options.auroraColor1[1] + (options.auroraColor2[1] - options.auroraColor1[1]) * colorBlend),
864
- Math.round(options.auroraColor1[2] + (options.auroraColor2[2] - options.auroraColor1[2]) * colorBlend)
865
- ];
866
- }
867
- } else {
868
- // Sparkle mode - original random twinkling
869
- const hash1 = Math.sin(x * 12.9898 + y * 78.233 + randomOffset) * 43758.5453;
870
- const rand1 = hash1 - Math.floor(hash1);
871
-
872
- const hash2 = Math.sin(x * 93.9898 + y * 67.345 + randomOffset * 2) * 23421.6312;
873
- const rand2 = hash2 - Math.floor(hash2);
874
-
875
- if (rand1 > this._twinkleThreshold) {
876
- const phase = rand2 * Math.PI * 2;
877
- const twinkleWave = Math.sin(_twinkleTime * this._twinkleSpeedValue + phase);
878
- const baseBrightness = Math.max(0, twinkleWave);
879
-
880
- const groupWave = noise.noise2D(
881
- x * this._twinkleScaleValue + _twinkleTime * 0.2 + randomOffset,
882
- y * this._twinkleScaleValue + randomOffset
883
- );
884
- const maxOpacity = 0.2 + (groupWave + 1) / 2 * 0.8;
885
-
886
- opacity = baseBrightness * maxOpacity * effect.intensity * centerFade;
887
-
888
- if (effect.aurora) {
889
- const colorRand = Math.sin(x * 45.123 + y * 89.456 + randomOffset) * 12345.6789;
890
- const colorBlend = colorRand - Math.floor(colorRand);
891
- color = [
892
- Math.round(options.auroraColor1[0] + (options.auroraColor2[0] - options.auroraColor1[0]) * colorBlend),
893
- Math.round(options.auroraColor1[1] + (options.auroraColor2[1] - options.auroraColor1[1]) * colorBlend),
894
- Math.round(options.auroraColor1[2] + (options.auroraColor2[2] - options.auroraColor1[2]) * colorBlend)
895
- ];
896
- }
897
- }
898
- }
899
- }
900
-
901
- return { color, opacity };
902
- }
903
-
904
- /**
905
- * Apply collapse effect
906
- * @private
907
- */
908
- _applyCollapse(x, y, cols, rows, opacity, effectOpacity) {
909
- const centerX = cols / 2;
910
- const centerY = rows / 2;
911
- const maxRadius = Math.sqrt(centerX * centerX + centerY * centerY);
912
- const distFromCenter = Math.sqrt((x - centerX) ** 2 + (y - centerY) ** 2);
913
- const normalizedDist = distFromCenter / maxRadius;
914
-
915
- const collapseAt = 1 - normalizedDist;
916
-
917
- if (this._collapseProgress > collapseAt + this.options.collapseWaveWidth) {
918
- opacity = 0;
919
- effectOpacity = 0;
920
- } else if (this._collapseProgress > collapseAt) {
921
- const t = 1 - (this._collapseProgress - collapseAt) / this.options.collapseWaveWidth;
922
- const smoothFade = t * t * (3 - 2 * t);
923
- opacity *= smoothFade;
924
- effectOpacity *= smoothFade;
925
- }
926
-
927
- return { opacity, effectOpacity };
928
- }
929
-
930
- /**
931
- * Update collapse animation
932
- * @private
933
- */
934
- _updateCollapse() {
935
- const collapseEnd = 1 + this.options.collapseWaveWidth;
936
-
937
- if (this._isCollapsing && this._collapseProgress < collapseEnd) {
938
- this._collapseProgress += this.options.collapseSpeed;
939
- if (this._collapseProgress >= collapseEnd) {
940
- this._collapseProgress = collapseEnd;
941
- if (this.options.onHide) {
942
- this.options.onHide();
943
- }
944
- }
945
- } else if (!this._isCollapsing && this._collapseProgress > 0) {
946
- this._collapseProgress -= this.options.collapseSpeed;
947
- if (this._collapseProgress <= 0) {
948
- this._collapseProgress = 0;
949
- if (this.options.onShow) {
950
- this.options.onShow();
951
- }
952
- }
953
- }
954
- }
955
-
956
- // ==================== PUBLIC API ====================
957
-
958
- /**
959
- * Start the animation
960
- * @returns {Borealis} this instance for chaining
961
- */
962
- start() {
963
- if (!this._isRunning) {
964
- this._isRunning = true;
965
- this._lastFrameTime = performance.now();
966
- this._animationId = requestAnimationFrame(this._draw);
967
- }
968
- return this;
969
- }
970
-
971
- /**
972
- * Stop the animation
973
- * @returns {Borealis} this instance for chaining
974
- */
975
- stop() {
976
- this._isRunning = false;
977
- if (this._animationId) {
978
- cancelAnimationFrame(this._animationId);
979
- this._animationId = null;
980
- }
981
- return this;
982
- }
983
-
984
- /**
985
- * Manually trigger a resize (useful when container size changes)
986
- * @param {number} [width] - Optional new width
987
- * @param {number} [height] - Optional new height
988
- * @returns {Borealis} this instance for chaining
989
- */
990
- resize(width, height) {
991
- if (width !== undefined) {
992
- this.options.width = width;
993
- }
994
- if (height !== undefined) {
995
- this.options.height = height;
996
- }
997
- this._resize();
998
- return this;
999
- }
1000
-
1001
- /**
1002
- * Force a single frame redraw (useful when animation is stopped)
1003
- * @returns {Borealis} this instance for chaining
1004
- */
1005
- redraw() {
1006
- const time = performance.now();
1007
- const wasRunning = this._isRunning;
1008
- this._isRunning = true;
1009
- this._lastFrameTime = time - 16; // Simulate ~60fps frame
1010
-
1011
- // Draw single frame without requesting next
1012
- const delta = 16;
1013
- this._animTime += delta * this.options.animationSpeed;
1014
- this._twinkleTime += delta * 0.001;
1015
-
1016
- this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
1017
-
1018
- const cols = Math.ceil(this.canvas.width / this._gridSize);
1019
- const rows = Math.ceil(this.canvas.height / this._gridSize);
1020
-
1021
- if (this.options.solidPattern) {
1022
- if (!this._offscreenCanvas || this._offscreenCanvas.width !== cols || this._offscreenCanvas.height !== rows) {
1023
- this._offscreenCanvas = document.createElement('canvas');
1024
- this._offscreenCanvas.width = cols;
1025
- this._offscreenCanvas.height = rows;
1026
- this._offscreenCtx = this._offscreenCanvas.getContext('2d');
1027
- }
1028
-
1029
- const offCtx = this._offscreenCtx;
1030
- const imageData = offCtx.createImageData(cols, rows);
1031
- const data = imageData.data;
1032
-
1033
- for (let y = 0; y < rows; y++) {
1034
- for (let x = 0; x < cols; x++) {
1035
- const cellData = this._calculateCellData(x, y, cols, rows);
1036
- const idx = (y * cols + x) * 4;
1037
- data[idx] = cellData.r;
1038
- data[idx + 1] = cellData.g;
1039
- data[idx + 2] = cellData.b;
1040
- data[idx + 3] = Math.round(cellData.opacity * 255);
1041
- }
1042
- }
1043
-
1044
- offCtx.putImageData(imageData, 0, 0);
1045
- this.ctx.imageSmoothingEnabled = true;
1046
- this.ctx.imageSmoothingQuality = 'high';
1047
- this.ctx.drawImage(this._offscreenCanvas, 0, 0, this.canvas.width, this.canvas.height);
1048
-
1049
- if (this.options.effect.type !== 'none') {
1050
- for (let y = 0; y < rows; y++) {
1051
- for (let x = 0; x < cols; x++) {
1052
- this._drawEffect(x, y, cols, rows);
1053
- }
1054
- }
1055
- }
1056
- } else {
1057
- for (let y = 0; y < rows; y++) {
1058
- for (let x = 0; x < cols; x++) {
1059
- this._drawCell(x, y, cols, rows);
1060
- }
1061
- }
1062
- }
1063
-
1064
- this._isRunning = wasRunning;
1065
- return this;
1066
- }
1067
-
1068
- /**
1069
- * Show the pattern (expand from center)
1070
- * @param {Function} [callback] - Called when animation completes
1071
- * @returns {Borealis} this instance for chaining
1072
- */
1073
- show(callback) {
1074
- this._isCollapsing = false;
1075
- if (callback) {
1076
- const originalCallback = this.options.onShow;
1077
- this.options.onShow = () => {
1078
- callback();
1079
- this.options.onShow = originalCallback;
1080
- };
1081
- }
1082
- return this;
1083
- }
1084
-
1085
- /**
1086
- * Hide the pattern (collapse to center)
1087
- * @param {Function} [callback] - Called when animation completes
1088
- * @returns {Borealis} this instance for chaining
1089
- */
1090
- hide(callback) {
1091
- this._isCollapsing = true;
1092
- if (callback) {
1093
- const originalCallback = this.options.onHide;
1094
- this.options.onHide = () => {
1095
- callback();
1096
- this.options.onHide = originalCallback;
1097
- };
1098
- }
1099
- return this;
1100
- }
1101
-
1102
- /**
1103
- * Toggle between show and hide
1104
- * @param {Function} [callback] - Called when animation completes
1105
- * @returns {Borealis} this instance for chaining
1106
- */
1107
- toggle(callback) {
1108
- if (this._isCollapsing) {
1109
- return this.show(callback);
1110
- } else {
1111
- return this.hide(callback);
1112
- }
1113
- }
1114
-
1115
- /**
1116
- * Check if currently visible (not collapsed)
1117
- * @returns {boolean}
1118
- */
1119
- isVisible() {
1120
- return !this._isCollapsing && this._collapseProgress === 0;
1121
- }
1122
-
1123
- /**
1124
- * Check if currently hidden (fully collapsed)
1125
- * @returns {boolean}
1126
- */
1127
- isHidden() {
1128
- return this._isCollapsing && this._collapseProgress >= 1 + this.options.collapseWaveWidth;
1129
- }
1130
-
1131
- /**
1132
- * Set a single option
1133
- * @param {string} key - Option key
1134
- * @param {*} value - Option value
1135
- * @returns {Borealis} this instance for chaining
1136
- */
1137
- setOption(key, value) {
1138
- // Handle effect as special case (use setEffect instead)
1139
- if (key === 'effect') {
1140
- if (typeof value === 'object') {
1141
- return this.setEffect(value.type, value);
1142
- }
1143
- return this;
1144
- }
1145
-
1146
- this.options[key] = value;
1147
-
1148
- // Handle special cases that need resize/recalculation
1149
- const needsResize = [
1150
- 'density', 'dotSize', 'solidPattern', 'patternAurora',
1151
- 'maxOpacity', 'minOpacity'
1152
- ];
1153
-
1154
- if (needsResize.includes(key)) {
1155
- this._updateDensity(this.options.density);
1156
- this._resize();
1157
- }
1158
-
1159
- return this;
1160
- }
1161
-
1162
- /**
1163
- * Set effect type and options
1164
- * @param {string} type - Effect type: 'none', 'wave', or 'twinkle'
1165
- * @param {Object} [effectOptions] - Effect-specific options
1166
- * @returns {Borealis} this instance for chaining
1167
- */
1168
- setEffect(type, effectOptions = {}) {
1169
- // Update effect type
1170
- if (type) {
1171
- this.options.effect.type = type;
1172
- }
1173
-
1174
- // Merge effect options
1175
- Object.keys(effectOptions).forEach(key => {
1176
- if (key !== 'type') {
1177
- this.options.effect[key] = effectOptions[key];
1178
- }
1179
- });
1180
-
1181
- // Update internal computed values
1182
- this._updateTwinkleSettings();
1183
- this._updateDeadzone();
1184
- this._resize();
1185
-
1186
- return this;
1187
- }
1188
-
1189
- /**
1190
- * Get current effect configuration
1191
- * @returns {Object} Effect configuration with type and options
1192
- */
1193
- getEffect() {
1194
- return { ...this.options.effect };
1195
- }
1196
-
1197
- /**
1198
- * Set multiple options at once
1199
- * @param {Object} options - Options object
1200
- * @returns {Borealis} this instance for chaining
1201
- */
1202
- setOptions(options) {
1203
- Object.keys(options).forEach(key => {
1204
- this.setOption(key, options[key]);
1205
- });
1206
- return this;
1207
- }
1208
-
1209
- /**
1210
- * Get current options
1211
- * @returns {Object} Current options
1212
- */
1213
- getOptions() {
1214
- return { ...this.options };
1215
- }
1216
-
1217
- /**
1218
- * Get a specific option value
1219
- * @param {string} key - Option key
1220
- * @returns {*} Option value
1221
- */
1222
- getOption(key) {
1223
- return this.options[key];
1224
- }
1225
-
1226
- /**
1227
- * Destroy the instance and clean up
1228
- */
1229
- destroy() {
1230
- this.stop();
1231
- window.removeEventListener('resize', this._resize);
1232
-
1233
- if (this.canvas && this.canvas.parentNode) {
1234
- this.canvas.parentNode.removeChild(this.canvas);
1235
- }
1236
-
1237
- this.canvas = null;
1238
- this.ctx = null;
1239
- this.noise = null;
1240
- }
6
+ /**
7
+ * Borealis - Interactive Animated Background
8
+ * A canvas-based particle animation system with noise patterns and effects
9
+ *
10
+ * @author Borealis
11
+ * @version 1.0.0
12
+ */
13
+
14
+ class SimplexNoise {
15
+ constructor(seed = Math.random()) {
16
+ this.p = new Uint8Array(256);
17
+ for (let i = 0; i < 256; i++) this.p[i] = i;
18
+
19
+ for (let i = 255; i > 0; i--) {
20
+ seed = (seed * 16807) % 2147483647;
21
+ const j = seed % (i + 1);
22
+ [this.p[i], this.p[j]] = [this.p[j], this.p[i]];
23
+ }
24
+
25
+ this.perm = new Uint8Array(512);
26
+ for (let i = 0; i < 512; i++) this.perm[i] = this.p[i & 255];
27
+ }
28
+
29
+ noise2D(x, y) {
30
+ const F2 = 0.5 * (Math.sqrt(3) - 1);
31
+ const G2 = (3 - Math.sqrt(3)) / 6;
32
+
33
+ const s = (x + y) * F2;
34
+ const i = Math.floor(x + s);
35
+ const j = Math.floor(y + s);
36
+
37
+ const t = (i + j) * G2;
38
+ const X0 = i - t;
39
+ const Y0 = j - t;
40
+ const x0 = x - X0;
41
+ const y0 = y - Y0;
42
+
43
+ const i1 = x0 > y0 ? 1 : 0;
44
+ const j1 = x0 > y0 ? 0 : 1;
45
+
46
+ const x1 = x0 - i1 + G2;
47
+ const y1 = y0 - j1 + G2;
48
+ const x2 = x0 - 1 + 2 * G2;
49
+ const y2 = y0 - 1 + 2 * G2;
50
+
51
+ const ii = i & 255;
52
+ const jj = j & 255;
53
+
54
+ const grad = (hash, x, y) => {
55
+ const h = hash & 7;
56
+ const u = h < 4 ? x : y;
57
+ const v = h < 4 ? y : x;
58
+ return ((h & 1) ? -u : u) + ((h & 2) ? -2 * v : 2 * v);
59
+ };
60
+
61
+ let n0 = 0, n1 = 0, n2 = 0;
62
+
63
+ let t0 = 0.5 - x0 * x0 - y0 * y0;
64
+ if (t0 >= 0) {
65
+ t0 *= t0;
66
+ n0 = t0 * t0 * grad(this.perm[ii + this.perm[jj]], x0, y0);
67
+ }
68
+
69
+ let t1 = 0.5 - x1 * x1 - y1 * y1;
70
+ if (t1 >= 0) {
71
+ t1 *= t1;
72
+ n1 = t1 * t1 * grad(this.perm[ii + i1 + this.perm[jj + j1]], x1, y1);
73
+ }
74
+
75
+ let t2 = 0.5 - x2 * x2 - y2 * y2;
76
+ if (t2 >= 0) {
77
+ t2 *= t2;
78
+ n2 = t2 * t2 * grad(this.perm[ii + 1 + this.perm[jj + 1]], x2, y2);
79
+ }
80
+
81
+ return 70 * (n0 + n1 + n2);
82
+ }
83
+ }
84
+
85
+ class Borealis {
86
+ /**
87
+ * Default options for Borealis
88
+ */
89
+ static get defaultOptions() {
90
+ return {
91
+ // Container & Size
92
+ container: document.body,
93
+ width: null, // Canvas width (null = auto from container/window)
94
+ height: null, // Canvas height (null = auto from container/window)
95
+ fullscreen: true, // If true, uses fixed positioning to cover viewport
96
+ zIndex: 0, // Canvas z-index (can be any integer)
97
+ initiallyHidden: false, // If true, starts collapsed/hidden
98
+ className: null, // Custom class name for canvas
99
+ background: null, // Canvas background (color, gradient, etc.)
100
+
101
+ // Grid settings
102
+ density: 50, // Grid density (10-100)
103
+ dotSize: 5, // Dot size (0-10, 0=smallest)
104
+ solidPattern: false, // Solid pattern without gaps/circles
105
+ densityMinCell: 2, // Cell size at max density
106
+ densityMaxCell: 8, // Cell size at min density
107
+ densityMinGap: 1, // Gap at max density
108
+ densityMaxGap: 4, // Gap at min density
109
+
110
+ // Pattern settings
111
+ patternScale: 0.001, // Noise scale (smaller = larger patterns)
112
+ patternAurora: false, // Use aurora colors for pattern
113
+ warpScale: 0.5, // Domain warp frequency multiplier
114
+ warpAmount: 20, // Domain warp intensity
115
+ animationSpeed: 0.00002, // Animation speed multiplier
116
+ ridgePower: 2, // Ridge sharpness (higher = sharper lines)
117
+ minOpacity: 0, // Minimum opacity (0-1)
118
+ maxOpacity: 1, // Maximum opacity (0-1)
119
+ waveFrequency: 3, // Wave oscillation frequency
120
+ waveAmplitude: 0.5, // Wave intensity (0-1)
121
+
122
+ // Effect settings (unified structure)
123
+ effect: {
124
+ type: 'wave', // 'none', 'wave', 'twinkle'
125
+ aurora: false, // Use aurora colors for effect
126
+ deadzone: 20, // Center dead zone size (0-100)
127
+ // Wave-specific options
128
+ speed: 0.0008, // Diagonal line speed
129
+ width: 120, // Width of the wave band
130
+ chance: 0.08, // Chance of a cell sparkling (0-1)
131
+ intensity: 1, // Max brightness
132
+ delayMin: 1000, // Min delay between sweeps (ms)
133
+ delayMax: 3000, // Max delay between sweeps (ms)
134
+ combineSparkle: false, // Add sparkles that get boosted by wave
135
+ sparkleBaseOpacity: 0, // Sparkle base opacity when wave not passing (0-100)
136
+ // Twinkle-specific options
137
+ mode: 'sparkle', // 'sparkle' (random) or 'wave' (flowing waves)
138
+ combined: false, // Combine sparkle with wave (sparkles boosted by wave)
139
+ baseOpacity: 30, // Base opacity when wave is not passing (0-100)
140
+ twinkleSpeed: 50, // Twinkle animation speed (10-100)
141
+ size: 50, // Pattern size (10-100)
142
+ density: 50, // Star density (0-100)
143
+ },
144
+
145
+ // Aurora colors
146
+ auroraColor1: [0, 255, 128], // Cyan-green
147
+ auroraColor2: [148, 0, 211], // Violet
148
+ colorScale: 0.003, // Color variation scale
149
+
150
+ // Collapse settings
151
+ collapseSpeed: 0.1, // Collapse animation speed
152
+ collapseWaveWidth: 0.4, // Width of the collapse transition
153
+
154
+ // Animation
155
+ autoStart: true, // Start animation automatically
156
+
157
+ // Callbacks
158
+ onShow: null, // Called when show animation completes
159
+ onHide: null, // Called when hide animation completes
160
+ };
161
+ }
162
+
163
+ /**
164
+ * Create a new Borealis instance
165
+ * @param {Object} options - Configuration options
166
+ */
167
+ constructor(options = {}) {
168
+ // Deep merge for effect object
169
+ const defaultEffect = Borealis.defaultOptions.effect;
170
+ const userEffect = options.effect || {};
171
+
172
+ this.options = {
173
+ ...Borealis.defaultOptions,
174
+ ...options,
175
+ effect: { ...defaultEffect, ...userEffect }
176
+ };
177
+ this._init();
178
+ }
179
+
180
+ /**
181
+ * Initialize the Borealis instance
182
+ * @private
183
+ */
184
+ _init() {
185
+ // Create canvas
186
+ this.canvas = document.createElement('canvas');
187
+
188
+ // Set custom class name if provided
189
+ if (this.options.className) {
190
+ this.canvas.className = this.options.className;
191
+ }
192
+
193
+ // Set canvas styles based on mode
194
+ const zIndex = this.options.zIndex;
195
+ const background = this.options.background ? `background: ${this.options.background};` : '';
196
+ if (this.options.fullscreen) {
197
+ this.canvas.style.cssText = `
198
+ position: fixed;
199
+ top: 0;
200
+ left: 0;
201
+ width: 100%;
202
+ height: 100%;
203
+ pointer-events: none;
204
+ z-index: ${zIndex};
205
+ ${background}
206
+ `;
207
+ } else {
208
+ this.canvas.style.cssText = `
209
+ position: absolute;
210
+ top: 0;
211
+ left: 0;
212
+ width: 100%;
213
+ height: 100%;
214
+ pointer-events: none;
215
+ z-index: ${zIndex};
216
+ ${background}
217
+ `;
218
+ }
219
+
220
+ // Add to container
221
+ const container = this.options.container;
222
+ if (container === document.body && this.options.fullscreen) {
223
+ document.body.insertBefore(this.canvas, document.body.firstChild);
224
+ } else {
225
+ // Ensure container has position for absolute positioning
226
+ const containerStyle = window.getComputedStyle(container);
227
+ if (containerStyle.position === 'static') {
228
+ container.style.position = 'relative';
229
+ }
230
+ container.appendChild(this.canvas);
231
+ }
232
+
233
+ this.ctx = this.canvas.getContext('2d');
234
+ this.noise = new SimplexNoise(Math.random() * 10000);
235
+ this.randomOffset = Math.random() * 1000;
236
+
237
+ // Internal state
238
+ this._cellSize = 4;
239
+ this._gap = 2;
240
+ this._gridSize = 6;
241
+ this._sparkleMap = {};
242
+ this._animTime = 0;
243
+ this._twinkleTime = 0;
244
+ this._lastFrameTime = 0;
245
+ this._sparkleWaiting = false;
246
+ this._sparkleWaitUntil = 0;
247
+ this._diagPos = 0;
248
+ this._isCollapsing = this.options.initiallyHidden; // Stay collapsed until manual show() call
249
+ this._collapseProgress = this.options.initiallyHidden ? 1 + this.options.collapseWaveWidth : 0; // Start fully hidden if initiallyHidden is true
250
+ this._isRunning = false;
251
+ this._animationId = null;
252
+
253
+ // Computed twinkle values
254
+ this._twinkleThreshold = 0.8;
255
+ this._twinkleSpeedValue = 3;
256
+ this._twinkleScaleValue = 0.01;
257
+ this._deadzoneValue = 0.2;
258
+
259
+ // Apply initial options
260
+ this._updateDensity(this.options.density);
261
+ this._updateTwinkleSettings();
262
+ this._updateDeadzone();
263
+
264
+ // Bind methods
265
+ this._draw = this._draw.bind(this);
266
+ this._resize = this._resize.bind(this);
267
+
268
+ // Setup event listeners
269
+ window.addEventListener('resize', this._resize);
270
+
271
+ // Initial resize
272
+ this._resize();
273
+
274
+ // Auto start
275
+ if (this.options.autoStart) {
276
+ this.start();
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Update density settings
282
+ * @private
283
+ */
284
+ _updateDensity(value) {
285
+ const t = (100 - value) / 90;
286
+ const baseCell = this.options.densityMinCell + t * (this.options.densityMaxCell - this.options.densityMinCell);
287
+ // Apply dotSize multiplier (0 = 0.3x, 5 = 1x, 10 = 2x)
288
+ const sizeMultiplier = 0.3 + (this.options.dotSize / 10) * 1.7;
289
+ this._cellSize = baseCell * sizeMultiplier;
290
+ this._gap = this.options.densityMinGap + t * (this.options.densityMaxGap - this.options.densityMinGap);
291
+ this._gridSize = this._cellSize + this._gap;
292
+ }
293
+
294
+ /**
295
+ * Update twinkle settings from options
296
+ * @private
297
+ */
298
+ _updateTwinkleSettings() {
299
+ const effect = this.options.effect;
300
+ // Speed: 10-100 maps to 1-6
301
+ this._twinkleSpeedValue = 1 + (effect.twinkleSpeed - 10) / 90 * 5;
302
+ // Size: 10-100 maps to 0.5-0.001 (inverted, much wider range)
303
+ this._twinkleScaleValue = 0.5 - (effect.size - 10) / 90 * 0.499;
304
+ // Density: 0-100 maps to threshold 1.0-0.1
305
+ this._twinkleThreshold = 1 - effect.density / 100 * 0.9;
306
+ }
307
+
308
+ /**
309
+ * Update deadzone setting (applies to all effects)
310
+ * @private
311
+ */
312
+ _updateDeadzone() {
313
+ // Deadzone: 0-100 maps to 0-1 (percentage of diagonal distance from center to corner)
314
+ this._deadzoneValue = this.options.effect.deadzone / 100;
315
+ }
316
+
317
+ /**
318
+ * Generate sparkle map
319
+ * @private
320
+ */
321
+ _generateSparkles(cols, rows) {
322
+ this._sparkleMap = {};
323
+ for (let y = 0; y < rows; y++) {
324
+ for (let x = 0; x < cols; x++) {
325
+ if (Math.random() < this.options.effect.chance) {
326
+ this._sparkleMap[`${x},${y}`] = Math.random();
327
+ }
328
+ }
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Resize handler
334
+ * @private
335
+ */
336
+ _resize() {
337
+ // Determine dimensions
338
+ let width, height;
339
+
340
+ if (this.options.width !== null && this.options.height !== null) {
341
+ // Use explicit dimensions
342
+ width = this.options.width;
343
+ height = this.options.height;
344
+ } else if (this.options.fullscreen) {
345
+ // Use window dimensions
346
+ width = window.innerWidth;
347
+ height = window.innerHeight;
348
+ } else {
349
+ // Use container dimensions
350
+ const container = this.options.container;
351
+ width = this.options.width !== null ? this.options.width : container.clientWidth;
352
+ height = this.options.height !== null ? this.options.height : container.clientHeight;
353
+ }
354
+
355
+ this.canvas.width = width;
356
+ this.canvas.height = height;
357
+ const cols = Math.ceil(this.canvas.width / this._gridSize);
358
+ const rows = Math.ceil(this.canvas.height / this._gridSize);
359
+ this._generateSparkles(cols, rows);
360
+ // Clear offscreen canvas cache on resize
361
+ this._offscreenCanvas = null;
362
+ this._offscreenCtx = null;
363
+ }
364
+
365
+ /**
366
+ * Main draw loop
367
+ * @private
368
+ */
369
+ _draw(time) {
370
+ if (!this._isRunning) return;
371
+
372
+ const delta = time - this._lastFrameTime;
373
+
374
+ this._animTime += delta * this.options.animationSpeed;
375
+ this._twinkleTime += delta * 0.001;
376
+
377
+ // Handle wave timing
378
+ const effect = this.options.effect;
379
+ if (!this._sparkleWaiting) {
380
+ this._diagPos += delta * effect.speed * 100;
381
+
382
+ const cols = Math.ceil(this.canvas.width / this._gridSize);
383
+ const rows = Math.ceil(this.canvas.height / this._gridSize);
384
+ const maxDiag = cols + rows;
385
+
386
+ if (this._diagPos > maxDiag + effect.width) {
387
+ this._sparkleWaiting = true;
388
+ const delay = effect.delayMin + Math.random() * (effect.delayMax - effect.delayMin);
389
+ this._sparkleWaitUntil = time + delay;
390
+ this._generateSparkles(cols, rows);
391
+ }
392
+ } else {
393
+ if (time >= this._sparkleWaitUntil) {
394
+ this._sparkleWaiting = false;
395
+ this._diagPos = -effect.width;
396
+ }
397
+ }
398
+
399
+ this._lastFrameTime = time;
400
+ this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
401
+
402
+ const cols = Math.ceil(this.canvas.width / this._gridSize);
403
+ const rows = Math.ceil(this.canvas.height / this._gridSize);
404
+
405
+ // For solid pattern, use offscreen canvas for pixel-perfect base pattern
406
+ if (this.options.solidPattern) {
407
+ // Create or reuse offscreen canvas at grid resolution
408
+ if (!this._offscreenCanvas || this._offscreenCanvas.width !== cols || this._offscreenCanvas.height !== rows) {
409
+ this._offscreenCanvas = document.createElement('canvas');
410
+ this._offscreenCanvas.width = cols;
411
+ this._offscreenCanvas.height = rows;
412
+ this._offscreenCtx = this._offscreenCanvas.getContext('2d');
413
+ }
414
+
415
+ const offCtx = this._offscreenCtx;
416
+ const imageData = offCtx.createImageData(cols, rows);
417
+ const data = imageData.data;
418
+
419
+ // Draw only base pattern to ImageData (no effects)
420
+ for (let y = 0; y < rows; y++) {
421
+ for (let x = 0; x < cols; x++) {
422
+ const cellData = this._calculateCellData(x, y, cols, rows);
423
+
424
+ const idx = (y * cols + x) * 4;
425
+ data[idx] = cellData.r;
426
+ data[idx + 1] = cellData.g;
427
+ data[idx + 2] = cellData.b;
428
+ data[idx + 3] = Math.round(cellData.opacity * 255);
429
+ }
430
+ }
431
+
432
+ offCtx.putImageData(imageData, 0, 0);
433
+
434
+ // Scale up to full canvas size with smooth interpolation
435
+ this.ctx.imageSmoothingEnabled = true;
436
+ this.ctx.imageSmoothingQuality = 'high';
437
+ this.ctx.drawImage(this._offscreenCanvas, 0, 0, this.canvas.width, this.canvas.height);
438
+
439
+ // Draw effects on top using regular canvas API (crisp circles)
440
+ if (this.options.effect.type !== 'none') {
441
+ for (let y = 0; y < rows; y++) {
442
+ for (let x = 0; x < cols; x++) {
443
+ this._drawEffect(x, y, cols, rows);
444
+ }
445
+ }
446
+ }
447
+ } else {
448
+ for (let y = 0; y < rows; y++) {
449
+ for (let x = 0; x < cols; x++) {
450
+ this._drawCell(x, y, cols, rows);
451
+ }
452
+ }
453
+ }
454
+
455
+ // Update collapse
456
+ this._updateCollapse();
457
+
458
+ this._animationId = requestAnimationFrame(this._draw);
459
+ }
460
+
461
+ /**
462
+ * Calculate cell data for solid pattern (used for ImageData rendering)
463
+ * @private
464
+ */
465
+ _calculateCellData(x, y, cols, rows) {
466
+ const { options, noise, randomOffset, _animTime } = this;
467
+
468
+ // Oscillating wave effect
469
+ const wave1 = Math.sin(_animTime * options.waveFrequency + x * options.patternScale * 10) * options.waveAmplitude;
470
+ const wave2 = Math.cos(_animTime * options.waveFrequency * 0.7 + y * options.patternScale * 10) * options.waveAmplitude;
471
+
472
+ // Domain warping
473
+ const warpX = noise.noise2D(x * options.patternScale * options.warpScale + wave1 + randomOffset, y * options.patternScale * options.warpScale + _animTime + randomOffset) * options.warpAmount;
474
+ const warpY = noise.noise2D(x * options.patternScale * options.warpScale + 100 + randomOffset, y * options.patternScale * options.warpScale + _animTime + wave2 + randomOffset) * options.warpAmount;
475
+
476
+ const noiseVal = noise.noise2D(
477
+ (x + warpX) * options.patternScale + wave2 * 0.5 + randomOffset,
478
+ (y + warpY) * options.patternScale + wave1 * 0.5 + randomOffset
479
+ );
480
+
481
+ // Ridge noise
482
+ const ridge = 1 - Math.abs(noiseVal);
483
+ const rawOpacity = Math.pow(ridge, options.ridgePower);
484
+ let opacity = options.minOpacity + rawOpacity * (options.maxOpacity - options.minOpacity);
485
+
486
+ // Pattern color (no effects in solid pattern base - effects drawn separately)
487
+ let r, g, b;
488
+ if (options.patternAurora) {
489
+ const colorNoise = noise.noise2D(x * options.colorScale + randomOffset * 0.5, y * options.colorScale + _animTime * 0.5 + randomOffset * 0.5);
490
+ const colorBlend = (colorNoise + 1) / 2;
491
+ r = Math.round(options.auroraColor1[0] + (options.auroraColor2[0] - options.auroraColor1[0]) * colorBlend);
492
+ g = Math.round(options.auroraColor1[1] + (options.auroraColor2[1] - options.auroraColor1[1]) * colorBlend);
493
+ b = Math.round(options.auroraColor1[2] + (options.auroraColor2[2] - options.auroraColor1[2]) * colorBlend);
494
+ } else {
495
+ r = g = b = 255;
496
+ }
497
+
498
+ // Apply collapse (only base pattern, no effect)
499
+ if (this._collapseProgress > 0) {
500
+ const collapseResult = this._applyCollapse(x, y, cols, rows, opacity, 0);
501
+ opacity = collapseResult.opacity;
502
+ }
503
+
504
+ return { r, g, b, opacity };
505
+ }
506
+
507
+ /**
508
+ * Draw only effect for a cell (used in solid pattern mode)
509
+ * @private
510
+ */
511
+ _drawEffect(x, y, cols, rows) {
512
+ const effect = this.options.effect;
513
+
514
+ let effectColor = [255, 255, 255];
515
+ let effectOpacity = 0;
516
+
517
+ // Wave effect
518
+ if (effect.type === 'wave' && !this._sparkleWaiting) {
519
+ const result = this._calculateWaveEffect(x, y, cols, rows);
520
+ effectColor = result.color;
521
+ effectOpacity = result.opacity;
522
+ }
523
+
524
+ // Twinkle effect
525
+ if (effect.type === 'twinkle') {
526
+ const result = this._calculateTwinkleEffect(x, y, cols, rows);
527
+ effectColor = result.color;
528
+ effectOpacity = result.opacity;
529
+ }
530
+
531
+ // Apply collapse
532
+ if (this._collapseProgress > 0) {
533
+ const collapseResult = this._applyCollapse(x, y, cols, rows, 0, effectOpacity);
534
+ effectOpacity = collapseResult.effectOpacity;
535
+ }
536
+
537
+ // Draw effect circle if visible
538
+ if (effectOpacity > 0) {
539
+ this.ctx.fillStyle = `rgba(${effectColor[0]}, ${effectColor[1]}, ${effectColor[2]}, ${effectOpacity})`;
540
+ this.ctx.beginPath();
541
+ this.ctx.arc(x * this._gridSize + this._cellSize / 2, y * this._gridSize + this._cellSize / 2, this._cellSize / 2, 0, Math.PI * 2);
542
+ this.ctx.fill();
543
+ }
544
+ }
545
+
546
+ /**
547
+ * Draw a single cell
548
+ * @private
549
+ */
550
+ _drawCell(x, y, cols, rows) {
551
+ const { options, noise, randomOffset, _animTime, _twinkleTime } = this;
552
+
553
+ // Oscillating wave effect
554
+ const wave1 = Math.sin(_animTime * options.waveFrequency + x * options.patternScale * 10) * options.waveAmplitude;
555
+ const wave2 = Math.cos(_animTime * options.waveFrequency * 0.7 + y * options.patternScale * 10) * options.waveAmplitude;
556
+
557
+ // Domain warping
558
+ const warpX = noise.noise2D(x * options.patternScale * options.warpScale + wave1 + randomOffset, y * options.patternScale * options.warpScale + _animTime + randomOffset) * options.warpAmount;
559
+ const warpY = noise.noise2D(x * options.patternScale * options.warpScale + 100 + randomOffset, y * options.patternScale * options.warpScale + _animTime + wave2 + randomOffset) * options.warpAmount;
560
+
561
+ const noiseVal = noise.noise2D(
562
+ (x + warpX) * options.patternScale + wave2 * 0.5 + randomOffset,
563
+ (y + warpY) * options.patternScale + wave1 * 0.5 + randomOffset
564
+ );
565
+
566
+ // Ridge noise
567
+ const ridge = 1 - Math.abs(noiseVal);
568
+ const rawOpacity = Math.pow(ridge, options.ridgePower);
569
+ let opacity = options.minOpacity + rawOpacity * (options.maxOpacity - options.minOpacity);
570
+
571
+ // Effect variables
572
+ let effectColor = [255, 255, 255];
573
+ let effectOpacity = 0;
574
+
575
+ // Wave effect
576
+ if (options.effect.type === 'wave' && !this._sparkleWaiting) {
577
+ const result = this._calculateWaveEffect(x, y, cols, rows);
578
+ effectColor = result.color;
579
+ effectOpacity = result.opacity;
580
+ }
581
+
582
+ // Twinkle effect
583
+ if (options.effect.type === 'twinkle') {
584
+ const result = this._calculateTwinkleEffect(x, y, cols, rows);
585
+ effectColor = result.color;
586
+ effectOpacity = result.opacity;
587
+ }
588
+
589
+ // Pattern color
590
+ let r, g, b;
591
+ if (options.patternAurora) {
592
+ const colorNoise = noise.noise2D(x * options.colorScale + randomOffset * 0.5, y * options.colorScale + _animTime * 0.5 + randomOffset * 0.5);
593
+ const colorBlend = (colorNoise + 1) / 2;
594
+ r = Math.round(options.auroraColor1[0] + (options.auroraColor2[0] - options.auroraColor1[0]) * colorBlend);
595
+ g = Math.round(options.auroraColor1[1] + (options.auroraColor2[1] - options.auroraColor1[1]) * colorBlend);
596
+ b = Math.round(options.auroraColor1[2] + (options.auroraColor2[2] - options.auroraColor1[2]) * colorBlend);
597
+ } else {
598
+ r = g = b = 255;
599
+ }
600
+
601
+ // Apply collapse
602
+ if (this._collapseProgress > 0) {
603
+ const collapseResult = this._applyCollapse(x, y, cols, rows, opacity, effectOpacity);
604
+ opacity = collapseResult.opacity;
605
+ effectOpacity = collapseResult.effectOpacity;
606
+ }
607
+
608
+ // Skip rendering if both opacities are 0 (performance optimization)
609
+ if (opacity <= 0 && effectOpacity <= 0) {
610
+ return;
611
+ }
612
+
613
+ // Draw base pattern
614
+ this.ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${opacity})`;
615
+ if (this.options.solidPattern) {
616
+ // Solid mode: fill entire cell without gaps (add 0.5px overlap to prevent gaps)
617
+ const px = Math.floor(x * this._gridSize);
618
+ const py = Math.floor(y * this._gridSize);
619
+ this.ctx.fillRect(px, py, Math.ceil(this._gridSize) + 1, Math.ceil(this._gridSize) + 1);
620
+ } else {
621
+ // Circle mode
622
+ this.ctx.beginPath();
623
+ this.ctx.arc(x * this._gridSize + this._cellSize / 2, y * this._gridSize + this._cellSize / 2, this._cellSize / 2, 0, Math.PI * 2);
624
+ this.ctx.fill();
625
+ }
626
+
627
+ // Draw effect on top (always circles)
628
+ if (effectOpacity > 0) {
629
+ this.ctx.fillStyle = `rgba(${effectColor[0]}, ${effectColor[1]}, ${effectColor[2]}, ${effectOpacity})`;
630
+ this.ctx.beginPath();
631
+ this.ctx.arc(x * this._gridSize + this._cellSize / 2, y * this._gridSize + this._cellSize / 2, this._cellSize / 2, 0, Math.PI * 2);
632
+ this.ctx.fill();
633
+ }
634
+ }
635
+
636
+ /**
637
+ * Calculate wave effect
638
+ * @private
639
+ */
640
+ _calculateWaveEffect(x, y, cols, rows) {
641
+ const { options, noise, randomOffset, _animTime, _twinkleTime } = this;
642
+ const effect = options.effect;
643
+ let color = [255, 255, 255];
644
+ let opacity = 0;
645
+
646
+ // Dead zone calculation (using diagonal distance to corner)
647
+ const centerX = cols / 2;
648
+ const centerY = rows / 2;
649
+ const distFromCenter = Math.sqrt((x - centerX) ** 2 + (y - centerY) ** 2);
650
+ const maxDist = Math.sqrt(centerX ** 2 + centerY ** 2); // Distance from center to corner
651
+ const maxRadius = maxDist * this._deadzoneValue;
652
+ const fadeZone = maxRadius * 0.3;
653
+
654
+ let centerFade = 1;
655
+ if (distFromCenter < maxRadius) {
656
+ centerFade = 0;
657
+ } else if (distFromCenter < maxRadius + fadeZone) {
658
+ const t = (distFromCenter - maxRadius) / fadeZone;
659
+ centerFade = t * t * (3 - 2 * t);
660
+ }
661
+
662
+ // Combined sparkle mode - sparkles that get boosted by wave
663
+ if (effect.combineSparkle && centerFade > 0) {
664
+ // Calculate wave proximity (0-1, 1 = wave is here)
665
+ const cellDiag = x + y;
666
+ const distFromLine = Math.abs(cellDiag - this._diagPos);
667
+ // Narrower wave effect zone for more dramatic boost
668
+ const waveProximity = Math.max(0, 1 - distFromLine / effect.width);
669
+ // Sharper falloff - wave effect drops quickly
670
+ const smoothWaveProximity = Math.pow(waveProximity, 0.5);
671
+
672
+ // Calculate sparkle
673
+ const hash1 = Math.sin(x * 12.9898 + y * 78.233 + randomOffset) * 43758.5453;
674
+ const rand1 = hash1 - Math.floor(hash1);
675
+ const hash2 = Math.sin(x * 93.9898 + y * 67.345 + randomOffset * 2) * 23421.6312;
676
+ const rand2 = hash2 - Math.floor(hash2);
677
+
678
+ // Use twinkle density for sparkle distribution
679
+ const sparkleThreshold = 1 - effect.density / 100 * 0.9;
680
+
681
+ if (rand1 > sparkleThreshold) {
682
+ const phase = rand2 * Math.PI * 2;
683
+ const sparkleSpeed = 0.1 + (effect.twinkleSpeed / 100) * 0.4;
684
+ const twinkleWave = Math.sin(_twinkleTime * sparkleSpeed + phase);
685
+ const sparkle = Math.max(0, twinkleWave);
686
+
687
+ // Base opacity is limited, wave boosts it to full
688
+ const baseOpacity = effect.sparkleBaseOpacity / 100;
689
+ const maxBoost = 1 - baseOpacity;
690
+ const finalOpacity = baseOpacity + (maxBoost * smoothWaveProximity);
691
+
692
+ opacity = sparkle * finalOpacity * centerFade;
693
+
694
+ if (effect.aurora) {
695
+ const colorRand = Math.sin(x * 45.123 + y * 89.456 + randomOffset) * 12345.6789;
696
+ const colorBlend = colorRand - Math.floor(colorRand);
697
+ color = [
698
+ Math.round(options.auroraColor1[0] + (options.auroraColor2[0] - options.auroraColor1[0]) * colorBlend),
699
+ Math.round(options.auroraColor1[1] + (options.auroraColor2[1] - options.auroraColor1[1]) * colorBlend),
700
+ Math.round(options.auroraColor1[2] + (options.auroraColor2[2] - options.auroraColor1[2]) * colorBlend)
701
+ ];
702
+ }
703
+ }
704
+
705
+ return { color, opacity };
706
+ }
707
+
708
+ const cellDiag = x + y;
709
+ const distFromLine = Math.abs(cellDiag - this._diagPos);
710
+
711
+ if (distFromLine < effect.width && this._sparkleMap[`${x},${y}`] !== undefined) {
712
+ const normalizedDist = distFromLine / effect.width;
713
+ const sparkle = Math.cos(normalizedDist * Math.PI * 0.5) * effect.intensity;
714
+
715
+ // Cylinder effect
716
+ const fullDiagonalLength = Math.min(cols, rows);
717
+ const diagStartX = Math.max(0, Math.floor(this._diagPos) - (rows - 1));
718
+ const diagEndX = Math.min(cols - 1, Math.floor(this._diagPos));
719
+ const currentLineLength = Math.max(1, diagEndX - diagStartX + 1);
720
+
721
+ let cylinderFade = 1;
722
+ if (currentLineLength >= fullDiagonalLength && currentLineLength > 1) {
723
+ const posAlongLine = (x - diagStartX) / (currentLineLength - 1);
724
+ const clampedPos = Math.max(0, Math.min(1, posAlongLine));
725
+ cylinderFade = 0.3 + 0.7 * Math.sin(clampedPos * Math.PI);
726
+ } else if (currentLineLength > 1) {
727
+ const completeness = currentLineLength / fullDiagonalLength;
728
+ const posAlongLine = (x - diagStartX) / (currentLineLength - 1);
729
+ const clampedPos = Math.max(0, Math.min(1, posAlongLine));
730
+ const baseFade = Math.sin(clampedPos * Math.PI);
731
+ cylinderFade = Math.max(0.3, 1 - (1 - baseFade) * completeness * 0.7);
732
+ }
733
+
734
+ opacity = sparkle * this._sparkleMap[`${x},${y}`] * Math.max(0, cylinderFade) * centerFade;
735
+
736
+ // Color
737
+ if (effect.aurora) {
738
+ const colorNoise = noise.noise2D(x * options.colorScale * 2 + randomOffset, y * options.colorScale * 2 + _animTime + randomOffset);
739
+ const colorBlend = (colorNoise + 1) / 2;
740
+ color = [
741
+ Math.round(options.auroraColor1[0] + (options.auroraColor2[0] - options.auroraColor1[0]) * colorBlend),
742
+ Math.round(options.auroraColor1[1] + (options.auroraColor2[1] - options.auroraColor1[1]) * colorBlend),
743
+ Math.round(options.auroraColor1[2] + (options.auroraColor2[2] - options.auroraColor1[2]) * colorBlend)
744
+ ];
745
+ }
746
+ }
747
+
748
+ return { color, opacity };
749
+ }
750
+
751
+ /**
752
+ * Calculate twinkle effect
753
+ * @private
754
+ */
755
+ _calculateTwinkleEffect(x, y, cols, rows) {
756
+ const { options, noise, randomOffset, _twinkleTime } = this;
757
+ const effect = options.effect;
758
+ let color = [255, 255, 255];
759
+ let opacity = 0;
760
+
761
+ // Dead zone calculation (using diagonal distance to corner)
762
+ const centerX = cols / 2;
763
+ const centerY = rows / 2;
764
+ const distFromCenter = Math.sqrt((x - centerX) ** 2 + (y - centerY) ** 2);
765
+ const maxDist = Math.sqrt(centerX ** 2 + centerY ** 2); // Distance from center to corner
766
+ const maxRadius = maxDist * this._deadzoneValue;
767
+ const fadeZone = maxRadius * 0.3;
768
+
769
+ let centerFade = 1;
770
+ if (distFromCenter < maxRadius) {
771
+ centerFade = 0;
772
+ } else if (distFromCenter < maxRadius + fadeZone) {
773
+ const t = (distFromCenter - maxRadius) / fadeZone;
774
+ centerFade = t * t * (3 - 2 * t);
775
+ }
776
+
777
+ if (centerFade > 0) {
778
+ // Combined mode - sparkles that get boosted by passing waves
779
+ if (effect.combined) {
780
+ // Calculate wave intensity first
781
+ const baseScale = 0.0005 + (1 - this._twinkleScaleValue) * 0.003;
782
+ const waveSpeed = this._twinkleSpeedValue * 0.15;
783
+
784
+ const wave1 = noise.noise2D(
785
+ x * baseScale + _twinkleTime * waveSpeed,
786
+ y * baseScale + _twinkleTime * waveSpeed * 0.5 + randomOffset
787
+ );
788
+ const wave2 = noise.noise2D(
789
+ x * baseScale * 0.5 + _twinkleTime * waveSpeed * 0.3 + 50,
790
+ y * baseScale * 0.7 - _twinkleTime * waveSpeed * 0.2 + randomOffset + 50
791
+ );
792
+ const wave3 = noise.noise2D(
793
+ (x + y * 0.5) * baseScale * 0.8 + _twinkleTime * waveSpeed * 0.4,
794
+ (y - x * 0.3) * baseScale * 0.8 + randomOffset + 100
795
+ );
796
+
797
+ const combined = (wave1 * 0.5 + wave2 * 0.3 + wave3 * 0.2);
798
+ const smoothWave = (Math.sin(combined * Math.PI * 2) + 1) / 2;
799
+ const waveIntensity = Math.pow(smoothWave, 0.5); // Smoother wave
800
+
801
+ // Calculate sparkle
802
+ const hash1 = Math.sin(x * 12.9898 + y * 78.233 + randomOffset) * 43758.5453;
803
+ const rand1 = hash1 - Math.floor(hash1);
804
+ const hash2 = Math.sin(x * 93.9898 + y * 67.345 + randomOffset * 2) * 23421.6312;
805
+ const rand2 = hash2 - Math.floor(hash2);
806
+
807
+ if (rand1 > this._twinkleThreshold) {
808
+ const phase = rand2 * Math.PI * 2;
809
+ const twinkleWave = Math.sin(_twinkleTime * this._twinkleSpeedValue * 2 + phase);
810
+ const sparkle = Math.max(0, twinkleWave);
811
+
812
+ // Base opacity is limited, wave boosts it to full
813
+ const baseOpacity = effect.baseOpacity / 100;
814
+ const maxBoost = 1 - baseOpacity;
815
+ const finalOpacity = baseOpacity + (maxBoost * waveIntensity);
816
+
817
+ opacity = sparkle * finalOpacity * effect.intensity * centerFade;
818
+
819
+ if (effect.aurora) {
820
+ const colorRand = Math.sin(x * 45.123 + y * 89.456 + randomOffset) * 12345.6789;
821
+ const colorBlend = colorRand - Math.floor(colorRand);
822
+ color = [
823
+ Math.round(options.auroraColor1[0] + (options.auroraColor2[0] - options.auroraColor1[0]) * colorBlend),
824
+ Math.round(options.auroraColor1[1] + (options.auroraColor2[1] - options.auroraColor1[1]) * colorBlend),
825
+ Math.round(options.auroraColor1[2] + (options.auroraColor2[2] - options.auroraColor1[2]) * colorBlend)
826
+ ];
827
+ }
828
+ }
829
+ }
830
+ // Wave mode - flowing waves that boost opacity to 100%
831
+ else if (effect.mode === 'wave') {
832
+ // Create smooth, wide flowing light bands
833
+ // Size controls the width of the bands
834
+ const baseScale = 0.0005 + (1 - this._twinkleScaleValue) * 0.003;
835
+ const waveSpeed = this._twinkleSpeedValue * 0.15;
836
+
837
+ // Slow, smooth primary wave - creates wide bands
838
+ const wave1 = noise.noise2D(
839
+ x * baseScale + _twinkleTime * waveSpeed,
840
+ y * baseScale + _twinkleTime * waveSpeed * 0.5 + randomOffset
841
+ );
842
+
843
+ // Very slow secondary wave for organic variation
844
+ const wave2 = noise.noise2D(
845
+ x * baseScale * 0.5 + _twinkleTime * waveSpeed * 0.3 + 50,
846
+ y * baseScale * 0.7 - _twinkleTime * waveSpeed * 0.2 + randomOffset + 50
847
+ );
848
+
849
+ // Third wave for extra organic feel
850
+ const wave3 = noise.noise2D(
851
+ (x + y * 0.5) * baseScale * 0.8 + _twinkleTime * waveSpeed * 0.4,
852
+ (y - x * 0.3) * baseScale * 0.8 + randomOffset + 100
853
+ );
854
+
855
+ // Combine waves smoothly
856
+ const combined = (wave1 * 0.5 + wave2 * 0.3 + wave3 * 0.2);
857
+
858
+ // Smooth sine-based intensity (no harsh ridges)
859
+ const smoothWave = (Math.sin(combined * Math.PI * 2) + 1) / 2;
860
+
861
+ // Apply density as band width control
862
+ const densityFactor = 0.3 + this._twinkleThreshold * 0.7;
863
+ const intensity = Math.pow(smoothWave, 1 / densityFactor);
864
+
865
+ // Smooth the final output
866
+ opacity = intensity * effect.intensity * centerFade;
867
+
868
+ // Aurora colors for wave mode
869
+ if (effect.aurora && opacity > 0) {
870
+ const colorWave = noise.noise2D(
871
+ x * baseScale * 0.3 + _twinkleTime * waveSpeed * 0.1 + randomOffset,
872
+ y * baseScale * 0.3 + randomOffset
873
+ );
874
+ const colorBlend = (colorWave + 1) / 2;
875
+ color = [
876
+ Math.round(options.auroraColor1[0] + (options.auroraColor2[0] - options.auroraColor1[0]) * colorBlend),
877
+ Math.round(options.auroraColor1[1] + (options.auroraColor2[1] - options.auroraColor1[1]) * colorBlend),
878
+ Math.round(options.auroraColor1[2] + (options.auroraColor2[2] - options.auroraColor1[2]) * colorBlend)
879
+ ];
880
+ }
881
+ } else {
882
+ // Sparkle mode - original random twinkling
883
+ const hash1 = Math.sin(x * 12.9898 + y * 78.233 + randomOffset) * 43758.5453;
884
+ const rand1 = hash1 - Math.floor(hash1);
885
+
886
+ const hash2 = Math.sin(x * 93.9898 + y * 67.345 + randomOffset * 2) * 23421.6312;
887
+ const rand2 = hash2 - Math.floor(hash2);
888
+
889
+ if (rand1 > this._twinkleThreshold) {
890
+ const phase = rand2 * Math.PI * 2;
891
+ const twinkleWave = Math.sin(_twinkleTime * this._twinkleSpeedValue + phase);
892
+ const baseBrightness = Math.max(0, twinkleWave);
893
+
894
+ const groupWave = noise.noise2D(
895
+ x * this._twinkleScaleValue + _twinkleTime * 0.2 + randomOffset,
896
+ y * this._twinkleScaleValue + randomOffset
897
+ );
898
+ const maxOpacity = 0.2 + (groupWave + 1) / 2 * 0.8;
899
+
900
+ opacity = baseBrightness * maxOpacity * effect.intensity * centerFade;
901
+
902
+ if (effect.aurora) {
903
+ const colorRand = Math.sin(x * 45.123 + y * 89.456 + randomOffset) * 12345.6789;
904
+ const colorBlend = colorRand - Math.floor(colorRand);
905
+ color = [
906
+ Math.round(options.auroraColor1[0] + (options.auroraColor2[0] - options.auroraColor1[0]) * colorBlend),
907
+ Math.round(options.auroraColor1[1] + (options.auroraColor2[1] - options.auroraColor1[1]) * colorBlend),
908
+ Math.round(options.auroraColor1[2] + (options.auroraColor2[2] - options.auroraColor1[2]) * colorBlend)
909
+ ];
910
+ }
911
+ }
912
+ }
913
+ }
914
+
915
+ return { color, opacity };
916
+ }
917
+
918
+ /**
919
+ * Apply collapse effect
920
+ * @private
921
+ */
922
+ _applyCollapse(x, y, cols, rows, opacity, effectOpacity) {
923
+ const centerX = cols / 2;
924
+ const centerY = rows / 2;
925
+ const maxRadius = Math.sqrt(centerX * centerX + centerY * centerY);
926
+ const distFromCenter = Math.sqrt((x - centerX) ** 2 + (y - centerY) ** 2);
927
+ const normalizedDist = distFromCenter / maxRadius;
928
+
929
+ const collapseAt = 1 - normalizedDist;
930
+
931
+ if (this._collapseProgress > collapseAt + this.options.collapseWaveWidth) {
932
+ opacity = 0;
933
+ effectOpacity = 0;
934
+ } else if (this._collapseProgress > collapseAt) {
935
+ const t = 1 - (this._collapseProgress - collapseAt) / this.options.collapseWaveWidth;
936
+ const smoothFade = t * t * (3 - 2 * t);
937
+ opacity *= smoothFade;
938
+ effectOpacity *= smoothFade;
939
+ }
940
+
941
+ return { opacity, effectOpacity };
942
+ }
943
+
944
+ /**
945
+ * Update collapse animation
946
+ * @private
947
+ */
948
+ _updateCollapse() {
949
+ const collapseEnd = 1 + this.options.collapseWaveWidth;
950
+
951
+ if (this._isCollapsing && this._collapseProgress < collapseEnd) {
952
+ this._collapseProgress += this.options.collapseSpeed;
953
+ if (this._collapseProgress >= collapseEnd) {
954
+ this._collapseProgress = collapseEnd;
955
+ if (this.options.onHide) {
956
+ this.options.onHide();
957
+ }
958
+ }
959
+ } else if (!this._isCollapsing && this._collapseProgress > 0) {
960
+ this._collapseProgress -= this.options.collapseSpeed;
961
+ if (this._collapseProgress <= 0) {
962
+ this._collapseProgress = 0;
963
+ if (this.options.onShow) {
964
+ this.options.onShow();
965
+ }
966
+ }
967
+ }
968
+ }
969
+
970
+ // ==================== PUBLIC API ====================
971
+
972
+ /**
973
+ * Start the animation
974
+ * @returns {Borealis} this instance for chaining
975
+ */
976
+ start() {
977
+ if (!this._isRunning) {
978
+ this._isRunning = true;
979
+ this._lastFrameTime = performance.now();
980
+ this._animationId = requestAnimationFrame(this._draw);
981
+ }
982
+ return this;
983
+ }
984
+
985
+ /**
986
+ * Stop the animation
987
+ * @returns {Borealis} this instance for chaining
988
+ */
989
+ stop() {
990
+ this._isRunning = false;
991
+ if (this._animationId) {
992
+ cancelAnimationFrame(this._animationId);
993
+ this._animationId = null;
994
+ }
995
+ return this;
996
+ }
997
+
998
+ /**
999
+ * Manually trigger a resize (useful when container size changes)
1000
+ * @param {number} [width] - Optional new width
1001
+ * @param {number} [height] - Optional new height
1002
+ * @returns {Borealis} this instance for chaining
1003
+ */
1004
+ resize(width, height) {
1005
+ if (width !== undefined) {
1006
+ this.options.width = width;
1007
+ }
1008
+ if (height !== undefined) {
1009
+ this.options.height = height;
1010
+ }
1011
+ this._resize();
1012
+ return this;
1013
+ }
1014
+
1015
+ /**
1016
+ * Force a single frame redraw (useful when animation is stopped)
1017
+ * @returns {Borealis} this instance for chaining
1018
+ */
1019
+ redraw() {
1020
+ const time = performance.now();
1021
+ const wasRunning = this._isRunning;
1022
+ this._isRunning = true;
1023
+ this._lastFrameTime = time - 16; // Simulate ~60fps frame
1024
+
1025
+ // Draw single frame without requesting next
1026
+ const delta = 16;
1027
+ this._animTime += delta * this.options.animationSpeed;
1028
+ this._twinkleTime += delta * 0.001;
1029
+
1030
+ this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
1031
+
1032
+ const cols = Math.ceil(this.canvas.width / this._gridSize);
1033
+ const rows = Math.ceil(this.canvas.height / this._gridSize);
1034
+
1035
+ if (this.options.solidPattern) {
1036
+ if (!this._offscreenCanvas || this._offscreenCanvas.width !== cols || this._offscreenCanvas.height !== rows) {
1037
+ this._offscreenCanvas = document.createElement('canvas');
1038
+ this._offscreenCanvas.width = cols;
1039
+ this._offscreenCanvas.height = rows;
1040
+ this._offscreenCtx = this._offscreenCanvas.getContext('2d');
1041
+ }
1042
+
1043
+ const offCtx = this._offscreenCtx;
1044
+ const imageData = offCtx.createImageData(cols, rows);
1045
+ const data = imageData.data;
1046
+
1047
+ for (let y = 0; y < rows; y++) {
1048
+ for (let x = 0; x < cols; x++) {
1049
+ const cellData = this._calculateCellData(x, y, cols, rows);
1050
+ const idx = (y * cols + x) * 4;
1051
+ data[idx] = cellData.r;
1052
+ data[idx + 1] = cellData.g;
1053
+ data[idx + 2] = cellData.b;
1054
+ data[idx + 3] = Math.round(cellData.opacity * 255);
1055
+ }
1056
+ }
1057
+
1058
+ offCtx.putImageData(imageData, 0, 0);
1059
+ this.ctx.imageSmoothingEnabled = true;
1060
+ this.ctx.imageSmoothingQuality = 'high';
1061
+ this.ctx.drawImage(this._offscreenCanvas, 0, 0, this.canvas.width, this.canvas.height);
1062
+
1063
+ if (this.options.effect.type !== 'none') {
1064
+ for (let y = 0; y < rows; y++) {
1065
+ for (let x = 0; x < cols; x++) {
1066
+ this._drawEffect(x, y, cols, rows);
1067
+ }
1068
+ }
1069
+ }
1070
+ } else {
1071
+ for (let y = 0; y < rows; y++) {
1072
+ for (let x = 0; x < cols; x++) {
1073
+ this._drawCell(x, y, cols, rows);
1074
+ }
1075
+ }
1076
+ }
1077
+
1078
+ this._isRunning = wasRunning;
1079
+ return this;
1080
+ }
1081
+
1082
+ /**
1083
+ * Show the pattern (expand from center)
1084
+ * @param {Function} [callback] - Called when animation completes
1085
+ * @returns {Borealis} this instance for chaining
1086
+ */
1087
+ show(callback) {
1088
+ this._isCollapsing = false;
1089
+ if (callback) {
1090
+ const originalCallback = this.options.onShow;
1091
+ this.options.onShow = () => {
1092
+ callback();
1093
+ this.options.onShow = originalCallback;
1094
+ };
1095
+ }
1096
+ return this;
1097
+ }
1098
+
1099
+ /**
1100
+ * Hide the pattern (collapse to center)
1101
+ * @param {Function} [callback] - Called when animation completes
1102
+ * @returns {Borealis} this instance for chaining
1103
+ */
1104
+ hide(callback) {
1105
+ this._isCollapsing = true;
1106
+ if (callback) {
1107
+ const originalCallback = this.options.onHide;
1108
+ this.options.onHide = () => {
1109
+ callback();
1110
+ this.options.onHide = originalCallback;
1111
+ };
1112
+ }
1113
+ return this;
1114
+ }
1115
+
1116
+ /**
1117
+ * Toggle between show and hide
1118
+ * @param {Function} [callback] - Called when animation completes
1119
+ * @returns {Borealis} this instance for chaining
1120
+ */
1121
+ toggle(callback) {
1122
+ if (this._isCollapsing) {
1123
+ return this.show(callback);
1124
+ } else {
1125
+ return this.hide(callback);
1126
+ }
1127
+ }
1128
+
1129
+ /**
1130
+ * Check if currently visible (not collapsed)
1131
+ * @returns {boolean}
1132
+ */
1133
+ isVisible() {
1134
+ return !this._isCollapsing && this._collapseProgress === 0;
1135
+ }
1136
+
1137
+ /**
1138
+ * Check if currently hidden (fully collapsed)
1139
+ * @returns {boolean}
1140
+ */
1141
+ isHidden() {
1142
+ return this._isCollapsing && this._collapseProgress >= 1 + this.options.collapseWaveWidth;
1143
+ }
1144
+
1145
+ /**
1146
+ * Set a single option
1147
+ * @param {string} key - Option key
1148
+ * @param {*} value - Option value
1149
+ * @returns {Borealis} this instance for chaining
1150
+ */
1151
+ setOption(key, value) {
1152
+ // Handle effect as special case (use setEffect instead)
1153
+ if (key === 'effect') {
1154
+ if (typeof value === 'object') {
1155
+ return this.setEffect(value.type, value);
1156
+ }
1157
+ return this;
1158
+ }
1159
+
1160
+ this.options[key] = value;
1161
+
1162
+ // Handle special cases that need resize/recalculation
1163
+ const needsResize = [
1164
+ 'density', 'dotSize', 'solidPattern', 'patternAurora',
1165
+ 'maxOpacity', 'minOpacity'
1166
+ ];
1167
+
1168
+ if (needsResize.includes(key)) {
1169
+ this._updateDensity(this.options.density);
1170
+ this._resize();
1171
+ }
1172
+
1173
+ return this;
1174
+ }
1175
+
1176
+ /**
1177
+ * Set effect type and options
1178
+ * @param {string} type - Effect type: 'none', 'wave', or 'twinkle'
1179
+ * @param {Object} [effectOptions] - Effect-specific options
1180
+ * @returns {Borealis} this instance for chaining
1181
+ */
1182
+ setEffect(type, effectOptions = {}) {
1183
+ // Update effect type
1184
+ if (type) {
1185
+ this.options.effect.type = type;
1186
+ }
1187
+
1188
+ // Merge effect options
1189
+ Object.keys(effectOptions).forEach(key => {
1190
+ if (key !== 'type') {
1191
+ this.options.effect[key] = effectOptions[key];
1192
+ }
1193
+ });
1194
+
1195
+ // Update internal computed values
1196
+ this._updateTwinkleSettings();
1197
+ this._updateDeadzone();
1198
+ this._resize();
1199
+
1200
+ return this;
1201
+ }
1202
+
1203
+ /**
1204
+ * Get current effect configuration
1205
+ * @returns {Object} Effect configuration with type and options
1206
+ */
1207
+ getEffect() {
1208
+ return { ...this.options.effect };
1209
+ }
1210
+
1211
+ /**
1212
+ * Set multiple options at once
1213
+ * @param {Object} options - Options object
1214
+ * @returns {Borealis} this instance for chaining
1215
+ */
1216
+ setOptions(options) {
1217
+ Object.keys(options).forEach(key => {
1218
+ this.setOption(key, options[key]);
1219
+ });
1220
+ return this;
1221
+ }
1222
+
1223
+ /**
1224
+ * Get current options
1225
+ * @returns {Object} Current options
1226
+ */
1227
+ getOptions() {
1228
+ return { ...this.options };
1229
+ }
1230
+
1231
+ /**
1232
+ * Get a specific option value
1233
+ * @param {string} key - Option key
1234
+ * @returns {*} Option value
1235
+ */
1236
+ getOption(key) {
1237
+ return this.options[key];
1238
+ }
1239
+
1240
+ /**
1241
+ * Destroy the instance and clean up
1242
+ */
1243
+ destroy() {
1244
+ this.stop();
1245
+ window.removeEventListener('resize', this._resize);
1246
+
1247
+ if (this.canvas && this.canvas.parentNode) {
1248
+ this.canvas.parentNode.removeChild(this.canvas);
1249
+ }
1250
+
1251
+ this.canvas = null;
1252
+ this.ctx = null;
1253
+ this.noise = null;
1254
+ }
1241
1255
  }
1242
1256
 
1243
1257
  export { Borealis as default };