@autumnsgrove/gossamer 0.0.1 → 0.1.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.
- package/dist/animation.d.ts +80 -0
- package/dist/animation.d.ts.map +1 -0
- package/dist/characters.d.ts +49 -0
- package/dist/characters.d.ts.map +1 -0
- package/dist/index.d.ts +37 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1284 -2
- package/dist/index.js.map +1 -1
- package/dist/patterns.d.ts +100 -0
- package/dist/patterns.d.ts.map +1 -0
- package/dist/renderer.d.ts +113 -0
- package/dist/renderer.d.ts.map +1 -0
- package/dist/style.css +124 -0
- package/dist/svelte/GossamerBorder.svelte.d.ts +1 -0
- package/dist/svelte/GossamerClouds.svelte.d.ts +1 -0
- package/dist/svelte/GossamerImage.svelte.d.ts +1 -0
- package/dist/svelte/GossamerOverlay.svelte.d.ts +1 -0
- package/dist/svelte/GossamerText.svelte.d.ts +1 -0
- package/dist/svelte/index.d.ts +20 -0
- package/dist/svelte/index.d.ts.map +1 -0
- package/dist/svelte/index.js +3651 -0
- package/dist/svelte/index.js.map +1 -0
- package/dist/svelte/presets.d.ts +38 -0
- package/dist/svelte/presets.d.ts.map +1 -0
- package/dist/utils/canvas.d.ts +73 -0
- package/dist/utils/canvas.d.ts.map +1 -0
- package/dist/utils/image.d.ts +74 -0
- package/dist/utils/image.d.ts.map +1 -0
- package/dist/utils/performance.d.ts +86 -0
- package/dist/utils/performance.d.ts.map +1 -0
- package/package.json +23 -5
- package/src/animation.test.ts +254 -0
- package/src/animation.ts +243 -0
- package/src/characters.test.ts +148 -0
- package/src/characters.ts +164 -0
- package/src/index.test.ts +115 -0
- package/src/index.ts +133 -11
- package/src/patterns.test.ts +273 -0
- package/src/patterns.ts +316 -0
- package/src/renderer.ts +309 -0
- package/src/svelte/GossamerBorder.svelte +326 -0
- package/src/svelte/GossamerClouds.svelte +269 -0
- package/src/svelte/GossamerImage.svelte +266 -0
- package/src/svelte/GossamerOverlay.svelte +232 -0
- package/src/svelte/GossamerText.svelte +239 -0
- package/src/svelte/index.ts +75 -0
- package/src/svelte/presets.ts +174 -0
- package/src/utils/canvas.ts +210 -0
- package/src/utils/image.ts +275 -0
- package/src/utils/performance.ts +282 -0
package/src/patterns.ts
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gossamer Pattern Generators
|
|
3
|
+
*
|
|
4
|
+
* Provides noise and pattern generation functions for ambient ASCII effects.
|
|
5
|
+
* Includes Perlin noise, wave patterns, and static noise.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Configuration for pattern generation
|
|
10
|
+
*/
|
|
11
|
+
export interface PatternConfig {
|
|
12
|
+
/** Pattern scale - higher values create finer detail */
|
|
13
|
+
frequency: number;
|
|
14
|
+
/** Pattern intensity multiplier */
|
|
15
|
+
amplitude: number;
|
|
16
|
+
/** Animation speed multiplier */
|
|
17
|
+
speed: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Default pattern configuration
|
|
22
|
+
*/
|
|
23
|
+
export const DEFAULT_PATTERN_CONFIG: PatternConfig = {
|
|
24
|
+
frequency: 0.05,
|
|
25
|
+
amplitude: 1.0,
|
|
26
|
+
speed: 0.5,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Permutation table for Perlin noise
|
|
30
|
+
const PERMUTATION = new Uint8Array(512);
|
|
31
|
+
const P = new Uint8Array([
|
|
32
|
+
151, 160, 137, 91, 90, 15, 131, 13, 201, 95, 96, 53, 194, 233, 7, 225, 140, 36, 103, 30, 69, 142,
|
|
33
|
+
8, 99, 37, 240, 21, 10, 23, 190, 6, 148, 247, 120, 234, 75, 0, 26, 197, 62, 94, 252, 219, 203,
|
|
34
|
+
117, 35, 11, 32, 57, 177, 33, 88, 237, 149, 56, 87, 174, 20, 125, 136, 171, 168, 68, 175, 74, 165,
|
|
35
|
+
71, 134, 139, 48, 27, 166, 77, 146, 158, 231, 83, 111, 229, 122, 60, 211, 133, 230, 220, 105, 92,
|
|
36
|
+
41, 55, 46, 245, 40, 244, 102, 143, 54, 65, 25, 63, 161, 1, 216, 80, 73, 209, 76, 132, 187, 208,
|
|
37
|
+
89, 18, 169, 200, 196, 135, 130, 116, 188, 159, 86, 164, 100, 109, 198, 173, 186, 3, 64, 52, 217,
|
|
38
|
+
226, 250, 124, 123, 5, 202, 38, 147, 118, 126, 255, 82, 85, 212, 207, 206, 59, 227, 47, 16, 58,
|
|
39
|
+
17, 182, 189, 28, 42, 223, 183, 170, 213, 119, 248, 152, 2, 44, 154, 163, 70, 221, 153, 101, 155,
|
|
40
|
+
167, 43, 172, 9, 129, 22, 39, 253, 19, 98, 108, 110, 79, 113, 224, 232, 178, 185, 112, 104, 218,
|
|
41
|
+
246, 97, 228, 251, 34, 242, 193, 238, 210, 144, 12, 191, 179, 162, 241, 81, 51, 145, 235, 249,
|
|
42
|
+
14, 239, 107, 49, 192, 214, 31, 181, 199, 106, 157, 184, 84, 204, 176, 115, 121, 50, 45, 127, 4,
|
|
43
|
+
150, 254, 138, 236, 205, 93, 222, 114, 67, 29, 24, 72, 243, 141, 128, 195, 78, 66, 215, 61, 156,
|
|
44
|
+
180,
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
// Initialize permutation table
|
|
48
|
+
for (let i = 0; i < 256; i++) {
|
|
49
|
+
PERMUTATION[i] = P[i];
|
|
50
|
+
PERMUTATION[i + 256] = P[i];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Fade function for smooth interpolation (6t^5 - 15t^4 + 10t^3)
|
|
55
|
+
*/
|
|
56
|
+
function fade(t: number): number {
|
|
57
|
+
return t * t * t * (t * (t * 6 - 15) + 10);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Linear interpolation
|
|
62
|
+
*/
|
|
63
|
+
function lerp(a: number, b: number, t: number): number {
|
|
64
|
+
return a + t * (b - a);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Gradient function for Perlin noise
|
|
69
|
+
*/
|
|
70
|
+
function grad(hash: number, x: number, y: number): number {
|
|
71
|
+
const h = hash & 3;
|
|
72
|
+
const u = h < 2 ? x : y;
|
|
73
|
+
const v = h < 2 ? y : x;
|
|
74
|
+
return ((h & 1) === 0 ? u : -u) + ((h & 2) === 0 ? v : -v);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 2D Perlin noise function
|
|
79
|
+
*
|
|
80
|
+
* @param x - X coordinate
|
|
81
|
+
* @param y - Y coordinate
|
|
82
|
+
* @returns Noise value between -1 and 1
|
|
83
|
+
*/
|
|
84
|
+
export function perlinNoise2D(x: number, y: number): number {
|
|
85
|
+
// Find unit square containing point
|
|
86
|
+
const xi = Math.floor(x) & 255;
|
|
87
|
+
const yi = Math.floor(y) & 255;
|
|
88
|
+
|
|
89
|
+
// Find relative position in square
|
|
90
|
+
const xf = x - Math.floor(x);
|
|
91
|
+
const yf = y - Math.floor(y);
|
|
92
|
+
|
|
93
|
+
// Fade curves
|
|
94
|
+
const u = fade(xf);
|
|
95
|
+
const v = fade(yf);
|
|
96
|
+
|
|
97
|
+
// Hash coordinates of square corners
|
|
98
|
+
const aa = PERMUTATION[PERMUTATION[xi] + yi];
|
|
99
|
+
const ab = PERMUTATION[PERMUTATION[xi] + yi + 1];
|
|
100
|
+
const ba = PERMUTATION[PERMUTATION[xi + 1] + yi];
|
|
101
|
+
const bb = PERMUTATION[PERMUTATION[xi + 1] + yi + 1];
|
|
102
|
+
|
|
103
|
+
// Blend results from 4 corners
|
|
104
|
+
const x1 = lerp(grad(aa, xf, yf), grad(ba, xf - 1, yf), u);
|
|
105
|
+
const x2 = lerp(grad(ab, xf, yf - 1), grad(bb, xf - 1, yf - 1), u);
|
|
106
|
+
|
|
107
|
+
return lerp(x1, x2, v);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Fractal Brownian Motion (fBm) using Perlin noise
|
|
112
|
+
* Creates more organic-looking noise by layering multiple octaves
|
|
113
|
+
*
|
|
114
|
+
* @param x - X coordinate
|
|
115
|
+
* @param y - Y coordinate
|
|
116
|
+
* @param octaves - Number of noise layers (default: 4)
|
|
117
|
+
* @param persistence - Amplitude decay per octave (default: 0.5)
|
|
118
|
+
* @returns Noise value between -1 and 1
|
|
119
|
+
*/
|
|
120
|
+
export function fbmNoise(x: number, y: number, octaves: number = 4, persistence: number = 0.5): number {
|
|
121
|
+
let total = 0;
|
|
122
|
+
let frequency = 1;
|
|
123
|
+
let amplitude = 1;
|
|
124
|
+
let maxValue = 0;
|
|
125
|
+
|
|
126
|
+
for (let i = 0; i < octaves; i++) {
|
|
127
|
+
total += perlinNoise2D(x * frequency, y * frequency) * amplitude;
|
|
128
|
+
maxValue += amplitude;
|
|
129
|
+
amplitude *= persistence;
|
|
130
|
+
frequency *= 2;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return total / maxValue;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Wave pattern generator
|
|
138
|
+
*
|
|
139
|
+
* @param x - X coordinate
|
|
140
|
+
* @param y - Y coordinate
|
|
141
|
+
* @param time - Time value for animation
|
|
142
|
+
* @param config - Pattern configuration
|
|
143
|
+
* @returns Value between -1 and 1
|
|
144
|
+
*/
|
|
145
|
+
export function wavePattern(
|
|
146
|
+
x: number,
|
|
147
|
+
y: number,
|
|
148
|
+
time: number,
|
|
149
|
+
config: PatternConfig = DEFAULT_PATTERN_CONFIG
|
|
150
|
+
): number {
|
|
151
|
+
const { frequency, amplitude, speed } = config;
|
|
152
|
+
const wave1 = Math.sin(x * frequency + time * speed);
|
|
153
|
+
const wave2 = Math.cos(y * frequency + time * speed * 0.7);
|
|
154
|
+
const wave3 = Math.sin((x + y) * frequency * 0.5 + time * speed * 0.5);
|
|
155
|
+
|
|
156
|
+
return ((wave1 + wave2 + wave3) / 3) * amplitude;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Ripple pattern (concentric waves from center)
|
|
161
|
+
*
|
|
162
|
+
* @param x - X coordinate
|
|
163
|
+
* @param y - Y coordinate
|
|
164
|
+
* @param centerX - Ripple center X
|
|
165
|
+
* @param centerY - Ripple center Y
|
|
166
|
+
* @param time - Time value for animation
|
|
167
|
+
* @param config - Pattern configuration
|
|
168
|
+
* @returns Value between -1 and 1
|
|
169
|
+
*/
|
|
170
|
+
export function ripplePattern(
|
|
171
|
+
x: number,
|
|
172
|
+
y: number,
|
|
173
|
+
centerX: number,
|
|
174
|
+
centerY: number,
|
|
175
|
+
time: number,
|
|
176
|
+
config: PatternConfig = DEFAULT_PATTERN_CONFIG
|
|
177
|
+
): number {
|
|
178
|
+
const { frequency, amplitude, speed } = config;
|
|
179
|
+
const dx = x - centerX;
|
|
180
|
+
const dy = y - centerY;
|
|
181
|
+
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
182
|
+
|
|
183
|
+
return Math.sin(distance * frequency - time * speed) * amplitude;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Static noise generator (random values)
|
|
188
|
+
*
|
|
189
|
+
* @param seed - Optional seed for reproducible noise
|
|
190
|
+
* @returns Value between 0 and 1
|
|
191
|
+
*/
|
|
192
|
+
export function staticNoise(seed?: number): number {
|
|
193
|
+
if (seed !== undefined) {
|
|
194
|
+
// Simple seeded random using sine
|
|
195
|
+
const x = Math.sin(seed * 12.9898) * 43758.5453;
|
|
196
|
+
return x - Math.floor(x);
|
|
197
|
+
}
|
|
198
|
+
return Math.random();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Seeded 2D noise for reproducible patterns
|
|
203
|
+
*
|
|
204
|
+
* @param x - X coordinate
|
|
205
|
+
* @param y - Y coordinate
|
|
206
|
+
* @param seed - Seed value
|
|
207
|
+
* @returns Value between 0 and 1
|
|
208
|
+
*/
|
|
209
|
+
export function seededNoise2D(x: number, y: number, seed: number = 0): number {
|
|
210
|
+
const n = Math.sin(x * 12.9898 + y * 78.233 + seed) * 43758.5453;
|
|
211
|
+
return n - Math.floor(n);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Generate a brightness grid for pattern rendering
|
|
216
|
+
*
|
|
217
|
+
* @param cols - Number of columns
|
|
218
|
+
* @param rows - Number of rows
|
|
219
|
+
* @param pattern - Pattern type
|
|
220
|
+
* @param time - Current time in seconds
|
|
221
|
+
* @param config - Pattern configuration
|
|
222
|
+
* @returns 2D array of brightness values (0-255)
|
|
223
|
+
*/
|
|
224
|
+
export function generateBrightnessGrid(
|
|
225
|
+
cols: number,
|
|
226
|
+
rows: number,
|
|
227
|
+
pattern: 'perlin' | 'waves' | 'static' | 'ripple' | 'fbm',
|
|
228
|
+
time: number = 0,
|
|
229
|
+
config: PatternConfig = DEFAULT_PATTERN_CONFIG
|
|
230
|
+
): number[][] {
|
|
231
|
+
const grid: number[][] = [];
|
|
232
|
+
const { frequency, amplitude, speed } = config;
|
|
233
|
+
|
|
234
|
+
for (let row = 0; row < rows; row++) {
|
|
235
|
+
grid[row] = [];
|
|
236
|
+
for (let col = 0; col < cols; col++) {
|
|
237
|
+
let value: number;
|
|
238
|
+
|
|
239
|
+
switch (pattern) {
|
|
240
|
+
case 'perlin':
|
|
241
|
+
value = perlinNoise2D(
|
|
242
|
+
col * frequency + time * speed * 0.1,
|
|
243
|
+
row * frequency + time * speed * 0.05
|
|
244
|
+
);
|
|
245
|
+
break;
|
|
246
|
+
|
|
247
|
+
case 'fbm':
|
|
248
|
+
value = fbmNoise(
|
|
249
|
+
col * frequency + time * speed * 0.1,
|
|
250
|
+
row * frequency + time * speed * 0.05,
|
|
251
|
+
4,
|
|
252
|
+
0.5
|
|
253
|
+
);
|
|
254
|
+
break;
|
|
255
|
+
|
|
256
|
+
case 'waves':
|
|
257
|
+
value = wavePattern(col, row, time, config);
|
|
258
|
+
break;
|
|
259
|
+
|
|
260
|
+
case 'ripple':
|
|
261
|
+
value = ripplePattern(col, row, cols / 2, rows / 2, time, config);
|
|
262
|
+
break;
|
|
263
|
+
|
|
264
|
+
case 'static':
|
|
265
|
+
default:
|
|
266
|
+
// For static, use time as seed for animated static
|
|
267
|
+
value = seededNoise2D(col, row, Math.floor(time * speed * 10)) * 2 - 1;
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Normalize from [-1, 1] to [0, 255] with amplitude
|
|
272
|
+
const normalized = (value + 1) * 0.5 * amplitude;
|
|
273
|
+
const brightness = Math.max(0, Math.min(255, Math.floor(normalized * 255)));
|
|
274
|
+
grid[row][col] = brightness;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return grid;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Generate ImageData from a brightness grid
|
|
283
|
+
*
|
|
284
|
+
* @param grid - 2D array of brightness values
|
|
285
|
+
* @param cellWidth - Width of each cell
|
|
286
|
+
* @param cellHeight - Height of each cell
|
|
287
|
+
* @returns ImageData object
|
|
288
|
+
*/
|
|
289
|
+
export function gridToImageData(grid: number[][], cellWidth: number, cellHeight: number): ImageData {
|
|
290
|
+
const rows = grid.length;
|
|
291
|
+
const cols = grid[0]?.length || 0;
|
|
292
|
+
const width = cols * cellWidth;
|
|
293
|
+
const height = rows * cellHeight;
|
|
294
|
+
const data = new Uint8ClampedArray(width * height * 4);
|
|
295
|
+
|
|
296
|
+
for (let row = 0; row < rows; row++) {
|
|
297
|
+
for (let col = 0; col < cols; col++) {
|
|
298
|
+
const brightness = grid[row][col];
|
|
299
|
+
|
|
300
|
+
// Fill cell region with brightness value
|
|
301
|
+
for (let cy = 0; cy < cellHeight; cy++) {
|
|
302
|
+
for (let cx = 0; cx < cellWidth; cx++) {
|
|
303
|
+
const px = ((row * cellHeight + cy) * width + (col * cellWidth + cx)) * 4;
|
|
304
|
+
data[px] = brightness; // R
|
|
305
|
+
data[px + 1] = brightness; // G
|
|
306
|
+
data[px + 2] = brightness; // B
|
|
307
|
+
data[px + 3] = 255; // A
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return new ImageData(data, width, height);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export type PatternType = 'perlin' | 'waves' | 'static' | 'ripple' | 'fbm';
|
package/src/renderer.ts
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gossamer Core Renderer
|
|
3
|
+
*
|
|
4
|
+
* Canvas-based ASCII rendering engine. Converts image data to ASCII characters
|
|
5
|
+
* by mapping brightness values to a character set.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Define locally to avoid circular dependency with index.ts
|
|
9
|
+
const DEFAULT_CHARACTERS = ' .:-=+*#%@';
|
|
10
|
+
|
|
11
|
+
function calculateBrightness(r: number, g: number, b: number): number {
|
|
12
|
+
return 0.21 * r + 0.72 * g + 0.07 * b;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Configuration for the Gossamer renderer
|
|
17
|
+
*/
|
|
18
|
+
export interface RenderConfig {
|
|
19
|
+
/** Canvas element to render to */
|
|
20
|
+
canvas: HTMLCanvasElement;
|
|
21
|
+
/** Character set ordered from light to dark */
|
|
22
|
+
characters: string;
|
|
23
|
+
/** Width of each character cell in pixels */
|
|
24
|
+
cellWidth: number;
|
|
25
|
+
/** Height of each character cell in pixels */
|
|
26
|
+
cellHeight: number;
|
|
27
|
+
/** Color for rendering characters */
|
|
28
|
+
color: string;
|
|
29
|
+
/** Background color (empty string for transparent) */
|
|
30
|
+
backgroundColor: string;
|
|
31
|
+
/** Font family */
|
|
32
|
+
fontFamily: string;
|
|
33
|
+
/** Custom brightness calculation function */
|
|
34
|
+
brightnessFunction: (r: number, g: number, b: number) => number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Default render configuration
|
|
39
|
+
*/
|
|
40
|
+
const DEFAULT_RENDER_CONFIG: Omit<RenderConfig, 'canvas'> = {
|
|
41
|
+
characters: DEFAULT_CHARACTERS,
|
|
42
|
+
cellWidth: 8,
|
|
43
|
+
cellHeight: 12,
|
|
44
|
+
color: '#ffffff',
|
|
45
|
+
backgroundColor: '',
|
|
46
|
+
fontFamily: 'monospace',
|
|
47
|
+
brightnessFunction: calculateBrightness,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Core ASCII renderer class
|
|
52
|
+
*
|
|
53
|
+
* Handles all canvas rendering operations for ASCII effects.
|
|
54
|
+
* Supports both static rendering and animation loops.
|
|
55
|
+
*/
|
|
56
|
+
export class GossamerRenderer {
|
|
57
|
+
private ctx: CanvasRenderingContext2D;
|
|
58
|
+
private config: RenderConfig;
|
|
59
|
+
private animationId: number | null = null;
|
|
60
|
+
private lastFrameTime: number = 0;
|
|
61
|
+
private isRunning: boolean = false;
|
|
62
|
+
|
|
63
|
+
constructor(canvas: HTMLCanvasElement, config: Partial<Omit<RenderConfig, 'canvas'>> = {}) {
|
|
64
|
+
const context = canvas.getContext('2d');
|
|
65
|
+
if (!context) {
|
|
66
|
+
throw new Error('Failed to get 2D rendering context');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
this.ctx = context;
|
|
70
|
+
this.config = {
|
|
71
|
+
canvas,
|
|
72
|
+
...DEFAULT_RENDER_CONFIG,
|
|
73
|
+
...config,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
this.setupCanvas();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Set up the canvas with optimal rendering settings
|
|
81
|
+
*/
|
|
82
|
+
private setupCanvas(): void {
|
|
83
|
+
const { fontFamily, cellHeight } = this.config;
|
|
84
|
+
|
|
85
|
+
// Set font for consistent character sizing
|
|
86
|
+
this.ctx.font = `${cellHeight}px ${fontFamily}`;
|
|
87
|
+
this.ctx.textBaseline = 'top';
|
|
88
|
+
|
|
89
|
+
// Enable image smoothing for better quality
|
|
90
|
+
this.ctx.imageSmoothingEnabled = true;
|
|
91
|
+
this.ctx.imageSmoothingQuality = 'high';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Update the renderer configuration
|
|
96
|
+
*/
|
|
97
|
+
updateConfig(config: Partial<Omit<RenderConfig, 'canvas'>>): void {
|
|
98
|
+
this.config = { ...this.config, ...config };
|
|
99
|
+
this.setupCanvas();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Resize the canvas to match new dimensions
|
|
104
|
+
*/
|
|
105
|
+
resize(width: number, height: number): void {
|
|
106
|
+
const { canvas } = this.config;
|
|
107
|
+
canvas.width = width;
|
|
108
|
+
canvas.height = height;
|
|
109
|
+
this.setupCanvas();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Get the current canvas dimensions
|
|
114
|
+
*/
|
|
115
|
+
getDimensions(): { width: number; height: number } {
|
|
116
|
+
return {
|
|
117
|
+
width: this.config.canvas.width,
|
|
118
|
+
height: this.config.canvas.height,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Calculate the number of cells that fit in the canvas
|
|
124
|
+
*/
|
|
125
|
+
getCellCount(): { cols: number; rows: number } {
|
|
126
|
+
const { width, height } = this.getDimensions();
|
|
127
|
+
const { cellWidth, cellHeight } = this.config;
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
cols: Math.ceil(width / cellWidth),
|
|
131
|
+
rows: Math.ceil(height / cellHeight),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Clear the canvas
|
|
137
|
+
*/
|
|
138
|
+
clear(): void {
|
|
139
|
+
const { canvas, backgroundColor } = this.config;
|
|
140
|
+
|
|
141
|
+
if (backgroundColor) {
|
|
142
|
+
this.ctx.fillStyle = backgroundColor;
|
|
143
|
+
this.ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
144
|
+
} else {
|
|
145
|
+
this.ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Render a single frame from image data
|
|
151
|
+
*/
|
|
152
|
+
renderFrame(imageData: ImageData): void {
|
|
153
|
+
const { canvas, characters, cellWidth, cellHeight, color, brightnessFunction } = this.config;
|
|
154
|
+
const { width, data } = imageData;
|
|
155
|
+
|
|
156
|
+
this.clear();
|
|
157
|
+
this.ctx.fillStyle = color;
|
|
158
|
+
|
|
159
|
+
for (let y = 0; y < canvas.height; y += cellHeight) {
|
|
160
|
+
for (let x = 0; x < canvas.width; x += cellWidth) {
|
|
161
|
+
const brightness = this.getCellBrightness(data, x, y, width, cellWidth, cellHeight, brightnessFunction);
|
|
162
|
+
const charIndex = Math.floor((brightness / 255) * (characters.length - 1));
|
|
163
|
+
const char = characters[Math.min(charIndex, characters.length - 1)];
|
|
164
|
+
|
|
165
|
+
if (char !== ' ') {
|
|
166
|
+
this.ctx.fillText(char, x, y);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Render ASCII from a brightness grid (for pattern-based rendering)
|
|
174
|
+
*/
|
|
175
|
+
renderFromBrightnessGrid(grid: number[][]): void {
|
|
176
|
+
const { characters, cellWidth, cellHeight, color } = this.config;
|
|
177
|
+
|
|
178
|
+
this.clear();
|
|
179
|
+
this.ctx.fillStyle = color;
|
|
180
|
+
|
|
181
|
+
for (let row = 0; row < grid.length; row++) {
|
|
182
|
+
for (let col = 0; col < grid[row].length; col++) {
|
|
183
|
+
const brightness = grid[row][col];
|
|
184
|
+
const charIndex = Math.floor((brightness / 255) * (characters.length - 1));
|
|
185
|
+
const char = characters[Math.min(charIndex, characters.length - 1)];
|
|
186
|
+
|
|
187
|
+
if (char !== ' ') {
|
|
188
|
+
this.ctx.fillText(char, col * cellWidth, row * cellHeight);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Render ASCII with per-cell colors (for colored image rendering)
|
|
196
|
+
*/
|
|
197
|
+
renderWithColors(data: Array<{ char: string; color: string; x: number; y: number }>): void {
|
|
198
|
+
this.clear();
|
|
199
|
+
|
|
200
|
+
for (const { char, color, x, y } of data) {
|
|
201
|
+
if (char !== ' ') {
|
|
202
|
+
this.ctx.fillStyle = color;
|
|
203
|
+
this.ctx.fillText(char, x, y);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Calculate average brightness for a cell region
|
|
210
|
+
*/
|
|
211
|
+
private getCellBrightness(
|
|
212
|
+
data: Uint8ClampedArray,
|
|
213
|
+
startX: number,
|
|
214
|
+
startY: number,
|
|
215
|
+
imageWidth: number,
|
|
216
|
+
cellWidth: number,
|
|
217
|
+
cellHeight: number,
|
|
218
|
+
brightnessFunction: (r: number, g: number, b: number) => number
|
|
219
|
+
): number {
|
|
220
|
+
let total = 0;
|
|
221
|
+
let count = 0;
|
|
222
|
+
|
|
223
|
+
for (let cy = 0; cy < cellHeight; cy++) {
|
|
224
|
+
for (let cx = 0; cx < cellWidth; cx++) {
|
|
225
|
+
const px = ((startY + cy) * imageWidth + (startX + cx)) * 4;
|
|
226
|
+
if (px >= 0 && px + 2 < data.length) {
|
|
227
|
+
total += brightnessFunction(data[px], data[px + 1], data[px + 2]);
|
|
228
|
+
count++;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return count > 0 ? total / count : 0;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Start an animation loop with FPS limiting
|
|
238
|
+
*/
|
|
239
|
+
startAnimation(
|
|
240
|
+
updateFn: (time: number, deltaTime: number) => ImageData | number[][],
|
|
241
|
+
fps: number = 30
|
|
242
|
+
): void {
|
|
243
|
+
if (this.isRunning) {
|
|
244
|
+
this.stopAnimation();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
this.isRunning = true;
|
|
248
|
+
const frameInterval = 1000 / fps;
|
|
249
|
+
this.lastFrameTime = performance.now();
|
|
250
|
+
|
|
251
|
+
const animate = (currentTime: number): void => {
|
|
252
|
+
if (!this.isRunning) return;
|
|
253
|
+
|
|
254
|
+
const deltaTime = currentTime - this.lastFrameTime;
|
|
255
|
+
|
|
256
|
+
if (deltaTime >= frameInterval) {
|
|
257
|
+
const result = updateFn(currentTime, deltaTime);
|
|
258
|
+
|
|
259
|
+
if (result instanceof ImageData) {
|
|
260
|
+
this.renderFrame(result);
|
|
261
|
+
} else {
|
|
262
|
+
this.renderFromBrightnessGrid(result);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
this.lastFrameTime = currentTime - (deltaTime % frameInterval);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
this.animationId = requestAnimationFrame(animate);
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
this.animationId = requestAnimationFrame(animate);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Stop the animation loop
|
|
276
|
+
*/
|
|
277
|
+
stopAnimation(): void {
|
|
278
|
+
this.isRunning = false;
|
|
279
|
+
if (this.animationId !== null) {
|
|
280
|
+
cancelAnimationFrame(this.animationId);
|
|
281
|
+
this.animationId = null;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Check if animation is currently running
|
|
287
|
+
*/
|
|
288
|
+
isAnimating(): boolean {
|
|
289
|
+
return this.isRunning;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Pause animation (can be resumed)
|
|
294
|
+
*/
|
|
295
|
+
pause(): void {
|
|
296
|
+
this.isRunning = false;
|
|
297
|
+
if (this.animationId !== null) {
|
|
298
|
+
cancelAnimationFrame(this.animationId);
|
|
299
|
+
this.animationId = null;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Clean up and destroy the renderer
|
|
305
|
+
*/
|
|
306
|
+
destroy(): void {
|
|
307
|
+
this.stopAnimation();
|
|
308
|
+
}
|
|
309
|
+
}
|