@5even7/dlc-ui 0.2.18 → 0.2.19

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/color.mjs CHANGED
@@ -1,102 +1,102 @@
1
- /**
2
- * Tiny event emitter. Accepts any event name so the API stays open for
3
- * future events (hover, click, custom) without breaking changes.
4
- */
5
- function createEmitter() {
6
- // Plain object storage (no Map/Set) so the legacy build has no API
7
- // dependencies beyond what IE11 provides.
8
- const listeners = Object.create(null);
9
-
10
- return {
11
- on(event, fn) {
12
- if (!listeners[event]) listeners[event] = [];
13
- listeners[event].push(fn);
14
- return () => {
15
- const current = listeners[event];
16
- if (current) {
17
- const index = current.indexOf(fn);
18
- if (index !== -1) current.splice(index, 1);
19
- }
20
- };
21
- },
22
- off(event, fn) {
23
- const current = listeners[event];
24
- if (!current) return false;
25
- const index = current.indexOf(fn);
26
- if (index === -1) return false;
27
- current.splice(index, 1);
28
- return true;
29
- },
30
- emit(event, ...args) {
31
- const current = listeners[event];
32
- if (!current) return;
33
- for (const fn of current.slice()) fn(...args);
34
- }
35
- };
1
+ /**
2
+ * Tiny event emitter. Accepts any event name so the API stays open for
3
+ * future events (hover, click, custom) without breaking changes.
4
+ */
5
+ function createEmitter() {
6
+ // Plain object storage (no Map/Set) so the legacy build has no API
7
+ // dependencies beyond what IE11 provides.
8
+ const listeners = Object.create(null);
9
+
10
+ return {
11
+ on(event, fn) {
12
+ if (!listeners[event]) listeners[event] = [];
13
+ listeners[event].push(fn);
14
+ return () => {
15
+ const current = listeners[event];
16
+ if (current) {
17
+ const index = current.indexOf(fn);
18
+ if (index !== -1) current.splice(index, 1);
19
+ }
20
+ };
21
+ },
22
+ off(event, fn) {
23
+ const current = listeners[event];
24
+ if (!current) return false;
25
+ const index = current.indexOf(fn);
26
+ if (index === -1) return false;
27
+ current.splice(index, 1);
28
+ return true;
29
+ },
30
+ emit(event, ...args) {
31
+ const current = listeners[event];
32
+ if (!current) return;
33
+ for (const fn of current.slice()) fn(...args);
34
+ }
35
+ };
36
36
  }
37
37
 
38
- const QUALITY_TIERS = {
39
- low: { dpr: 1 },
40
- medium: { dpr: 1.5 },
41
- high: { dpr: 2 }
42
- };
43
-
44
- function autoDprCap() {
45
- const nav = typeof navigator !== 'undefined' ? navigator : null;
46
- const memory = nav && typeof nav.deviceMemory === 'number' ? nav.deviceMemory : 8;
47
- const cores = nav && typeof nav.hardwareConcurrency === 'number' ? nav.hardwareConcurrency : 8;
48
- return memory <= 4 || cores <= 4 ? 1.5 : 2;
49
- }
50
-
51
- function dprCapFor(quality) {
52
- if (quality === 'auto') return autoDprCap();
53
- const tier = QUALITY_TIERS[quality] || QUALITY_TIERS.medium;
54
- return tier.dpr;
55
- }
56
-
57
- function effectiveDprCap(quality, renderScale = 1) {
58
- const scale = Number(renderScale);
59
- const normalizedScale = Number.isFinite(scale) ? Math.min(1, Math.max(0.25, scale)) : 1;
60
- return Math.max(0.5, dprCapFor(quality) * normalizedScale);
38
+ const QUALITY_TIERS = {
39
+ low: { dpr: 1 },
40
+ medium: { dpr: 1.5 },
41
+ high: { dpr: 2 }
42
+ };
43
+
44
+ function autoDprCap() {
45
+ const nav = typeof navigator !== 'undefined' ? navigator : null;
46
+ const memory = nav && typeof nav.deviceMemory === 'number' ? nav.deviceMemory : 8;
47
+ const cores = nav && typeof nav.hardwareConcurrency === 'number' ? nav.hardwareConcurrency : 8;
48
+ return memory <= 4 || cores <= 4 ? 1.5 : 2;
61
49
  }
62
50
 
63
- /**
64
- * Merge defaults < preset < user. Unknown user keys are preserved so the
65
- * component API can grow (new props, cssVars, callbacks) without a breaking
66
- * change.
67
- */
68
- function normalizeOptions(defaults, preset, user) {
69
- // undefined means "not provided" (e.g. Vue $props with unset props):
70
- // drop those keys before merging so defaults/preset values survive.
71
- const omitUndefined = (source) => {
72
- const result = {};
73
- for (const key of Object.keys(source)) {
74
- if (source[key] !== undefined) result[key] = source[key];
75
- }
76
- return result;
77
- };
78
- const presetOptions = omitUndefined(preset && typeof preset === 'object' ? preset : {});
79
- const userOptions = omitUndefined(user && typeof user === 'object' ? user : {});
80
- return { ...defaults, ...presetOptions, ...userOptions };
51
+ function dprCapFor(quality) {
52
+ if (quality === 'auto') return autoDprCap();
53
+ const tier = QUALITY_TIERS[quality] || QUALITY_TIERS.medium;
54
+ return tier.dpr;
81
55
  }
82
56
 
83
- /**
84
- * 公共色板:NC-01~NC-06 的四色组(底色 / 主色 / 辅色 / 高光色)。
85
- * Capsule 预置与 dlc-color 等组件共用,新增色板只改这里。
86
- */
87
- const PALETTES = {
88
- original: ['#FFF3EA', '#F5B27A', '#F67BC6', '#A978E8'],
89
- ocean: ['#EAF6FF', '#8FD0FF', '#3B87F6', '#6B58E9'],
90
- klein: ['#EDF2FF', '#2F58D5', '#1B2040', '#E07A43'],
91
- ultraviolet: ['#F2EEFF', '#B99AF1', '#8F74DB', '#D7D85C'],
92
- chrome: ['#F5F6F8', '#B9C0CC', '#7F8793', '#4A4F59'],
93
- plus: ['#FFF0E6', '#F6C26B', '#F98A64', '#E86D74']
57
+ function effectiveDprCap(quality, renderScale = 1) {
58
+ const scale = Number(renderScale);
59
+ const normalizedScale = Number.isFinite(scale) ? Math.min(1, Math.max(0.25, scale)) : 1;
60
+ return Math.max(0.5, dprCapFor(quality) * normalizedScale);
61
+ }
62
+
63
+ /**
64
+ * Merge defaults < preset < user. Unknown user keys are preserved so the
65
+ * component API can grow (new props, cssVars, callbacks) without a breaking
66
+ * change.
67
+ */
68
+ function normalizeOptions(defaults, preset, user) {
69
+ // undefined means "not provided" (e.g. Vue $props with unset props):
70
+ // drop those keys before merging so defaults/preset values survive.
71
+ const omitUndefined = (source) => {
72
+ const result = {};
73
+ for (const key of Object.keys(source)) {
74
+ if (source[key] !== undefined) result[key] = source[key];
75
+ }
76
+ return result;
77
+ };
78
+ const presetOptions = omitUndefined(preset && typeof preset === 'object' ? preset : {});
79
+ const userOptions = omitUndefined(user && typeof user === 'object' ? user : {});
80
+ return { ...defaults, ...presetOptions, ...userOptions };
81
+ }
82
+
83
+ /**
84
+ * 公共色板:NC-01~NC-06 的四色组(底色 / 主色 / 辅色 / 高光色)。
85
+ * Capsule 预置与 dlc-color 等组件共用,新增色板只改这里。
86
+ */
87
+ const PALETTES = {
88
+ original: ['#FFF3EA', '#F5B27A', '#F67BC6', '#A978E8'],
89
+ ocean: ['#EAF6FF', '#8FD0FF', '#3B87F6', '#6B58E9'],
90
+ klein: ['#EDF2FF', '#2F58D5', '#1B2040', '#E07A43'],
91
+ ultraviolet: ['#F2EEFF', '#B99AF1', '#8F74DB', '#D7D85C'],
92
+ chrome: ['#F5F6F8', '#B9C0CC', '#7F8793', '#4A4F59'],
93
+ plus: ['#FFF0E6', '#F6C26B', '#F98A64', '#E86D74']
94
94
  };
95
95
 
96
- /**
97
- * Capsule 预置:仅星云材质 NC-01~NC-06(NC-07~09 aurora 已移除)。
98
- * 颜色引用公共色板,seed/speed 决定形态与流速。
99
- */
96
+ /**
97
+ * Capsule 预置:仅星云材质 NC-01~NC-06(NC-07~09 aurora 已移除)。
98
+ * 颜色引用公共色板,seed/speed 决定形态与流速。
99
+ */
100
100
  const CAPSULE_PRESETS = [
101
101
  { id: 'original', code: 'NC-01', name: '初光', group: 'warm', seed: 1.7, speed: 0.5, colors: [...PALETTES.original] },
102
102
  { id: 'ocean', code: 'NC-02', name: '沧溟', group: 'cold', seed: 8.2, speed: 0.48, colors: [...PALETTES.ocean] },
@@ -126,19 +126,25 @@ function getPreset(kind, ref) {
126
126
  return found;
127
127
  }
128
128
 
129
- const DEFAULTS = {
130
- capsule: {
131
- quality: 'auto',
132
- renderer: 'auto',
133
- fps: 60,
129
+ const DEFAULTS = {
130
+ capsule: {
131
+ quality: 'auto',
132
+ renderer: 'auto',
133
+ fps: 60,
134
134
  respectReducedMotion: true}};
135
135
 
136
136
  /**
137
137
  * Color helpers. `normalizeColor` converts any supported CSS color
138
- * (hex / named / rgb() / rgba()) into a 6-digit hex string, so renderers can
139
- * keep working with #rrggbb only. rgba() alpha is intentionally dropped:
140
- * the WebGL shader has no per-color alpha channel, and the component is
141
- * opaque by design — use component-level `opacity` for transparency.
138
+ * (hex / named / rgb() / rgba() / hsl() / hsla()) into a 6-digit hex string,
139
+ * so renderers can keep working with #rrggbb only. rgba()/hsla() alpha is
140
+ * intentionally dropped: the WebGL shader has no per-color alpha channel, and
141
+ * the component is opaque by design — use component-level `opacity` for
142
+ * transparency.
143
+ *
144
+ * `normalizeColorWithAlpha` keeps the alpha channel: it returns a 6-digit hex
145
+ * when alpha is 1, an 8-digit #rrggbbaa hex when alpha < 1, and the literal
146
+ * string 'transparent' for `transparent`. Use it for renderers (e.g. SVG)
147
+ * that can represent per-color transparency.
142
148
  */
143
149
 
144
150
  const NAMED_COLORS = {
@@ -296,46 +302,145 @@ function clampChannel(value) {
296
302
  return Math.min(255, Math.max(0, Math.round(value)));
297
303
  }
298
304
 
299
- function normalizeRgbArgs(args) {
300
- const parts = args.split(/[,\s/]+/).filter(Boolean);
305
+ function clampUnit(value) {
306
+ return Math.min(1, Math.max(0, value));
307
+ }
308
+
309
+ function hexPair(value) {
310
+ const text = clampChannel(value).toString(16);
311
+ return text.length === 1 ? `0${text}` : text;
312
+ }
313
+
314
+ /**
315
+ * Parse the argument list of rgb()/rgba()/hsl()/hsla() (comma syntax or
316
+ * modern space + slash syntax). Returns { parts, alpha } where alpha is 1 when
317
+ * omitted. Returns null when the argument list cannot be parsed.
318
+ */
319
+ function parseFunctionArgs(args) {
320
+ const parts = args
321
+ .split(/[,\s]+/)
322
+ .map((part) => part.trim())
323
+ .filter((part) => part !== '' && part !== '/');
301
324
  if (parts.length < 3) return null;
302
- const to255 = (value) => {
303
- if (typeof value !== 'string' || !value) return null;
304
- if (value.charAt(value.length - 1) === '%') return clampChannel((parseFloat(value) / 100) * 255);
305
- const number = Number(value);
306
- return Number.isFinite(number) ? clampChannel(number) : null;
307
- };
308
- const red = to255(parts[0]);
309
- const green = to255(parts[1]);
310
- const blue = to255(parts[2]);
311
- if (red === null || green === null || blue === null) return null;
312
- const hex = (n) => {
313
- const text = n.toString(16);
314
- return text.length === 1 ? `0${text}` : text;
315
- };
316
- return `#${hex(red)}${hex(green)}${hex(blue)}`;
325
+ let alpha = 1;
326
+ if (parts.length >= 4) {
327
+ const alphaText = parts.pop().trim();
328
+ if (alphaText.endsWith('%')) {
329
+ const percent = parseFloat(alphaText);
330
+ if (!Number.isFinite(percent)) return null;
331
+ alpha = clampUnit(percent / 100);
332
+ } else {
333
+ const number = Number(alphaText);
334
+ if (!Number.isFinite(number)) return null;
335
+ alpha = clampUnit(number);
336
+ }
337
+ }
338
+ return { parts, alpha };
339
+ }
340
+
341
+ function parseChannel(value) {
342
+ if (typeof value !== 'string' || value === '') return null;
343
+ const text = value.trim();
344
+ if (text.endsWith('%')) return clampChannel((parseFloat(text) / 100) * 255);
345
+ const number = Number(text);
346
+ return Number.isFinite(number) ? clampChannel(number) : null;
347
+ }
348
+
349
+ function parsePercent(value) {
350
+ if (typeof value !== 'string' || value === '') return null;
351
+ const text = value.trim();
352
+ if (!text.endsWith('%')) return null;
353
+ const number = parseFloat(text);
354
+ return Number.isFinite(number) ? Math.min(100, Math.max(0, number)) : null;
355
+ }
356
+
357
+ function parseHue(value) {
358
+ if (typeof value !== 'string' || value === '') return null;
359
+ const number = parseFloat(value);
360
+ return Number.isFinite(number) ? number : null;
361
+ }
362
+
363
+ /** Standard HSL -> RGB. h in degrees, s/l in 0..100, returns r/g/b in 0..255. */
364
+ function hslToRgb(h, s, l) {
365
+ const hue = ((h % 360) + 360) % 360 / 360;
366
+ const saturation = s / 100;
367
+ const lightness = l / 100;
368
+ const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation;
369
+ const section = hue * 6;
370
+ const x = chroma * (1 - Math.abs((section % 2) - 1));
371
+ let red = 0;
372
+ let green = 0;
373
+ let blue = 0;
374
+ if (section < 1) { red = chroma; green = x; }
375
+ else if (section < 2) { red = x; green = chroma; }
376
+ else if (section < 3) { green = chroma; blue = x; }
377
+ else if (section < 4) { green = x; blue = chroma; }
378
+ else if (section < 5) { red = x; blue = chroma; }
379
+ else { red = chroma; blue = x; }
380
+ const match = lightness - chroma / 2;
381
+ return [
382
+ Math.round((red + match) * 255),
383
+ Math.round((green + match) * 255),
384
+ Math.round((blue + match) * 255)
385
+ ];
317
386
  }
318
387
 
319
388
  /**
320
- * Convert any supported CSS color into `#rrggbb`. Returns null when the
321
- * value cannot be parsed. Supports: #rgb / #rrggbb (case-insensitive),
322
- * CSS named colors, rgb() / rgba() with comma or modern space syntax,
323
- * including percentages. Alpha in rgba() is ignored.
389
+ * Parse any supported CSS color. When keepAlpha is true the alpha channel is
390
+ * preserved (8-digit hex for alpha < 1); when false it is dropped. 'transparent'
391
+ * is only preserved when keepAlpha is true.
324
392
  */
325
- function normalizeColor(value) {
393
+ function parseColor(value, keepAlpha) {
326
394
  if (typeof value !== 'string') return null;
327
395
  const input = value.trim();
328
396
  if (!input) return null;
329
397
  const lower = input.toLowerCase();
330
398
  if (NAMED_COLORS[lower]) return NAMED_COLORS[lower];
331
- if (/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(input)) {
332
- const hex = input.slice(1).toLowerCase();
333
- if (hex.length === 3) return `#${hex.split('').map((c) => c + c).join('')}`;
334
- return `#${hex}`;
399
+ // #rgb / #rgba / #rrggbb / #rrggbbaa,以及无 # 前缀的 6/8 位 hex(dicebear 习惯)
400
+ const hexMatch = input.match(/^#?([0-9a-f]{3,8})$/i);
401
+ if (hexMatch) {
402
+ let hex = hexMatch[1].toLowerCase();
403
+ if (hex.length === 3 || hex.length === 4) {
404
+ hex = hex.split('').map((c) => c + c).join('');
405
+ }
406
+ return `#${hex.slice(0, 6)}`;
407
+ }
408
+ const funcMatch = input.match(/^(rgba?|hsla?)\(([^)]*)\)$/i);
409
+ if (!funcMatch) return null;
410
+ const kind = funcMatch[1].toLowerCase();
411
+ const parsed = parseFunctionArgs(funcMatch[2]);
412
+ if (!parsed) return null;
413
+ const { parts, alpha } = parsed;
414
+ let red;
415
+ let green;
416
+ let blue;
417
+ if (kind === 'rgb' || kind === 'rgba') {
418
+ if (parts.length !== 3) return null;
419
+ red = parseChannel(parts[0]);
420
+ green = parseChannel(parts[1]);
421
+ blue = parseChannel(parts[2]);
422
+ if (red === null || green === null || blue === null) return null;
423
+ } else {
424
+ if (parts.length !== 3) return null;
425
+ const hue = parseHue(parts[0]);
426
+ const saturation = parsePercent(parts[1]);
427
+ const lightness = parsePercent(parts[2]);
428
+ if (hue === null || saturation === null || lightness === null) return null;
429
+ [red, green, blue] = hslToRgb(hue, saturation, lightness);
335
430
  }
336
- const match = input.match(/^rgba?\((.*)\)$/i);
337
- if (match) return normalizeRgbArgs(match[1]);
338
- return null;
431
+ const hex = `#${hexPair(red)}${hexPair(green)}${hexPair(blue)}`;
432
+ return hex;
433
+ }
434
+
435
+ /**
436
+ * Convert any supported CSS color into `#rrggbb`. Returns null when the
437
+ * value cannot be parsed. Supports: #rgb / #rgba / #rrggbb / #rrggbbaa
438
+ * (case-insensitive), bare 6/8-digit hex, CSS named colors, rgb() / rgba() /
439
+ * hsl() / hsla() with comma or modern space syntax, including percentages.
440
+ * Alpha is ignored.
441
+ */
442
+ function normalizeColor(value) {
443
+ return parseColor(value);
339
444
  }
340
445
 
341
446
  function hexToRgb01(color) {
@@ -349,524 +454,524 @@ function hexToRgb01(color) {
349
454
  ];
350
455
  }
351
456
 
352
- const VERTEX_SHADER = `#version 300 es
353
- in vec2 a_position;
354
- out vec2 v_uv;
355
- void main() {
356
- v_uv = a_position * 0.5 + 0.5;
357
- gl_Position = vec4(a_position, 0.0, 1.0);
358
- }`;
359
-
360
- const FRAGMENT_SHADER = `#version 300 es
361
- precision highp float;
362
-
363
- in vec2 v_uv;
364
- out vec4 outColor;
365
-
366
- uniform vec2 u_resolution;
367
- uniform float u_time;
368
- uniform float u_seed;
369
- uniform float u_motion;
370
- uniform vec2 u_pointer;
371
- uniform vec3 u_colorA;
372
- uniform vec3 u_colorB;
373
- uniform vec3 u_colorC;
374
- uniform vec3 u_colorD;
375
-
376
- float hash21(vec2 p) {
377
- p = fract(p * vec2(123.34, 456.21));
378
- p += dot(p, p + 45.32 + u_seed);
379
- return fract(p.x * p.y);
380
- }
381
-
382
- float noise(vec2 p) {
383
- vec2 i = floor(p);
384
- vec2 f = fract(p);
385
- f = f * f * (3.0 - 2.0 * f);
386
- float a = hash21(i);
387
- float b = hash21(i + vec2(1.0, 0.0));
388
- float c = hash21(i + vec2(0.0, 1.0));
389
- float d = hash21(i + vec2(1.0, 1.0));
390
- return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
391
- }
392
-
393
- float fbm(vec2 p) {
394
- float value = 0.0;
395
- float amplitude = 0.52;
396
- mat2 rotation = mat2(0.80, 0.60, -0.60, 0.80);
397
- for (int i = 0; i < 6; i++) {
398
- value += amplitude * noise(p);
399
- p = rotation * p * 2.03 + 17.7;
400
- amplitude *= 0.5;
401
- }
402
- return value;
403
- }
404
-
405
- float gaussian(float value, float center, float width) {
406
- return exp(-pow(value - center, 2.0) / max(width, 0.0001));
407
- }
408
-
409
- vec3 palette(float t) {
410
- t = clamp(t, 0.0, 1.0);
411
- vec3 shadow = mix(u_colorA, u_colorB, smoothstep(0.06, 0.62, t));
412
- vec3 body = mix(u_colorB, u_colorC, smoothstep(0.30, 0.82, t));
413
- vec3 highlight = mix(u_colorC, u_colorD, smoothstep(0.74, 1.0, t));
414
- vec3 restrained = mix(shadow, body, smoothstep(0.26, 0.72, t));
415
- return mix(restrained, highlight, smoothstep(0.78, 0.97, t));
416
- }
417
-
418
- vec3 renderNebula(vec2 uv, vec2 p, vec2 pointer, float distanceToPointer, float t) {
419
- vec2 delta = p - pointer;
420
- float influence = exp(-distanceToPointer * 4.6) * u_motion;
421
- float angle = influence * 1.7;
422
- mat2 swirl = mat2(cos(angle), -sin(angle), sin(angle), cos(angle));
423
- p = pointer + swirl * delta;
424
- p += normalize(delta + 0.0001) * influence * 0.08;
425
-
426
- vec2 drift = vec2(t * 0.22, -t * 0.13);
427
- vec2 q = vec2(
428
- fbm(p * 1.35 + drift + u_seed),
429
- fbm(p * 1.35 + vec2(5.2, 1.3) - drift * 0.85)
430
- );
431
- vec2 r = vec2(
432
- fbm(p * 2.0 + 3.6 * q + vec2(1.7, 9.2) + t * 0.10),
433
- fbm(p * 2.0 + 3.0 * q + vec2(8.3, 2.8) - t * 0.085)
434
- );
435
-
436
- float cloud = fbm(p * 1.7 + 4.2 * r);
437
- float veins = fbm(p * 4.0 - 2.0 * q + t * 0.065);
438
- float nebula = smoothstep(0.18, 0.91, cloud * 0.9 + veins * 0.22);
439
-
440
- vec3 color = palette(nebula);
441
- color += u_colorD * pow(max(cloud - 0.63, 0.0), 2.0) * 1.05;
442
- color *= 0.78 + 0.34 * smoothstep(0.15, 0.9, veins);
443
-
444
- vec2 starGrid = floor((uv + vec2(u_seed * 0.013, 0.0)) * vec2(132.0, 58.0));
445
- vec2 starCell = fract(uv * vec2(132.0, 58.0)) - 0.5;
446
- float starRandom = hash21(starGrid);
447
- float starShape = smoothstep(0.075, 0.0, length(starCell));
448
- float starMask = step(0.989, starRandom) * starShape;
449
- float twinkle = 0.35 + 0.65 * sin(t * (1.0 + starRandom * 2.4) + starRandom * 40.0) * 0.5 + 0.5;
450
- color += starMask * twinkle * mix(u_colorC, u_colorD, starRandom) * 1.05;
451
-
452
- float pointerGlow = exp(-distanceToPointer * 7.0) * u_motion;
453
- color += u_colorD * pointerGlow * 0.28;
454
- return color;
455
- }
456
-
457
- void main() {
458
- vec2 uv = v_uv;
459
- vec2 p = uv - 0.5;
460
- p.x *= u_resolution.x / max(u_resolution.y, 1.0);
461
-
462
- vec2 pointer = u_pointer - 0.5;
463
- pointer.x *= u_resolution.x / max(u_resolution.y, 1.0);
464
- float distanceToPointer = length(p - pointer);
465
-
466
- vec3 color = renderNebula(uv, p, pointer, distanceToPointer, u_time);
467
- float vignette = smoothstep(0.94, 0.18, length((uv - 0.5) * vec2(1.0, 1.35)));
468
- color *= 0.70 + vignette * 0.42;
469
- color = pow(max(color, vec3(0.0)), vec3(0.88));
470
-
471
- outColor = vec4(color, 1.0);
472
- }`;
473
-
474
- function compileShader(gl, type, source) {
475
- const shader = gl.createShader(type);
476
- gl.shaderSource(shader, source);
477
- gl.compileShader(shader);
478
- if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
479
- const message = gl.getShaderInfoLog(shader) || 'Unknown shader compile error';
480
- gl.deleteShader(shader);
481
- throw new Error(message);
482
- }
483
- return shader;
484
- }
485
-
486
- function createProgram(gl) {
487
- const vertex = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
488
- const fragment = compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER);
489
- const program = gl.createProgram();
490
- gl.attachShader(program, vertex);
491
- gl.attachShader(program, fragment);
492
- gl.linkProgram(program);
493
- gl.deleteShader(vertex);
494
- gl.deleteShader(fragment);
495
- if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
496
- const message = gl.getProgramInfoLog(program) || 'Unknown program link error';
497
- gl.deleteProgram(program);
498
- throw new Error(message);
499
- }
500
- return program;
501
- }
502
-
503
- class CosmicRenderer {
504
- constructor(canvas, preset, options = {}) {
505
- this.canvas = canvas;
506
- this.preset = { ...preset };
507
- this.options = { dprCap: 1.75, mouseColor: true, ...options };
508
- this.gl = canvas.getContext('webgl2', {
509
- alpha: false,
510
- antialias: false,
511
- depth: false,
512
- powerPreference: this.options.powerPreference || 'high-performance',
513
- preserveDrawingBuffer: false
514
- });
515
- if (!this.gl) throw new Error('WebGL2 is not available');
516
-
517
- this.program = createProgram(this.gl);
518
- this.locations = this.#getLocations();
519
- this.pointer = [0.72, 0.45];
520
- this.pointerTarget = [...this.pointer];
521
- this.motion = 0;
522
- this.motionTarget = 0;
523
- this.timeOffset = preset.seed * 0.73;
524
- this.colors = preset.colors.map(hexToRgb01);
525
- this.visible = true;
526
- this.disposed = false;
527
-
528
- this.#setupGeometry();
529
- this.#bindEvents();
530
- this.#bindContextEvents();
531
- this.resize();
532
- }
533
-
534
- #getLocations() {
535
- const gl = this.gl;
536
- const uniform = (name) => gl.getUniformLocation(this.program, name);
537
- return {
538
- position: gl.getAttribLocation(this.program, 'a_position'),
539
- resolution: uniform('u_resolution'),
540
- time: uniform('u_time'),
541
- seed: uniform('u_seed'),
542
- motion: uniform('u_motion'),
543
- pointer: uniform('u_pointer'),
544
- colorA: uniform('u_colorA'),
545
- colorB: uniform('u_colorB'),
546
- colorC: uniform('u_colorC'),
547
- colorD: uniform('u_colorD')
548
- };
549
- }
550
-
551
- #setupGeometry() {
552
- const gl = this.gl;
553
- const vertices = new Float32Array([-1, -1, 3, -1, -1, 3]);
554
- this.buffer = gl.createBuffer();
555
- gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
556
- gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
557
- }
558
-
559
- #bindEvents() {
560
- if (this.options.mouseColor === false) return;
561
- this.eventTarget = this.options.eventTarget || this.canvas.parentElement || this.canvas;
562
- this.onPointerMove = (event) => {
563
- const rect = this.canvas.getBoundingClientRect();
564
- this.pointerTarget[0] = (event.clientX - rect.left) / Math.max(rect.width, 1);
565
- this.pointerTarget[1] = 1 - (event.clientY - rect.top) / Math.max(rect.height, 1);
566
- this.motionTarget = 1;
567
- };
568
- this.onPointerLeave = () => { this.motionTarget = 0; };
569
- this.eventTarget.addEventListener('pointermove', this.onPointerMove, { passive: true });
570
- this.eventTarget.addEventListener('pointerdown', this.onPointerMove, { passive: true });
571
- this.eventTarget.addEventListener('pointerleave', this.onPointerLeave, { passive: true });
572
- }
573
-
574
- #bindContextEvents() {
575
- this.onContextLost = (event) => {
576
- event.preventDefault();
577
- if (this.disposed) return;
578
- this.visible = false;
579
- if (typeof this.options.onContextLost === 'function') this.options.onContextLost(event);
580
- };
581
- this.onContextRestored = () => {
582
- if (this.disposed) return;
583
- this.program = createProgram(this.gl);
584
- this.locations = this.#getLocations();
585
- this.#setupGeometry();
586
- this.visible = true;
587
- this.resize();
588
- if (typeof this.options.onContextRestored === 'function') this.options.onContextRestored();
589
- };
590
- this.canvas.addEventListener('webglcontextlost', this.onContextLost, false);
591
- this.canvas.addEventListener('webglcontextrestored', this.onContextRestored, false);
592
- }
593
-
594
- setPreset(preset) {
595
- this.preset = { ...preset };
596
- this.colors = preset.colors.map(hexToRgb01);
597
- this.timeOffset = preset.seed * 0.73;
598
- }
599
-
600
- setDprCap(cap) {
601
- this.options.dprCap = cap;
602
- this.resize();
603
- }
604
-
605
- setMouseColor(enabled) {
606
- this.options.mouseColor = enabled !== false;
607
- if (this.options.mouseColor) {
608
- if (this.onPointerMove) return this;
609
- this.#bindEvents();
610
- return this;
611
- }
612
- if (this.onPointerMove) {
613
- const target = this.eventTarget;
614
- target.removeEventListener('pointermove', this.onPointerMove);
615
- target.removeEventListener('pointerdown', this.onPointerMove);
616
- target.removeEventListener('pointerleave', this.onPointerLeave);
617
- this.onPointerMove = null;
618
- this.onPointerLeave = null;
619
- }
620
- this.motionTarget = 0;
621
- return this;
622
- }
623
-
624
- randomize() {
625
- this.preset.seed = Math.random() * 100;
626
- this.timeOffset = Math.random() * 40;
627
- }
628
-
629
- resize() {
630
- const dpr = Math.min(window.devicePixelRatio || 1, this.options.dprCap);
631
- const rect = this.canvas.getBoundingClientRect();
632
- const width = Math.max(2, Math.round(rect.width * dpr));
633
- const height = Math.max(2, Math.round(rect.height * dpr));
634
- if (this.canvas.width !== width || this.canvas.height !== height) {
635
- this.canvas.width = width;
636
- this.canvas.height = height;
637
- }
638
- this.gl.viewport(0, 0, width, height);
639
- }
640
-
641
- draw(elapsedSeconds, paused = false) {
642
- if (this.disposed || !this.visible) return;
643
- const gl = this.gl;
644
- this.pointer[0] += (this.pointerTarget[0] - this.pointer[0]) * 0.08;
645
- this.pointer[1] += (this.pointerTarget[1] - this.pointer[1]) * 0.08;
646
- this.motion += (this.motionTarget - this.motion) * 0.07;
647
-
648
- gl.useProgram(this.program);
649
- gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
650
- gl.enableVertexAttribArray(this.locations.position);
651
- gl.vertexAttribPointer(this.locations.position, 2, gl.FLOAT, false, 0, 0);
652
-
653
- gl.uniform2f(this.locations.resolution, this.canvas.width, this.canvas.height);
654
- gl.uniform1f(this.locations.time, this.timeOffset + (paused ? 0 : elapsedSeconds * this.preset.speed));
655
- gl.uniform1f(this.locations.seed, this.preset.seed);
656
- gl.uniform1f(this.locations.motion, this.motion);
657
- gl.uniform2f(this.locations.pointer, this.pointer[0], this.pointer[1]);
658
- gl.uniform3fv(this.locations.colorA, this.colors[0]);
659
- gl.uniform3fv(this.locations.colorB, this.colors[1]);
660
- gl.uniform3fv(this.locations.colorC, this.colors[2]);
661
- gl.uniform3fv(this.locations.colorD, this.colors[3]);
662
- gl.drawArrays(gl.TRIANGLES, 0, 3);
663
- }
664
-
665
- dispose() {
666
- this.disposed = true;
667
- const target = this.eventTarget || this.canvas;
668
- target.removeEventListener('pointermove', this.onPointerMove);
669
- target.removeEventListener('pointerdown', this.onPointerMove);
670
- target.removeEventListener('pointerleave', this.onPointerLeave);
671
- this.canvas.removeEventListener('webglcontextlost', this.onContextLost, false);
672
- this.canvas.removeEventListener('webglcontextrestored', this.onContextRestored, false);
673
- this.gl.deleteBuffer(this.buffer);
674
- this.gl.deleteProgram(this.program);
675
- const lose = this.gl.getExtension('WEBGL_lose_context');
676
- if (lose) lose.loseContext();
677
- }
457
+ const VERTEX_SHADER = `#version 300 es
458
+ in vec2 a_position;
459
+ out vec2 v_uv;
460
+ void main() {
461
+ v_uv = a_position * 0.5 + 0.5;
462
+ gl_Position = vec4(a_position, 0.0, 1.0);
463
+ }`;
464
+
465
+ const FRAGMENT_SHADER = `#version 300 es
466
+ precision highp float;
467
+
468
+ in vec2 v_uv;
469
+ out vec4 outColor;
470
+
471
+ uniform vec2 u_resolution;
472
+ uniform float u_time;
473
+ uniform float u_seed;
474
+ uniform float u_motion;
475
+ uniform vec2 u_pointer;
476
+ uniform vec3 u_colorA;
477
+ uniform vec3 u_colorB;
478
+ uniform vec3 u_colorC;
479
+ uniform vec3 u_colorD;
480
+
481
+ float hash21(vec2 p) {
482
+ p = fract(p * vec2(123.34, 456.21));
483
+ p += dot(p, p + 45.32 + u_seed);
484
+ return fract(p.x * p.y);
678
485
  }
679
486
 
680
- function rgb(color, alpha = 1) {
681
- const [r, g, b] = hexToRgb01(color).map((value) => Math.round(value * 255));
682
- return `rgba(${r}, ${g}, ${b}, ${alpha})`;
683
- }
684
-
685
- class FallbackRenderer {
686
- constructor(canvas, preset, options = {}) {
687
- this.canvas = canvas;
688
- this.preset = preset;
689
- this.context = canvas.getContext('2d');
690
- this.visible = true;
691
- this.timePhase = 0;
692
- this.dprCap = options.dprCap || 1.5;
693
- }
694
-
695
- resize() {
696
- const rect = this.canvas.getBoundingClientRect();
697
- const dpr = Math.min(window.devicePixelRatio || 1, this.dprCap);
698
- const width = Math.max(2, Math.round(rect.width * dpr));
699
- const height = Math.max(2, Math.round(rect.height * dpr));
700
- if (this.canvas.width !== width || this.canvas.height !== height) {
701
- this.canvas.width = width;
702
- this.canvas.height = height;
703
- }
704
- }
705
-
706
- drawNebula(time) {
707
- const ctx = this.context;
708
- const { width, height } = this.canvas;
709
- const t = (time + this.timePhase) * (this.preset.speed || 1);
710
- const phase = (this.preset.seed || 0) * 0.7;
711
- const gradient = ctx.createLinearGradient(0, 0, width, height);
712
- gradient.addColorStop(0, this.preset.colors[0]);
713
- gradient.addColorStop(0.38, this.preset.colors[1]);
714
- gradient.addColorStop(0.72, this.preset.colors[2]);
715
- gradient.addColorStop(1, this.preset.colors[3]);
716
- ctx.fillStyle = gradient;
717
- ctx.fillRect(0, 0, width, height);
718
-
719
- ctx.globalCompositeOperation = 'screen';
720
- for (let index = 0; index < 6; index += 1) {
721
- const x = (0.5 + 0.45 * Math.sin(t * 0.32 + index * 1.7 + phase)) * width;
722
- const y = (0.5 + 0.4 * Math.cos(t * 0.25 + index + phase)) * height;
723
- const radius = Math.max(width, height) * (0.08 + (index % 3) * 0.04);
724
- const glow = ctx.createRadialGradient(x, y, 0, x, y, radius);
725
- glow.addColorStop(0, rgb(this.preset.colors[(index + 1) % 4], 0.24));
726
- glow.addColorStop(1, rgb(this.preset.colors[index % 4], 0));
727
- ctx.fillStyle = glow;
728
- ctx.fillRect(x - radius, y - radius, radius * 2, radius * 2);
729
- }
730
- ctx.globalCompositeOperation = 'source-over';
731
- }
732
-
733
- draw(time) {
734
- if (!this.visible) return;
735
- this.drawNebula(time);
736
- }
737
-
738
- setPreset(preset) {
739
- this.preset = preset;
740
- }
741
-
742
- setDprCap(cap) {
743
- this.dprCap = cap;
744
- this.resize();
745
- }
746
-
747
- randomize() {
748
- if (this.preset) {
749
- this.preset.seed = Math.random() * 100;
750
- this.timePhase = Math.random() * Math.PI * 2;
751
- }
752
- }
753
- setMouseColor() {}
754
- dispose() {}
487
+ float noise(vec2 p) {
488
+ vec2 i = floor(p);
489
+ vec2 f = fract(p);
490
+ f = f * f * (3.0 - 2.0 * f);
491
+ float a = hash21(i);
492
+ float b = hash21(i + vec2(1.0, 0.0));
493
+ float c = hash21(i + vec2(0.0, 1.0));
494
+ float d = hash21(i + vec2(1.0, 1.0));
495
+ return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
496
+ }
497
+
498
+ float fbm(vec2 p) {
499
+ float value = 0.0;
500
+ float amplitude = 0.52;
501
+ mat2 rotation = mat2(0.80, 0.60, -0.60, 0.80);
502
+ for (int i = 0; i < 6; i++) {
503
+ value += amplitude * noise(p);
504
+ p = rotation * p * 2.03 + 17.7;
505
+ amplitude *= 0.5;
506
+ }
507
+ return value;
755
508
  }
756
509
 
757
- /**
758
- * Document-level shared rAF scheduler. Every component instance subscribes
759
- * its own frame callback; the whole page runs ONE animation loop (like the
760
- * original demo), which avoids jank from many competing rAF loops.
761
- */
762
- const subscribers = [];
763
- let running = false;
764
- let rafId = 0;
765
- let last = 0;
766
-
767
- function tick(now) {
768
- if (!running) return;
769
- const activeItems = [];
770
- {
771
- for (const item of subscribers.slice()) {
772
- try {
773
- if (!item.isPaused()) activeItems.push(item);
774
- } catch {}
775
- }
776
- }
777
- if (activeItems.length === 0) {
778
- running = false;
779
- rafId = 0;
780
- last = 0;
781
- return;
782
- }
783
- const delta = last ? Math.min((now - last) / 1000, 0.05) : 0;
784
- last = now;
785
- // Schedule the next frame BEFORE running callbacks so one throwing
786
- // subscriber can never kill the whole animation loop.
787
- rafId = requestAnimationFrame(tick);
788
- for (const item of activeItems) {
789
- try {
790
- item.onFrame(delta, now);
791
- } catch (error) {
792
- console.warn('[dlc-ui] frame error:', error);
793
- }
794
- }
795
- if (subscribers.length === 0) {
796
- cancelAnimationFrame(rafId);
797
- running = false;
798
- rafId = 0;
799
- }
800
- }
801
-
802
- function start() {
803
- if (running) return;
804
- running = true;
805
- last = 0;
806
- rafId = requestAnimationFrame(tick);
807
- }
808
-
809
- function wakeScheduler() {
810
- start();
811
- }
812
-
813
- function subscribeScheduler(onFrame, isPaused) {
814
- const item = { onFrame, isPaused };
815
- subscribers.push(item);
816
- start();
817
- return () => {
818
- const index = subscribers.indexOf(item);
819
- if (index !== -1) subscribers.splice(index, 1);
820
- if (subscribers.length === 0 && rafId) {
821
- cancelAnimationFrame(rafId);
822
- running = false;
823
- rafId = 0;
824
- }
825
- };
510
+ float gaussian(float value, float center, float width) {
511
+ return exp(-pow(value - center, 2.0) / max(width, 0.0001));
826
512
  }
827
513
 
828
- /**
829
- * Gates drawing on "element intersects viewport AND the page tab is visible".
830
- * Falls back to always-visible when IntersectionObserver is unavailable.
831
- */
832
- function createVisibilityGuard(element, onChange = null) {
833
- let intersecting = true;
834
- let pageVisible = typeof document === 'undefined' || !document.hidden;
835
- let disposed = false;
836
- let observer = null;
837
-
838
- if (typeof IntersectionObserver !== 'undefined') {
839
- observer = new IntersectionObserver(
840
- (entries) => {
841
- intersecting = entries.some((entry) => entry.isIntersecting);
842
- if (typeof onChange === 'function') onChange();
843
- },
844
- { rootMargin: '180px' }
845
- );
846
- observer.observe(element);
847
- }
848
-
849
- const onVisibilityChange = () => {
850
- pageVisible = typeof document !== 'undefined' && !document.hidden;
851
- if (typeof onChange === 'function') onChange();
852
- };
853
- if (typeof document !== 'undefined') {
854
- document.addEventListener('visibilitychange', onVisibilityChange);
855
- }
856
-
857
- return {
858
- isVisible() {
859
- return intersecting && pageVisible;
860
- },
861
- dispose() {
862
- if (disposed) return;
863
- disposed = true;
864
- if (observer) observer.disconnect();
865
- if (typeof document !== 'undefined') {
866
- document.removeEventListener('visibilitychange', onVisibilityChange);
867
- }
868
- }
869
- };
514
+ vec3 palette(float t) {
515
+ t = clamp(t, 0.0, 1.0);
516
+ vec3 shadow = mix(u_colorA, u_colorB, smoothstep(0.06, 0.62, t));
517
+ vec3 body = mix(u_colorB, u_colorC, smoothstep(0.30, 0.82, t));
518
+ vec3 highlight = mix(u_colorC, u_colorD, smoothstep(0.74, 1.0, t));
519
+ vec3 restrained = mix(shadow, body, smoothstep(0.26, 0.72, t));
520
+ return mix(restrained, highlight, smoothstep(0.78, 0.97, t));
521
+ }
522
+
523
+ vec3 renderNebula(vec2 uv, vec2 p, vec2 pointer, float distanceToPointer, float t) {
524
+ vec2 delta = p - pointer;
525
+ float influence = exp(-distanceToPointer * 4.6) * u_motion;
526
+ float angle = influence * 1.7;
527
+ mat2 swirl = mat2(cos(angle), -sin(angle), sin(angle), cos(angle));
528
+ p = pointer + swirl * delta;
529
+ p += normalize(delta + 0.0001) * influence * 0.08;
530
+
531
+ vec2 drift = vec2(t * 0.22, -t * 0.13);
532
+ vec2 q = vec2(
533
+ fbm(p * 1.35 + drift + u_seed),
534
+ fbm(p * 1.35 + vec2(5.2, 1.3) - drift * 0.85)
535
+ );
536
+ vec2 r = vec2(
537
+ fbm(p * 2.0 + 3.6 * q + vec2(1.7, 9.2) + t * 0.10),
538
+ fbm(p * 2.0 + 3.0 * q + vec2(8.3, 2.8) - t * 0.085)
539
+ );
540
+
541
+ float cloud = fbm(p * 1.7 + 4.2 * r);
542
+ float veins = fbm(p * 4.0 - 2.0 * q + t * 0.065);
543
+ float nebula = smoothstep(0.18, 0.91, cloud * 0.9 + veins * 0.22);
544
+
545
+ vec3 color = palette(nebula);
546
+ color += u_colorD * pow(max(cloud - 0.63, 0.0), 2.0) * 1.05;
547
+ color *= 0.78 + 0.34 * smoothstep(0.15, 0.9, veins);
548
+
549
+ vec2 starGrid = floor((uv + vec2(u_seed * 0.013, 0.0)) * vec2(132.0, 58.0));
550
+ vec2 starCell = fract(uv * vec2(132.0, 58.0)) - 0.5;
551
+ float starRandom = hash21(starGrid);
552
+ float starShape = smoothstep(0.075, 0.0, length(starCell));
553
+ float starMask = step(0.989, starRandom) * starShape;
554
+ float twinkle = 0.35 + 0.65 * sin(t * (1.0 + starRandom * 2.4) + starRandom * 40.0) * 0.5 + 0.5;
555
+ color += starMask * twinkle * mix(u_colorC, u_colorD, starRandom) * 1.05;
556
+
557
+ float pointerGlow = exp(-distanceToPointer * 7.0) * u_motion;
558
+ color += u_colorD * pointerGlow * 0.28;
559
+ return color;
560
+ }
561
+
562
+ void main() {
563
+ vec2 uv = v_uv;
564
+ vec2 p = uv - 0.5;
565
+ p.x *= u_resolution.x / max(u_resolution.y, 1.0);
566
+
567
+ vec2 pointer = u_pointer - 0.5;
568
+ pointer.x *= u_resolution.x / max(u_resolution.y, 1.0);
569
+ float distanceToPointer = length(p - pointer);
570
+
571
+ vec3 color = renderNebula(uv, p, pointer, distanceToPointer, u_time);
572
+ float vignette = smoothstep(0.94, 0.18, length((uv - 0.5) * vec2(1.0, 1.35)));
573
+ color *= 0.70 + vignette * 0.42;
574
+ color = pow(max(color, vec3(0.0)), vec3(0.88));
575
+
576
+ outColor = vec4(color, 1.0);
577
+ }`;
578
+
579
+ function compileShader(gl, type, source) {
580
+ const shader = gl.createShader(type);
581
+ gl.shaderSource(shader, source);
582
+ gl.compileShader(shader);
583
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
584
+ const message = gl.getShaderInfoLog(shader) || 'Unknown shader compile error';
585
+ gl.deleteShader(shader);
586
+ throw new Error(message);
587
+ }
588
+ return shader;
589
+ }
590
+
591
+ function createProgram(gl) {
592
+ const vertex = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
593
+ const fragment = compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER);
594
+ const program = gl.createProgram();
595
+ gl.attachShader(program, vertex);
596
+ gl.attachShader(program, fragment);
597
+ gl.linkProgram(program);
598
+ gl.deleteShader(vertex);
599
+ gl.deleteShader(fragment);
600
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
601
+ const message = gl.getProgramInfoLog(program) || 'Unknown program link error';
602
+ gl.deleteProgram(program);
603
+ throw new Error(message);
604
+ }
605
+ return program;
606
+ }
607
+
608
+ class CosmicRenderer {
609
+ constructor(canvas, preset, options = {}) {
610
+ this.canvas = canvas;
611
+ this.preset = { ...preset };
612
+ this.options = { dprCap: 1.75, mouseColor: true, ...options };
613
+ this.gl = canvas.getContext('webgl2', {
614
+ alpha: false,
615
+ antialias: false,
616
+ depth: false,
617
+ powerPreference: this.options.powerPreference || 'high-performance',
618
+ preserveDrawingBuffer: false
619
+ });
620
+ if (!this.gl) throw new Error('WebGL2 is not available');
621
+
622
+ this.program = createProgram(this.gl);
623
+ this.locations = this.#getLocations();
624
+ this.pointer = [0.72, 0.45];
625
+ this.pointerTarget = [...this.pointer];
626
+ this.motion = 0;
627
+ this.motionTarget = 0;
628
+ this.timeOffset = preset.seed * 0.73;
629
+ this.colors = preset.colors.map(hexToRgb01);
630
+ this.visible = true;
631
+ this.disposed = false;
632
+
633
+ this.#setupGeometry();
634
+ this.#bindEvents();
635
+ this.#bindContextEvents();
636
+ this.resize();
637
+ }
638
+
639
+ #getLocations() {
640
+ const gl = this.gl;
641
+ const uniform = (name) => gl.getUniformLocation(this.program, name);
642
+ return {
643
+ position: gl.getAttribLocation(this.program, 'a_position'),
644
+ resolution: uniform('u_resolution'),
645
+ time: uniform('u_time'),
646
+ seed: uniform('u_seed'),
647
+ motion: uniform('u_motion'),
648
+ pointer: uniform('u_pointer'),
649
+ colorA: uniform('u_colorA'),
650
+ colorB: uniform('u_colorB'),
651
+ colorC: uniform('u_colorC'),
652
+ colorD: uniform('u_colorD')
653
+ };
654
+ }
655
+
656
+ #setupGeometry() {
657
+ const gl = this.gl;
658
+ const vertices = new Float32Array([-1, -1, 3, -1, -1, 3]);
659
+ this.buffer = gl.createBuffer();
660
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
661
+ gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
662
+ }
663
+
664
+ #bindEvents() {
665
+ if (this.options.mouseColor === false) return;
666
+ this.eventTarget = this.options.eventTarget || this.canvas.parentElement || this.canvas;
667
+ this.onPointerMove = (event) => {
668
+ const rect = this.canvas.getBoundingClientRect();
669
+ this.pointerTarget[0] = (event.clientX - rect.left) / Math.max(rect.width, 1);
670
+ this.pointerTarget[1] = 1 - (event.clientY - rect.top) / Math.max(rect.height, 1);
671
+ this.motionTarget = 1;
672
+ };
673
+ this.onPointerLeave = () => { this.motionTarget = 0; };
674
+ this.eventTarget.addEventListener('pointermove', this.onPointerMove, { passive: true });
675
+ this.eventTarget.addEventListener('pointerdown', this.onPointerMove, { passive: true });
676
+ this.eventTarget.addEventListener('pointerleave', this.onPointerLeave, { passive: true });
677
+ }
678
+
679
+ #bindContextEvents() {
680
+ this.onContextLost = (event) => {
681
+ event.preventDefault();
682
+ if (this.disposed) return;
683
+ this.visible = false;
684
+ if (typeof this.options.onContextLost === 'function') this.options.onContextLost(event);
685
+ };
686
+ this.onContextRestored = () => {
687
+ if (this.disposed) return;
688
+ this.program = createProgram(this.gl);
689
+ this.locations = this.#getLocations();
690
+ this.#setupGeometry();
691
+ this.visible = true;
692
+ this.resize();
693
+ if (typeof this.options.onContextRestored === 'function') this.options.onContextRestored();
694
+ };
695
+ this.canvas.addEventListener('webglcontextlost', this.onContextLost, false);
696
+ this.canvas.addEventListener('webglcontextrestored', this.onContextRestored, false);
697
+ }
698
+
699
+ setPreset(preset) {
700
+ this.preset = { ...preset };
701
+ this.colors = preset.colors.map(hexToRgb01);
702
+ this.timeOffset = preset.seed * 0.73;
703
+ }
704
+
705
+ setDprCap(cap) {
706
+ this.options.dprCap = cap;
707
+ this.resize();
708
+ }
709
+
710
+ setMouseColor(enabled) {
711
+ this.options.mouseColor = enabled !== false;
712
+ if (this.options.mouseColor) {
713
+ if (this.onPointerMove) return this;
714
+ this.#bindEvents();
715
+ return this;
716
+ }
717
+ if (this.onPointerMove) {
718
+ const target = this.eventTarget;
719
+ target.removeEventListener('pointermove', this.onPointerMove);
720
+ target.removeEventListener('pointerdown', this.onPointerMove);
721
+ target.removeEventListener('pointerleave', this.onPointerLeave);
722
+ this.onPointerMove = null;
723
+ this.onPointerLeave = null;
724
+ }
725
+ this.motionTarget = 0;
726
+ return this;
727
+ }
728
+
729
+ randomize() {
730
+ this.preset.seed = Math.random() * 100;
731
+ this.timeOffset = Math.random() * 40;
732
+ }
733
+
734
+ resize() {
735
+ const dpr = Math.min(window.devicePixelRatio || 1, this.options.dprCap);
736
+ const rect = this.canvas.getBoundingClientRect();
737
+ const width = Math.max(2, Math.round(rect.width * dpr));
738
+ const height = Math.max(2, Math.round(rect.height * dpr));
739
+ if (this.canvas.width !== width || this.canvas.height !== height) {
740
+ this.canvas.width = width;
741
+ this.canvas.height = height;
742
+ }
743
+ this.gl.viewport(0, 0, width, height);
744
+ }
745
+
746
+ draw(elapsedSeconds, paused = false) {
747
+ if (this.disposed || !this.visible) return;
748
+ const gl = this.gl;
749
+ this.pointer[0] += (this.pointerTarget[0] - this.pointer[0]) * 0.08;
750
+ this.pointer[1] += (this.pointerTarget[1] - this.pointer[1]) * 0.08;
751
+ this.motion += (this.motionTarget - this.motion) * 0.07;
752
+
753
+ gl.useProgram(this.program);
754
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
755
+ gl.enableVertexAttribArray(this.locations.position);
756
+ gl.vertexAttribPointer(this.locations.position, 2, gl.FLOAT, false, 0, 0);
757
+
758
+ gl.uniform2f(this.locations.resolution, this.canvas.width, this.canvas.height);
759
+ gl.uniform1f(this.locations.time, this.timeOffset + (paused ? 0 : elapsedSeconds * this.preset.speed));
760
+ gl.uniform1f(this.locations.seed, this.preset.seed);
761
+ gl.uniform1f(this.locations.motion, this.motion);
762
+ gl.uniform2f(this.locations.pointer, this.pointer[0], this.pointer[1]);
763
+ gl.uniform3fv(this.locations.colorA, this.colors[0]);
764
+ gl.uniform3fv(this.locations.colorB, this.colors[1]);
765
+ gl.uniform3fv(this.locations.colorC, this.colors[2]);
766
+ gl.uniform3fv(this.locations.colorD, this.colors[3]);
767
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
768
+ }
769
+
770
+ dispose() {
771
+ this.disposed = true;
772
+ const target = this.eventTarget || this.canvas;
773
+ target.removeEventListener('pointermove', this.onPointerMove);
774
+ target.removeEventListener('pointerdown', this.onPointerMove);
775
+ target.removeEventListener('pointerleave', this.onPointerLeave);
776
+ this.canvas.removeEventListener('webglcontextlost', this.onContextLost, false);
777
+ this.canvas.removeEventListener('webglcontextrestored', this.onContextRestored, false);
778
+ this.gl.deleteBuffer(this.buffer);
779
+ this.gl.deleteProgram(this.program);
780
+ const lose = this.gl.getExtension('WEBGL_lose_context');
781
+ if (lose) lose.loseContext();
782
+ }
783
+ }
784
+
785
+ function rgb(color, alpha = 1) {
786
+ const [r, g, b] = hexToRgb01(color).map((value) => Math.round(value * 255));
787
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
788
+ }
789
+
790
+ class FallbackRenderer {
791
+ constructor(canvas, preset, options = {}) {
792
+ this.canvas = canvas;
793
+ this.preset = preset;
794
+ this.context = canvas.getContext('2d');
795
+ this.visible = true;
796
+ this.timePhase = 0;
797
+ this.dprCap = options.dprCap || 1.5;
798
+ }
799
+
800
+ resize() {
801
+ const rect = this.canvas.getBoundingClientRect();
802
+ const dpr = Math.min(window.devicePixelRatio || 1, this.dprCap);
803
+ const width = Math.max(2, Math.round(rect.width * dpr));
804
+ const height = Math.max(2, Math.round(rect.height * dpr));
805
+ if (this.canvas.width !== width || this.canvas.height !== height) {
806
+ this.canvas.width = width;
807
+ this.canvas.height = height;
808
+ }
809
+ }
810
+
811
+ drawNebula(time) {
812
+ const ctx = this.context;
813
+ const { width, height } = this.canvas;
814
+ const t = (time + this.timePhase) * (this.preset.speed || 1);
815
+ const phase = (this.preset.seed || 0) * 0.7;
816
+ const gradient = ctx.createLinearGradient(0, 0, width, height);
817
+ gradient.addColorStop(0, this.preset.colors[0]);
818
+ gradient.addColorStop(0.38, this.preset.colors[1]);
819
+ gradient.addColorStop(0.72, this.preset.colors[2]);
820
+ gradient.addColorStop(1, this.preset.colors[3]);
821
+ ctx.fillStyle = gradient;
822
+ ctx.fillRect(0, 0, width, height);
823
+
824
+ ctx.globalCompositeOperation = 'screen';
825
+ for (let index = 0; index < 6; index += 1) {
826
+ const x = (0.5 + 0.45 * Math.sin(t * 0.32 + index * 1.7 + phase)) * width;
827
+ const y = (0.5 + 0.4 * Math.cos(t * 0.25 + index + phase)) * height;
828
+ const radius = Math.max(width, height) * (0.08 + (index % 3) * 0.04);
829
+ const glow = ctx.createRadialGradient(x, y, 0, x, y, radius);
830
+ glow.addColorStop(0, rgb(this.preset.colors[(index + 1) % 4], 0.24));
831
+ glow.addColorStop(1, rgb(this.preset.colors[index % 4], 0));
832
+ ctx.fillStyle = glow;
833
+ ctx.fillRect(x - radius, y - radius, radius * 2, radius * 2);
834
+ }
835
+ ctx.globalCompositeOperation = 'source-over';
836
+ }
837
+
838
+ draw(time) {
839
+ if (!this.visible) return;
840
+ this.drawNebula(time);
841
+ }
842
+
843
+ setPreset(preset) {
844
+ this.preset = preset;
845
+ }
846
+
847
+ setDprCap(cap) {
848
+ this.dprCap = cap;
849
+ this.resize();
850
+ }
851
+
852
+ randomize() {
853
+ if (this.preset) {
854
+ this.preset.seed = Math.random() * 100;
855
+ this.timePhase = Math.random() * Math.PI * 2;
856
+ }
857
+ }
858
+ setMouseColor() {}
859
+ dispose() {}
860
+ }
861
+
862
+ /**
863
+ * Document-level shared rAF scheduler. Every component instance subscribes
864
+ * its own frame callback; the whole page runs ONE animation loop (like the
865
+ * original demo), which avoids jank from many competing rAF loops.
866
+ */
867
+ const subscribers = [];
868
+ let running = false;
869
+ let rafId = 0;
870
+ let last = 0;
871
+
872
+ function tick(now) {
873
+ if (!running) return;
874
+ const activeItems = [];
875
+ {
876
+ for (const item of subscribers.slice()) {
877
+ try {
878
+ if (!item.isPaused()) activeItems.push(item);
879
+ } catch {}
880
+ }
881
+ }
882
+ if (activeItems.length === 0) {
883
+ running = false;
884
+ rafId = 0;
885
+ last = 0;
886
+ return;
887
+ }
888
+ const delta = last ? Math.min((now - last) / 1000, 0.05) : 0;
889
+ last = now;
890
+ // Schedule the next frame BEFORE running callbacks so one throwing
891
+ // subscriber can never kill the whole animation loop.
892
+ rafId = requestAnimationFrame(tick);
893
+ for (const item of activeItems) {
894
+ try {
895
+ item.onFrame(delta, now);
896
+ } catch (error) {
897
+ console.warn('[dlc-ui] frame error:', error);
898
+ }
899
+ }
900
+ if (subscribers.length === 0) {
901
+ cancelAnimationFrame(rafId);
902
+ running = false;
903
+ rafId = 0;
904
+ }
905
+ }
906
+
907
+ function start() {
908
+ if (running) return;
909
+ running = true;
910
+ last = 0;
911
+ rafId = requestAnimationFrame(tick);
912
+ }
913
+
914
+ function wakeScheduler() {
915
+ start();
916
+ }
917
+
918
+ function subscribeScheduler(onFrame, isPaused) {
919
+ const item = { onFrame, isPaused };
920
+ subscribers.push(item);
921
+ start();
922
+ return () => {
923
+ const index = subscribers.indexOf(item);
924
+ if (index !== -1) subscribers.splice(index, 1);
925
+ if (subscribers.length === 0 && rafId) {
926
+ cancelAnimationFrame(rafId);
927
+ running = false;
928
+ rafId = 0;
929
+ }
930
+ };
931
+ }
932
+
933
+ /**
934
+ * Gates drawing on "element intersects viewport AND the page tab is visible".
935
+ * Falls back to always-visible when IntersectionObserver is unavailable.
936
+ */
937
+ function createVisibilityGuard(element, onChange = null) {
938
+ let intersecting = true;
939
+ let pageVisible = typeof document === 'undefined' || !document.hidden;
940
+ let disposed = false;
941
+ let observer = null;
942
+
943
+ if (typeof IntersectionObserver !== 'undefined') {
944
+ observer = new IntersectionObserver(
945
+ (entries) => {
946
+ intersecting = entries.some((entry) => entry.isIntersecting);
947
+ if (typeof onChange === 'function') onChange();
948
+ },
949
+ { rootMargin: '180px' }
950
+ );
951
+ observer.observe(element);
952
+ }
953
+
954
+ const onVisibilityChange = () => {
955
+ pageVisible = typeof document !== 'undefined' && !document.hidden;
956
+ if (typeof onChange === 'function') onChange();
957
+ };
958
+ if (typeof document !== 'undefined') {
959
+ document.addEventListener('visibilitychange', onVisibilityChange);
960
+ }
961
+
962
+ return {
963
+ isVisible() {
964
+ return intersecting && pageVisible;
965
+ },
966
+ dispose() {
967
+ if (disposed) return;
968
+ disposed = true;
969
+ if (observer) observer.disconnect();
970
+ if (typeof document !== 'undefined') {
971
+ document.removeEventListener('visibilitychange', onVisibilityChange);
972
+ }
973
+ }
974
+ };
870
975
  }
871
976
 
872
977
  /**
@@ -885,421 +990,421 @@ function nextTick(fn) {
885
990
  }
886
991
  }
887
992
 
888
- function normalizeFps(value, fallback = 60) {
889
- const fps = Number(value);
890
- if (!Number.isFinite(fps)) return fallback;
891
- return Math.min(60, Math.max(1, fps));
892
- }
893
-
894
- function createFrameGate(initialFps = 60) {
895
- let fps = normalizeFps(initialFps);
896
- let elapsed = 0;
897
- return {
898
- shouldDraw(delta) {
899
- elapsed += delta;
900
- const interval = 1 / fps;
901
- if (elapsed + 0.0001 < interval) return false;
902
- elapsed %= interval;
903
- return true;
904
- },
905
- setFps(value) {
906
- fps = normalizeFps(value, fps);
907
- elapsed = 0;
908
- return fps;
909
- },
910
- getFps() {
911
- return fps;
912
- }
913
- };
914
- }
915
-
916
- function createReducedMotionPreference(enabled, onChange) {
917
- const query = enabled && typeof matchMedia !== 'undefined'
918
- ? matchMedia('(prefers-reduced-motion: reduce)')
919
- : null;
920
- const notify = () => {
921
- if (typeof onChange === 'function') onChange(Boolean(query && query.matches));
922
- };
923
- if (query) {
924
- if (typeof query.addEventListener === 'function') query.addEventListener('change', notify);
925
- else if (typeof query.addListener === 'function') query.addListener(notify);
926
- }
927
- return {
928
- matches() {
929
- return Boolean(query && query.matches);
930
- },
931
- dispose() {
932
- if (!query) return;
933
- if (typeof query.removeEventListener === 'function') query.removeEventListener('change', notify);
934
- else if (typeof query.removeListener === 'function') query.removeListener(notify);
935
- }
936
- };
993
+ function normalizeFps(value, fallback = 60) {
994
+ const fps = Number(value);
995
+ if (!Number.isFinite(fps)) return fallback;
996
+ return Math.min(60, Math.max(1, fps));
997
+ }
998
+
999
+ function createFrameGate(initialFps = 60) {
1000
+ let fps = normalizeFps(initialFps);
1001
+ let elapsed = 0;
1002
+ return {
1003
+ shouldDraw(delta) {
1004
+ elapsed += delta;
1005
+ const interval = 1 / fps;
1006
+ if (elapsed + 0.0001 < interval) return false;
1007
+ elapsed %= interval;
1008
+ return true;
1009
+ },
1010
+ setFps(value) {
1011
+ fps = normalizeFps(value, fps);
1012
+ elapsed = 0;
1013
+ return fps;
1014
+ },
1015
+ getFps() {
1016
+ return fps;
1017
+ }
1018
+ };
1019
+ }
1020
+
1021
+ function createReducedMotionPreference(enabled, onChange) {
1022
+ const query = enabled && typeof matchMedia !== 'undefined'
1023
+ ? matchMedia('(prefers-reduced-motion: reduce)')
1024
+ : null;
1025
+ const notify = () => {
1026
+ if (typeof onChange === 'function') onChange(Boolean(query && query.matches));
1027
+ };
1028
+ if (query) {
1029
+ if (typeof query.addEventListener === 'function') query.addEventListener('change', notify);
1030
+ else if (typeof query.addListener === 'function') query.addListener(notify);
1031
+ }
1032
+ return {
1033
+ matches() {
1034
+ return Boolean(query && query.matches);
1035
+ },
1036
+ dispose() {
1037
+ if (!query) return;
1038
+ if (typeof query.removeEventListener === 'function') query.removeEventListener('change', notify);
1039
+ else if (typeof query.removeListener === 'function') query.removeListener(notify);
1040
+ }
1041
+ };
1042
+ }
1043
+
1044
+ function toFiniteNumber(value) {
1045
+ if (value == null || typeof value === 'boolean') return null;
1046
+ if (typeof value === 'string' && value.trim() === '') return null;
1047
+ const number = Number(value);
1048
+ return Number.isFinite(number) ? number : null;
1049
+ }
1050
+
1051
+ const HOST_STYLES = new WeakMap();
1052
+
1053
+ function acquireHostStyles(host) {
1054
+ const active = HOST_STYLES.get(host);
1055
+ if (active) {
1056
+ active.count += 1;
1057
+ return;
1058
+ }
1059
+ const state = {
1060
+ count: 1,
1061
+ position: host.style.position,
1062
+ isolation: host.style.isolation
1063
+ };
1064
+ const computed = getComputedStyle(host);
1065
+ if (computed.position === 'static' || computed.position === '') host.style.position = 'relative';
1066
+ host.style.isolation = 'isolate';
1067
+ HOST_STYLES.set(host, state);
937
1068
  }
938
1069
 
939
- function toFiniteNumber(value) {
940
- if (value == null || typeof value === 'boolean') return null;
941
- if (typeof value === 'string' && value.trim() === '') return null;
942
- const number = Number(value);
943
- return Number.isFinite(number) ? number : null;
1070
+ function releaseHostStyles(host) {
1071
+ const state = HOST_STYLES.get(host);
1072
+ if (!state) return;
1073
+ state.count -= 1;
1074
+ if (state.count > 0) return;
1075
+ host.style.position = state.position;
1076
+ host.style.isolation = state.isolation;
1077
+ HOST_STYLES.delete(host);
944
1078
  }
945
1079
 
946
- const HOST_STYLES = new WeakMap();
947
-
948
- function acquireHostStyles(host) {
949
- const active = HOST_STYLES.get(host);
950
- if (active) {
951
- active.count += 1;
952
- return;
953
- }
954
- const state = {
955
- count: 1,
956
- position: host.style.position,
957
- isolation: host.style.isolation
958
- };
959
- const computed = getComputedStyle(host);
960
- if (computed.position === 'static' || computed.position === '') host.style.position = 'relative';
961
- host.style.isolation = 'isolate';
962
- HOST_STYLES.set(host, state);
963
- }
964
-
965
- function releaseHostStyles(host) {
966
- const state = HOST_STYLES.get(host);
967
- if (!state) return;
968
- state.count -= 1;
969
- if (state.count > 0) return;
970
- host.style.position = state.position;
971
- host.style.isolation = state.isolation;
972
- HOST_STYLES.delete(host);
973
- }
974
-
975
- /**
976
- * Mount the cosmic (nebula) material as a background layer inside `host`.
977
- *
978
- * The layer is absolutely positioned and defaults to `z-index: -1`, so it
979
- * paints behind the host's in-flow content: text/buttons/images inside the
980
- * host sit on top with zero extra CSS. Host gets `position: relative;
981
- * isolation: isolate` automatically so the layer can never escape its box
982
- * (or break stacking outside it).
983
- *
984
- * Options: preset (literary name), colors, seed, speed, quality,
985
- * renderer, renderScale, powerPreference, fps, paused/static, mouseColor,
986
- * respectReducedMotion, opacity (0-1), fallbackColor (true=preset base
987
- * color, a hex string, or false to disable).
988
- */
989
- function createColorBackground(host, options = {}) {
990
- if (!host || typeof host.appendChild !== 'function') {
991
- throw new Error('createColorBackground: host element is required');
992
- }
993
-
994
- const preset = { ...getPreset('capsule', options.preset ?? '初光') };
995
- const merged = normalizeOptions(
996
- {
997
- quality: DEFAULTS.capsule.quality,
998
- renderer: DEFAULTS.capsule.renderer,
999
- respectReducedMotion: DEFAULTS.capsule.respectReducedMotion,
1000
- mouseColor: true,
1001
- renderScale: 1,
1002
- powerPreference: 'high-performance',
1003
- fps: DEFAULTS.capsule.fps,
1004
- paused: false,
1005
- static: false,
1006
- opacity: 1,
1007
- fallbackColor: true
1008
- },
1009
- preset,
1010
- options
1011
- );
1012
- merged.opacity = Number.isFinite(Number(merged.opacity))
1013
- ? Math.min(1, Math.max(0, Number(merged.opacity)))
1014
- : 1;
1015
- const normalizedColors = Array.isArray(merged.colors) ? merged.colors.map(normalizeColor) : [];
1016
- if (normalizedColors.length === 4 && normalizedColors.every(Boolean)) preset.colors = normalizedColors;
1017
- const initialSeed = toFiniteNumber(merged.seed);
1018
- const initialSpeed = toFiniteNumber(merged.speed);
1019
- if (initialSeed !== null) preset.seed = initialSeed;
1020
- if (initialSpeed !== null) preset.speed = initialSpeed;
1021
- merged.colors = [...preset.colors];
1022
- merged.seed = preset.seed;
1023
- merged.speed = preset.speed;
1024
- const optionColors = Array.isArray(options.colors) ? options.colors.map(normalizeColor) : [];
1025
- let colorOverride = optionColors.length === 4 && optionColors.every(Boolean)
1026
- ? [...optionColors]
1027
- : null;
1028
- let seedOverride = options.seed !== undefined ? toFiniteNumber(options.seed) : null;
1029
- let speedOverride = options.speed !== undefined ? toFiniteNumber(options.speed) : null;
1030
-
1031
- acquireHostStyles(host);
1032
-
1033
- const layer = document.createElement('div');
1034
- layer.className = 'dlc-color-layer';
1035
-
1036
- const syncLayerVars = () => {
1037
- layer.style.setProperty('--dlc-color-opacity', String(merged.opacity));
1038
- const fallback =
1039
- merged.fallbackColor === false || merged.fallbackColor == null
1040
- ? 'transparent'
1041
- : typeof merged.fallbackColor === 'string'
1042
- ? merged.fallbackColor
1043
- : preset.colors[0];
1044
- layer.style.setProperty('--dlc-color-bg', fallback);
1045
- };
1046
- syncLayerVars();
1047
-
1048
- const canvas = document.createElement('canvas');
1049
- canvas.className = 'dlc-color-canvas';
1050
- canvas.setAttribute('aria-hidden', 'true');
1051
- layer.appendChild(canvas);
1052
- host.appendChild(layer);
1053
-
1054
- const emitter = createEmitter();
1055
- let manuallyPaused = merged.paused === true;
1056
- let reducedPaused = false;
1057
- let staticMode = merged.static === true;
1058
- let contextLost = false;
1059
- let disposed = false;
1060
- let renderOnce = () => {};
1061
- const dirty = {
1062
- preset: false,
1063
- seed: false,
1064
- speed: false,
1065
- colors: false,
1066
- opacity: false,
1067
- fallbackColor: false,
1068
- mouseColor: false,
1069
- quality: false,
1070
- renderScale: false,
1071
- paused: false,
1072
- static: false,
1073
- fps: false
1074
- };
1075
-
1076
- let renderer;
1077
- const useWebgl = merged.renderer !== 'canvas2d';
1078
- if (useWebgl) {
1079
- try {
1080
- renderer = new CosmicRenderer(canvas, merged, {
1081
- dprCap: effectiveDprCap(merged.quality, merged.renderScale),
1082
- mouseColor: merged.mouseColor !== false,
1083
- eventTarget: host,
1084
- powerPreference: merged.powerPreference,
1085
- onContextLost: () => {
1086
- contextLost = true;
1087
- emitter.emit('contextlost', {});
1088
- },
1089
- onContextRestored: () => {
1090
- contextLost = false;
1091
- emitter.emit('contextrestored', {});
1092
- renderOnce();
1093
- wakeScheduler();
1094
- }
1095
- });
1096
- } catch (error) {
1097
- renderer = new FallbackRenderer(canvas, merged, {
1098
- dprCap: effectiveDprCap(merged.quality, merged.renderScale)
1099
- });
1100
- nextTick(() => {
1101
- if (disposed) return;
1102
- emitter.emit('error', {
1103
- message: String(error && error.message ? error.message : error)
1104
- });
1105
- });
1106
- }
1107
- } else {
1108
- renderer = new FallbackRenderer(canvas, merged, {
1109
- dprCap: effectiveDprCap(merged.quality, merged.renderScale)
1110
- });
1111
- }
1112
- renderer.resize();
1113
-
1114
- let animationTime = 0;
1115
- const frameGate = createFrameGate(merged.fps);
1116
- renderOnce = () => renderer.draw(animationTime);
1117
-
1118
- const resize = () => {
1119
- renderer.resize();
1120
- if (manuallyPaused || reducedPaused || staticMode) renderOnce();
1121
- };
1122
- const resizeObserver =
1123
- typeof ResizeObserver !== 'undefined'
1124
- ? new ResizeObserver(resize)
1125
- : null;
1126
- if (resizeObserver) resizeObserver.observe(host);
1127
- else window.addEventListener('resize', resize);
1128
-
1129
- const visibility = createVisibilityGuard(layer, wakeScheduler);
1130
- const motionPreference = createReducedMotionPreference(
1131
- merged.respectReducedMotion,
1132
- (matches) => {
1133
- reducedPaused = matches;
1134
- if (matches) renderOnce();
1135
- else wakeScheduler();
1136
- }
1137
- );
1138
- reducedPaused = motionPreference.matches();
1139
- const isMotionPaused = () => manuallyPaused || reducedPaused || staticMode || contextLost;
1140
-
1141
- renderOnce();
1142
- const unsubscribe = subscribeScheduler(
1143
- (delta) => {
1144
- animationTime += delta;
1145
- if (frameGate.shouldDraw(delta)) renderer.draw(animationTime);
1146
- },
1147
- () => isMotionPaused() || !visibility.isVisible()
1148
- );
1149
-
1150
- nextTick(() => {
1151
- if (disposed) return;
1152
- emitter.emit('ready', { preset: { ...preset } });
1153
- });
1154
-
1155
- return {
1156
- element: host,
1157
- layer,
1158
- canvas,
1159
- preset,
1160
- on: emitter.on,
1161
- off: emitter.off,
1162
- setPreset(ref) {
1163
- const next = getPreset('capsule', ref);
1164
- dirty.preset = true;
1165
- Object.assign(preset, next);
1166
- if (colorOverride) preset.colors = [...colorOverride];
1167
- if (seedOverride !== null) preset.seed = seedOverride;
1168
- if (speedOverride !== null) preset.speed = speedOverride;
1169
- const nextColors = preset.colors.map(normalizeColor);
1170
- if (nextColors.every(Boolean)) preset.colors = nextColors;
1171
- renderer.setPreset({ ...preset });
1172
- renderer.resize();
1173
- syncLayerVars();
1174
- if (isMotionPaused()) renderOnce();
1175
- emitter.emit('presetchange', { preset: { ...preset } });
1176
- return this;
1177
- },
1178
- setColors(colors) {
1179
- const next = Array.isArray(colors) ? colors.map(normalizeColor) : [];
1180
- if (next.length !== 4 || next.some((color) => !color)) return this;
1181
- dirty.colors = true;
1182
- colorOverride = [...next];
1183
- preset.colors = next;
1184
- renderer.setPreset({ ...preset, colors: next });
1185
- syncLayerVars();
1186
- if (isMotionPaused()) renderOnce();
1187
- return this;
1188
- },
1189
- setSeed(seed) {
1190
- const value = toFiniteNumber(seed);
1191
- if (value === null) return this;
1192
- dirty.seed = true;
1193
- seedOverride = value;
1194
- preset.seed = value;
1195
- renderer.setPreset({ ...preset });
1196
- if (isMotionPaused()) renderOnce();
1197
- return this;
1198
- },
1199
- setSpeed(speed) {
1200
- const value = toFiniteNumber(speed);
1201
- if (value === null) return this;
1202
- dirty.speed = true;
1203
- speedOverride = value;
1204
- preset.speed = value;
1205
- renderer.setPreset({ ...preset });
1206
- if (isMotionPaused()) renderOnce();
1207
- return this;
1208
- },
1209
- setFallbackColor(value) {
1210
- dirty.fallbackColor = true;
1211
- merged.fallbackColor = value;
1212
- syncLayerVars();
1213
- return this;
1214
- },
1215
- setMouseColor(value) {
1216
- dirty.mouseColor = true;
1217
- merged.mouseColor = value !== false;
1218
- if (typeof renderer.setMouseColor === 'function') renderer.setMouseColor(merged.mouseColor);
1219
- return this;
1220
- },
1221
- setQuality(quality) {
1222
- dirty.quality = true;
1223
- merged.quality = quality;
1224
- if (typeof renderer.setDprCap === 'function') {
1225
- renderer.setDprCap(effectiveDprCap(quality, merged.renderScale));
1226
- }
1227
- if (isMotionPaused()) renderOnce();
1228
- return this;
1229
- },
1230
- setRenderScale(renderScale) {
1231
- const value = Number(renderScale);
1232
- if (!Number.isFinite(value)) return this;
1233
- dirty.renderScale = true;
1234
- merged.renderScale = Math.min(1, Math.max(0.25, value));
1235
- if (typeof renderer.setDprCap === 'function') {
1236
- renderer.setDprCap(effectiveDprCap(merged.quality, merged.renderScale));
1237
- }
1238
- if (isMotionPaused()) renderOnce();
1239
- return this;
1240
- },
1241
- setOpacity(value) {
1242
- const opacity = Number(value);
1243
- if (!Number.isFinite(opacity)) return this;
1244
- dirty.opacity = true;
1245
- merged.opacity = Math.min(1, Math.max(0, opacity));
1246
- layer.style.setProperty('--dlc-color-opacity', String(merged.opacity));
1247
- return this;
1248
- },
1249
- randomize() {
1250
- this.setSeed(Math.random() * 100);
1251
- return this;
1252
- },
1253
- pause() {
1254
- dirty.paused = true;
1255
- manuallyPaused = true;
1256
- renderOnce();
1257
- return this;
1258
- },
1259
- resume() {
1260
- dirty.paused = true;
1261
- manuallyPaused = false;
1262
- wakeScheduler();
1263
- return this;
1264
- },
1265
- setPaused(value) {
1266
- return value ? this.pause() : this.resume();
1267
- },
1268
- setStatic(value) {
1269
- dirty.static = true;
1270
- staticMode = value === true;
1271
- merged.static = staticMode;
1272
- if (staticMode) renderOnce();
1273
- else wakeScheduler();
1274
- return this;
1275
- },
1276
- setFps(fps) {
1277
- dirty.fps = true;
1278
- merged.fps = frameGate.setFps(fps);
1279
- wakeScheduler();
1280
- return this;
1281
- },
1282
- dispose() {
1283
- disposed = true;
1284
- unsubscribe();
1285
- visibility.dispose();
1286
- motionPreference.dispose();
1287
- if (resizeObserver) resizeObserver.disconnect();
1288
- else window.removeEventListener('resize', resize);
1289
- renderer.dispose();
1290
- layer.remove();
1291
- releaseHostStyles(host);
1292
- },
1293
- get opacity() { return merged.opacity; },
1294
- get fallbackColor() { return merged.fallbackColor; },
1295
- get mouseColor() { return merged.mouseColor; },
1296
- get quality() { return merged.quality; },
1297
- get renderScale() { return merged.renderScale; },
1298
- get paused() { return manuallyPaused; },
1299
- get static() { return staticMode; },
1300
- get fps() { return frameGate.getFps(); },
1301
- dirty
1302
- };
1080
+ /**
1081
+ * Mount the cosmic (nebula) material as a background layer inside `host`.
1082
+ *
1083
+ * The layer is absolutely positioned and defaults to `z-index: -1`, so it
1084
+ * paints behind the host's in-flow content: text/buttons/images inside the
1085
+ * host sit on top with zero extra CSS. Host gets `position: relative;
1086
+ * isolation: isolate` automatically so the layer can never escape its box
1087
+ * (or break stacking outside it).
1088
+ *
1089
+ * Options: preset (literary name), colors, seed, speed, quality,
1090
+ * renderer, renderScale, powerPreference, fps, paused/static, mouseColor,
1091
+ * respectReducedMotion, opacity (0-1), fallbackColor (true=preset base
1092
+ * color, a hex string, or false to disable).
1093
+ */
1094
+ function createColorBackground(host, options = {}) {
1095
+ if (!host || typeof host.appendChild !== 'function') {
1096
+ throw new Error('createColorBackground: host element is required');
1097
+ }
1098
+
1099
+ const preset = { ...getPreset('capsule', options.preset ?? '初光') };
1100
+ const merged = normalizeOptions(
1101
+ {
1102
+ quality: DEFAULTS.capsule.quality,
1103
+ renderer: DEFAULTS.capsule.renderer,
1104
+ respectReducedMotion: DEFAULTS.capsule.respectReducedMotion,
1105
+ mouseColor: true,
1106
+ renderScale: 1,
1107
+ powerPreference: 'high-performance',
1108
+ fps: DEFAULTS.capsule.fps,
1109
+ paused: false,
1110
+ static: false,
1111
+ opacity: 1,
1112
+ fallbackColor: true
1113
+ },
1114
+ preset,
1115
+ options
1116
+ );
1117
+ merged.opacity = Number.isFinite(Number(merged.opacity))
1118
+ ? Math.min(1, Math.max(0, Number(merged.opacity)))
1119
+ : 1;
1120
+ const normalizedColors = Array.isArray(merged.colors) ? merged.colors.map(normalizeColor) : [];
1121
+ if (normalizedColors.length === 4 && normalizedColors.every(Boolean)) preset.colors = normalizedColors;
1122
+ const initialSeed = toFiniteNumber(merged.seed);
1123
+ const initialSpeed = toFiniteNumber(merged.speed);
1124
+ if (initialSeed !== null) preset.seed = initialSeed;
1125
+ if (initialSpeed !== null) preset.speed = initialSpeed;
1126
+ merged.colors = [...preset.colors];
1127
+ merged.seed = preset.seed;
1128
+ merged.speed = preset.speed;
1129
+ const optionColors = Array.isArray(options.colors) ? options.colors.map(normalizeColor) : [];
1130
+ let colorOverride = optionColors.length === 4 && optionColors.every(Boolean)
1131
+ ? [...optionColors]
1132
+ : null;
1133
+ let seedOverride = options.seed !== undefined ? toFiniteNumber(options.seed) : null;
1134
+ let speedOverride = options.speed !== undefined ? toFiniteNumber(options.speed) : null;
1135
+
1136
+ acquireHostStyles(host);
1137
+
1138
+ const layer = document.createElement('div');
1139
+ layer.className = 'dlc-color-layer';
1140
+
1141
+ const syncLayerVars = () => {
1142
+ layer.style.setProperty('--dlc-color-opacity', String(merged.opacity));
1143
+ const fallback =
1144
+ merged.fallbackColor === false || merged.fallbackColor == null
1145
+ ? 'transparent'
1146
+ : typeof merged.fallbackColor === 'string'
1147
+ ? merged.fallbackColor
1148
+ : preset.colors[0];
1149
+ layer.style.setProperty('--dlc-color-bg', fallback);
1150
+ };
1151
+ syncLayerVars();
1152
+
1153
+ const canvas = document.createElement('canvas');
1154
+ canvas.className = 'dlc-color-canvas';
1155
+ canvas.setAttribute('aria-hidden', 'true');
1156
+ layer.appendChild(canvas);
1157
+ host.appendChild(layer);
1158
+
1159
+ const emitter = createEmitter();
1160
+ let manuallyPaused = merged.paused === true;
1161
+ let reducedPaused = false;
1162
+ let staticMode = merged.static === true;
1163
+ let contextLost = false;
1164
+ let disposed = false;
1165
+ let renderOnce = () => {};
1166
+ const dirty = {
1167
+ preset: false,
1168
+ seed: false,
1169
+ speed: false,
1170
+ colors: false,
1171
+ opacity: false,
1172
+ fallbackColor: false,
1173
+ mouseColor: false,
1174
+ quality: false,
1175
+ renderScale: false,
1176
+ paused: false,
1177
+ static: false,
1178
+ fps: false
1179
+ };
1180
+
1181
+ let renderer;
1182
+ const useWebgl = merged.renderer !== 'canvas2d';
1183
+ if (useWebgl) {
1184
+ try {
1185
+ renderer = new CosmicRenderer(canvas, merged, {
1186
+ dprCap: effectiveDprCap(merged.quality, merged.renderScale),
1187
+ mouseColor: merged.mouseColor !== false,
1188
+ eventTarget: host,
1189
+ powerPreference: merged.powerPreference,
1190
+ onContextLost: () => {
1191
+ contextLost = true;
1192
+ emitter.emit('contextlost', {});
1193
+ },
1194
+ onContextRestored: () => {
1195
+ contextLost = false;
1196
+ emitter.emit('contextrestored', {});
1197
+ renderOnce();
1198
+ wakeScheduler();
1199
+ }
1200
+ });
1201
+ } catch (error) {
1202
+ renderer = new FallbackRenderer(canvas, merged, {
1203
+ dprCap: effectiveDprCap(merged.quality, merged.renderScale)
1204
+ });
1205
+ nextTick(() => {
1206
+ if (disposed) return;
1207
+ emitter.emit('error', {
1208
+ message: String(error && error.message ? error.message : error)
1209
+ });
1210
+ });
1211
+ }
1212
+ } else {
1213
+ renderer = new FallbackRenderer(canvas, merged, {
1214
+ dprCap: effectiveDprCap(merged.quality, merged.renderScale)
1215
+ });
1216
+ }
1217
+ renderer.resize();
1218
+
1219
+ let animationTime = 0;
1220
+ const frameGate = createFrameGate(merged.fps);
1221
+ renderOnce = () => renderer.draw(animationTime);
1222
+
1223
+ const resize = () => {
1224
+ renderer.resize();
1225
+ if (manuallyPaused || reducedPaused || staticMode) renderOnce();
1226
+ };
1227
+ const resizeObserver =
1228
+ typeof ResizeObserver !== 'undefined'
1229
+ ? new ResizeObserver(resize)
1230
+ : null;
1231
+ if (resizeObserver) resizeObserver.observe(host);
1232
+ else window.addEventListener('resize', resize);
1233
+
1234
+ const visibility = createVisibilityGuard(layer, wakeScheduler);
1235
+ const motionPreference = createReducedMotionPreference(
1236
+ merged.respectReducedMotion,
1237
+ (matches) => {
1238
+ reducedPaused = matches;
1239
+ if (matches) renderOnce();
1240
+ else wakeScheduler();
1241
+ }
1242
+ );
1243
+ reducedPaused = motionPreference.matches();
1244
+ const isMotionPaused = () => manuallyPaused || reducedPaused || staticMode || contextLost;
1245
+
1246
+ renderOnce();
1247
+ const unsubscribe = subscribeScheduler(
1248
+ (delta) => {
1249
+ animationTime += delta;
1250
+ if (frameGate.shouldDraw(delta)) renderer.draw(animationTime);
1251
+ },
1252
+ () => isMotionPaused() || !visibility.isVisible()
1253
+ );
1254
+
1255
+ nextTick(() => {
1256
+ if (disposed) return;
1257
+ emitter.emit('ready', { preset: { ...preset } });
1258
+ });
1259
+
1260
+ return {
1261
+ element: host,
1262
+ layer,
1263
+ canvas,
1264
+ preset,
1265
+ on: emitter.on,
1266
+ off: emitter.off,
1267
+ setPreset(ref) {
1268
+ const next = getPreset('capsule', ref);
1269
+ dirty.preset = true;
1270
+ Object.assign(preset, next);
1271
+ if (colorOverride) preset.colors = [...colorOverride];
1272
+ if (seedOverride !== null) preset.seed = seedOverride;
1273
+ if (speedOverride !== null) preset.speed = speedOverride;
1274
+ const nextColors = preset.colors.map(normalizeColor);
1275
+ if (nextColors.every(Boolean)) preset.colors = nextColors;
1276
+ renderer.setPreset({ ...preset });
1277
+ renderer.resize();
1278
+ syncLayerVars();
1279
+ if (isMotionPaused()) renderOnce();
1280
+ emitter.emit('presetchange', { preset: { ...preset } });
1281
+ return this;
1282
+ },
1283
+ setColors(colors) {
1284
+ const next = Array.isArray(colors) ? colors.map(normalizeColor) : [];
1285
+ if (next.length !== 4 || next.some((color) => !color)) return this;
1286
+ dirty.colors = true;
1287
+ colorOverride = [...next];
1288
+ preset.colors = next;
1289
+ renderer.setPreset({ ...preset, colors: next });
1290
+ syncLayerVars();
1291
+ if (isMotionPaused()) renderOnce();
1292
+ return this;
1293
+ },
1294
+ setSeed(seed) {
1295
+ const value = toFiniteNumber(seed);
1296
+ if (value === null) return this;
1297
+ dirty.seed = true;
1298
+ seedOverride = value;
1299
+ preset.seed = value;
1300
+ renderer.setPreset({ ...preset });
1301
+ if (isMotionPaused()) renderOnce();
1302
+ return this;
1303
+ },
1304
+ setSpeed(speed) {
1305
+ const value = toFiniteNumber(speed);
1306
+ if (value === null) return this;
1307
+ dirty.speed = true;
1308
+ speedOverride = value;
1309
+ preset.speed = value;
1310
+ renderer.setPreset({ ...preset });
1311
+ if (isMotionPaused()) renderOnce();
1312
+ return this;
1313
+ },
1314
+ setFallbackColor(value) {
1315
+ dirty.fallbackColor = true;
1316
+ merged.fallbackColor = value;
1317
+ syncLayerVars();
1318
+ return this;
1319
+ },
1320
+ setMouseColor(value) {
1321
+ dirty.mouseColor = true;
1322
+ merged.mouseColor = value !== false;
1323
+ if (typeof renderer.setMouseColor === 'function') renderer.setMouseColor(merged.mouseColor);
1324
+ return this;
1325
+ },
1326
+ setQuality(quality) {
1327
+ dirty.quality = true;
1328
+ merged.quality = quality;
1329
+ if (typeof renderer.setDprCap === 'function') {
1330
+ renderer.setDprCap(effectiveDprCap(quality, merged.renderScale));
1331
+ }
1332
+ if (isMotionPaused()) renderOnce();
1333
+ return this;
1334
+ },
1335
+ setRenderScale(renderScale) {
1336
+ const value = Number(renderScale);
1337
+ if (!Number.isFinite(value)) return this;
1338
+ dirty.renderScale = true;
1339
+ merged.renderScale = Math.min(1, Math.max(0.25, value));
1340
+ if (typeof renderer.setDprCap === 'function') {
1341
+ renderer.setDprCap(effectiveDprCap(merged.quality, merged.renderScale));
1342
+ }
1343
+ if (isMotionPaused()) renderOnce();
1344
+ return this;
1345
+ },
1346
+ setOpacity(value) {
1347
+ const opacity = Number(value);
1348
+ if (!Number.isFinite(opacity)) return this;
1349
+ dirty.opacity = true;
1350
+ merged.opacity = Math.min(1, Math.max(0, opacity));
1351
+ layer.style.setProperty('--dlc-color-opacity', String(merged.opacity));
1352
+ return this;
1353
+ },
1354
+ randomize() {
1355
+ this.setSeed(Math.random() * 100);
1356
+ return this;
1357
+ },
1358
+ pause() {
1359
+ dirty.paused = true;
1360
+ manuallyPaused = true;
1361
+ renderOnce();
1362
+ return this;
1363
+ },
1364
+ resume() {
1365
+ dirty.paused = true;
1366
+ manuallyPaused = false;
1367
+ wakeScheduler();
1368
+ return this;
1369
+ },
1370
+ setPaused(value) {
1371
+ return value ? this.pause() : this.resume();
1372
+ },
1373
+ setStatic(value) {
1374
+ dirty.static = true;
1375
+ staticMode = value === true;
1376
+ merged.static = staticMode;
1377
+ if (staticMode) renderOnce();
1378
+ else wakeScheduler();
1379
+ return this;
1380
+ },
1381
+ setFps(fps) {
1382
+ dirty.fps = true;
1383
+ merged.fps = frameGate.setFps(fps);
1384
+ wakeScheduler();
1385
+ return this;
1386
+ },
1387
+ dispose() {
1388
+ disposed = true;
1389
+ unsubscribe();
1390
+ visibility.dispose();
1391
+ motionPreference.dispose();
1392
+ if (resizeObserver) resizeObserver.disconnect();
1393
+ else window.removeEventListener('resize', resize);
1394
+ renderer.dispose();
1395
+ layer.remove();
1396
+ releaseHostStyles(host);
1397
+ },
1398
+ get opacity() { return merged.opacity; },
1399
+ get fallbackColor() { return merged.fallbackColor; },
1400
+ get mouseColor() { return merged.mouseColor; },
1401
+ get quality() { return merged.quality; },
1402
+ get renderScale() { return merged.renderScale; },
1403
+ get paused() { return manuallyPaused; },
1404
+ get static() { return staticMode; },
1405
+ get fps() { return frameGate.getFps(); },
1406
+ dirty
1407
+ };
1303
1408
  }
1304
1409
 
1305
1410
  export { createColorBackground };