@diabolic/borealis 1.0.2 → 1.0.3

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