@diabolic/borealis 1.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.
@@ -0,0 +1,1243 @@
1
+ /**
2
+ * Borealis - Interactive Animated Background
3
+ * @version 1.0.0
4
+ * @license MIT
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
+ }
1241
+ }
1242
+
1243
+ export { Borealis as default };