@diabolic/borealis 1.0.1 → 1.0.2

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