@alkemdotdev/alkemist-components 1.0.0-beta.1

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/src/shader.ts ADDED
@@ -0,0 +1,375 @@
1
+ import { ALK_INKS } from '@alkemdotdev/alkemist-theme/palette';
2
+
3
+ const VERTEX = `#version 300 es
4
+ precision highp float;
5
+ void main() {
6
+ vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
7
+ gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
8
+ }`;
9
+
10
+ /** Fixed, bundled GLSL source. Exported for displaying the exact specimen implementation. */
11
+ export const ALK_INTERFERENCE_FRAGMENT = `#version 300 es
12
+ precision highp float;
13
+ uniform vec2 u_resolution;
14
+ uniform float u_phase;
15
+ uniform float u_frequency;
16
+ uniform float u_angle;
17
+ uniform vec3 u_paper;
18
+ uniform vec3 u_inks[8];
19
+ out vec4 outColor;
20
+
21
+ void main() {
22
+ vec2 p = (gl_FragCoord.xy - 0.5 * u_resolution) / min(u_resolution.x, u_resolution.y) * 4.0;
23
+ float c = cos(u_angle), s = sin(u_angle);
24
+ p = mat2(c, -s, s, c) * p;
25
+ float r1 = length(p - vec2(-0.66, 0.0));
26
+ float r2 = length(p - vec2( 0.66, 0.0));
27
+ float wave = sin(u_frequency * r1 - u_phase) + sin(u_frequency * r2 - u_phase);
28
+ float bands = (wave + 2.0) * 2.0;
29
+ int ink = int(clamp(floor(bands), 0.0, 7.0));
30
+ float edge = abs(fract(bands) - 0.5);
31
+ float line = 1.0 - smoothstep(0.06, 0.06 + max(fwidth(bands), 0.005), edge);
32
+ vec3 color = mix(u_paper, u_inks[ink], line);
33
+ float source = 1.0 - smoothstep(0.035, 0.045, min(r1, r2));
34
+ color = mix(color, u_inks[7], source);
35
+ outColor = vec4(color, 1.0);
36
+ }`;
37
+
38
+ type ShaderRuntime = {
39
+ dispose: () => void;
40
+ setVisible: (value: boolean) => void;
41
+ };
42
+
43
+ function mountShader(
44
+ host: HTMLElement,
45
+ onError: (message: string) => void,
46
+ ): ShaderRuntime {
47
+ const viewport = host.querySelector<HTMLElement>('.alk-shader-viewport')!;
48
+ const canvas = host.querySelector<HTMLCanvasElement>('canvas')!;
49
+ const gl = canvas.getContext('webgl2', {
50
+ alpha: false,
51
+ antialias: false,
52
+ depth: false,
53
+ stencil: false,
54
+ });
55
+ if (!gl)
56
+ throw new Error(
57
+ 'WebGL 2 is unavailable. The static wave illustration is shown instead.',
58
+ );
59
+ const shaders: WebGLShader[] = [];
60
+ let program: WebGLProgram | null = null;
61
+ let vao: WebGLVertexArrayObject | null = null;
62
+ let resizeObserver: ResizeObserver | undefined;
63
+ const events = new AbortController();
64
+ let disposed = false;
65
+ let visible = false;
66
+ let playing = false;
67
+ let phase = 0;
68
+ let previousTime = 0;
69
+ let frame = 0;
70
+ let compilerNotes = false;
71
+ const pauseFrame = () => {
72
+ cancelAnimationFrame(frame);
73
+ frame = 0;
74
+ previousTime = 0;
75
+ };
76
+ const dispose = () => {
77
+ if (disposed) return;
78
+ disposed = true;
79
+ pauseFrame();
80
+ events.abort();
81
+ resizeObserver?.disconnect();
82
+ if (program) gl.deleteProgram(program);
83
+ if (vao) gl.deleteVertexArray(vao);
84
+ for (const shader of shaders) gl.deleteShader(shader);
85
+ gl.getExtension('WEBGL_lose_context')?.loseContext();
86
+ if (canvas.parentNode === viewport)
87
+ canvas.replaceWith(canvas.cloneNode(false));
88
+ };
89
+ const compile = (type: number, source: string) => {
90
+ const shader = gl.createShader(type);
91
+ if (!shader)
92
+ throw new Error(
93
+ 'The browser could not allocate a shader. Reload to retry.',
94
+ );
95
+ shaders.push(shader);
96
+ gl.shaderSource(shader, source);
97
+ gl.compileShader(shader);
98
+ const diagnostic = gl.getShaderInfoLog(shader)?.trim();
99
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS))
100
+ throw new Error(
101
+ `Shader compilation failed: ${diagnostic || 'No compiler detail was provided.'}`,
102
+ );
103
+ if (diagnostic) {
104
+ compilerNotes = true;
105
+ console.warn('AlkShader compiler note:', diagnostic);
106
+ }
107
+ return shader;
108
+ };
109
+
110
+ try {
111
+ const vertex = compile(gl.VERTEX_SHADER, VERTEX);
112
+ const fragment = compile(gl.FRAGMENT_SHADER, ALK_INTERFERENCE_FRAGMENT);
113
+ program = gl.createProgram();
114
+ if (!program)
115
+ throw new Error('The browser could not allocate a shader program.');
116
+ gl.attachShader(program, vertex);
117
+ gl.attachShader(program, fragment);
118
+ gl.linkProgram(program);
119
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS))
120
+ throw new Error(
121
+ `Shader linking failed: ${gl.getProgramInfoLog(program) || 'No linker detail was provided.'}`,
122
+ );
123
+ vao = gl.createVertexArray();
124
+ if (!vao) throw new Error('The browser could not allocate a vertex array.');
125
+ gl.bindVertexArray(vao);
126
+ gl.useProgram(program);
127
+ const uniform = (name: string) => {
128
+ const location = gl.getUniformLocation(program!, name);
129
+ if (location === null)
130
+ throw new Error(`Shader uniform ${name} is unavailable.`);
131
+ return location;
132
+ };
133
+ const resolutionUniform = uniform('u_resolution');
134
+ const phaseUniform = uniform('u_phase');
135
+ const frequencyUniform = uniform('u_frequency');
136
+ const angleUniform = uniform('u_angle');
137
+ const paperUniform = uniform('u_paper');
138
+ const inksUniform = uniform('u_inks[0]');
139
+ const frequency = host.querySelector<HTMLInputElement>('[data-frequency]')!;
140
+ const angle = host.querySelector<HTMLInputElement>('[data-angle]')!;
141
+ const button = host.querySelector<HTMLButtonElement>('[data-play]')!;
142
+ const status = host.querySelector<HTMLElement>('[role="status"]')!;
143
+ const render = (time: number) => {
144
+ frame = 0;
145
+ if (disposed || !visible || document.hidden) return;
146
+ if (playing && previousTime)
147
+ phase += Math.min((time - previousTime) / 1000, 0.05) * 0.8;
148
+ previousTime = time;
149
+ gl.useProgram(program);
150
+ gl.bindVertexArray(vao);
151
+ gl.uniform1f(phaseUniform, phase);
152
+ gl.uniform1f(frequencyUniform, Number(frequency.value));
153
+ gl.uniform1f(angleUniform, (Number(angle.value) * Math.PI) / 180);
154
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
155
+ if (playing) requestRender();
156
+ };
157
+ const requestRender = () => {
158
+ if (!disposed && visible && !document.hidden && !frame)
159
+ frame = requestAnimationFrame(render);
160
+ };
161
+ const updatePlay = () => {
162
+ button.textContent = playing ? 'Pause waves' : 'Play waves';
163
+ button.setAttribute('aria-pressed', String(playing));
164
+ status.textContent = `${playing ? 'Playing' : 'Paused'} · GLSL fragment shader${compilerNotes ? ' · compiler notes in console' : ''}`;
165
+ previousTime = 0;
166
+ if (!playing) pauseFrame();
167
+ requestRender();
168
+ };
169
+ const theme = () => {
170
+ const style = getComputedStyle(viewport);
171
+ // CSS supplies sRGB board and ink values; the framebuffer uses the same values.
172
+ const paper = style.backgroundColor
173
+ .match(/[\d.]+/g)
174
+ ?.slice(0, 3)
175
+ .map(Number);
176
+ if (!paper || paper.length !== 3)
177
+ throw new Error(
178
+ 'The current board background could not be resolved for the shader.',
179
+ );
180
+ gl.useProgram(program);
181
+ gl.uniform3fv(
182
+ paperUniform,
183
+ paper.map((value) => value / 255),
184
+ );
185
+ const inks = ALK_INKS.flatMap((ink) => {
186
+ const hex = ink.hex.replace('#', '');
187
+ return [0, 2, 4].map(
188
+ (offset) => parseInt(hex.slice(offset, offset + 2), 16) / 255,
189
+ );
190
+ });
191
+ gl.uniform3fv(inksUniform, inks);
192
+ requestRender();
193
+ };
194
+ const resize = () => {
195
+ const dpr = Math.min(devicePixelRatio || 1, 2);
196
+ const maxSize = gl.getParameter(gl.MAX_RENDERBUFFER_SIZE) as number;
197
+ const width = Math.min(
198
+ maxSize,
199
+ Math.max(1, Math.round(viewport.clientWidth * dpr)),
200
+ );
201
+ const height = Math.min(
202
+ maxSize,
203
+ Math.max(1, Math.round(viewport.clientHeight * dpr)),
204
+ );
205
+ if (canvas.width !== width || canvas.height !== height) {
206
+ canvas.width = width;
207
+ canvas.height = height;
208
+ }
209
+ gl.viewport(0, 0, width, height);
210
+ gl.useProgram(program);
211
+ gl.uniform2f(resolutionUniform, width, height);
212
+ requestRender();
213
+ };
214
+ for (const control of [frequency, angle, button]) control.disabled = false;
215
+ frequency.addEventListener(
216
+ 'input',
217
+ () => {
218
+ host.querySelector<HTMLOutputElement>(
219
+ '[data-frequency-output]',
220
+ )!.value = Number(frequency.value).toFixed(1);
221
+ requestRender();
222
+ },
223
+ { signal: events.signal },
224
+ );
225
+ angle.addEventListener(
226
+ 'input',
227
+ () => {
228
+ host.querySelector<HTMLOutputElement>('[data-angle-output]')!.value =
229
+ `${Number(angle.value).toFixed(0)}°`;
230
+ requestRender();
231
+ },
232
+ { signal: events.signal },
233
+ );
234
+ button.addEventListener(
235
+ 'click',
236
+ () => {
237
+ playing = !playing;
238
+ updatePlay();
239
+ },
240
+ { signal: events.signal },
241
+ );
242
+ const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)');
243
+ reducedMotion.addEventListener(
244
+ 'change',
245
+ () => {
246
+ if (reducedMotion.matches) {
247
+ playing = false;
248
+ updatePlay();
249
+ }
250
+ },
251
+ { signal: events.signal },
252
+ );
253
+ window.addEventListener('alk:theme-change', theme, {
254
+ signal: events.signal,
255
+ });
256
+ matchMedia('(prefers-color-scheme: dark)').addEventListener(
257
+ 'change',
258
+ theme,
259
+ { signal: events.signal },
260
+ );
261
+ document.addEventListener(
262
+ 'visibilitychange',
263
+ () => {
264
+ if (document.hidden) pauseFrame();
265
+ else requestRender();
266
+ },
267
+ { signal: events.signal },
268
+ );
269
+ canvas.addEventListener(
270
+ 'webglcontextlost',
271
+ (event) => {
272
+ event.preventDefault();
273
+ onError(
274
+ 'The shader graphics context was lost. Reload to retry; the static illustration is available.',
275
+ );
276
+ },
277
+ { signal: events.signal },
278
+ );
279
+ resizeObserver = new ResizeObserver(resize);
280
+ resizeObserver.observe(viewport);
281
+ resize();
282
+ theme();
283
+ updatePlay();
284
+ return {
285
+ dispose,
286
+ setVisible: (value) => {
287
+ visible = value;
288
+ if (value) requestRender();
289
+ else pauseFrame();
290
+ },
291
+ };
292
+ } catch (error) {
293
+ dispose();
294
+ throw error;
295
+ }
296
+ }
297
+
298
+ function registerShader() {
299
+ class AlkShaderElement extends HTMLElement {
300
+ private runtime?: ShaderRuntime;
301
+ private observer?: IntersectionObserver;
302
+ private events?: AbortController;
303
+
304
+ connectedCallback() {
305
+ if (this.events) return;
306
+ this.events = new AbortController();
307
+ window.addEventListener('pagehide', () => this.stop(), {
308
+ signal: this.events.signal,
309
+ });
310
+ window.addEventListener('pageshow', () => this.observe(), {
311
+ signal: this.events.signal,
312
+ });
313
+ this.observe();
314
+ }
315
+ disconnectedCallback() {
316
+ this.events?.abort();
317
+ this.events = undefined;
318
+ this.stop();
319
+ }
320
+ private observe() {
321
+ if (this.observer || !this.isConnected) return;
322
+ this.observer = new IntersectionObserver(
323
+ (entries) => {
324
+ const visible = entries[0]?.isIntersecting ?? false;
325
+ if (visible && !this.runtime && this.dataset.state !== 'error') {
326
+ try {
327
+ this.runtime = mountShader(this, (message) => this.fail(message));
328
+ this.dataset.state = 'ready';
329
+ } catch (error) {
330
+ this.fail(
331
+ error instanceof Error
332
+ ? error.message
333
+ : 'The shader could not be initialized.',
334
+ );
335
+ }
336
+ }
337
+ this.runtime?.setVisible(visible);
338
+ },
339
+ { threshold: 0.01 },
340
+ );
341
+ this.observer.observe(this);
342
+ }
343
+ private fail(message: string) {
344
+ this.runtime?.dispose();
345
+ this.runtime = undefined;
346
+ this.dataset.state = 'error';
347
+ this.querySelector<HTMLElement>('[role="status"]')!.textContent = message;
348
+ for (const control of this.querySelectorAll<
349
+ HTMLInputElement | HTMLButtonElement
350
+ >('input, button'))
351
+ control.disabled = true;
352
+ }
353
+ private stop() {
354
+ this.observer?.disconnect();
355
+ this.observer = undefined;
356
+ this.runtime?.dispose();
357
+ this.runtime = undefined;
358
+ this.dataset.state = 'idle';
359
+ const play = this.querySelector<HTMLButtonElement>('[data-play]')!;
360
+ play.textContent = 'Play waves';
361
+ play.setAttribute('aria-pressed', 'false');
362
+ this.querySelector<HTMLElement>('[role="status"]')!.textContent =
363
+ 'Shader loads when visible. The static illustration remains available.';
364
+ for (const control of this.querySelectorAll<
365
+ HTMLInputElement | HTMLButtonElement
366
+ >('input, button'))
367
+ control.disabled = true;
368
+ }
369
+ }
370
+ if (!customElements.get('alk-shader'))
371
+ customElements.define('alk-shader', AlkShaderElement);
372
+ }
373
+
374
+ if (typeof window !== 'undefined' && typeof customElements !== 'undefined')
375
+ registerShader();