@autumnsgrove/gossamer 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/dist/animation.js +165 -0
  3. package/dist/animation.test.js +204 -0
  4. package/dist/characters.js +176 -0
  5. package/dist/characters.test.js +115 -0
  6. package/dist/colors.js +199 -0
  7. package/dist/index.js +79 -1850
  8. package/dist/index.test.js +92 -0
  9. package/dist/patterns.js +539 -0
  10. package/dist/patterns.test.js +223 -0
  11. package/dist/renderer.js +362 -0
  12. package/dist/svelte/GossamerBorder.svelte.d.ts +56 -1
  13. package/dist/svelte/GossamerBorder.svelte.d.ts.map +1 -0
  14. package/dist/svelte/GossamerClouds.svelte.d.ts +31 -1
  15. package/dist/svelte/GossamerClouds.svelte.d.ts.map +1 -0
  16. package/dist/svelte/GossamerImage.svelte.d.ts +28 -1
  17. package/dist/svelte/GossamerImage.svelte.d.ts.map +1 -0
  18. package/dist/svelte/GossamerOverlay.svelte.d.ts +32 -1
  19. package/dist/svelte/GossamerOverlay.svelte.d.ts.map +1 -0
  20. package/dist/svelte/GossamerText.svelte.d.ts +29 -1
  21. package/dist/svelte/GossamerText.svelte.d.ts.map +1 -0
  22. package/dist/svelte/index.js +31 -3646
  23. package/dist/svelte/presets.d.ts +4 -2
  24. package/dist/svelte/presets.js +161 -0
  25. package/dist/utils/canvas.js +139 -0
  26. package/dist/utils/image.js +195 -0
  27. package/dist/utils/performance.js +205 -0
  28. package/package.json +18 -22
  29. package/dist/index.js.map +0 -1
  30. package/dist/style.css +0 -124
  31. package/dist/svelte/index.js.map +0 -1
  32. package/src/animation.test.ts +0 -254
  33. package/src/animation.ts +0 -243
  34. package/src/characters.test.ts +0 -148
  35. package/src/characters.ts +0 -219
  36. package/src/colors.ts +0 -234
  37. package/src/index.test.ts +0 -115
  38. package/src/index.ts +0 -234
  39. package/src/patterns.test.ts +0 -273
  40. package/src/patterns.ts +0 -760
  41. package/src/renderer.ts +0 -470
  42. package/src/svelte/index.ts +0 -75
  43. package/src/svelte/presets.ts +0 -174
  44. package/src/utils/canvas.ts +0 -210
  45. package/src/utils/image.ts +0 -275
  46. package/src/utils/performance.ts +0 -282
  47. /package/{src → dist}/svelte/GossamerBorder.svelte +0 -0
  48. /package/{src → dist}/svelte/GossamerClouds.svelte +0 -0
  49. /package/{src → dist}/svelte/GossamerImage.svelte +0 -0
  50. /package/{src → dist}/svelte/GossamerOverlay.svelte +0 -0
  51. /package/{src → dist}/svelte/GossamerText.svelte +0 -0
@@ -1,5 +1,7 @@
1
- import { PresetConfig } from '../index';
2
-
1
+ /**
2
+ * Preset configurations for Gossamer effects
3
+ */
4
+ import type { PresetConfig } from '../index';
3
5
  /**
4
6
  * Grove-themed presets
5
7
  * Organic, nature-inspired effects
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Preset configurations for Gossamer effects
3
+ */
4
+ /**
5
+ * Grove-themed presets
6
+ * Organic, nature-inspired effects
7
+ */
8
+ export const grovePresets = {
9
+ 'grove-mist': {
10
+ name: 'Grove Mist',
11
+ description: 'Soft fog effect drifting through the trees',
12
+ characters: ' ·∙•◦',
13
+ pattern: 'perlin',
14
+ frequency: 0.03,
15
+ amplitude: 0.8,
16
+ speed: 0.3,
17
+ opacity: 0.2,
18
+ },
19
+ 'grove-fireflies': {
20
+ name: 'Grove Fireflies',
21
+ description: 'Twinkling points of light in the darkness',
22
+ characters: ' ·*✦✧',
23
+ pattern: 'static',
24
+ frequency: 0.01,
25
+ amplitude: 1.2,
26
+ speed: 0.8,
27
+ opacity: 0.3,
28
+ },
29
+ 'grove-rain': {
30
+ name: 'Grove Rain',
31
+ description: 'Gentle rain falling through the canopy',
32
+ characters: ' │\\|/',
33
+ pattern: 'waves',
34
+ frequency: 0.05,
35
+ amplitude: 1.0,
36
+ speed: 1.5,
37
+ opacity: 0.15,
38
+ },
39
+ 'grove-dew': {
40
+ name: 'Grove Dew',
41
+ description: 'Morning dew glistening on spider silk',
42
+ characters: ' ·∘∙●',
43
+ pattern: 'fbm',
44
+ frequency: 0.04,
45
+ amplitude: 0.7,
46
+ speed: 0.1,
47
+ opacity: 0.15,
48
+ },
49
+ };
50
+ /**
51
+ * Seasonal presets
52
+ * Effects themed around the four seasons
53
+ */
54
+ export const seasonalPresets = {
55
+ 'winter-snow': {
56
+ name: 'Winter Snow',
57
+ description: 'Gentle snowfall on a quiet night',
58
+ characters: ' ·∙*❄',
59
+ pattern: 'perlin',
60
+ frequency: 0.04,
61
+ amplitude: 0.9,
62
+ speed: 0.5,
63
+ opacity: 0.25,
64
+ },
65
+ 'autumn-leaves': {
66
+ name: 'Autumn Leaves',
67
+ description: 'Scattered leaves drifting on the wind',
68
+ characters: ' 🍂·∙',
69
+ pattern: 'perlin',
70
+ frequency: 0.06,
71
+ amplitude: 1.1,
72
+ speed: 0.4,
73
+ opacity: 0.2,
74
+ },
75
+ 'spring-petals': {
76
+ name: 'Spring Petals',
77
+ description: 'Cherry blossom petals floating on the breeze',
78
+ characters: ' ·✿❀',
79
+ pattern: 'waves',
80
+ frequency: 0.05,
81
+ amplitude: 0.8,
82
+ speed: 0.6,
83
+ opacity: 0.2,
84
+ },
85
+ 'summer-heat': {
86
+ name: 'Summer Heat',
87
+ description: 'Heat shimmer rising from sun-warmed ground',
88
+ characters: ' ~≈∿',
89
+ pattern: 'waves',
90
+ frequency: 0.08,
91
+ amplitude: 1.3,
92
+ speed: 1.0,
93
+ opacity: 0.1,
94
+ },
95
+ };
96
+ /**
97
+ * Ambient presets
98
+ * Subtle background textures
99
+ */
100
+ export const ambientPresets = {
101
+ 'ambient-static': {
102
+ name: 'Ambient Static',
103
+ description: 'Gentle static noise texture',
104
+ characters: ' .:',
105
+ pattern: 'static',
106
+ frequency: 0.1,
107
+ amplitude: 0.5,
108
+ speed: 0.2,
109
+ opacity: 0.08,
110
+ },
111
+ 'ambient-waves': {
112
+ name: 'Ambient Waves',
113
+ description: 'Soft flowing wave pattern',
114
+ characters: ' ·~',
115
+ pattern: 'waves',
116
+ frequency: 0.02,
117
+ amplitude: 0.6,
118
+ speed: 0.3,
119
+ opacity: 0.1,
120
+ },
121
+ 'ambient-clouds': {
122
+ name: 'Ambient Clouds',
123
+ description: 'Drifting cloud-like patterns',
124
+ characters: ' .:-',
125
+ pattern: 'fbm',
126
+ frequency: 0.02,
127
+ amplitude: 0.7,
128
+ speed: 0.15,
129
+ opacity: 0.12,
130
+ },
131
+ };
132
+ /**
133
+ * All presets combined for easy access
134
+ */
135
+ export const PRESETS = {
136
+ ...grovePresets,
137
+ ...seasonalPresets,
138
+ ...ambientPresets,
139
+ };
140
+ /**
141
+ * Get a preset by name
142
+ */
143
+ export function getPreset(name) {
144
+ return PRESETS[name];
145
+ }
146
+ /**
147
+ * List all available preset names
148
+ */
149
+ export function getPresetNames() {
150
+ return Object.keys(PRESETS);
151
+ }
152
+ /**
153
+ * List preset names by category
154
+ */
155
+ export function getPresetsByCategory() {
156
+ return {
157
+ grove: Object.keys(grovePresets),
158
+ seasonal: Object.keys(seasonalPresets),
159
+ ambient: Object.keys(ambientPresets),
160
+ };
161
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Canvas Utilities
3
+ *
4
+ * Helper functions for canvas creation, setup, and manipulation.
5
+ */
6
+ /**
7
+ * Create a canvas element with optimal settings
8
+ */
9
+ export function createCanvas(options = {}) {
10
+ const { width = 300, height = 150, highDPI = true, className, style } = options;
11
+ const canvas = document.createElement('canvas');
12
+ if (highDPI) {
13
+ const dpr = window.devicePixelRatio || 1;
14
+ canvas.width = width * dpr;
15
+ canvas.height = height * dpr;
16
+ canvas.style.width = `${width}px`;
17
+ canvas.style.height = `${height}px`;
18
+ const ctx = canvas.getContext('2d');
19
+ if (ctx) {
20
+ ctx.scale(dpr, dpr);
21
+ }
22
+ }
23
+ else {
24
+ canvas.width = width;
25
+ canvas.height = height;
26
+ }
27
+ if (className) {
28
+ canvas.className = className;
29
+ }
30
+ if (style) {
31
+ Object.assign(canvas.style, style);
32
+ }
33
+ return canvas;
34
+ }
35
+ /**
36
+ * Get the device pixel ratio
37
+ */
38
+ export function getDevicePixelRatio() {
39
+ return typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1;
40
+ }
41
+ /**
42
+ * Resize canvas to match container dimensions
43
+ */
44
+ export function resizeCanvasToContainer(canvas, container, highDPI = true) {
45
+ const rect = container.getBoundingClientRect();
46
+ const dpr = highDPI ? getDevicePixelRatio() : 1;
47
+ const width = rect.width;
48
+ const height = rect.height;
49
+ canvas.width = width * dpr;
50
+ canvas.height = height * dpr;
51
+ canvas.style.width = `${width}px`;
52
+ canvas.style.height = `${height}px`;
53
+ if (highDPI) {
54
+ const ctx = canvas.getContext('2d');
55
+ if (ctx) {
56
+ ctx.scale(dpr, dpr);
57
+ }
58
+ }
59
+ return { width, height };
60
+ }
61
+ /**
62
+ * Create an offscreen canvas for buffer rendering
63
+ */
64
+ export function createOffscreenCanvas(width, height) {
65
+ if (typeof OffscreenCanvas !== 'undefined') {
66
+ return new OffscreenCanvas(width, height);
67
+ }
68
+ // Fallback for environments without OffscreenCanvas
69
+ const canvas = document.createElement('canvas');
70
+ canvas.width = width;
71
+ canvas.height = height;
72
+ return canvas;
73
+ }
74
+ /**
75
+ * Clear a canvas
76
+ */
77
+ export function clearCanvas(ctx, width, height, backgroundColor) {
78
+ if (backgroundColor) {
79
+ ctx.fillStyle = backgroundColor;
80
+ ctx.fillRect(0, 0, width, height);
81
+ }
82
+ else {
83
+ ctx.clearRect(0, 0, width, height);
84
+ }
85
+ }
86
+ /**
87
+ * Get image data from a canvas region
88
+ */
89
+ export function getImageData(ctx, x = 0, y = 0, width, height) {
90
+ const canvas = ctx.canvas;
91
+ const w = width ?? canvas.width;
92
+ const h = height ?? canvas.height;
93
+ return ctx.getImageData(x, y, w, h);
94
+ }
95
+ /**
96
+ * Set optimal rendering context settings
97
+ */
98
+ export function optimizeContext(ctx) {
99
+ // Disable image smoothing for crisp ASCII rendering
100
+ ctx.imageSmoothingEnabled = false;
101
+ // Use source-over for standard compositing
102
+ ctx.globalCompositeOperation = 'source-over';
103
+ }
104
+ /**
105
+ * Set text rendering options for ASCII display
106
+ */
107
+ export function setupTextRendering(ctx, fontSize, fontFamily = 'monospace', color = '#ffffff') {
108
+ ctx.font = `${fontSize}px ${fontFamily}`;
109
+ ctx.textBaseline = 'top';
110
+ ctx.textAlign = 'left';
111
+ ctx.fillStyle = color;
112
+ }
113
+ /**
114
+ * Measure text width for a given font configuration
115
+ */
116
+ export function measureTextWidth(ctx, text, fontSize, fontFamily = 'monospace') {
117
+ const originalFont = ctx.font;
118
+ ctx.font = `${fontSize}px ${fontFamily}`;
119
+ const metrics = ctx.measureText(text);
120
+ ctx.font = originalFont;
121
+ return metrics.width;
122
+ }
123
+ /**
124
+ * Calculate optimal cell size for a given canvas and desired columns
125
+ */
126
+ export function calculateCellSize(canvasWidth, canvasHeight, targetCols) {
127
+ const cellWidth = Math.floor(canvasWidth / targetCols);
128
+ // Use a typical monospace aspect ratio of ~0.6
129
+ const cellHeight = Math.floor(cellWidth * 1.5);
130
+ const cols = Math.floor(canvasWidth / cellWidth);
131
+ const rows = Math.floor(canvasHeight / cellHeight);
132
+ return { cellWidth, cellHeight, cols, rows };
133
+ }
134
+ /**
135
+ * Apply a CSS blend mode to canvas compositing
136
+ */
137
+ export function setBlendMode(ctx, mode) {
138
+ ctx.globalCompositeOperation = mode === 'normal' ? 'source-over' : mode;
139
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Image Utilities
3
+ *
4
+ * Image loading, processing, and pixel manipulation for ASCII conversion.
5
+ */
6
+ /**
7
+ * Load an image from a URL
8
+ */
9
+ export function loadImage(src, options = {}) {
10
+ return new Promise((resolve, reject) => {
11
+ const img = new Image();
12
+ if (options.crossOrigin !== undefined) {
13
+ img.crossOrigin = options.crossOrigin;
14
+ }
15
+ img.onload = () => resolve(img);
16
+ img.onerror = () => reject(new Error(`Failed to load image: ${src}`));
17
+ img.src = src;
18
+ });
19
+ }
20
+ /**
21
+ * Load and scale an image to fit within bounds
22
+ */
23
+ export async function loadAndScaleImage(src, maxWidth, maxHeight, options = {}) {
24
+ const img = await loadImage(src, options);
25
+ let width = img.naturalWidth;
26
+ let height = img.naturalHeight;
27
+ // Scale down if needed
28
+ if (width > maxWidth || height > maxHeight) {
29
+ const widthRatio = maxWidth / width;
30
+ const heightRatio = maxHeight / height;
31
+ const scale = Math.min(widthRatio, heightRatio);
32
+ width = Math.floor(width * scale);
33
+ height = Math.floor(height * scale);
34
+ }
35
+ return { image: img, width, height };
36
+ }
37
+ /**
38
+ * Draw an image to a canvas and get its pixel data
39
+ */
40
+ export function imageToPixelData(image, width, height) {
41
+ const w = width ?? (image instanceof HTMLImageElement ? image.naturalWidth : image.width);
42
+ const h = height ?? (image instanceof HTMLImageElement ? image.naturalHeight : image.height);
43
+ const canvas = document.createElement('canvas');
44
+ canvas.width = w;
45
+ canvas.height = h;
46
+ const ctx = canvas.getContext('2d');
47
+ if (!ctx) {
48
+ throw new Error('Failed to get 2D context');
49
+ }
50
+ ctx.drawImage(image, 0, 0, w, h);
51
+ return ctx.getImageData(0, 0, w, h);
52
+ }
53
+ /**
54
+ * Extract brightness values from image data
55
+ */
56
+ export function extractBrightness(imageData, brightnessFunction = (r, g, b) => 0.21 * r + 0.72 * g + 0.07 * b) {
57
+ const { data, width, height } = imageData;
58
+ const brightness = new Array(width * height);
59
+ for (let i = 0; i < data.length; i += 4) {
60
+ brightness[i / 4] = brightnessFunction(data[i], data[i + 1], data[i + 2]);
61
+ }
62
+ return brightness;
63
+ }
64
+ /**
65
+ * Sample image data at cell-based intervals
66
+ */
67
+ export function sampleImageCells(imageData, cellWidth, cellHeight, brightnessFunction = (r, g, b) => 0.21 * r + 0.72 * g + 0.07 * b) {
68
+ const { data, width, height } = imageData;
69
+ const cols = Math.ceil(width / cellWidth);
70
+ const rows = Math.ceil(height / cellHeight);
71
+ const result = [];
72
+ for (let row = 0; row < rows; row++) {
73
+ result[row] = [];
74
+ for (let col = 0; col < cols; col++) {
75
+ const cellData = sampleCell(data, width, col * cellWidth, row * cellHeight, cellWidth, cellHeight);
76
+ const brightness = brightnessFunction(cellData.r, cellData.g, cellData.b);
77
+ result[row][col] = {
78
+ brightness,
79
+ color: `rgb(${Math.round(cellData.r)}, ${Math.round(cellData.g)}, ${Math.round(cellData.b)})`,
80
+ };
81
+ }
82
+ }
83
+ return result;
84
+ }
85
+ /**
86
+ * Sample average color from a cell region
87
+ */
88
+ function sampleCell(data, imageWidth, startX, startY, cellWidth, cellHeight) {
89
+ let totalR = 0;
90
+ let totalG = 0;
91
+ let totalB = 0;
92
+ let totalA = 0;
93
+ let count = 0;
94
+ for (let y = startY; y < startY + cellHeight; y++) {
95
+ for (let x = startX; x < startX + cellWidth; x++) {
96
+ const px = (y * imageWidth + x) * 4;
97
+ if (px >= 0 && px + 3 < data.length) {
98
+ totalR += data[px];
99
+ totalG += data[px + 1];
100
+ totalB += data[px + 2];
101
+ totalA += data[px + 3];
102
+ count++;
103
+ }
104
+ }
105
+ }
106
+ if (count === 0) {
107
+ return { r: 0, g: 0, b: 0, a: 0 };
108
+ }
109
+ return {
110
+ r: totalR / count,
111
+ g: totalG / count,
112
+ b: totalB / count,
113
+ a: totalA / count,
114
+ };
115
+ }
116
+ /**
117
+ * Convert RGB to hex color string
118
+ */
119
+ export function rgbToHex(r, g, b) {
120
+ const toHex = (n) => Math.max(0, Math.min(255, Math.round(n)))
121
+ .toString(16)
122
+ .padStart(2, '0');
123
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
124
+ }
125
+ /**
126
+ * Convert hex color to RGB
127
+ */
128
+ export function hexToRgb(hex) {
129
+ const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
130
+ return result
131
+ ? {
132
+ r: parseInt(result[1], 16),
133
+ g: parseInt(result[2], 16),
134
+ b: parseInt(result[3], 16),
135
+ }
136
+ : null;
137
+ }
138
+ /**
139
+ * Adjust image brightness
140
+ */
141
+ export function adjustBrightness(imageData, amount) {
142
+ const { data, width, height } = imageData;
143
+ const adjusted = new Uint8ClampedArray(data.length);
144
+ for (let i = 0; i < data.length; i += 4) {
145
+ adjusted[i] = Math.min(255, Math.max(0, data[i] + amount));
146
+ adjusted[i + 1] = Math.min(255, Math.max(0, data[i + 1] + amount));
147
+ adjusted[i + 2] = Math.min(255, Math.max(0, data[i + 2] + amount));
148
+ adjusted[i + 3] = data[i + 3];
149
+ }
150
+ return new ImageData(adjusted, width, height);
151
+ }
152
+ /**
153
+ * Adjust image contrast
154
+ */
155
+ export function adjustContrast(imageData, amount) {
156
+ const { data, width, height } = imageData;
157
+ const adjusted = new Uint8ClampedArray(data.length);
158
+ const factor = (259 * (amount + 255)) / (255 * (259 - amount));
159
+ for (let i = 0; i < data.length; i += 4) {
160
+ adjusted[i] = Math.min(255, Math.max(0, factor * (data[i] - 128) + 128));
161
+ adjusted[i + 1] = Math.min(255, Math.max(0, factor * (data[i + 1] - 128) + 128));
162
+ adjusted[i + 2] = Math.min(255, Math.max(0, factor * (data[i + 2] - 128) + 128));
163
+ adjusted[i + 3] = data[i + 3];
164
+ }
165
+ return new ImageData(adjusted, width, height);
166
+ }
167
+ /**
168
+ * Invert image colors
169
+ */
170
+ export function invertColors(imageData) {
171
+ const { data, width, height } = imageData;
172
+ const inverted = new Uint8ClampedArray(data.length);
173
+ for (let i = 0; i < data.length; i += 4) {
174
+ inverted[i] = 255 - data[i];
175
+ inverted[i + 1] = 255 - data[i + 1];
176
+ inverted[i + 2] = 255 - data[i + 2];
177
+ inverted[i + 3] = data[i + 3];
178
+ }
179
+ return new ImageData(inverted, width, height);
180
+ }
181
+ /**
182
+ * Convert image to grayscale
183
+ */
184
+ export function toGrayscale(imageData) {
185
+ const { data, width, height } = imageData;
186
+ const grayscale = new Uint8ClampedArray(data.length);
187
+ for (let i = 0; i < data.length; i += 4) {
188
+ const gray = 0.21 * data[i] + 0.72 * data[i + 1] + 0.07 * data[i + 2];
189
+ grayscale[i] = gray;
190
+ grayscale[i + 1] = gray;
191
+ grayscale[i + 2] = gray;
192
+ grayscale[i + 3] = data[i + 3];
193
+ }
194
+ return new ImageData(grayscale, width, height);
195
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Performance Utilities
3
+ *
4
+ * Visibility detection, resource management, and optimization helpers.
5
+ */
6
+ /**
7
+ * Create an IntersectionObserver for visibility-based animation control
8
+ *
9
+ * @param element - Element to observe
10
+ * @param callback - Called when visibility changes
11
+ * @param threshold - Visibility threshold (0-1, default: 0.1)
12
+ * @returns Cleanup function to disconnect observer
13
+ */
14
+ export function createVisibilityObserver(element, callback, threshold = 0.1) {
15
+ const observer = new IntersectionObserver((entries) => {
16
+ for (const entry of entries) {
17
+ callback(entry.isIntersecting, entry);
18
+ }
19
+ }, { threshold });
20
+ observer.observe(element);
21
+ return () => {
22
+ observer.disconnect();
23
+ };
24
+ }
25
+ /**
26
+ * Create a ResizeObserver for responsive canvas sizing
27
+ *
28
+ * @param element - Element to observe
29
+ * @param callback - Called on resize with new dimensions
30
+ * @param debounceMs - Debounce delay in ms (default: 100)
31
+ * @returns Cleanup function to disconnect observer
32
+ */
33
+ export function createResizeObserver(element, callback, debounceMs = 100) {
34
+ let timeout = null;
35
+ const observer = new ResizeObserver((entries) => {
36
+ if (timeout) {
37
+ clearTimeout(timeout);
38
+ }
39
+ timeout = setTimeout(() => {
40
+ const entry = entries[0];
41
+ if (entry) {
42
+ const { width, height } = entry.contentRect;
43
+ callback(width, height, entry);
44
+ }
45
+ }, debounceMs);
46
+ });
47
+ observer.observe(element);
48
+ return () => {
49
+ if (timeout) {
50
+ clearTimeout(timeout);
51
+ }
52
+ observer.disconnect();
53
+ };
54
+ }
55
+ /**
56
+ * Check if the user prefers reduced motion
57
+ */
58
+ export function prefersReducedMotion() {
59
+ if (typeof window === 'undefined')
60
+ return false;
61
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
62
+ }
63
+ /**
64
+ * Create a listener for reduced motion preference changes
65
+ *
66
+ * @param callback - Called when preference changes
67
+ * @returns Cleanup function to remove listener
68
+ */
69
+ export function onReducedMotionChange(callback) {
70
+ if (typeof window === 'undefined') {
71
+ return () => { };
72
+ }
73
+ const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
74
+ const handler = (event) => {
75
+ callback(event.matches);
76
+ };
77
+ mediaQuery.addEventListener('change', handler);
78
+ // Call immediately with current value
79
+ callback(mediaQuery.matches);
80
+ return () => {
81
+ mediaQuery.removeEventListener('change', handler);
82
+ };
83
+ }
84
+ /**
85
+ * Check if the browser is in a low-power mode or has reduced capabilities
86
+ */
87
+ export function isLowPowerMode() {
88
+ // Check for battery API (if available)
89
+ if (typeof navigator !== 'undefined' && 'getBattery' in navigator) {
90
+ // Battery API is async, so this is a simplified check
91
+ // Real implementation would need to be async
92
+ return false;
93
+ }
94
+ // Fallback: check for hardware concurrency
95
+ if (typeof navigator !== 'undefined' && navigator.hardwareConcurrency) {
96
+ return navigator.hardwareConcurrency <= 2;
97
+ }
98
+ return false;
99
+ }
100
+ /**
101
+ * Get recommended FPS based on device capabilities
102
+ */
103
+ export function getRecommendedFPS() {
104
+ if (prefersReducedMotion()) {
105
+ return 0; // No animation
106
+ }
107
+ if (isLowPowerMode()) {
108
+ return 15; // Low power mode
109
+ }
110
+ // Default for capable devices
111
+ return 30;
112
+ }
113
+ /**
114
+ * Create a simple FPS counter
115
+ */
116
+ export function createFPSCounter() {
117
+ const frameTimes = [];
118
+ const maxSamples = 60;
119
+ let droppedFrames = 0;
120
+ let lastFrameTime = performance.now();
121
+ function tick() {
122
+ const now = performance.now();
123
+ const delta = now - lastFrameTime;
124
+ lastFrameTime = now;
125
+ frameTimes.push(now);
126
+ if (frameTimes.length > maxSamples) {
127
+ frameTimes.shift();
128
+ }
129
+ // Count dropped frames (assuming 60fps target, frames taking >20ms are "dropped")
130
+ if (delta > 20) {
131
+ droppedFrames += Math.floor(delta / 16.67) - 1;
132
+ }
133
+ }
134
+ function getFPS() {
135
+ if (frameTimes.length < 2)
136
+ return 0;
137
+ const oldest = frameTimes[0];
138
+ const newest = frameTimes[frameTimes.length - 1];
139
+ const elapsed = newest - oldest;
140
+ if (elapsed === 0)
141
+ return 0;
142
+ return ((frameTimes.length - 1) / elapsed) * 1000;
143
+ }
144
+ function getMetrics() {
145
+ const fps = getFPS();
146
+ const frameTime = fps > 0 ? 1000 / fps : 0;
147
+ return {
148
+ fps: Math.round(fps * 10) / 10,
149
+ frameTime: Math.round(frameTime * 100) / 100,
150
+ droppedFrames,
151
+ };
152
+ }
153
+ function reset() {
154
+ frameTimes.length = 0;
155
+ droppedFrames = 0;
156
+ lastFrameTime = performance.now();
157
+ }
158
+ return { tick, getFPS, getMetrics, reset };
159
+ }
160
+ /**
161
+ * Request idle callback with fallback
162
+ */
163
+ export function requestIdleCallback(callback, options) {
164
+ // Use globalThis for cross-environment compatibility
165
+ const global = globalThis;
166
+ if (typeof global.requestIdleCallback === 'function') {
167
+ return global.requestIdleCallback(callback, options);
168
+ }
169
+ // Fallback using setTimeout
170
+ return global.setTimeout(callback, options?.timeout ?? 1);
171
+ }
172
+ /**
173
+ * Cancel idle callback with fallback
174
+ */
175
+ export function cancelIdleCallback(id) {
176
+ // Use globalThis for cross-environment compatibility
177
+ const global = globalThis;
178
+ if (typeof global.cancelIdleCallback === 'function') {
179
+ global.cancelIdleCallback(id);
180
+ }
181
+ else {
182
+ global.clearTimeout(id);
183
+ }
184
+ }
185
+ /**
186
+ * Check if we're running in a browser environment
187
+ */
188
+ export function isBrowser() {
189
+ return typeof window !== 'undefined' && typeof document !== 'undefined';
190
+ }
191
+ /**
192
+ * Check if Canvas is supported
193
+ */
194
+ export function isCanvasSupported() {
195
+ if (!isBrowser())
196
+ return false;
197
+ const canvas = document.createElement('canvas');
198
+ return !!(canvas.getContext && canvas.getContext('2d'));
199
+ }
200
+ /**
201
+ * Check if OffscreenCanvas is supported
202
+ */
203
+ export function isOffscreenCanvasSupported() {
204
+ return typeof OffscreenCanvas !== 'undefined';
205
+ }