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