@5even7/dlc-ui 0.2.11 → 0.2.12

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/index.mjs CHANGED
@@ -1,117 +1,123 @@
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
- }
37
-
38
- const UNIT_PATTERN = /^[\d.]+(?:px|%|vw|vh|vmin|vmax|em|rem)$/;
39
-
40
- /**
41
- * Normalize a size option to a CSS length string.
42
- * Accepts numbers (treated as px) or strings with px/%, vw/vh/vmin/vmax, em/rem.
43
- */
44
- function parseSize(value) {
45
- if (typeof value === 'number') {
46
- if (!Number.isFinite(value) || value < 0) throw new Error(`Invalid size: ${value}`);
47
- return `${value}px`;
48
- }
49
- if (typeof value !== 'string') throw new Error(`Invalid size: ${String(value)}`);
50
- const trimmed = value.trim();
51
- if (!trimmed) throw new Error('Invalid size: empty string');
52
- if (/^\d+(?:\.\d+)?$/.test(trimmed)) return `${trimmed}px`;
53
- if (!UNIT_PATTERN.test(trimmed)) {
54
- throw new Error(`Invalid size or unsupported unit: "${value}" (use px, %, vw, vh, vmin, vmax, em, rem)`);
55
- }
56
- return trimmed;
57
- }
58
-
59
- const QUALITY_TIERS = {
60
- low: { dpr: 1 },
61
- medium: { dpr: 1.5 },
62
- high: { dpr: 2 }
63
- };
64
-
65
- function autoDprCap() {
66
- const nav = typeof navigator !== 'undefined' ? navigator : null;
67
- const memory = nav && typeof nav.deviceMemory === 'number' ? nav.deviceMemory : 8;
68
- const cores = nav && typeof nav.hardwareConcurrency === 'number' ? nav.hardwareConcurrency : 8;
69
- return memory <= 4 || cores <= 4 ? 1.5 : 2;
70
- }
71
-
72
- function dprCapFor(quality) {
73
- if (quality === 'auto') return autoDprCap();
74
- const tier = QUALITY_TIERS[quality] || QUALITY_TIERS.medium;
75
- return tier.dpr;
76
- }
77
-
78
- /**
79
- * Merge defaults < preset < user. Unknown user keys are preserved so the
80
- * component API can grow (new props, cssVars, callbacks) without a breaking
81
- * change.
82
- */
83
- function normalizeOptions(defaults, preset, user) {
84
- // undefined means "not provided" (e.g. Vue $props with unset props):
85
- // drop those keys before merging so defaults/preset values survive.
86
- const omitUndefined = (source) => {
87
- const result = {};
88
- for (const key of Object.keys(source)) {
89
- if (source[key] !== undefined) result[key] = source[key];
90
- }
91
- return result;
92
- };
93
- const presetOptions = omitUndefined(preset && typeof preset === 'object' ? preset : {});
94
- const userOptions = omitUndefined(user && typeof user === 'object' ? user : {});
95
- return { ...defaults, ...presetOptions, ...userOptions };
96
- }
97
-
98
- /**
99
- * 公共色板:NC-01~NC-06 的四色组(底色 / 主色 / 辅色 / 高光色)。
100
- * Capsule 预置与 dlc-color 等组件共用,新增色板只改这里。
101
- */
102
- const PALETTES$1 = {
103
- original: ['#FFF3EA', '#F5B27A', '#F67BC6', '#A978E8'],
104
- ocean: ['#EAF6FF', '#8FD0FF', '#3B87F6', '#6B58E9'],
105
- klein: ['#EDF2FF', '#2F58D5', '#1B2040', '#E07A43'],
106
- ultraviolet: ['#F2EEFF', '#B99AF1', '#8F74DB', '#D7D85C'],
107
- chrome: ['#F5F6F8', '#B9C0CC', '#7F8793', '#4A4F59'],
108
- plus: ['#FFF0E6', '#F6C26B', '#F98A64', '#E86D74']
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
+ }
37
+
38
+ const UNIT_PATTERN = /^[\d.]+(?:px|%|vw|vh|vmin|vmax|em|rem)$/;
39
+
40
+ /**
41
+ * Normalize a size option to a CSS length string.
42
+ * Accepts numbers (treated as px) or strings with px/%, vw/vh/vmin/vmax, em/rem.
43
+ */
44
+ function parseSize(value) {
45
+ if (typeof value === 'number') {
46
+ if (!Number.isFinite(value) || value < 0) throw new Error(`Invalid size: ${value}`);
47
+ return `${value}px`;
48
+ }
49
+ if (typeof value !== 'string') throw new Error(`Invalid size: ${String(value)}`);
50
+ const trimmed = value.trim();
51
+ if (!trimmed) throw new Error('Invalid size: empty string');
52
+ if (/^\d+(?:\.\d+)?$/.test(trimmed)) return `${trimmed}px`;
53
+ if (!UNIT_PATTERN.test(trimmed)) {
54
+ throw new Error(`Invalid size or unsupported unit: "${value}" (use px, %, vw, vh, vmin, vmax, em, rem)`);
55
+ }
56
+ return trimmed;
57
+ }
58
+
59
+ const QUALITY_TIERS = {
60
+ low: { dpr: 1 },
61
+ medium: { dpr: 1.5 },
62
+ high: { dpr: 2 }
63
+ };
64
+
65
+ function autoDprCap() {
66
+ const nav = typeof navigator !== 'undefined' ? navigator : null;
67
+ const memory = nav && typeof nav.deviceMemory === 'number' ? nav.deviceMemory : 8;
68
+ const cores = nav && typeof nav.hardwareConcurrency === 'number' ? nav.hardwareConcurrency : 8;
69
+ return memory <= 4 || cores <= 4 ? 1.5 : 2;
70
+ }
71
+
72
+ function dprCapFor(quality) {
73
+ if (quality === 'auto') return autoDprCap();
74
+ const tier = QUALITY_TIERS[quality] || QUALITY_TIERS.medium;
75
+ return tier.dpr;
76
+ }
77
+
78
+ function effectiveDprCap(quality, renderScale = 1) {
79
+ const scale = Number(renderScale);
80
+ const normalizedScale = Number.isFinite(scale) ? Math.min(1, Math.max(0.25, scale)) : 1;
81
+ return Math.max(0.5, dprCapFor(quality) * normalizedScale);
82
+ }
83
+
84
+ /**
85
+ * Merge defaults < preset < user. Unknown user keys are preserved so the
86
+ * component API can grow (new props, cssVars, callbacks) without a breaking
87
+ * change.
88
+ */
89
+ function normalizeOptions(defaults, preset, user) {
90
+ // undefined means "not provided" (e.g. Vue $props with unset props):
91
+ // drop those keys before merging so defaults/preset values survive.
92
+ const omitUndefined = (source) => {
93
+ const result = {};
94
+ for (const key of Object.keys(source)) {
95
+ if (source[key] !== undefined) result[key] = source[key];
96
+ }
97
+ return result;
98
+ };
99
+ const presetOptions = omitUndefined(preset && typeof preset === 'object' ? preset : {});
100
+ const userOptions = omitUndefined(user && typeof user === 'object' ? user : {});
101
+ return { ...defaults, ...presetOptions, ...userOptions };
102
+ }
103
+
104
+ /**
105
+ * 公共色板:NC-01~NC-06 的四色组(底色 / 主色 / 辅色 / 高光色)。
106
+ * Capsule 预置与 dlc-color 等组件共用,新增色板只改这里。
107
+ */
108
+ const PALETTES$1 = {
109
+ original: ['#FFF3EA', '#F5B27A', '#F67BC6', '#A978E8'],
110
+ ocean: ['#EAF6FF', '#8FD0FF', '#3B87F6', '#6B58E9'],
111
+ klein: ['#EDF2FF', '#2F58D5', '#1B2040', '#E07A43'],
112
+ ultraviolet: ['#F2EEFF', '#B99AF1', '#8F74DB', '#D7D85C'],
113
+ chrome: ['#F5F6F8', '#B9C0CC', '#7F8793', '#4A4F59'],
114
+ plus: ['#FFF0E6', '#F6C26B', '#F98A64', '#E86D74']
109
115
  };
110
116
 
111
- /**
112
- * Capsule 预置:仅星云材质 NC-01~NC-06(NC-07~09 aurora 已移除)。
113
- * 颜色引用公共色板,seed/speed 决定形态与流速。
114
- */
117
+ /**
118
+ * Capsule 预置:仅星云材质 NC-01~NC-06(NC-07~09 aurora 已移除)。
119
+ * 颜色引用公共色板,seed/speed 决定形态与流速。
120
+ */
115
121
  const CAPSULE_PRESETS = [
116
122
  { id: 'original', code: 'NC-01', name: '初光', group: 'warm', seed: 1.7, speed: 0.5, colors: [...PALETTES$1.original] },
117
123
  { id: 'ocean', code: 'NC-02', name: '沧溟', group: 'cold', seed: 8.2, speed: 0.48, colors: [...PALETTES$1.ocean] },
@@ -121,47 +127,47 @@ const CAPSULE_PRESETS = [
121
127
  { id: 'plus', code: 'NC-06', name: '熔金', group: 'warm', seed: 51.3, speed: 0.5, colors: [...PALETTES$1.plus] }
122
128
  ];
123
129
 
124
- const PROGRESS_PRESETS = [
130
+ const PROGRESS_PRESETS = [
125
131
  {
126
132
  id: 'model-training',
127
133
  code: 'NC-10',
128
134
  name: '星火',
129
- subtitle: 'SHA 4.5 + 100 2026 TKN',
130
- group: 'progress',
131
- initialProgress: 43,
132
- edgeStyle: 'flow',
133
- colors: ['#2B1025', '#FF3F94', '#FF8A3D', '#FFF06A']
134
- },
135
+ subtitle: 'SHA 4.5 + 100 2026 TKN',
136
+ group: 'progress',
137
+ initialProgress: 43,
138
+ edgeStyle: 'flow',
139
+ colors: ['#2B1025', '#FF3F94', '#FF8A3D', '#FFF06A']
140
+ },
135
141
  {
136
142
  id: 'agent-migration',
137
143
  code: 'NC-11',
138
144
  name: '迁流',
139
- subtitle: 'TRANSFERRING PROTOCOL',
140
- group: 'progress',
141
- initialProgress: 33,
142
- edgeStyle: 'flow',
143
- colors: ['#101C37', '#245BFF', '#00CFFF', '#5DFFE6']
144
- },
145
+ subtitle: 'TRANSFERRING PROTOCOL',
146
+ group: 'progress',
147
+ initialProgress: 33,
148
+ edgeStyle: 'flow',
149
+ colors: ['#101C37', '#245BFF', '#00CFFF', '#5DFFE6']
150
+ },
145
151
  {
146
152
  id: 'visual-training',
147
153
  code: 'NC-12',
148
154
  name: '幻境',
149
- subtitle: 'GENERATING POWER ++',
150
- group: 'progress',
151
- initialProgress: 58,
152
- edgeStyle: 'flow',
153
- colors: ['#21142D', '#7042FF', '#42F58D', '#C4FF8A']
154
- },
155
+ subtitle: 'GENERATING POWER ++',
156
+ group: 'progress',
157
+ initialProgress: 58,
158
+ edgeStyle: 'flow',
159
+ colors: ['#21142D', '#7042FF', '#42F58D', '#C4FF8A']
160
+ },
155
161
  {
156
162
  id: 'tide',
157
163
  code: 'NC-13',
158
164
  name: '汐潮',
159
- subtitle: 'MOON PULL / COAST',
160
- group: 'progress',
161
- initialProgress: 30,
162
- edgeStyle: 'tide',
163
- colors: ['#0A2239', '#2E9BFF', '#7FE3FF', '#EAF9FF']
164
- }
165
+ subtitle: 'MOON PULL / COAST',
166
+ group: 'progress',
167
+ initialProgress: 30,
168
+ edgeStyle: 'tide',
169
+ colors: ['#0A2239', '#2E9BFF', '#7FE3FF', '#EAF9FF']
170
+ }
165
171
  ];
166
172
 
167
173
  const PRESETS = CAPSULE_PRESETS;
@@ -193,6 +199,11 @@ const DEFAULTS = {
193
199
  height: 160,
194
200
  quality: 'auto',
195
201
  renderer: 'auto',
202
+ renderScale: 1,
203
+ powerPreference: 'high-performance',
204
+ fps: 60,
205
+ paused: false,
206
+ static: false,
196
207
  respectReducedMotion: true,
197
208
  mouseColor: true,
198
209
  textRatio: 39
@@ -202,13 +213,22 @@ const DEFAULTS = {
202
213
  height: 104,
203
214
  min: 0,
204
215
  max: 100,
216
+ step: 'any',
205
217
  draggable: true,
218
+ keyboard: true,
206
219
  disabled: false,
207
220
  readonly: false,
221
+ direction: 'ltr',
222
+ precision: 0,
208
223
  valueSuffix: '%',
209
224
  edgeStyle: 'flow',
210
225
  quality: 'auto',
211
226
  renderer: 'auto',
227
+ renderScale: 1,
228
+ powerPreference: 'high-performance',
229
+ fps: 60,
230
+ paused: false,
231
+ static: false,
212
232
  textRatio: 54,
213
233
  showValue: true,
214
234
  respectReducedMotion: true
@@ -223,8 +243,8 @@ const DEFAULTS = {
223
243
  const COPY = {
224
244
  brandName: '画境观屿',
225
245
  valueSuffix: '%',
226
- progressAria: '{brand} {code} 加载进度',
227
- capsuleAria: '打开 {name} 沉浸预览'
246
+ progressAria: '{brand} {code} 加载进度',
247
+ capsuleAria: '打开 {name} 沉浸预览'
228
248
  };
229
249
 
230
250
  /**
@@ -459,231 +479,254 @@ function validateColors(colors) {
459
479
  && colors.every((color) => normalizeColor(color) !== null);
460
480
  }
461
481
 
462
- const VERTEX_SHADER$1 = `#version 300 es
463
- in vec2 a_position;
464
- out vec2 v_uv;
465
- void main() {
466
- v_uv = a_position * 0.5 + 0.5;
467
- gl_Position = vec4(a_position, 0.0, 1.0);
468
- }`;
469
-
470
- const FRAGMENT_SHADER$1 = `#version 300 es
471
- precision highp float;
472
-
473
- in vec2 v_uv;
474
- out vec4 outColor;
475
-
476
- uniform vec2 u_resolution;
477
- uniform float u_time;
478
- uniform float u_seed;
479
- uniform float u_motion;
480
- uniform vec2 u_pointer;
481
- uniform vec3 u_colorA;
482
- uniform vec3 u_colorB;
483
- uniform vec3 u_colorC;
484
- uniform vec3 u_colorD;
485
-
486
- float hash21(vec2 p) {
487
- p = fract(p * vec2(123.34, 456.21));
488
- p += dot(p, p + 45.32 + u_seed);
489
- return fract(p.x * p.y);
490
- }
491
-
492
- float noise(vec2 p) {
493
- vec2 i = floor(p);
494
- vec2 f = fract(p);
495
- f = f * f * (3.0 - 2.0 * f);
496
- float a = hash21(i);
497
- float b = hash21(i + vec2(1.0, 0.0));
498
- float c = hash21(i + vec2(0.0, 1.0));
499
- float d = hash21(i + vec2(1.0, 1.0));
500
- return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
501
- }
502
-
503
- float fbm(vec2 p) {
504
- float value = 0.0;
505
- float amplitude = 0.52;
506
- mat2 rotation = mat2(0.80, 0.60, -0.60, 0.80);
507
- for (int i = 0; i < 6; i++) {
508
- value += amplitude * noise(p);
509
- p = rotation * p * 2.03 + 17.7;
510
- amplitude *= 0.5;
511
- }
512
- return value;
513
- }
514
-
515
- float gaussian(float value, float center, float width) {
516
- return exp(-pow(value - center, 2.0) / max(width, 0.0001));
517
- }
518
-
519
- vec3 palette(float t) {
520
- t = clamp(t, 0.0, 1.0);
521
- vec3 shadow = mix(u_colorA, u_colorB, smoothstep(0.06, 0.62, t));
522
- vec3 body = mix(u_colorB, u_colorC, smoothstep(0.30, 0.82, t));
523
- vec3 highlight = mix(u_colorC, u_colorD, smoothstep(0.74, 1.0, t));
524
- vec3 restrained = mix(shadow, body, smoothstep(0.26, 0.72, t));
525
- return mix(restrained, highlight, smoothstep(0.78, 0.97, t));
526
- }
527
-
528
- vec3 renderNebula(vec2 uv, vec2 p, vec2 pointer, float distanceToPointer, float t) {
529
- vec2 delta = p - pointer;
530
- float influence = exp(-distanceToPointer * 4.6) * u_motion;
531
- float angle = influence * 1.7;
532
- mat2 swirl = mat2(cos(angle), -sin(angle), sin(angle), cos(angle));
533
- p = pointer + swirl * delta;
534
- p += normalize(delta + 0.0001) * influence * 0.08;
535
-
536
- vec2 drift = vec2(t * 0.22, -t * 0.13);
537
- vec2 q = vec2(
538
- fbm(p * 1.35 + drift + u_seed),
539
- fbm(p * 1.35 + vec2(5.2, 1.3) - drift * 0.85)
540
- );
541
- vec2 r = vec2(
542
- fbm(p * 2.0 + 3.6 * q + vec2(1.7, 9.2) + t * 0.10),
543
- fbm(p * 2.0 + 3.0 * q + vec2(8.3, 2.8) - t * 0.085)
544
- );
545
-
546
- float cloud = fbm(p * 1.7 + 4.2 * r);
547
- float veins = fbm(p * 4.0 - 2.0 * q + t * 0.065);
548
- float nebula = smoothstep(0.18, 0.91, cloud * 0.9 + veins * 0.22);
549
-
550
- vec3 color = palette(nebula);
551
- color += u_colorD * pow(max(cloud - 0.63, 0.0), 2.0) * 1.05;
552
- color *= 0.78 + 0.34 * smoothstep(0.15, 0.9, veins);
553
-
554
- vec2 starGrid = floor((uv + vec2(u_seed * 0.013, 0.0)) * vec2(132.0, 58.0));
555
- vec2 starCell = fract(uv * vec2(132.0, 58.0)) - 0.5;
556
- float starRandom = hash21(starGrid);
557
- float starShape = smoothstep(0.075, 0.0, length(starCell));
558
- float starMask = step(0.989, starRandom) * starShape;
559
- float twinkle = 0.35 + 0.65 * sin(t * (1.0 + starRandom * 2.4) + starRandom * 40.0) * 0.5 + 0.5;
560
- color += starMask * twinkle * mix(u_colorC, u_colorD, starRandom) * 1.05;
561
-
562
- float pointerGlow = exp(-distanceToPointer * 7.0) * u_motion;
563
- color += u_colorD * pointerGlow * 0.28;
564
- return color;
565
- }
566
-
567
- void main() {
568
- vec2 uv = v_uv;
569
- vec2 p = uv - 0.5;
570
- p.x *= u_resolution.x / max(u_resolution.y, 1.0);
571
-
572
- vec2 pointer = u_pointer - 0.5;
573
- pointer.x *= u_resolution.x / max(u_resolution.y, 1.0);
574
- float distanceToPointer = length(p - pointer);
575
-
576
- vec3 color = renderNebula(uv, p, pointer, distanceToPointer, u_time);
577
- float vignette = smoothstep(0.94, 0.18, length((uv - 0.5) * vec2(1.0, 1.35)));
578
- color *= 0.70 + vignette * 0.42;
579
- color = pow(max(color, vec3(0.0)), vec3(0.88));
580
-
581
- outColor = vec4(color, 1.0);
582
- }`;
583
-
584
- function compileShader$1(gl, type, source) {
585
- const shader = gl.createShader(type);
586
- gl.shaderSource(shader, source);
587
- gl.compileShader(shader);
588
- if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
589
- const message = gl.getShaderInfoLog(shader) || 'Unknown shader compile error';
590
- gl.deleteShader(shader);
591
- throw new Error(message);
592
- }
593
- return shader;
594
- }
595
-
596
- function createProgram$1(gl) {
597
- const vertex = compileShader$1(gl, gl.VERTEX_SHADER, VERTEX_SHADER$1);
598
- const fragment = compileShader$1(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER$1);
599
- const program = gl.createProgram();
600
- gl.attachShader(program, vertex);
601
- gl.attachShader(program, fragment);
602
- gl.linkProgram(program);
603
- gl.deleteShader(vertex);
604
- gl.deleteShader(fragment);
605
- if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
606
- const message = gl.getProgramInfoLog(program) || 'Unknown program link error';
607
- gl.deleteProgram(program);
608
- throw new Error(message);
609
- }
610
- return program;
611
- }
612
-
613
- class CosmicRenderer {
614
- constructor(canvas, preset, options = {}) {
615
- this.canvas = canvas;
616
- this.preset = { ...preset };
617
- this.options = { dprCap: 1.75, mouseColor: true, ...options };
618
- this.gl = canvas.getContext('webgl2', {
619
- alpha: false,
620
- antialias: false,
621
- depth: false,
622
- powerPreference: 'high-performance',
623
- preserveDrawingBuffer: false
624
- });
625
- if (!this.gl) throw new Error('WebGL2 is not available');
626
-
627
- this.program = createProgram$1(this.gl);
628
- this.locations = this.#getLocations();
629
- this.pointer = [0.72, 0.45];
630
- this.pointerTarget = [...this.pointer];
631
- this.motion = 0;
632
- this.motionTarget = 0;
633
- this.timeOffset = preset.seed * 0.73;
634
- this.visible = true;
635
- this.disposed = false;
636
-
637
- this.#setupGeometry();
638
- this.#bindEvents();
639
- this.resize();
640
- }
641
-
642
- #getLocations() {
643
- const gl = this.gl;
644
- const uniform = (name) => gl.getUniformLocation(this.program, name);
645
- return {
646
- position: gl.getAttribLocation(this.program, 'a_position'),
647
- resolution: uniform('u_resolution'),
648
- time: uniform('u_time'),
649
- seed: uniform('u_seed'),
650
- motion: uniform('u_motion'),
651
- pointer: uniform('u_pointer'),
652
- colorA: uniform('u_colorA'),
653
- colorB: uniform('u_colorB'),
654
- colorC: uniform('u_colorC'),
655
- colorD: uniform('u_colorD')
656
- };
657
- }
658
-
659
- #setupGeometry() {
660
- const gl = this.gl;
661
- const vertices = new Float32Array([-1, -1, 3, -1, -1, 3]);
662
- this.buffer = gl.createBuffer();
663
- gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
664
- gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
665
- }
666
-
667
- #bindEvents() {
668
- if (this.options.mouseColor === false) return;
669
- this.eventTarget = this.options.eventTarget || this.canvas.parentElement || this.canvas;
670
- this.onPointerMove = (event) => {
671
- const rect = this.canvas.getBoundingClientRect();
672
- this.pointerTarget[0] = (event.clientX - rect.left) / Math.max(rect.width, 1);
673
- this.pointerTarget[1] = 1 - (event.clientY - rect.top) / Math.max(rect.height, 1);
674
- this.motionTarget = 1;
675
- };
676
- this.onPointerLeave = () => { this.motionTarget = 0; };
677
- this.eventTarget.addEventListener('pointermove', this.onPointerMove, { passive: true });
678
- this.eventTarget.addEventListener('pointerdown', this.onPointerMove, { passive: true });
679
- this.eventTarget.addEventListener('pointerleave', this.onPointerLeave, { passive: true });
680
- }
681
-
682
- setPreset(preset) {
683
- this.preset = { ...preset };
684
- this.timeOffset = preset.seed * 0.73;
685
- }
686
-
482
+ const VERTEX_SHADER$1 = `#version 300 es
483
+ in vec2 a_position;
484
+ out vec2 v_uv;
485
+ void main() {
486
+ v_uv = a_position * 0.5 + 0.5;
487
+ gl_Position = vec4(a_position, 0.0, 1.0);
488
+ }`;
489
+
490
+ const FRAGMENT_SHADER$1 = `#version 300 es
491
+ precision highp float;
492
+
493
+ in vec2 v_uv;
494
+ out vec4 outColor;
495
+
496
+ uniform vec2 u_resolution;
497
+ uniform float u_time;
498
+ uniform float u_seed;
499
+ uniform float u_motion;
500
+ uniform vec2 u_pointer;
501
+ uniform vec3 u_colorA;
502
+ uniform vec3 u_colorB;
503
+ uniform vec3 u_colorC;
504
+ uniform vec3 u_colorD;
505
+
506
+ float hash21(vec2 p) {
507
+ p = fract(p * vec2(123.34, 456.21));
508
+ p += dot(p, p + 45.32 + u_seed);
509
+ return fract(p.x * p.y);
510
+ }
511
+
512
+ float noise(vec2 p) {
513
+ vec2 i = floor(p);
514
+ vec2 f = fract(p);
515
+ f = f * f * (3.0 - 2.0 * f);
516
+ float a = hash21(i);
517
+ float b = hash21(i + vec2(1.0, 0.0));
518
+ float c = hash21(i + vec2(0.0, 1.0));
519
+ float d = hash21(i + vec2(1.0, 1.0));
520
+ return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
521
+ }
522
+
523
+ float fbm(vec2 p) {
524
+ float value = 0.0;
525
+ float amplitude = 0.52;
526
+ mat2 rotation = mat2(0.80, 0.60, -0.60, 0.80);
527
+ for (int i = 0; i < 6; i++) {
528
+ value += amplitude * noise(p);
529
+ p = rotation * p * 2.03 + 17.7;
530
+ amplitude *= 0.5;
531
+ }
532
+ return value;
533
+ }
534
+
535
+ float gaussian(float value, float center, float width) {
536
+ return exp(-pow(value - center, 2.0) / max(width, 0.0001));
537
+ }
538
+
539
+ vec3 palette(float t) {
540
+ t = clamp(t, 0.0, 1.0);
541
+ vec3 shadow = mix(u_colorA, u_colorB, smoothstep(0.06, 0.62, t));
542
+ vec3 body = mix(u_colorB, u_colorC, smoothstep(0.30, 0.82, t));
543
+ vec3 highlight = mix(u_colorC, u_colorD, smoothstep(0.74, 1.0, t));
544
+ vec3 restrained = mix(shadow, body, smoothstep(0.26, 0.72, t));
545
+ return mix(restrained, highlight, smoothstep(0.78, 0.97, t));
546
+ }
547
+
548
+ vec3 renderNebula(vec2 uv, vec2 p, vec2 pointer, float distanceToPointer, float t) {
549
+ vec2 delta = p - pointer;
550
+ float influence = exp(-distanceToPointer * 4.6) * u_motion;
551
+ float angle = influence * 1.7;
552
+ mat2 swirl = mat2(cos(angle), -sin(angle), sin(angle), cos(angle));
553
+ p = pointer + swirl * delta;
554
+ p += normalize(delta + 0.0001) * influence * 0.08;
555
+
556
+ vec2 drift = vec2(t * 0.22, -t * 0.13);
557
+ vec2 q = vec2(
558
+ fbm(p * 1.35 + drift + u_seed),
559
+ fbm(p * 1.35 + vec2(5.2, 1.3) - drift * 0.85)
560
+ );
561
+ vec2 r = vec2(
562
+ fbm(p * 2.0 + 3.6 * q + vec2(1.7, 9.2) + t * 0.10),
563
+ fbm(p * 2.0 + 3.0 * q + vec2(8.3, 2.8) - t * 0.085)
564
+ );
565
+
566
+ float cloud = fbm(p * 1.7 + 4.2 * r);
567
+ float veins = fbm(p * 4.0 - 2.0 * q + t * 0.065);
568
+ float nebula = smoothstep(0.18, 0.91, cloud * 0.9 + veins * 0.22);
569
+
570
+ vec3 color = palette(nebula);
571
+ color += u_colorD * pow(max(cloud - 0.63, 0.0), 2.0) * 1.05;
572
+ color *= 0.78 + 0.34 * smoothstep(0.15, 0.9, veins);
573
+
574
+ vec2 starGrid = floor((uv + vec2(u_seed * 0.013, 0.0)) * vec2(132.0, 58.0));
575
+ vec2 starCell = fract(uv * vec2(132.0, 58.0)) - 0.5;
576
+ float starRandom = hash21(starGrid);
577
+ float starShape = smoothstep(0.075, 0.0, length(starCell));
578
+ float starMask = step(0.989, starRandom) * starShape;
579
+ float twinkle = 0.35 + 0.65 * sin(t * (1.0 + starRandom * 2.4) + starRandom * 40.0) * 0.5 + 0.5;
580
+ color += starMask * twinkle * mix(u_colorC, u_colorD, starRandom) * 1.05;
581
+
582
+ float pointerGlow = exp(-distanceToPointer * 7.0) * u_motion;
583
+ color += u_colorD * pointerGlow * 0.28;
584
+ return color;
585
+ }
586
+
587
+ void main() {
588
+ vec2 uv = v_uv;
589
+ vec2 p = uv - 0.5;
590
+ p.x *= u_resolution.x / max(u_resolution.y, 1.0);
591
+
592
+ vec2 pointer = u_pointer - 0.5;
593
+ pointer.x *= u_resolution.x / max(u_resolution.y, 1.0);
594
+ float distanceToPointer = length(p - pointer);
595
+
596
+ vec3 color = renderNebula(uv, p, pointer, distanceToPointer, u_time);
597
+ float vignette = smoothstep(0.94, 0.18, length((uv - 0.5) * vec2(1.0, 1.35)));
598
+ color *= 0.70 + vignette * 0.42;
599
+ color = pow(max(color, vec3(0.0)), vec3(0.88));
600
+
601
+ outColor = vec4(color, 1.0);
602
+ }`;
603
+
604
+ function compileShader$1(gl, type, source) {
605
+ const shader = gl.createShader(type);
606
+ gl.shaderSource(shader, source);
607
+ gl.compileShader(shader);
608
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
609
+ const message = gl.getShaderInfoLog(shader) || 'Unknown shader compile error';
610
+ gl.deleteShader(shader);
611
+ throw new Error(message);
612
+ }
613
+ return shader;
614
+ }
615
+
616
+ function createProgram$1(gl) {
617
+ const vertex = compileShader$1(gl, gl.VERTEX_SHADER, VERTEX_SHADER$1);
618
+ const fragment = compileShader$1(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER$1);
619
+ const program = gl.createProgram();
620
+ gl.attachShader(program, vertex);
621
+ gl.attachShader(program, fragment);
622
+ gl.linkProgram(program);
623
+ gl.deleteShader(vertex);
624
+ gl.deleteShader(fragment);
625
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
626
+ const message = gl.getProgramInfoLog(program) || 'Unknown program link error';
627
+ gl.deleteProgram(program);
628
+ throw new Error(message);
629
+ }
630
+ return program;
631
+ }
632
+
633
+ class CosmicRenderer {
634
+ constructor(canvas, preset, options = {}) {
635
+ this.canvas = canvas;
636
+ this.preset = { ...preset };
637
+ this.options = { dprCap: 1.75, mouseColor: true, ...options };
638
+ this.gl = canvas.getContext('webgl2', {
639
+ alpha: false,
640
+ antialias: false,
641
+ depth: false,
642
+ powerPreference: this.options.powerPreference || 'high-performance',
643
+ preserveDrawingBuffer: false
644
+ });
645
+ if (!this.gl) throw new Error('WebGL2 is not available');
646
+
647
+ this.program = createProgram$1(this.gl);
648
+ this.locations = this.#getLocations();
649
+ this.pointer = [0.72, 0.45];
650
+ this.pointerTarget = [...this.pointer];
651
+ this.motion = 0;
652
+ this.motionTarget = 0;
653
+ this.timeOffset = preset.seed * 0.73;
654
+ this.colors = preset.colors.map(hexToRgb01$1);
655
+ this.visible = true;
656
+ this.disposed = false;
657
+
658
+ this.#setupGeometry();
659
+ this.#bindEvents();
660
+ this.#bindContextEvents();
661
+ this.resize();
662
+ }
663
+
664
+ #getLocations() {
665
+ const gl = this.gl;
666
+ const uniform = (name) => gl.getUniformLocation(this.program, name);
667
+ return {
668
+ position: gl.getAttribLocation(this.program, 'a_position'),
669
+ resolution: uniform('u_resolution'),
670
+ time: uniform('u_time'),
671
+ seed: uniform('u_seed'),
672
+ motion: uniform('u_motion'),
673
+ pointer: uniform('u_pointer'),
674
+ colorA: uniform('u_colorA'),
675
+ colorB: uniform('u_colorB'),
676
+ colorC: uniform('u_colorC'),
677
+ colorD: uniform('u_colorD')
678
+ };
679
+ }
680
+
681
+ #setupGeometry() {
682
+ const gl = this.gl;
683
+ const vertices = new Float32Array([-1, -1, 3, -1, -1, 3]);
684
+ this.buffer = gl.createBuffer();
685
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
686
+ gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
687
+ }
688
+
689
+ #bindEvents() {
690
+ if (this.options.mouseColor === false) return;
691
+ this.eventTarget = this.options.eventTarget || this.canvas.parentElement || this.canvas;
692
+ this.onPointerMove = (event) => {
693
+ const rect = this.canvas.getBoundingClientRect();
694
+ this.pointerTarget[0] = (event.clientX - rect.left) / Math.max(rect.width, 1);
695
+ this.pointerTarget[1] = 1 - (event.clientY - rect.top) / Math.max(rect.height, 1);
696
+ this.motionTarget = 1;
697
+ };
698
+ this.onPointerLeave = () => { this.motionTarget = 0; };
699
+ this.eventTarget.addEventListener('pointermove', this.onPointerMove, { passive: true });
700
+ this.eventTarget.addEventListener('pointerdown', this.onPointerMove, { passive: true });
701
+ this.eventTarget.addEventListener('pointerleave', this.onPointerLeave, { passive: true });
702
+ }
703
+
704
+ #bindContextEvents() {
705
+ this.onContextLost = (event) => {
706
+ event.preventDefault();
707
+ if (this.disposed) return;
708
+ this.visible = false;
709
+ if (typeof this.options.onContextLost === 'function') this.options.onContextLost(event);
710
+ };
711
+ this.onContextRestored = () => {
712
+ if (this.disposed) return;
713
+ this.program = createProgram$1(this.gl);
714
+ this.locations = this.#getLocations();
715
+ this.#setupGeometry();
716
+ this.visible = true;
717
+ this.resize();
718
+ if (typeof this.options.onContextRestored === 'function') this.options.onContextRestored();
719
+ };
720
+ this.canvas.addEventListener('webglcontextlost', this.onContextLost, false);
721
+ this.canvas.addEventListener('webglcontextrestored', this.onContextRestored, false);
722
+ }
723
+
724
+ setPreset(preset) {
725
+ this.preset = { ...preset };
726
+ this.colors = preset.colors.map(hexToRgb01$1);
727
+ this.timeOffset = preset.seed * 0.73;
728
+ }
729
+
687
730
  setDprCap(cap) {
688
731
  this.options.dprCap = cap;
689
732
  this.resize();
@@ -709,123 +752,128 @@ class CosmicRenderer {
709
752
  }
710
753
 
711
754
  randomize() {
712
- this.preset.seed = Math.random() * 100;
713
- this.timeOffset = Math.random() * 40;
714
- }
715
-
716
- resize() {
717
- const dpr = Math.min(window.devicePixelRatio || 1, this.options.dprCap);
718
- const rect = this.canvas.getBoundingClientRect();
719
- const width = Math.max(2, Math.round(rect.width * dpr));
720
- const height = Math.max(2, Math.round(rect.height * dpr));
721
- if (this.canvas.width !== width || this.canvas.height !== height) {
722
- this.canvas.width = width;
723
- this.canvas.height = height;
724
- this.gl.viewport(0, 0, width, height);
725
- }
726
- }
727
-
728
- draw(elapsedSeconds, paused = false) {
729
- if (this.disposed || !this.visible) return;
730
- this.resize();
731
- const gl = this.gl;
732
- this.pointer[0] += (this.pointerTarget[0] - this.pointer[0]) * 0.08;
733
- this.pointer[1] += (this.pointerTarget[1] - this.pointer[1]) * 0.08;
734
- this.motion += (this.motionTarget - this.motion) * 0.07;
735
-
736
- gl.useProgram(this.program);
737
- gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
738
- gl.enableVertexAttribArray(this.locations.position);
739
- gl.vertexAttribPointer(this.locations.position, 2, gl.FLOAT, false, 0, 0);
740
-
741
- const colors = this.preset.colors.map(hexToRgb01$1);
742
- gl.uniform2f(this.locations.resolution, this.canvas.width, this.canvas.height);
743
- gl.uniform1f(this.locations.time, this.timeOffset + (paused ? 0 : elapsedSeconds * this.preset.speed));
744
- gl.uniform1f(this.locations.seed, this.preset.seed);
745
- gl.uniform1f(this.locations.motion, this.motion);
746
- gl.uniform2f(this.locations.pointer, this.pointer[0], this.pointer[1]);
747
- gl.uniform3fv(this.locations.colorA, colors[0]);
748
- gl.uniform3fv(this.locations.colorB, colors[1]);
749
- gl.uniform3fv(this.locations.colorC, colors[2]);
750
- gl.uniform3fv(this.locations.colorD, colors[3]);
751
- gl.drawArrays(gl.TRIANGLES, 0, 3);
752
- }
753
-
754
- dispose() {
755
- this.disposed = true;
756
- const target = this.eventTarget || this.canvas;
757
- target.removeEventListener('pointermove', this.onPointerMove);
758
- target.removeEventListener('pointerdown', this.onPointerMove);
759
- target.removeEventListener('pointerleave', this.onPointerLeave);
760
- this.gl.deleteBuffer(this.buffer);
761
- this.gl.deleteProgram(this.program);
762
- const lose = this.gl.getExtension('WEBGL_lose_context');
763
- if (lose) lose.loseContext();
764
- }
765
- }
766
-
767
- function rgb(color, alpha = 1) {
768
- const [r, g, b] = hexToRgb01$1(color).map((value) => Math.round(value * 255));
769
- return `rgba(${r}, ${g}, ${b}, ${alpha})`;
770
- }
771
-
772
- class FallbackRenderer {
773
- constructor(canvas, preset) {
755
+ this.preset.seed = Math.random() * 100;
756
+ this.timeOffset = Math.random() * 40;
757
+ }
758
+
759
+ resize() {
760
+ const dpr = Math.min(window.devicePixelRatio || 1, this.options.dprCap);
761
+ const rect = this.canvas.getBoundingClientRect();
762
+ const width = Math.max(2, Math.round(rect.width * dpr));
763
+ const height = Math.max(2, Math.round(rect.height * dpr));
764
+ if (this.canvas.width !== width || this.canvas.height !== height) {
765
+ this.canvas.width = width;
766
+ this.canvas.height = height;
767
+ }
768
+ this.gl.viewport(0, 0, width, height);
769
+ }
770
+
771
+ draw(elapsedSeconds, paused = false) {
772
+ if (this.disposed || !this.visible) return;
773
+ const gl = this.gl;
774
+ this.pointer[0] += (this.pointerTarget[0] - this.pointer[0]) * 0.08;
775
+ this.pointer[1] += (this.pointerTarget[1] - this.pointer[1]) * 0.08;
776
+ this.motion += (this.motionTarget - this.motion) * 0.07;
777
+
778
+ gl.useProgram(this.program);
779
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
780
+ gl.enableVertexAttribArray(this.locations.position);
781
+ gl.vertexAttribPointer(this.locations.position, 2, gl.FLOAT, false, 0, 0);
782
+
783
+ gl.uniform2f(this.locations.resolution, this.canvas.width, this.canvas.height);
784
+ gl.uniform1f(this.locations.time, this.timeOffset + (paused ? 0 : elapsedSeconds * this.preset.speed));
785
+ gl.uniform1f(this.locations.seed, this.preset.seed);
786
+ gl.uniform1f(this.locations.motion, this.motion);
787
+ gl.uniform2f(this.locations.pointer, this.pointer[0], this.pointer[1]);
788
+ gl.uniform3fv(this.locations.colorA, this.colors[0]);
789
+ gl.uniform3fv(this.locations.colorB, this.colors[1]);
790
+ gl.uniform3fv(this.locations.colorC, this.colors[2]);
791
+ gl.uniform3fv(this.locations.colorD, this.colors[3]);
792
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
793
+ }
794
+
795
+ dispose() {
796
+ this.disposed = true;
797
+ const target = this.eventTarget || this.canvas;
798
+ target.removeEventListener('pointermove', this.onPointerMove);
799
+ target.removeEventListener('pointerdown', this.onPointerMove);
800
+ target.removeEventListener('pointerleave', this.onPointerLeave);
801
+ this.canvas.removeEventListener('webglcontextlost', this.onContextLost, false);
802
+ this.canvas.removeEventListener('webglcontextrestored', this.onContextRestored, false);
803
+ this.gl.deleteBuffer(this.buffer);
804
+ this.gl.deleteProgram(this.program);
805
+ const lose = this.gl.getExtension('WEBGL_lose_context');
806
+ if (lose) lose.loseContext();
807
+ }
808
+ }
809
+
810
+ function rgb(color, alpha = 1) {
811
+ const [r, g, b] = hexToRgb01$1(color).map((value) => Math.round(value * 255));
812
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
813
+ }
814
+
815
+ class FallbackRenderer {
816
+ constructor(canvas, preset, options = {}) {
774
817
  this.canvas = canvas;
775
818
  this.preset = preset;
776
819
  this.context = canvas.getContext('2d');
777
820
  this.visible = true;
778
821
  this.timePhase = 0;
822
+ this.dprCap = options.dprCap || 1.5;
823
+ }
824
+
825
+ resize() {
826
+ const rect = this.canvas.getBoundingClientRect();
827
+ const dpr = Math.min(window.devicePixelRatio || 1, this.dprCap);
828
+ const width = Math.max(2, Math.round(rect.width * dpr));
829
+ const height = Math.max(2, Math.round(rect.height * dpr));
830
+ if (this.canvas.width !== width || this.canvas.height !== height) {
831
+ this.canvas.width = width;
832
+ this.canvas.height = height;
833
+ }
779
834
  }
780
-
781
- resize() {
782
- const rect = this.canvas.getBoundingClientRect();
783
- const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
784
- const width = Math.max(2, Math.round(rect.width * dpr));
785
- const height = Math.max(2, Math.round(rect.height * dpr));
786
- if (this.canvas.width !== width || this.canvas.height !== height) {
787
- this.canvas.width = width;
788
- this.canvas.height = height;
789
- }
790
- }
791
-
835
+
792
836
  drawNebula(time) {
793
837
  const ctx = this.context;
794
838
  const { width, height } = this.canvas;
795
839
  const t = (time + this.timePhase) * (this.preset.speed || 1);
796
840
  const phase = (this.preset.seed || 0) * 0.7;
797
841
  const gradient = ctx.createLinearGradient(0, 0, width, height);
798
- gradient.addColorStop(0, this.preset.colors[0]);
799
- gradient.addColorStop(0.38, this.preset.colors[1]);
800
- gradient.addColorStop(0.72, this.preset.colors[2]);
801
- gradient.addColorStop(1, this.preset.colors[3]);
802
- ctx.fillStyle = gradient;
803
- ctx.fillRect(0, 0, width, height);
804
-
842
+ gradient.addColorStop(0, this.preset.colors[0]);
843
+ gradient.addColorStop(0.38, this.preset.colors[1]);
844
+ gradient.addColorStop(0.72, this.preset.colors[2]);
845
+ gradient.addColorStop(1, this.preset.colors[3]);
846
+ ctx.fillStyle = gradient;
847
+ ctx.fillRect(0, 0, width, height);
848
+
805
849
  ctx.globalCompositeOperation = 'screen';
806
850
  for (let index = 0; index < 6; index += 1) {
807
851
  const x = (0.5 + 0.45 * Math.sin(t * 0.32 + index * 1.7 + phase)) * width;
808
852
  const y = (0.5 + 0.4 * Math.cos(t * 0.25 + index + phase)) * height;
809
- const radius = Math.max(width, height) * (0.08 + (index % 3) * 0.04);
810
- const glow = ctx.createRadialGradient(x, y, 0, x, y, radius);
811
- glow.addColorStop(0, rgb(this.preset.colors[(index + 1) % 4], 0.24));
812
- glow.addColorStop(1, rgb(this.preset.colors[index % 4], 0));
813
- ctx.fillStyle = glow;
814
- ctx.fillRect(x - radius, y - radius, radius * 2, radius * 2);
815
- }
816
- ctx.globalCompositeOperation = 'source-over';
817
- }
818
-
819
- draw(time) {
820
- if (!this.visible) return;
821
- this.resize();
822
- this.drawNebula(time);
823
- }
824
-
825
- setPreset(preset) {
826
- this.preset = preset;
827
- }
828
-
853
+ const radius = Math.max(width, height) * (0.08 + (index % 3) * 0.04);
854
+ const glow = ctx.createRadialGradient(x, y, 0, x, y, radius);
855
+ glow.addColorStop(0, rgb(this.preset.colors[(index + 1) % 4], 0.24));
856
+ glow.addColorStop(1, rgb(this.preset.colors[index % 4], 0));
857
+ ctx.fillStyle = glow;
858
+ ctx.fillRect(x - radius, y - radius, radius * 2, radius * 2);
859
+ }
860
+ ctx.globalCompositeOperation = 'source-over';
861
+ }
862
+
863
+ draw(time) {
864
+ if (!this.visible) return;
865
+ this.drawNebula(time);
866
+ }
867
+
868
+ setPreset(preset) {
869
+ this.preset = preset;
870
+ }
871
+
872
+ setDprCap(cap) {
873
+ this.dprCap = cap;
874
+ this.resize();
875
+ }
876
+
829
877
  randomize() {
830
878
  if (this.preset) {
831
879
  this.preset.seed = Math.random() * 100;
@@ -836,110 +884,129 @@ class FallbackRenderer {
836
884
  dispose() {}
837
885
  }
838
886
 
839
- /**
840
- * Document-level shared rAF scheduler. Every component instance subscribes
841
- * its own frame callback; the whole page runs ONE animation loop (like the
842
- * original demo), which avoids jank from many competing rAF loops.
843
- */
844
- const subscribers = [];
845
- let running = false;
846
- let globallyPaused = false;
847
- let rafId = 0;
848
- let last = 0;
849
-
850
- function tick(now) {
851
- if (!running) return;
852
- const delta = last ? Math.min((now - last) / 1000, 0.05) : 0;
853
- last = now;
854
- // Schedule the next frame BEFORE running callbacks so one throwing
855
- // subscriber can never kill the whole animation loop.
856
- rafId = requestAnimationFrame(tick);
857
- if (!globallyPaused) {
858
- for (const item of subscribers.slice()) {
859
- try {
860
- if (!item.isPaused()) item.onFrame(delta, now);
861
- } catch (error) {
887
+ /**
888
+ * Document-level shared rAF scheduler. Every component instance subscribes
889
+ * its own frame callback; the whole page runs ONE animation loop (like the
890
+ * original demo), which avoids jank from many competing rAF loops.
891
+ */
892
+ const subscribers = [];
893
+ let running = false;
894
+ let globallyPaused = false;
895
+ let rafId = 0;
896
+ let last = 0;
897
+
898
+ function tick(now) {
899
+ if (!running) return;
900
+ const activeItems = [];
901
+ if (!globallyPaused) {
902
+ for (const item of subscribers.slice()) {
903
+ try {
904
+ if (!item.isPaused()) activeItems.push(item);
905
+ } catch {}
906
+ }
907
+ }
908
+ if (activeItems.length === 0) {
909
+ running = false;
910
+ rafId = 0;
911
+ last = 0;
912
+ return;
913
+ }
914
+ const delta = last ? Math.min((now - last) / 1000, 0.05) : 0;
915
+ last = now;
916
+ // Schedule the next frame BEFORE running callbacks so one throwing
917
+ // subscriber can never kill the whole animation loop.
918
+ rafId = requestAnimationFrame(tick);
919
+ for (const item of activeItems) {
920
+ try {
921
+ item.onFrame(delta, now);
922
+ } catch (error) {
862
923
  console.warn('[dlc-ui] frame error:', error);
863
- }
864
- }
865
- }
866
- if (subscribers.length === 0) {
867
- cancelAnimationFrame(rafId);
868
- running = false;
869
- rafId = 0;
870
- }
871
- }
872
-
873
- function start() {
874
- if (running) return;
875
- running = true;
876
- last = 0;
877
- rafId = requestAnimationFrame(tick);
878
- }
879
-
880
- function pauseAll() {
881
- globallyPaused = true;
882
- }
883
-
884
- function resumeAll() {
885
- globallyPaused = false;
886
- }
887
-
888
- function subscribeScheduler(onFrame, isPaused) {
889
- const item = { onFrame, isPaused };
890
- subscribers.push(item);
891
- start();
892
- return () => {
893
- const index = subscribers.indexOf(item);
894
- if (index !== -1) subscribers.splice(index, 1);
895
- if (subscribers.length === 0 && rafId) {
896
- cancelAnimationFrame(rafId);
897
- running = false;
898
- rafId = 0;
899
- }
900
- };
901
- }
902
-
903
- /**
904
- * Gates drawing on "element intersects viewport AND the page tab is visible".
905
- * Falls back to always-visible when IntersectionObserver is unavailable.
906
- */
907
- function createVisibilityGuard(element) {
908
- let intersecting = true;
909
- let pageVisible = typeof document === 'undefined' || !document.hidden;
910
- let disposed = false;
911
- let observer = null;
912
-
913
- if (typeof IntersectionObserver !== 'undefined') {
914
- observer = new IntersectionObserver(
915
- (entries) => {
916
- intersecting = entries.some((entry) => entry.isIntersecting);
917
- },
918
- { rootMargin: '180px' }
919
- );
920
- observer.observe(element);
921
- }
922
-
923
- const onVisibilityChange = () => {
924
- pageVisible = typeof document !== 'undefined' && !document.hidden;
925
- };
926
- if (typeof document !== 'undefined') {
927
- document.addEventListener('visibilitychange', onVisibilityChange);
928
- }
929
-
930
- return {
931
- isVisible() {
932
- return intersecting && pageVisible;
933
- },
934
- dispose() {
935
- if (disposed) return;
936
- disposed = true;
937
- if (observer) observer.disconnect();
938
- if (typeof document !== 'undefined') {
939
- document.removeEventListener('visibilitychange', onVisibilityChange);
940
- }
941
- }
942
- };
924
+ }
925
+ }
926
+ if (subscribers.length === 0) {
927
+ cancelAnimationFrame(rafId);
928
+ running = false;
929
+ rafId = 0;
930
+ }
931
+ }
932
+
933
+ function start() {
934
+ if (running) return;
935
+ running = true;
936
+ last = 0;
937
+ rafId = requestAnimationFrame(tick);
938
+ }
939
+
940
+ function pauseAll() {
941
+ globallyPaused = true;
942
+ }
943
+
944
+ function resumeAll() {
945
+ globallyPaused = false;
946
+ start();
947
+ }
948
+
949
+ function wakeScheduler() {
950
+ start();
951
+ }
952
+
953
+ function subscribeScheduler(onFrame, isPaused) {
954
+ const item = { onFrame, isPaused };
955
+ subscribers.push(item);
956
+ start();
957
+ return () => {
958
+ const index = subscribers.indexOf(item);
959
+ if (index !== -1) subscribers.splice(index, 1);
960
+ if (subscribers.length === 0 && rafId) {
961
+ cancelAnimationFrame(rafId);
962
+ running = false;
963
+ rafId = 0;
964
+ }
965
+ };
966
+ }
967
+
968
+ /**
969
+ * Gates drawing on "element intersects viewport AND the page tab is visible".
970
+ * Falls back to always-visible when IntersectionObserver is unavailable.
971
+ */
972
+ function createVisibilityGuard(element, onChange = null) {
973
+ let intersecting = true;
974
+ let pageVisible = typeof document === 'undefined' || !document.hidden;
975
+ let disposed = false;
976
+ let observer = null;
977
+
978
+ if (typeof IntersectionObserver !== 'undefined') {
979
+ observer = new IntersectionObserver(
980
+ (entries) => {
981
+ intersecting = entries.some((entry) => entry.isIntersecting);
982
+ if (typeof onChange === 'function') onChange();
983
+ },
984
+ { rootMargin: '180px' }
985
+ );
986
+ observer.observe(element);
987
+ }
988
+
989
+ const onVisibilityChange = () => {
990
+ pageVisible = typeof document !== 'undefined' && !document.hidden;
991
+ if (typeof onChange === 'function') onChange();
992
+ };
993
+ if (typeof document !== 'undefined') {
994
+ document.addEventListener('visibilitychange', onVisibilityChange);
995
+ }
996
+
997
+ return {
998
+ isVisible() {
999
+ return intersecting && pageVisible;
1000
+ },
1001
+ dispose() {
1002
+ if (disposed) return;
1003
+ disposed = true;
1004
+ if (observer) observer.disconnect();
1005
+ if (typeof document !== 'undefined') {
1006
+ document.removeEventListener('visibilitychange', onVisibilityChange);
1007
+ }
1008
+ }
1009
+ };
943
1010
  }
944
1011
 
945
1012
  /**
@@ -958,30 +1025,96 @@ function nextTick(fn) {
958
1025
  }
959
1026
  }
960
1027
 
961
- function prefersReducedMotion$2() {
962
- return typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches;
963
- }
964
-
965
- /**
1028
+ function normalizeFps(value, fallback = 60) {
1029
+ const fps = Number(value);
1030
+ if (!Number.isFinite(fps)) return fallback;
1031
+ return Math.min(60, Math.max(1, fps));
1032
+ }
1033
+
1034
+ function createFrameGate(initialFps = 60) {
1035
+ let fps = normalizeFps(initialFps);
1036
+ let elapsed = 0;
1037
+ return {
1038
+ shouldDraw(delta) {
1039
+ elapsed += delta;
1040
+ const interval = 1 / fps;
1041
+ if (elapsed + 0.0001 < interval) return false;
1042
+ elapsed %= interval;
1043
+ return true;
1044
+ },
1045
+ setFps(value) {
1046
+ fps = normalizeFps(value, fps);
1047
+ elapsed = 0;
1048
+ return fps;
1049
+ },
1050
+ getFps() {
1051
+ return fps;
1052
+ }
1053
+ };
1054
+ }
1055
+
1056
+ function createReducedMotionPreference(enabled, onChange) {
1057
+ const query = enabled && typeof matchMedia !== 'undefined'
1058
+ ? matchMedia('(prefers-reduced-motion: reduce)')
1059
+ : null;
1060
+ const notify = () => {
1061
+ if (typeof onChange === 'function') onChange(Boolean(query && query.matches));
1062
+ };
1063
+ if (query) {
1064
+ if (typeof query.addEventListener === 'function') query.addEventListener('change', notify);
1065
+ else if (typeof query.addListener === 'function') query.addListener(notify);
1066
+ }
1067
+ return {
1068
+ matches() {
1069
+ return Boolean(query && query.matches);
1070
+ },
1071
+ dispose() {
1072
+ if (!query) return;
1073
+ if (typeof query.removeEventListener === 'function') query.removeEventListener('change', notify);
1074
+ else if (typeof query.removeListener === 'function') query.removeListener(notify);
1075
+ }
1076
+ };
1077
+ }
1078
+
1079
+ function toFiniteNumber(value) {
1080
+ if (value == null || typeof value === 'boolean') return null;
1081
+ if (typeof value === 'string' && value.trim() === '') return null;
1082
+ const number = Number(value);
1083
+ return Number.isFinite(number) ? number : null;
1084
+ }
1085
+
1086
+ /**
966
1087
  * Mount a cosmic (nebula) capsule into `container`.
967
1088
  *
968
1089
  * Options: preset (literary name), width, height (number=px or string with
969
1090
  * px/%/vw/vh/em/rem), colors, seed, speed, textRatio (0-100, text region
970
1091
  * width in percent), text (HTML string or DOM nodes for the text slot),
971
1092
  * colorContent (HTML string or DOM nodes for the color slot), quality,
972
- * renderer, respectReducedMotion, mouseColor, cssVars.
1093
+ * renderer, quality, renderScale, powerPreference, fps, paused/static,
1094
+ * respectReducedMotion, mouseColor, cssVars.
973
1095
  */
974
1096
  function createCapsule(container, options = {}) {
975
- if (!container || typeof container.appendChild !== 'function') {
976
- throw new Error('createCapsule: container element is required');
977
- }
978
-
1097
+ if (!container || typeof container.appendChild !== 'function') {
1098
+ throw new Error('createCapsule: container element is required');
1099
+ }
1100
+
979
1101
  const preset = { ...getPreset('capsule', options.preset ?? '初光') };
980
1102
  const merged = normalizeOptions(DEFAULTS.capsule, preset, options);
981
- const normalizedColors = merged.colors.map(normalizeColor);
982
- if (normalizedColors.every(Boolean)) preset.colors = normalizedColors;
983
- preset.seed = merged.seed;
984
- preset.speed = merged.speed;
1103
+ const normalizedColors = Array.isArray(merged.colors) ? merged.colors.map(normalizeColor) : [];
1104
+ if (normalizedColors.length === 4 && normalizedColors.every(Boolean)) preset.colors = normalizedColors;
1105
+ const initialSeed = toFiniteNumber(merged.seed);
1106
+ const initialSpeed = toFiniteNumber(merged.speed);
1107
+ if (initialSeed !== null) preset.seed = initialSeed;
1108
+ if (initialSpeed !== null) preset.speed = initialSpeed;
1109
+ merged.colors = [...preset.colors];
1110
+ merged.seed = preset.seed;
1111
+ merged.speed = preset.speed;
1112
+ const optionColors = Array.isArray(options.colors) ? options.colors.map(normalizeColor) : [];
1113
+ let colorOverride = optionColors.length === 4 && optionColors.every(Boolean)
1114
+ ? [...optionColors]
1115
+ : null;
1116
+ let seedOverride = options.seed !== undefined ? toFiniteNumber(options.seed) : null;
1117
+ let speedOverride = options.speed !== undefined ? toFiniteNumber(options.speed) : null;
985
1118
  let customLabel = typeof options.label === 'string' && options.label ? options.label : null;
986
1119
 
987
1120
  const root = document.createElement('div');
@@ -1029,35 +1162,67 @@ function createCapsule(container, options = {}) {
1029
1162
  root.appendChild(visualLayer);
1030
1163
  root.appendChild(canvas);
1031
1164
  container.appendChild(root);
1032
-
1165
+
1033
1166
  const emitter = createEmitter();
1034
- let paused = merged.respectReducedMotion && prefersReducedMotion$2();
1167
+ let manuallyPaused = merged.paused === true;
1168
+ let reducedPaused = false;
1169
+ let staticMode = merged.static === true;
1170
+ let contextLost = false;
1035
1171
  let disposed = false;
1172
+ let renderOnce = () => {};
1036
1173
  const dirty = {
1037
1174
  preset: false,
1038
1175
  seed: false,
1039
1176
  speed: false,
1040
1177
  colors: false,
1041
1178
  textRatio: false,
1042
- cssVars: false
1179
+ cssVars: false,
1180
+ mouseColor: false,
1181
+ quality: false,
1182
+ renderScale: false,
1183
+ paused: false,
1184
+ static: false,
1185
+ fps: false
1043
1186
  };
1044
-
1045
- let renderer;
1046
- const useWebgl = merged.renderer !== 'canvas2d';
1047
- if (useWebgl) {
1048
- try {
1049
- renderer = new CosmicRenderer(canvas, merged, { dprCap: dprCapFor(merged.quality) });
1050
- } catch (error) {
1051
- renderer = new FallbackRenderer(canvas, merged);
1187
+
1188
+ let renderer;
1189
+ const useWebgl = merged.renderer !== 'canvas2d';
1190
+ if (useWebgl) {
1191
+ try {
1192
+ renderer = new CosmicRenderer(canvas, merged, {
1193
+ dprCap: effectiveDprCap(merged.quality, merged.renderScale),
1194
+ mouseColor: merged.mouseColor !== false,
1195
+ powerPreference: merged.powerPreference,
1196
+ onContextLost: () => {
1197
+ contextLost = true;
1198
+ emitter.emit('contextlost', {});
1199
+ },
1200
+ onContextRestored: () => {
1201
+ contextLost = false;
1202
+ emitter.emit('contextrestored', {});
1203
+ renderOnce();
1204
+ wakeScheduler();
1205
+ }
1206
+ });
1207
+ } catch (error) {
1208
+ renderer = new FallbackRenderer(canvas, merged, {
1209
+ dprCap: effectiveDprCap(merged.quality, merged.renderScale)
1210
+ });
1052
1211
  nextTick(() => {
1053
1212
  if (disposed) return;
1054
1213
  emitter.emit('error', { message: String(error && error.message ? error.message : error) });
1055
1214
  });
1056
- }
1057
- } else {
1058
- renderer = new FallbackRenderer(canvas, merged);
1059
- }
1060
-
1215
+ }
1216
+ } else {
1217
+ renderer = new FallbackRenderer(canvas, merged, {
1218
+ dprCap: effectiveDprCap(merged.quality, merged.renderScale)
1219
+ });
1220
+ }
1221
+
1222
+ let animationTime = 0;
1223
+ const frameGate = createFrameGate(merged.fps);
1224
+ renderOnce = () => renderer.draw(animationTime);
1225
+
1061
1226
  const applySize = () => {
1062
1227
  root.style.width = parseSize(merged.width);
1063
1228
  root.style.height = parseSize(merged.height);
@@ -1071,39 +1236,53 @@ function createCapsule(container, options = {}) {
1071
1236
  }
1072
1237
  renderer.resize();
1073
1238
  };
1074
- applySize();
1075
-
1076
- const resizeObserver = typeof ResizeObserver !== 'undefined'
1077
- ? new ResizeObserver(() => renderer.resize())
1078
- : null;
1079
- if (resizeObserver) resizeObserver.observe(root);
1080
- else window.addEventListener('resize', renderer.resize);
1081
-
1082
- const visibility = createVisibilityGuard(root);
1083
-
1239
+ applySize();
1240
+
1241
+ const resize = () => {
1242
+ renderer.resize();
1243
+ if (manuallyPaused || reducedPaused || staticMode) renderOnce();
1244
+ };
1245
+ const resizeObserver = typeof ResizeObserver !== 'undefined'
1246
+ ? new ResizeObserver(resize)
1247
+ : null;
1248
+ if (resizeObserver) resizeObserver.observe(root);
1249
+ else window.addEventListener('resize', resize);
1250
+
1251
+ const visibility = createVisibilityGuard(root, wakeScheduler);
1252
+ const motionPreference = createReducedMotionPreference(
1253
+ merged.respectReducedMotion,
1254
+ (matches) => {
1255
+ reducedPaused = matches;
1256
+ if (matches) renderOnce();
1257
+ else wakeScheduler();
1258
+ }
1259
+ );
1260
+ reducedPaused = motionPreference.matches();
1261
+ const isMotionPaused = () => manuallyPaused || reducedPaused || staticMode || contextLost;
1262
+
1084
1263
  root.addEventListener('pointerenter', () => emitter.emit('pointerenter', { preset: { ...preset } }));
1085
1264
  root.addEventListener('pointerleave', () => emitter.emit('pointerleave', { preset: { ...preset } }));
1086
- root.addEventListener('click', (event) => {
1087
- emitter.emit('click', { event, preset: { ...preset } });
1088
- });
1089
- root.addEventListener('pointerdown', (event) => emitter.emit('pointerdown', { event, preset: { ...preset } }));
1090
- root.addEventListener('pointerup', (event) => emitter.emit('pointerup', { event, preset: { ...preset } }));
1091
- root.addEventListener('dblclick', (event) => emitter.emit('dblclick', { event, preset: { ...preset } }));
1092
-
1093
- let animationTime = 0;
1094
- const unsubscribe = subscribeScheduler(
1095
- (delta) => {
1096
- animationTime += delta;
1097
- if (visibility.isVisible()) renderer.draw(animationTime);
1098
- },
1099
- () => paused
1100
- );
1101
-
1265
+ root.addEventListener('click', (event) => {
1266
+ emitter.emit('click', { event, preset: { ...preset } });
1267
+ });
1268
+ root.addEventListener('pointerdown', (event) => emitter.emit('pointerdown', { event, preset: { ...preset } }));
1269
+ root.addEventListener('pointerup', (event) => emitter.emit('pointerup', { event, preset: { ...preset } }));
1270
+ root.addEventListener('dblclick', (event) => emitter.emit('dblclick', { event, preset: { ...preset } }));
1271
+
1272
+ renderOnce();
1273
+ const unsubscribe = subscribeScheduler(
1274
+ (delta) => {
1275
+ animationTime += delta;
1276
+ if (frameGate.shouldDraw(delta)) renderer.draw(animationTime);
1277
+ },
1278
+ () => isMotionPaused() || !visibility.isVisible()
1279
+ );
1280
+
1102
1281
  nextTick(() => {
1103
1282
  if (disposed) return;
1104
1283
  emitter.emit('ready', { preset: { ...preset } });
1105
1284
  });
1106
-
1285
+
1107
1286
  const syncTheme = () => {
1108
1287
  root.dataset.mode = 'nebula';
1109
1288
  root.dataset.theme = preset.theme || 'light';
@@ -1111,45 +1290,55 @@ function createCapsule(container, options = {}) {
1111
1290
  };
1112
1291
 
1113
1292
  return {
1114
- element: root,
1115
- canvas,
1116
- preset,
1117
- on: emitter.on,
1118
- off: emitter.off,
1293
+ element: root,
1294
+ canvas,
1295
+ preset,
1296
+ on: emitter.on,
1297
+ off: emitter.off,
1119
1298
  setPreset(ref) {
1120
1299
  const next = getPreset('capsule', ref);
1121
1300
  dirty.preset = true;
1122
1301
  Object.assign(preset, next);
1302
+ if (colorOverride) preset.colors = [...colorOverride];
1303
+ if (seedOverride !== null) preset.seed = seedOverride;
1304
+ if (speedOverride !== null) preset.speed = speedOverride;
1123
1305
  const nextColors = preset.colors.map(normalizeColor);
1124
1306
  if (nextColors.every(Boolean)) preset.colors = nextColors;
1125
1307
  renderer.setPreset({ ...preset });
1126
1308
  syncTheme();
1127
1309
  renderer.resize();
1310
+ if (isMotionPaused()) renderOnce();
1128
1311
  emitter.emit('presetchange', { preset: { ...next } });
1129
1312
  return this;
1130
- },
1313
+ },
1131
1314
  setColors(colors) {
1132
1315
  const next = Array.isArray(colors) ? colors.map(normalizeColor) : [];
1133
1316
  if (next.length !== 4 || next.some((color) => !color)) return this;
1134
1317
  dirty.colors = true;
1318
+ colorOverride = [...next];
1135
1319
  preset.colors = next;
1136
1320
  renderer.setPreset({ ...preset, colors: next });
1321
+ if (isMotionPaused()) renderOnce();
1137
1322
  return this;
1138
1323
  },
1139
1324
  setSeed(seed) {
1140
- const value = Number(seed);
1141
- if (!Number.isFinite(value)) return this;
1325
+ const value = toFiniteNumber(seed);
1326
+ if (value === null) return this;
1142
1327
  dirty.seed = true;
1328
+ seedOverride = value;
1143
1329
  preset.seed = value;
1144
1330
  renderer.setPreset({ ...preset });
1331
+ if (isMotionPaused()) renderOnce();
1145
1332
  return this;
1146
1333
  },
1147
1334
  setSpeed(speed) {
1148
- const value = Number(speed);
1149
- if (!Number.isFinite(value)) return this;
1335
+ const value = toFiniteNumber(speed);
1336
+ if (value === null) return this;
1150
1337
  dirty.speed = true;
1338
+ speedOverride = value;
1151
1339
  preset.speed = value;
1152
1340
  renderer.setPreset({ ...preset });
1341
+ if (isMotionPaused()) renderOnce();
1153
1342
  return this;
1154
1343
  },
1155
1344
  setText(content) {
@@ -1182,6 +1371,7 @@ function createCapsule(container, options = {}) {
1182
1371
  return this;
1183
1372
  },
1184
1373
  setMouseColor(value) {
1374
+ dirty.mouseColor = true;
1185
1375
  merged.mouseColor = value !== false;
1186
1376
  if (typeof renderer.setMouseColor === 'function') renderer.setMouseColor(merged.mouseColor);
1187
1377
  return this;
@@ -1196,1113 +1386,1331 @@ function createCapsule(container, options = {}) {
1196
1386
  merged.height = height;
1197
1387
  }
1198
1388
  applySize();
1389
+ if (isMotionPaused()) renderOnce();
1199
1390
  return this;
1200
1391
  },
1201
1392
  randomize() {
1202
1393
  this.setSeed(Math.random() * 100);
1203
1394
  return this;
1204
1395
  },
1205
- pause() {
1206
- paused = true;
1207
- return this;
1208
- },
1209
- resume() {
1210
- paused = false;
1211
- return this;
1212
- },
1213
- setQuality(quality) {
1214
- merged.quality = quality;
1215
- if (typeof renderer.setDprCap === 'function') renderer.setDprCap(dprCapFor(quality));
1216
- return this;
1217
- },
1396
+ pause() {
1397
+ dirty.paused = true;
1398
+ manuallyPaused = true;
1399
+ renderOnce();
1400
+ return this;
1401
+ },
1402
+ resume() {
1403
+ dirty.paused = true;
1404
+ manuallyPaused = false;
1405
+ wakeScheduler();
1406
+ return this;
1407
+ },
1408
+ setPaused(value) {
1409
+ return value ? this.pause() : this.resume();
1410
+ },
1411
+ setStatic(value) {
1412
+ dirty.static = true;
1413
+ staticMode = value === true;
1414
+ merged.static = staticMode;
1415
+ if (staticMode) renderOnce();
1416
+ else wakeScheduler();
1417
+ return this;
1418
+ },
1419
+ setFps(fps) {
1420
+ dirty.fps = true;
1421
+ merged.fps = frameGate.setFps(fps);
1422
+ wakeScheduler();
1423
+ return this;
1424
+ },
1425
+ setQuality(quality) {
1426
+ dirty.quality = true;
1427
+ merged.quality = quality;
1428
+ if (typeof renderer.setDprCap === 'function') {
1429
+ renderer.setDprCap(effectiveDprCap(quality, merged.renderScale));
1430
+ }
1431
+ if (isMotionPaused()) renderOnce();
1432
+ return this;
1433
+ },
1434
+ setRenderScale(renderScale) {
1435
+ const value = Number(renderScale);
1436
+ if (!Number.isFinite(value)) return this;
1437
+ dirty.renderScale = true;
1438
+ merged.renderScale = Math.min(1, Math.max(0.25, value));
1439
+ if (typeof renderer.setDprCap === 'function') {
1440
+ renderer.setDprCap(effectiveDprCap(merged.quality, merged.renderScale));
1441
+ }
1442
+ if (isMotionPaused()) renderOnce();
1443
+ return this;
1444
+ },
1218
1445
  dispose() {
1219
1446
  disposed = true;
1220
1447
  unsubscribe();
1221
- visibility.dispose();
1222
- if (resizeObserver) resizeObserver.disconnect();
1223
- else window.removeEventListener('resize', renderer.resize);
1448
+ visibility.dispose();
1449
+ motionPreference.dispose();
1450
+ if (resizeObserver) resizeObserver.disconnect();
1451
+ else window.removeEventListener('resize', resize);
1224
1452
  renderer.dispose();
1225
1453
  root.remove();
1226
1454
  },
1227
1455
  get textRatio() { return merged.textRatio; },
1228
1456
  get cssVars() { return merged.cssVars; },
1457
+ get mouseColor() { return merged.mouseColor; },
1458
+ get quality() { return merged.quality; },
1459
+ get renderScale() { return merged.renderScale; },
1460
+ get paused() { return manuallyPaused; },
1461
+ get static() { return staticMode; },
1462
+ get fps() { return frameGate.getFps(); },
1229
1463
  dirty
1230
1464
  };
1231
1465
  }
1232
1466
 
1233
- const PROGRESS_MOTION_WIDTH = 240;
1234
- const PROGRESS_MOTION_HEIGHT = 80;
1235
- const PROGRESS_MOTION_DURATION = 12.0;
1236
- const PROGRESS_MOTION_MAX_PX = 40.0;
1237
-
1238
- const PROFILE_CONFIG = {
1239
- 'model-training': { seed: 0.37, broad: 0.58, middle: 0.25, detail: 0.13, lobe: 0.24 },
1240
- 'agent-migration': { seed: 1.71, broad: 0.72, middle: 0.10, detail: 0.03, lobe: 0.18 },
1241
- 'visual-training': { seed: 2.83, broad: 0.66, middle: 0.16, detail: 0.06, lobe: 0.23 },
1242
- // ponytail: first-pass tide = asymmetric time warp on the same motion pipeline.
1243
- // Refine (foam line / wash streaks) in the shader after visual QA.
1244
- 'tide': { seed: 4.12, broad: 0.82, middle: 0.07, detail: 0.02, lobe: 0.30, warp: 0.5 }
1245
- };
1246
-
1247
- const CACHE$1 = Object.create(null);
1248
-
1249
- function gaussian(value, center, width) {
1250
- const delta = (value - center) / Math.max(width, 0.001);
1251
- return Math.exp(-delta * delta);
1252
- }
1253
-
1254
- function createMotionData(id, edgeStyle = 'flow') {
1255
- const profile = edgeStyle === 'tide'
1256
- ? PROFILE_CONFIG.tide
1257
- : (PROFILE_CONFIG[id] || PROFILE_CONFIG['visual-training']);
1258
- const data = new Uint8Array(PROGRESS_MOTION_WIDTH * PROGRESS_MOTION_HEIGHT);
1259
-
1260
- for (let x = 0; x < PROGRESS_MOTION_WIDTH; x += 1) {
1261
- let time = (x / PROGRESS_MOTION_WIDTH) * Math.PI * 2;
1262
- if (profile.warp) time += profile.warp * Math.sin(time * 2);
1263
- const centerA = 0.28 + Math.sin(time * 0.53 + profile.seed) * 0.13;
1264
- const centerB = 0.70 + Math.cos(time * 0.47 + profile.seed * 1.7) * 0.12;
1265
-
1266
- for (let y = 0; y < PROGRESS_MOTION_HEIGHT; y += 1) {
1267
- const ratio = y / Math.max(PROGRESS_MOTION_HEIGHT - 1, 1);
1268
- const envelope = Math.pow(Math.max(Math.sin(Math.PI * ratio), 0), 0.48);
1269
- const broad = Math.sin(ratio * Math.PI * 2 * 1.35 + time * 0.58 + profile.seed) * profile.broad;
1270
- const middle = Math.sin(ratio * Math.PI * 2 * 3.2 - time * 0.91 + profile.seed * 2.1) * profile.middle;
1271
- const detail = Math.sin(ratio * Math.PI * 2 * 6.1 + time * 1.31 + profile.seed * 3.2) * profile.detail;
1272
- const lobes = (
1273
- gaussian(ratio, centerA, 0.09) * Math.sin(time * 1.11 + profile.seed * 4.0) -
1274
- gaussian(ratio, centerB, 0.10) * Math.cos(time * 0.97 + profile.seed * 3.3)
1275
- ) * profile.lobe;
1276
- const normalized = Math.max(-1, Math.min(1, (broad + middle + detail + lobes) * envelope));
1277
- data[y * PROGRESS_MOTION_WIDTH + x] = Math.round((normalized * 0.5 + 0.5) * 255);
1278
- }
1279
- }
1280
-
1281
- return data;
1282
- }
1283
-
1284
- function getProgressMotionData(id, edgeStyle = 'flow') {
1285
- const key = `${id}:${edgeStyle}`;
1286
- if (!CACHE$1[key]) CACHE$1[key] = createMotionData(id, edgeStyle);
1287
- return CACHE$1[key];
1288
- }
1289
-
1290
- function hexToRgb01(hex) {
1291
- const value = Number.parseInt(hex.replace('#', ''), 16);
1292
- return [
1293
- ((value >> 16) & 255) / 255,
1294
- ((value >> 8) & 255) / 255,
1295
- (value & 255) / 255
1296
- ];
1297
- }
1298
-
1299
- function stringSeed$1(value) {
1300
- let hash = 2166136261;
1301
- for (const character of value) {
1302
- hash ^= character.charCodeAt(0);
1303
- hash = Math.imul(hash, 16777619);
1304
- }
1305
- return (hash >>> 0) / 4294967295;
1306
- }
1307
-
1308
- const PROFILE_INDEX = {
1309
- 'model-training': 0,
1310
- 'agent-migration': 1,
1311
- 'visual-training': 2,
1312
- 'tide': 3
1313
- };
1314
-
1315
- const MOTION_SCALE_FACTORS = {
1316
- 'model-training': 1.05,
1317
- 'agent-migration': 1.04,
1318
- 'visual-training': 1.04,
1319
- 'tide': 1.18
1320
- };
1321
-
1322
- const VERTEX_SHADER = `#version 300 es
1323
- in vec2 a_position;
1324
- out vec2 v_uv;
1325
- void main() {
1326
- v_uv = a_position * 0.5 + 0.5;
1327
- gl_Position = vec4(a_position, 0.0, 1.0);
1328
- }`;
1329
-
1330
- const FRAGMENT_SHADER = `#version 300 es
1331
- precision highp float;
1332
-
1333
- in vec2 v_uv;
1334
- out vec4 outColor;
1335
-
1336
- uniform vec2 u_resolution;
1337
- uniform float u_time;
1338
- uniform float u_progress;
1339
- uniform float u_seed;
1340
- uniform float u_profile;
1341
- uniform sampler2D u_motion;
1342
- uniform sampler2D u_effect;
1343
- uniform float u_hasEffect;
1344
- uniform float u_effectFrames;
1345
- uniform float u_motionDuration;
1346
- uniform float u_motionScale;
1347
- uniform vec3 u_dark;
1348
- uniform vec3 u_accentA;
1349
- uniform vec3 u_accentB;
1350
- uniform vec3 u_glow;
1351
-
1352
- float hash21(vec2 p) {
1353
- p = fract(p * vec2(123.34, 456.21));
1354
- p += dot(p, p + 45.32 + u_seed * 11.7);
1355
- return fract(p.x * p.y);
1356
- }
1357
-
1358
- float noise(vec2 p) {
1359
- vec2 i = floor(p);
1360
- vec2 f = fract(p);
1361
- f = f * f * (3.0 - 2.0 * f);
1362
- float a = hash21(i);
1363
- float b = hash21(i + vec2(1.0, 0.0));
1364
- float c = hash21(i + vec2(0.0, 1.0));
1365
- float d = hash21(i + vec2(1.0, 1.0));
1366
- return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
1367
- }
1368
-
1369
- float fbm(vec2 p) {
1370
- float value = 0.0;
1371
- float amplitude = 0.55;
1372
- mat2 rotation = mat2(0.82, 0.57, -0.57, 0.82);
1373
- for (int i = 0; i < 6; i++) {
1374
- value += noise(p) * amplitude;
1375
- p = rotation * p * 2.02 + 13.7;
1376
- amplitude *= 0.48;
1377
- }
1378
- return value;
1379
- }
1380
-
1381
- float gaussian(float value, float center, float width) {
1382
- float delta = (value - center) / max(width, 0.0001);
1383
- return exp(-delta * delta);
1384
- }
1385
-
1386
- float profileMix(float model, float agent, float visual) {
1387
- if (u_profile < 0.5) return model;
1388
- if (u_profile < 1.5) return agent;
1389
- return visual;
1390
- }
1391
-
1392
- float motionSample(float y, float t) {
1393
- float phase = fract(t / max(u_motionDuration, 0.001));
1394
- float captured = texture(u_motion, vec2(phase, 1.0 - clamp(y, 0.0, 1.0))).r;
1395
- return (captured * 2.0 - 1.0) * u_motionScale;
1396
- }
1397
-
1398
- float edgeDisplacement(float y, float t) {
1399
- return motionSample(y, t);
1400
- }
1401
-
1402
- float flowDisplacement(float y, float t) {
1403
- return (
1404
- motionSample(y - 0.024, t) * 0.08 +
1405
- motionSample(y - 0.012, t) * 0.18 +
1406
- motionSample(y, t) * 0.48 +
1407
- motionSample(y + 0.012, t) * 0.18 +
1408
- motionSample(y + 0.024, t) * 0.08
1409
- );
1410
- }
1411
-
1412
- float ellipseRing(vec2 p, float radius, float width) {
1413
- return gaussian(length(p), radius, width);
1414
- }
1415
-
1416
- void main() {
1417
- vec2 uv = v_uv;
1418
- float t = u_time;
1419
- float edge = u_progress + edgeDisplacement(uv.y, t);
1420
- float flowEdge = u_progress + flowDisplacement(uv.y, t);
1421
- float d = uv.x - edge;
1422
- float fd = uv.x - flowEdge;
1423
-
1424
- vec3 rightBase = vec3(0.125, 0.129, 0.145);
1425
- vec3 color = rightBase;
1426
-
1427
- float leftMask = 1.0 - smoothstep(-0.001, 0.002, d);
1428
- color = mix(color, u_dark, leftMask * profileMix(0.96, 0.92, 0.96));
1429
-
1430
- vec2 flowP = vec2((fd + 0.10) * 6.2, uv.y * 1.95);
1431
- float flowA = fbm(flowP + vec2(-t * 0.22, t * 0.27) + u_seed * 1.7);
1432
- float flowB = fbm(flowP * 1.52 + vec2(t * 0.28, -t * 0.36) + 8.2 + u_seed);
1433
- float flowC = fbm(flowP * 2.25 + vec2(-t * 0.41, t * 0.46) + 19.0);
1434
-
1435
- float farCenter = profileMix(-0.060, -0.079, -0.045) + (flowA - 0.5) * profileMix(0.018, 0.022, 0.014);
1436
- float midCenter = profileMix(-0.039, -0.052, -0.030) + (flowB - 0.5) * profileMix(0.013, 0.016, 0.010);
1437
- float hotCenter = profileMix(-0.026, -0.029, -0.023) + (flowC - 0.5) * 0.010;
1438
-
1439
- float farBand = gaussian(fd, farCenter, profileMix(0.035, 0.049, 0.030));
1440
- float midBand = gaussian(fd, midCenter, profileMix(0.026, 0.034, 0.026));
1441
- float hotBand = gaussian(fd, hotCenter, profileMix(0.023, 0.027, 0.026));
1442
- float darkTrough = gaussian(fd, profileMix(-0.050, -0.058, -0.044) + (flowB - 0.5) * 0.010, profileMix(0.020, 0.025, 0.021));
1443
-
1444
- float ringY = 0.47 + sin(t * 0.58 + u_seed * 2.4) * 0.12;
1445
- vec2 ringP = vec2((fd + 0.086) / 0.078, (uv.y - ringY) / 0.25);
1446
- ringP += vec2((flowB - 0.5) * 0.08, (flowA - 0.5) * 0.06);
1447
- float ringTexture = fbm(ringP * 2.15 + vec2(t * 0.18, -t * 0.14) + u_seed * 1.9);
1448
- float ring = ellipseRing(ringP, 0.66, 0.32) * (0.30 + 0.64 * ringTexture);
1449
- float ringCore = gaussian(length(ringP), 0.25, 0.25);
1450
- float ringPulse = smoothstep(0.58, 0.90, 0.5 + 0.5 * sin(t * 0.82 + u_seed * 4.1));
1451
- float modelRing = ring * ringPulse * (1.0 - step(0.5, u_profile));
1452
- float visualRing = ring * 0.16 * step(1.5, u_profile) * ringPulse;
1453
-
1454
- float cloudGate = leftMask * smoothstep(-0.30, -0.008, fd);
1455
- float textureA = smoothstep(0.24, 0.92, flowA * 0.72 + flowB * 0.42);
1456
- float textureB = smoothstep(0.28, 0.94, flowB * 0.68 + flowC * 0.38);
1457
-
1458
- vec3 hotColor = u_accentB;
1459
- color += u_accentA * farBand * cloudGate * (0.07 + textureA * profileMix(0.42, 0.24, 0.40));
1460
- color += u_accentB * midBand * cloudGate * (0.15 + textureB * profileMix(0.70, 0.46, 0.68));
1461
- color += hotColor * hotBand * cloudGate * profileMix(0.88, 0.62, 0.84);
1462
- float modelMask = 1.0 - step(0.5, u_profile);
1463
- color += u_accentA * (modelRing + visualRing) * cloudGate * profileMix(0.54, 0.0, 0.34);
1464
- color *= 1.0 - darkTrough * profileMix(0.44, 0.24, 0.24) * cloudGate;
1465
- color *= 1.0 - ringCore * modelMask * ringPulse * 0.44 * cloudGate;
1466
-
1467
- float broadHalo = exp(-abs(d) * 96.0);
1468
- float innerHalo = exp(-abs(d) * 176.0);
1469
- float colorCore = exp(-abs(d) * 360.0);
1470
- float sharpCore = exp(-abs(d) * 760.0);
1471
- float leftGate = 1.0 - smoothstep(-0.003, 0.005, d);
1472
-
1473
- color += u_accentA * broadHalo * leftGate * profileMix(0.09, 0.04, 0.06);
1474
- color += hotColor * innerHalo * leftGate * profileMix(0.76, 0.72, 0.78);
1475
- color += u_glow * colorCore * profileMix(0.72, 0.34, 0.24);
1476
-
1477
- float whiteStrength = profileMix(0.10, 0.0, 0.0);
1478
- color += vec3(1.0, 0.99, 0.91) * sharpCore * whiteStrength;
1479
-
1480
- float rightCut = smoothstep(0.001, 0.006, d);
1481
- color = mix(color, rightBase, rightCut);
1482
-
1483
- float effectX = (d * 1257.0 + 260.0) / 320.0;
1484
- float atlasPhase = fract(t / 12.0) * u_effectFrames;
1485
- float atlasFrameA = floor(atlasPhase);
1486
- float atlasFrameB = mod(atlasFrameA + 1.0, u_effectFrames);
1487
- float atlasMix = smoothstep(0.0, 1.0, fract(atlasPhase));
1488
- float atlasXA = (atlasFrameA + clamp(effectX, 0.0, 1.0)) / u_effectFrames;
1489
- float atlasXB = (atlasFrameB + clamp(effectX, 0.0, 1.0)) / u_effectFrames;
1490
- vec3 referenceA = texture(u_effect, vec2(atlasXA, uv.y)).rgb;
1491
- vec3 referenceB = texture(u_effect, vec2(atlasXB, uv.y)).rgb;
1492
- vec3 referenceColor = mix(referenceA, referenceB, atlasMix);
1493
- float stripMask = smoothstep(0.0, 0.018, effectX) * (1.0 - smoothstep(0.982, 1.0, effectX));
1494
- float referenceLeft = 1.0 - smoothstep(-0.026, -0.012, d);
1495
- // 参考图集只提供亮度结构,颜色始终由用户 colors(u_dark / u_accentA /
1496
- // u_accentB / u_glow)决定:这样 setColors / colors 属性在 WebGL 路径下
1497
- // 真实生效,改色有可见反馈,而不是被图集整体覆盖。
1498
- float referenceLuma = dot(referenceColor, vec3(0.299, 0.587, 0.114));
1499
- vec3 tintedColor = color * (0.42 + 0.86 * referenceLuma);
1500
- color = mix(color, tintedColor, stripMask * referenceLeft * u_hasEffect);
1501
-
1502
- outColor = vec4(clamp(color, 0.0, 1.0), 1.0);
1503
- }`;
1504
-
1505
- function compileShader(gl, type, source) {
1506
- const shader = gl.createShader(type);
1507
- gl.shaderSource(shader, source);
1508
- gl.compileShader(shader);
1509
- if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
1510
- const message = gl.getShaderInfoLog(shader) || 'Unknown shader compile error';
1511
- gl.deleteShader(shader);
1512
- throw new Error(message);
1513
- }
1514
- return shader;
1515
- }
1516
-
1517
- function createProgram(gl) {
1518
- const vertex = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
1519
- const fragment = compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER);
1520
- const program = gl.createProgram();
1521
- gl.attachShader(program, vertex);
1522
- gl.attachShader(program, fragment);
1523
- gl.linkProgram(program);
1524
- gl.deleteShader(vertex);
1525
- gl.deleteShader(fragment);
1526
- if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
1527
- const message = gl.getProgramInfoLog(program) || 'Unknown shader link error';
1528
- gl.deleteProgram(program);
1529
- throw new Error(message);
1530
- }
1531
- return program;
1532
- }
1533
-
1534
- class ProgressFlowRenderer {
1535
- constructor(canvas, preset) {
1536
- const gl = canvas.getContext('webgl2', {
1537
- alpha: false,
1538
- antialias: true,
1539
- premultipliedAlpha: false,
1540
- powerPreference: 'high-performance'
1541
- });
1542
- if (!gl) throw new Error('WebGL2 unavailable');
1543
-
1544
- this.canvas = canvas;
1545
- this.gl = gl;
1546
- this.program = createProgram(gl);
1547
- this.profile = preset.edgeStyle === 'tide' ? 3 : (PROFILE_INDEX[preset.id] ?? 2);
1548
- this.seed = stringSeed$1(`${preset.id}-shader`) * 13.7 + 1.0;
1549
- this.colors = preset.colors.map(hexToRgb01);
1550
- this.motionData = getProgressMotionData(preset.id, preset.edgeStyle);
1551
- const motionFactor = preset.edgeStyle === 'tide'
1552
- ? MOTION_SCALE_FACTORS.tide
1553
- : (MOTION_SCALE_FACTORS[preset.id] || 1.04);
1554
- this.motionScale = (PROGRESS_MOTION_MAX_PX * motionFactor) / 1257;
1555
-
1556
- this.position = gl.getAttribLocation(this.program, 'a_position');
1557
- this.uniforms = {
1558
- resolution: gl.getUniformLocation(this.program, 'u_resolution'),
1559
- time: gl.getUniformLocation(this.program, 'u_time'),
1560
- progress: gl.getUniformLocation(this.program, 'u_progress'),
1561
- seed: gl.getUniformLocation(this.program, 'u_seed'),
1562
- profile: gl.getUniformLocation(this.program, 'u_profile'),
1563
- motion: gl.getUniformLocation(this.program, 'u_motion'),
1564
- effect: gl.getUniformLocation(this.program, 'u_effect'),
1565
- hasEffect: gl.getUniformLocation(this.program, 'u_hasEffect'),
1566
- effectFrames: gl.getUniformLocation(this.program, 'u_effectFrames'),
1567
- motionDuration: gl.getUniformLocation(this.program, 'u_motionDuration'),
1568
- motionScale: gl.getUniformLocation(this.program, 'u_motionScale'),
1569
- dark: gl.getUniformLocation(this.program, 'u_dark'),
1570
- accentA: gl.getUniformLocation(this.program, 'u_accentA'),
1571
- accentB: gl.getUniformLocation(this.program, 'u_accentB'),
1572
- glow: gl.getUniformLocation(this.program, 'u_glow')
1573
- };
1574
-
1575
- this.buffer = gl.createBuffer();
1576
- gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
1577
- gl.bufferData(
1578
- gl.ARRAY_BUFFER,
1579
- new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),
1580
- gl.STATIC_DRAW
1581
- );
1582
-
1583
- this.motionTexture = gl.createTexture();
1584
- gl.activeTexture(gl.TEXTURE0);
1585
- gl.bindTexture(gl.TEXTURE_2D, this.motionTexture);
1586
- gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
1587
- gl.texImage2D(
1588
- gl.TEXTURE_2D,
1589
- 0,
1590
- gl.R8,
1591
- PROGRESS_MOTION_WIDTH,
1592
- PROGRESS_MOTION_HEIGHT,
1593
- 0,
1594
- gl.RED,
1595
- gl.UNSIGNED_BYTE,
1596
- this.motionData
1597
- );
1598
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
1599
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
1600
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
1601
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
1602
-
1603
- this.effectTexture = gl.createTexture();
1604
- gl.activeTexture(gl.TEXTURE1);
1605
- gl.bindTexture(gl.TEXTURE_2D, this.effectTexture);
1606
- gl.texImage2D(
1607
- gl.TEXTURE_2D,
1608
- 0,
1609
- gl.RGB,
1610
- 1,
1611
- 1,
1612
- 0,
1613
- gl.RGB,
1614
- gl.UNSIGNED_BYTE,
1615
- new Uint8Array([32, 33, 38])
1616
- );
1617
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
1618
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
1619
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
1620
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
1621
- this.effectUploaded = false;
1622
- }
1623
-
1624
- resize(width, height, dpr) {
1625
- const pixelWidth = Math.max(1, Math.round(width * dpr));
1626
- const pixelHeight = Math.max(1, Math.round(height * dpr));
1627
- if (this.canvas.width !== pixelWidth || this.canvas.height !== pixelHeight) {
1628
- this.canvas.width = pixelWidth;
1629
- this.canvas.height = pixelHeight;
1630
- }
1631
- this.gl.viewport(0, 0, pixelWidth, pixelHeight);
1632
- }
1633
-
1634
- draw(time, progress, effectImage = null) {
1635
- const gl = this.gl;
1636
- gl.useProgram(this.program);
1637
- gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
1638
- gl.enableVertexAttribArray(this.position);
1639
- gl.vertexAttribPointer(this.position, 2, gl.FLOAT, false, 0, 0);
1640
-
1641
- gl.uniform2f(this.uniforms.resolution, this.canvas.width, this.canvas.height);
1642
- gl.uniform1f(this.uniforms.time, time);
1643
- gl.uniform1f(this.uniforms.progress, progress / 100);
1644
- gl.uniform1f(this.uniforms.seed, this.seed);
1645
- gl.uniform1f(this.uniforms.profile, this.profile);
1646
- gl.activeTexture(gl.TEXTURE0);
1647
- gl.bindTexture(gl.TEXTURE_2D, this.motionTexture);
1648
- gl.uniform1i(this.uniforms.motion, 0);
1649
- gl.uniform1f(this.uniforms.motionDuration, PROGRESS_MOTION_DURATION);
1650
- gl.uniform1f(this.uniforms.motionScale, this.motionScale);
1651
-
1652
- let hasEffect = this.effectUploaded ? 1 : 0;
1653
- if (!this.effectUploaded && effectImage && effectImage.complete && effectImage.naturalWidth > 0) {
1654
- gl.activeTexture(gl.TEXTURE1);
1655
- gl.bindTexture(gl.TEXTURE_2D, this.effectTexture);
1656
- gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
1657
- try {
1658
- gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, effectImage);
1659
- this.effectUploaded = true;
1660
- hasEffect = 1;
1661
- } catch (error) {
1662
- console.warn('[画境观屿] 参考纹理图集上传失败,继续使用程序化降级。', error);
1663
- }
1664
- gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
1665
- }
1666
- gl.activeTexture(gl.TEXTURE1);
1667
- gl.bindTexture(gl.TEXTURE_2D, this.effectTexture);
1668
- gl.uniform1i(this.uniforms.effect, 1);
1669
- gl.uniform1f(this.uniforms.hasEffect, hasEffect);
1670
- gl.uniform1f(this.uniforms.effectFrames, 24);
1671
-
1672
- gl.uniform3fv(this.uniforms.dark, this.colors[0]);
1673
- gl.uniform3fv(this.uniforms.accentA, this.colors[1]);
1674
- gl.uniform3fv(this.uniforms.accentB, this.colors[2]);
1675
- gl.uniform3fv(this.uniforms.glow, this.colors[3]);
1676
-
1677
- gl.drawArrays(gl.TRIANGLES, 0, 6);
1678
- }
1679
-
1680
- setColors(colors) {
1681
- this.colors = colors.map(hexToRgb01);
1682
- }
1683
-
1684
- dispose() {
1685
- const gl = this.gl;
1686
- gl.deleteBuffer(this.buffer);
1687
- gl.deleteTexture(this.motionTexture);
1688
- gl.deleteTexture(this.effectTexture);
1689
- gl.deleteProgram(this.program);
1690
- const lose = gl.getExtension('WEBGL_lose_context');
1691
- if (lose) lose.loseContext();
1692
- }
1693
- }
1694
-
1695
- function createProgressFlowRenderer(canvas, preset) {
1696
- try {
1697
- return new ProgressFlowRenderer(canvas, preset);
1698
- } catch (error) {
1699
- console.warn('[画境观屿] 进度流体 WebGL2 不可用,使用 Canvas 2D 降级。', error);
1700
- return null;
1701
- }
1702
- }
1703
-
1704
- const PROGRESS_REFERENCE_DURATION = 12;
1705
- const PROGRESS_REFERENCE_FRAME_COUNT = 24;
1706
-
1707
- const FRAME_WIDTH = 64;
1708
- const FRAME_HEIGHT = 32;
1709
- const CACHE = Object.create(null);
1710
-
1711
- const PALETTES = {
1712
- 'model-training': ['#20131f', '#ff3f94', '#ff8a3d', '#fff06a'],
1713
- 'agent-migration': ['#111a31', '#245bff', '#00cfff', '#5dffe6'],
1714
- 'visual-training': ['#1f172b', '#7042ff', '#42f58d', '#c4ff8a'],
1715
- 'tide': ['#0a2239', '#2e9bff', '#7fe3ff', '#eaf9ff']
1716
- };
1717
-
1718
- function drawCloud(context, x, y, radiusX, radiusY, color, alpha) {
1719
- context.save();
1720
- context.translate(x, y);
1721
- context.scale(1, radiusY / radiusX);
1722
- const gradient = context.createRadialGradient(0, 0, 0, 0, 0, radiusX);
1723
- const alphaHex = (value) => {
1724
- const text = Math.round(value).toString(16);
1467
+ const PROGRESS_MOTION_WIDTH = 240;
1468
+ const PROGRESS_MOTION_HEIGHT = 80;
1469
+ const PROGRESS_MOTION_DURATION = 12.0;
1470
+ const PROGRESS_MOTION_MAX_PX = 40.0;
1471
+
1472
+ const PROFILE_CONFIG = {
1473
+ 'model-training': { seed: 0.37, broad: 0.58, middle: 0.25, detail: 0.13, lobe: 0.24 },
1474
+ 'agent-migration': { seed: 1.71, broad: 0.72, middle: 0.10, detail: 0.03, lobe: 0.18 },
1475
+ 'visual-training': { seed: 2.83, broad: 0.66, middle: 0.16, detail: 0.06, lobe: 0.23 },
1476
+ // ponytail: first-pass tide = asymmetric time warp on the same motion pipeline.
1477
+ // Refine (foam line / wash streaks) in the shader after visual QA.
1478
+ 'tide': { seed: 4.12, broad: 0.82, middle: 0.07, detail: 0.02, lobe: 0.30, warp: 0.5 }
1479
+ };
1480
+
1481
+ const CACHE$1 = Object.create(null);
1482
+
1483
+ function gaussian(value, center, width) {
1484
+ const delta = (value - center) / Math.max(width, 0.001);
1485
+ return Math.exp(-delta * delta);
1486
+ }
1487
+
1488
+ function createMotionData(id, edgeStyle = 'flow') {
1489
+ const profile = edgeStyle === 'tide'
1490
+ ? PROFILE_CONFIG.tide
1491
+ : (PROFILE_CONFIG[id] || PROFILE_CONFIG['visual-training']);
1492
+ const data = new Uint8Array(PROGRESS_MOTION_WIDTH * PROGRESS_MOTION_HEIGHT);
1493
+
1494
+ for (let x = 0; x < PROGRESS_MOTION_WIDTH; x += 1) {
1495
+ let time = (x / PROGRESS_MOTION_WIDTH) * Math.PI * 2;
1496
+ if (profile.warp) time += profile.warp * Math.sin(time * 2);
1497
+ const centerA = 0.28 + Math.sin(time * 0.53 + profile.seed) * 0.13;
1498
+ const centerB = 0.70 + Math.cos(time * 0.47 + profile.seed * 1.7) * 0.12;
1499
+
1500
+ for (let y = 0; y < PROGRESS_MOTION_HEIGHT; y += 1) {
1501
+ const ratio = y / Math.max(PROGRESS_MOTION_HEIGHT - 1, 1);
1502
+ const envelope = Math.pow(Math.max(Math.sin(Math.PI * ratio), 0), 0.48);
1503
+ const broad = Math.sin(ratio * Math.PI * 2 * 1.35 + time * 0.58 + profile.seed) * profile.broad;
1504
+ const middle = Math.sin(ratio * Math.PI * 2 * 3.2 - time * 0.91 + profile.seed * 2.1) * profile.middle;
1505
+ const detail = Math.sin(ratio * Math.PI * 2 * 6.1 + time * 1.31 + profile.seed * 3.2) * profile.detail;
1506
+ const lobes = (
1507
+ gaussian(ratio, centerA, 0.09) * Math.sin(time * 1.11 + profile.seed * 4.0) -
1508
+ gaussian(ratio, centerB, 0.10) * Math.cos(time * 0.97 + profile.seed * 3.3)
1509
+ ) * profile.lobe;
1510
+ const normalized = Math.max(-1, Math.min(1, (broad + middle + detail + lobes) * envelope));
1511
+ data[y * PROGRESS_MOTION_WIDTH + x] = Math.round((normalized * 0.5 + 0.5) * 255);
1512
+ }
1513
+ }
1514
+
1515
+ return data;
1516
+ }
1517
+
1518
+ function getProgressMotionData(id, edgeStyle = 'flow') {
1519
+ const key = `${id}:${edgeStyle}`;
1520
+ if (!CACHE$1[key]) CACHE$1[key] = createMotionData(id, edgeStyle);
1521
+ return CACHE$1[key];
1522
+ }
1523
+
1524
+ function hexToRgb01(hex) {
1525
+ const value = Number.parseInt(hex.replace('#', ''), 16);
1526
+ return [
1527
+ ((value >> 16) & 255) / 255,
1528
+ ((value >> 8) & 255) / 255,
1529
+ (value & 255) / 255
1530
+ ];
1531
+ }
1532
+
1533
+ function stringSeed$1(value) {
1534
+ let hash = 2166136261;
1535
+ for (const character of value) {
1536
+ hash ^= character.charCodeAt(0);
1537
+ hash = Math.imul(hash, 16777619);
1538
+ }
1539
+ return (hash >>> 0) / 4294967295;
1540
+ }
1541
+
1542
+ const PROFILE_INDEX = {
1543
+ 'model-training': 0,
1544
+ 'agent-migration': 1,
1545
+ 'visual-training': 2,
1546
+ 'tide': 3
1547
+ };
1548
+
1549
+ const MOTION_SCALE_FACTORS = {
1550
+ 'model-training': 1.05,
1551
+ 'agent-migration': 1.04,
1552
+ 'visual-training': 1.04,
1553
+ 'tide': 1.18
1554
+ };
1555
+
1556
+ const VERTEX_SHADER = `#version 300 es
1557
+ in vec2 a_position;
1558
+ out vec2 v_uv;
1559
+ void main() {
1560
+ v_uv = a_position * 0.5 + 0.5;
1561
+ gl_Position = vec4(a_position, 0.0, 1.0);
1562
+ }`;
1563
+
1564
+ const FRAGMENT_SHADER = `#version 300 es
1565
+ precision highp float;
1566
+
1567
+ in vec2 v_uv;
1568
+ out vec4 outColor;
1569
+
1570
+ uniform vec2 u_resolution;
1571
+ uniform float u_time;
1572
+ uniform float u_progress;
1573
+ uniform float u_seed;
1574
+ uniform float u_profile;
1575
+ uniform sampler2D u_motion;
1576
+ uniform sampler2D u_effect;
1577
+ uniform float u_hasEffect;
1578
+ uniform float u_effectFrames;
1579
+ uniform float u_motionDuration;
1580
+ uniform float u_motionScale;
1581
+ uniform vec3 u_dark;
1582
+ uniform vec3 u_accentA;
1583
+ uniform vec3 u_accentB;
1584
+ uniform vec3 u_glow;
1585
+
1586
+ float hash21(vec2 p) {
1587
+ p = fract(p * vec2(123.34, 456.21));
1588
+ p += dot(p, p + 45.32 + u_seed * 11.7);
1589
+ return fract(p.x * p.y);
1590
+ }
1591
+
1592
+ float noise(vec2 p) {
1593
+ vec2 i = floor(p);
1594
+ vec2 f = fract(p);
1595
+ f = f * f * (3.0 - 2.0 * f);
1596
+ float a = hash21(i);
1597
+ float b = hash21(i + vec2(1.0, 0.0));
1598
+ float c = hash21(i + vec2(0.0, 1.0));
1599
+ float d = hash21(i + vec2(1.0, 1.0));
1600
+ return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
1601
+ }
1602
+
1603
+ float fbm(vec2 p) {
1604
+ float value = 0.0;
1605
+ float amplitude = 0.55;
1606
+ mat2 rotation = mat2(0.82, 0.57, -0.57, 0.82);
1607
+ for (int i = 0; i < 6; i++) {
1608
+ value += noise(p) * amplitude;
1609
+ p = rotation * p * 2.02 + 13.7;
1610
+ amplitude *= 0.48;
1611
+ }
1612
+ return value;
1613
+ }
1614
+
1615
+ float gaussian(float value, float center, float width) {
1616
+ float delta = (value - center) / max(width, 0.0001);
1617
+ return exp(-delta * delta);
1618
+ }
1619
+
1620
+ float profileMix(float model, float agent, float visual) {
1621
+ if (u_profile < 0.5) return model;
1622
+ if (u_profile < 1.5) return agent;
1623
+ return visual;
1624
+ }
1625
+
1626
+ float motionSample(float y, float t) {
1627
+ float phase = fract(t / max(u_motionDuration, 0.001));
1628
+ float captured = texture(u_motion, vec2(phase, 1.0 - clamp(y, 0.0, 1.0))).r;
1629
+ return (captured * 2.0 - 1.0) * u_motionScale;
1630
+ }
1631
+
1632
+ float edgeDisplacement(float y, float t) {
1633
+ return motionSample(y, t);
1634
+ }
1635
+
1636
+ float flowDisplacement(float y, float t) {
1637
+ return (
1638
+ motionSample(y - 0.024, t) * 0.08 +
1639
+ motionSample(y - 0.012, t) * 0.18 +
1640
+ motionSample(y, t) * 0.48 +
1641
+ motionSample(y + 0.012, t) * 0.18 +
1642
+ motionSample(y + 0.024, t) * 0.08
1643
+ );
1644
+ }
1645
+
1646
+ float ellipseRing(vec2 p, float radius, float width) {
1647
+ return gaussian(length(p), radius, width);
1648
+ }
1649
+
1650
+ void main() {
1651
+ vec2 uv = v_uv;
1652
+ float t = u_time;
1653
+ float edge = u_progress + edgeDisplacement(uv.y, t);
1654
+ float flowEdge = u_progress + flowDisplacement(uv.y, t);
1655
+ float d = uv.x - edge;
1656
+ float fd = uv.x - flowEdge;
1657
+
1658
+ vec3 rightBase = vec3(0.125, 0.129, 0.145);
1659
+ vec3 color = rightBase;
1660
+
1661
+ float leftMask = 1.0 - smoothstep(-0.001, 0.002, d);
1662
+ color = mix(color, u_dark, leftMask * profileMix(0.96, 0.92, 0.96));
1663
+
1664
+ vec2 flowP = vec2((fd + 0.10) * 6.2, uv.y * 1.95);
1665
+ float flowA = fbm(flowP + vec2(-t * 0.22, t * 0.27) + u_seed * 1.7);
1666
+ float flowB = fbm(flowP * 1.52 + vec2(t * 0.28, -t * 0.36) + 8.2 + u_seed);
1667
+ float flowC = fbm(flowP * 2.25 + vec2(-t * 0.41, t * 0.46) + 19.0);
1668
+
1669
+ float farCenter = profileMix(-0.060, -0.079, -0.045) + (flowA - 0.5) * profileMix(0.018, 0.022, 0.014);
1670
+ float midCenter = profileMix(-0.039, -0.052, -0.030) + (flowB - 0.5) * profileMix(0.013, 0.016, 0.010);
1671
+ float hotCenter = profileMix(-0.026, -0.029, -0.023) + (flowC - 0.5) * 0.010;
1672
+
1673
+ float farBand = gaussian(fd, farCenter, profileMix(0.035, 0.049, 0.030));
1674
+ float midBand = gaussian(fd, midCenter, profileMix(0.026, 0.034, 0.026));
1675
+ float hotBand = gaussian(fd, hotCenter, profileMix(0.023, 0.027, 0.026));
1676
+ float darkTrough = gaussian(fd, profileMix(-0.050, -0.058, -0.044) + (flowB - 0.5) * 0.010, profileMix(0.020, 0.025, 0.021));
1677
+
1678
+ float ringY = 0.47 + sin(t * 0.58 + u_seed * 2.4) * 0.12;
1679
+ vec2 ringP = vec2((fd + 0.086) / 0.078, (uv.y - ringY) / 0.25);
1680
+ ringP += vec2((flowB - 0.5) * 0.08, (flowA - 0.5) * 0.06);
1681
+ float ringTexture = fbm(ringP * 2.15 + vec2(t * 0.18, -t * 0.14) + u_seed * 1.9);
1682
+ float ring = ellipseRing(ringP, 0.66, 0.32) * (0.30 + 0.64 * ringTexture);
1683
+ float ringCore = gaussian(length(ringP), 0.25, 0.25);
1684
+ float ringPulse = smoothstep(0.58, 0.90, 0.5 + 0.5 * sin(t * 0.82 + u_seed * 4.1));
1685
+ float modelRing = ring * ringPulse * (1.0 - step(0.5, u_profile));
1686
+ float visualRing = ring * 0.16 * step(1.5, u_profile) * ringPulse;
1687
+
1688
+ float cloudGate = leftMask * smoothstep(-0.30, -0.008, fd);
1689
+ float textureA = smoothstep(0.24, 0.92, flowA * 0.72 + flowB * 0.42);
1690
+ float textureB = smoothstep(0.28, 0.94, flowB * 0.68 + flowC * 0.38);
1691
+
1692
+ vec3 hotColor = u_accentB;
1693
+ color += u_accentA * farBand * cloudGate * (0.07 + textureA * profileMix(0.42, 0.24, 0.40));
1694
+ color += u_accentB * midBand * cloudGate * (0.15 + textureB * profileMix(0.70, 0.46, 0.68));
1695
+ color += hotColor * hotBand * cloudGate * profileMix(0.88, 0.62, 0.84);
1696
+ float modelMask = 1.0 - step(0.5, u_profile);
1697
+ color += u_accentA * (modelRing + visualRing) * cloudGate * profileMix(0.54, 0.0, 0.34);
1698
+ color *= 1.0 - darkTrough * profileMix(0.44, 0.24, 0.24) * cloudGate;
1699
+ color *= 1.0 - ringCore * modelMask * ringPulse * 0.44 * cloudGate;
1700
+
1701
+ float broadHalo = exp(-abs(d) * 96.0);
1702
+ float innerHalo = exp(-abs(d) * 176.0);
1703
+ float colorCore = exp(-abs(d) * 360.0);
1704
+ float sharpCore = exp(-abs(d) * 760.0);
1705
+ float leftGate = 1.0 - smoothstep(-0.003, 0.005, d);
1706
+
1707
+ color += u_accentA * broadHalo * leftGate * profileMix(0.09, 0.04, 0.06);
1708
+ color += hotColor * innerHalo * leftGate * profileMix(0.76, 0.72, 0.78);
1709
+ color += u_glow * colorCore * profileMix(0.72, 0.34, 0.24);
1710
+
1711
+ float whiteStrength = profileMix(0.10, 0.0, 0.0);
1712
+ color += vec3(1.0, 0.99, 0.91) * sharpCore * whiteStrength;
1713
+
1714
+ float rightCut = smoothstep(0.001, 0.006, d);
1715
+ color = mix(color, rightBase, rightCut);
1716
+
1717
+ float effectX = (d * 1257.0 + 260.0) / 320.0;
1718
+ float atlasPhase = fract(t / 12.0) * u_effectFrames;
1719
+ float atlasFrameA = floor(atlasPhase);
1720
+ float atlasFrameB = mod(atlasFrameA + 1.0, u_effectFrames);
1721
+ float atlasMix = smoothstep(0.0, 1.0, fract(atlasPhase));
1722
+ float atlasXA = (atlasFrameA + clamp(effectX, 0.0, 1.0)) / u_effectFrames;
1723
+ float atlasXB = (atlasFrameB + clamp(effectX, 0.0, 1.0)) / u_effectFrames;
1724
+ vec3 referenceA = texture(u_effect, vec2(atlasXA, uv.y)).rgb;
1725
+ vec3 referenceB = texture(u_effect, vec2(atlasXB, uv.y)).rgb;
1726
+ vec3 referenceColor = mix(referenceA, referenceB, atlasMix);
1727
+ float stripMask = smoothstep(0.0, 0.018, effectX) * (1.0 - smoothstep(0.982, 1.0, effectX));
1728
+ float referenceLeft = 1.0 - smoothstep(-0.026, -0.012, d);
1729
+ // 参考图集只提供亮度结构,颜色始终由用户 colors(u_dark / u_accentA /
1730
+ // u_accentB / u_glow)决定:这样 setColors / colors 属性在 WebGL 路径下
1731
+ // 真实生效,改色有可见反馈,而不是被图集整体覆盖。
1732
+ float referenceLuma = dot(referenceColor, vec3(0.299, 0.587, 0.114));
1733
+ vec3 tintedColor = color * (0.42 + 0.86 * referenceLuma);
1734
+ color = mix(color, tintedColor, stripMask * referenceLeft * u_hasEffect);
1735
+
1736
+ outColor = vec4(clamp(color, 0.0, 1.0), 1.0);
1737
+ }`;
1738
+
1739
+ function compileShader(gl, type, source) {
1740
+ const shader = gl.createShader(type);
1741
+ gl.shaderSource(shader, source);
1742
+ gl.compileShader(shader);
1743
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
1744
+ const message = gl.getShaderInfoLog(shader) || 'Unknown shader compile error';
1745
+ gl.deleteShader(shader);
1746
+ throw new Error(message);
1747
+ }
1748
+ return shader;
1749
+ }
1750
+
1751
+ function createProgram(gl) {
1752
+ const vertex = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
1753
+ const fragment = compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER);
1754
+ const program = gl.createProgram();
1755
+ gl.attachShader(program, vertex);
1756
+ gl.attachShader(program, fragment);
1757
+ gl.linkProgram(program);
1758
+ gl.deleteShader(vertex);
1759
+ gl.deleteShader(fragment);
1760
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
1761
+ const message = gl.getProgramInfoLog(program) || 'Unknown shader link error';
1762
+ gl.deleteProgram(program);
1763
+ throw new Error(message);
1764
+ }
1765
+ return program;
1766
+ }
1767
+
1768
+ class ProgressFlowRenderer {
1769
+ constructor(canvas, preset, options = {}) {
1770
+ const gl = canvas.getContext('webgl2', {
1771
+ alpha: false,
1772
+ antialias: true,
1773
+ premultipliedAlpha: false,
1774
+ powerPreference: options.powerPreference || 'high-performance'
1775
+ });
1776
+ if (!gl) throw new Error('WebGL2 unavailable');
1777
+
1778
+ this.canvas = canvas;
1779
+ this.options = options;
1780
+ this.gl = gl;
1781
+ this.profile = preset.edgeStyle === 'tide' ? 3 : (PROFILE_INDEX[preset.id] ?? 2);
1782
+ this.seed = stringSeed$1(`${preset.id}-shader`) * 13.7 + 1.0;
1783
+ this.colors = preset.colors.map(hexToRgb01);
1784
+ this.motionData = getProgressMotionData(preset.id, preset.edgeStyle);
1785
+ const motionFactor = preset.edgeStyle === 'tide'
1786
+ ? MOTION_SCALE_FACTORS.tide
1787
+ : (MOTION_SCALE_FACTORS[preset.id] || 1.04);
1788
+ this.motionScale = (PROGRESS_MOTION_MAX_PX * motionFactor) / 1257;
1789
+ this.disposed = false;
1790
+ this.contextLost = false;
1791
+ this.setupResources();
1792
+ this.bindContextEvents();
1793
+ }
1794
+
1795
+ setupResources() {
1796
+ const gl = this.gl;
1797
+ this.program = createProgram(gl);
1798
+
1799
+ this.position = gl.getAttribLocation(this.program, 'a_position');
1800
+ this.uniforms = {
1801
+ resolution: gl.getUniformLocation(this.program, 'u_resolution'),
1802
+ time: gl.getUniformLocation(this.program, 'u_time'),
1803
+ progress: gl.getUniformLocation(this.program, 'u_progress'),
1804
+ seed: gl.getUniformLocation(this.program, 'u_seed'),
1805
+ profile: gl.getUniformLocation(this.program, 'u_profile'),
1806
+ motion: gl.getUniformLocation(this.program, 'u_motion'),
1807
+ effect: gl.getUniformLocation(this.program, 'u_effect'),
1808
+ hasEffect: gl.getUniformLocation(this.program, 'u_hasEffect'),
1809
+ effectFrames: gl.getUniformLocation(this.program, 'u_effectFrames'),
1810
+ motionDuration: gl.getUniformLocation(this.program, 'u_motionDuration'),
1811
+ motionScale: gl.getUniformLocation(this.program, 'u_motionScale'),
1812
+ dark: gl.getUniformLocation(this.program, 'u_dark'),
1813
+ accentA: gl.getUniformLocation(this.program, 'u_accentA'),
1814
+ accentB: gl.getUniformLocation(this.program, 'u_accentB'),
1815
+ glow: gl.getUniformLocation(this.program, 'u_glow')
1816
+ };
1817
+
1818
+ this.buffer = gl.createBuffer();
1819
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
1820
+ gl.bufferData(
1821
+ gl.ARRAY_BUFFER,
1822
+ new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),
1823
+ gl.STATIC_DRAW
1824
+ );
1825
+
1826
+ this.motionTexture = gl.createTexture();
1827
+ gl.activeTexture(gl.TEXTURE0);
1828
+ gl.bindTexture(gl.TEXTURE_2D, this.motionTexture);
1829
+ gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
1830
+ gl.texImage2D(
1831
+ gl.TEXTURE_2D,
1832
+ 0,
1833
+ gl.R8,
1834
+ PROGRESS_MOTION_WIDTH,
1835
+ PROGRESS_MOTION_HEIGHT,
1836
+ 0,
1837
+ gl.RED,
1838
+ gl.UNSIGNED_BYTE,
1839
+ this.motionData
1840
+ );
1841
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
1842
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
1843
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
1844
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
1845
+
1846
+ this.effectTexture = gl.createTexture();
1847
+ gl.activeTexture(gl.TEXTURE1);
1848
+ gl.bindTexture(gl.TEXTURE_2D, this.effectTexture);
1849
+ gl.texImage2D(
1850
+ gl.TEXTURE_2D,
1851
+ 0,
1852
+ gl.RGB,
1853
+ 1,
1854
+ 1,
1855
+ 0,
1856
+ gl.RGB,
1857
+ gl.UNSIGNED_BYTE,
1858
+ new Uint8Array([32, 33, 38])
1859
+ );
1860
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
1861
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
1862
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
1863
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
1864
+ this.effectUploaded = false;
1865
+ }
1866
+
1867
+ bindContextEvents() {
1868
+ this.onContextLost = (event) => {
1869
+ event.preventDefault();
1870
+ if (this.disposed) return;
1871
+ this.contextLost = true;
1872
+ if (typeof this.options.onContextLost === 'function') this.options.onContextLost(event);
1873
+ };
1874
+ this.onContextRestored = () => {
1875
+ if (this.disposed) return;
1876
+ this.setupResources();
1877
+ this.contextLost = false;
1878
+ this.gl.viewport(0, 0, this.canvas.width, this.canvas.height);
1879
+ if (typeof this.options.onContextRestored === 'function') this.options.onContextRestored();
1880
+ };
1881
+ this.canvas.addEventListener('webglcontextlost', this.onContextLost, false);
1882
+ this.canvas.addEventListener('webglcontextrestored', this.onContextRestored, false);
1883
+ }
1884
+
1885
+ resize(width, height, dpr) {
1886
+ const pixelWidth = Math.max(1, Math.round(width * dpr));
1887
+ const pixelHeight = Math.max(1, Math.round(height * dpr));
1888
+ if (this.canvas.width !== pixelWidth || this.canvas.height !== pixelHeight) {
1889
+ this.canvas.width = pixelWidth;
1890
+ this.canvas.height = pixelHeight;
1891
+ }
1892
+ this.gl.viewport(0, 0, pixelWidth, pixelHeight);
1893
+ }
1894
+
1895
+ draw(time, progress, effectImage = null) {
1896
+ if (this.disposed || this.contextLost) return;
1897
+ const gl = this.gl;
1898
+ gl.useProgram(this.program);
1899
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
1900
+ gl.enableVertexAttribArray(this.position);
1901
+ gl.vertexAttribPointer(this.position, 2, gl.FLOAT, false, 0, 0);
1902
+
1903
+ gl.uniform2f(this.uniforms.resolution, this.canvas.width, this.canvas.height);
1904
+ gl.uniform1f(this.uniforms.time, time);
1905
+ gl.uniform1f(this.uniforms.progress, progress / 100);
1906
+ gl.uniform1f(this.uniforms.seed, this.seed);
1907
+ gl.uniform1f(this.uniforms.profile, this.profile);
1908
+ gl.activeTexture(gl.TEXTURE0);
1909
+ gl.bindTexture(gl.TEXTURE_2D, this.motionTexture);
1910
+ gl.uniform1i(this.uniforms.motion, 0);
1911
+ gl.uniform1f(this.uniforms.motionDuration, PROGRESS_MOTION_DURATION);
1912
+ gl.uniform1f(this.uniforms.motionScale, this.motionScale);
1913
+
1914
+ let hasEffect = this.effectUploaded ? 1 : 0;
1915
+ const effectWidth = effectImage && (effectImage.naturalWidth || effectImage.width || 0);
1916
+ if (!this.effectUploaded && effectImage && effectWidth > 0) {
1917
+ gl.activeTexture(gl.TEXTURE1);
1918
+ gl.bindTexture(gl.TEXTURE_2D, this.effectTexture);
1919
+ gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
1920
+ try {
1921
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, effectImage);
1922
+ this.effectUploaded = true;
1923
+ hasEffect = 1;
1924
+ } catch (error) {
1925
+ console.warn('[画境观屿] 参考纹理图集上传失败,继续使用程序化降级。', error);
1926
+ }
1927
+ gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
1928
+ }
1929
+ gl.activeTexture(gl.TEXTURE1);
1930
+ gl.bindTexture(gl.TEXTURE_2D, this.effectTexture);
1931
+ gl.uniform1i(this.uniforms.effect, 1);
1932
+ gl.uniform1f(this.uniforms.hasEffect, hasEffect);
1933
+ gl.uniform1f(this.uniforms.effectFrames, 24);
1934
+
1935
+ gl.uniform3fv(this.uniforms.dark, this.colors[0]);
1936
+ gl.uniform3fv(this.uniforms.accentA, this.colors[1]);
1937
+ gl.uniform3fv(this.uniforms.accentB, this.colors[2]);
1938
+ gl.uniform3fv(this.uniforms.glow, this.colors[3]);
1939
+
1940
+ gl.drawArrays(gl.TRIANGLES, 0, 6);
1941
+ }
1942
+
1943
+ setColors(colors) {
1944
+ this.colors = colors.map(hexToRgb01);
1945
+ }
1946
+
1947
+ dispose() {
1948
+ this.disposed = true;
1949
+ const gl = this.gl;
1950
+ this.canvas.removeEventListener('webglcontextlost', this.onContextLost, false);
1951
+ this.canvas.removeEventListener('webglcontextrestored', this.onContextRestored, false);
1952
+ gl.deleteBuffer(this.buffer);
1953
+ gl.deleteTexture(this.motionTexture);
1954
+ gl.deleteTexture(this.effectTexture);
1955
+ gl.deleteProgram(this.program);
1956
+ const lose = gl.getExtension('WEBGL_lose_context');
1957
+ if (lose) lose.loseContext();
1958
+ }
1959
+ }
1960
+
1961
+ function createProgressFlowRenderer(canvas, preset, options = {}) {
1962
+ try {
1963
+ return new ProgressFlowRenderer(canvas, preset, options);
1964
+ } catch (error) {
1965
+ console.warn('[画境观屿] 进度流体 WebGL2 不可用,使用 Canvas 2D 降级。', error);
1966
+ return null;
1967
+ }
1968
+ }
1969
+
1970
+ const PROGRESS_REFERENCE_DURATION = 12;
1971
+ const PROGRESS_REFERENCE_FRAME_COUNT = 24;
1972
+
1973
+ const FRAME_WIDTH = 64;
1974
+ const FRAME_HEIGHT = 32;
1975
+ const CACHE = Object.create(null);
1976
+
1977
+ const PALETTES = {
1978
+ 'model-training': ['#20131f', '#ff3f94', '#ff8a3d', '#fff06a'],
1979
+ 'agent-migration': ['#111a31', '#245bff', '#00cfff', '#5dffe6'],
1980
+ 'visual-training': ['#1f172b', '#7042ff', '#42f58d', '#c4ff8a'],
1981
+ 'tide': ['#0a2239', '#2e9bff', '#7fe3ff', '#eaf9ff']
1982
+ };
1983
+
1984
+ function drawCloud(context, x, y, radiusX, radiusY, color, alpha) {
1985
+ context.save();
1986
+ context.translate(x, y);
1987
+ context.scale(1, radiusY / radiusX);
1988
+ const gradient = context.createRadialGradient(0, 0, 0, 0, 0, radiusX);
1989
+ const alphaHex = (value) => {
1990
+ const text = Math.round(value).toString(16);
1725
1991
  return text.length === 1 ? `0${text}` : text;
1726
1992
  };
1727
1993
  gradient.addColorStop(0, `${color}${alphaHex(alpha * 255)}`);
1728
1994
  gradient.addColorStop(0.46, `${color}${alphaHex(alpha * 0.42 * 255)}`);
1729
- gradient.addColorStop(1, `${color}00`);
1730
- context.fillStyle = gradient;
1731
- context.fillRect(-radiusX, -radiusX, radiusX * 2, radiusX * 2);
1732
- context.restore();
1733
- }
1734
-
1735
- function createAtlas(id) {
1736
- const palette = PALETTES[id] || PALETTES['visual-training'];
1737
- const canvas = document.createElement('canvas');
1738
- canvas.width = FRAME_WIDTH * PROGRESS_REFERENCE_FRAME_COUNT;
1739
- canvas.height = FRAME_HEIGHT;
1740
- const context = canvas.getContext('2d');
1741
-
1742
- for (let frame = 0; frame < PROGRESS_REFERENCE_FRAME_COUNT; frame += 1) {
1743
- const phase = (frame / PROGRESS_REFERENCE_FRAME_COUNT) * Math.PI * 2;
1744
- const left = frame * FRAME_WIDTH;
1745
- context.save();
1746
- context.translate(left, 0);
1747
- context.fillStyle = palette[0];
1748
- context.fillRect(0, 0, FRAME_WIDTH, FRAME_HEIGHT);
1749
- context.globalCompositeOperation = 'screen';
1750
-
1751
- drawCloud(context, 35 + Math.sin(phase * 0.83) * 5, 10 + Math.cos(phase * 0.61) * 5, 27, 18, palette[1], id === 'agent-migration' ? 0.32 : 0.42);
1752
- drawCloud(context, 45 + Math.cos(phase * 0.72) * 4, 23 + Math.sin(phase * 0.54) * 4, 22, 15, palette[2], 0.44);
1753
- drawCloud(context, 53 + Math.sin(phase * 1.07) * 2, 16 + Math.cos(phase * 0.89) * 6, 12, 13, palette[3], id === 'model-training' ? 0.30 : 0.20);
1754
-
1755
- context.globalCompositeOperation = 'source-over';
1756
- const trough = context.createRadialGradient(41, 16, 1, 41, 16, 17);
1757
- trough.addColorStop(0, 'rgba(5,6,11,0.64)');
1758
- trough.addColorStop(0.58, 'rgba(6,7,12,0.26)');
1759
- trough.addColorStop(1, 'rgba(6,7,12,0)');
1760
- context.fillStyle = trough;
1761
- context.fillRect(20, 0, 44, FRAME_HEIGHT);
1762
- context.restore();
1763
- }
1764
-
1765
- const image = new Image();
1766
- image.decoding = 'async';
1767
- const state = { image, ready: false };
1768
- image.addEventListener('load', () => { state.ready = true; }, { once: true });
1769
- image.addEventListener('error', () => { state.ready = false; }, { once: true });
1770
- image.src = canvas.toDataURL('image/png');
1771
- return state;
1772
- }
1773
-
1774
- function getProgressReferenceAtlas(id) {
1775
- if (!CACHE[id]) CACHE[id] = createAtlas(id);
1776
- return CACHE[id];
1777
- }
1778
-
1779
- /**
1780
- * Attach a WebGL2 fluid overlay to a progress capsule root.
1781
- *
1782
- * @param {object} params
1783
- * @param {HTMLElement} params.root progress capsule root element
1784
- * @param {HTMLCanvasElement} params.canvas 2D fallback canvas (kept beneath)
1785
- * @param {object} params.preset progress preset
1786
- * @param {() => number} params.getProgress reads the current progress value
1787
- * @returns {{ update(flowTime: number): void, dispose(): void } | null}
1788
- */
1789
- function attachProgressFlowOverlay({ root, canvas, preset, getProgress }) {
1790
- const overlay = document.createElement('canvas');
1791
- overlay.className = 'hj-progress-canvas hj-progress-overlay';
1792
- overlay.setAttribute('aria-hidden', 'true');
1793
- canvas.insertAdjacentElement('afterend', overlay);
1794
-
1795
- const renderer = createProgressFlowRenderer(overlay, preset);
1796
- if (!renderer) {
1797
- overlay.remove();
1798
- return null;
1799
- }
1800
-
1801
- const atlas = getProgressReferenceAtlas(preset.id);
1802
- root.classList.add('has-webgl-progress');
1803
-
1804
- const resize = () => {
1805
- const bounds = root.getBoundingClientRect();
1806
- const width = Math.max(1, bounds.width);
1807
- const height = Math.max(1, bounds.height);
1808
- const dpr = Math.min(window.devicePixelRatio || 1, 2);
1809
- overlay.style.width = `${width}px`;
1810
- overlay.style.height = `${height}px`;
1811
- renderer.resize(width, height, dpr);
1812
- };
1813
-
1814
- let resizeObserver = null;
1815
- let onWindowResize = null;
1816
- if (typeof ResizeObserver !== 'undefined') {
1817
- resizeObserver = new ResizeObserver(resize);
1818
- resizeObserver.observe(root);
1819
- } else {
1820
- onWindowResize = resize;
1821
- window.addEventListener('resize', onWindowResize);
1822
- }
1823
- resize();
1824
-
1825
- return {
1826
- update(flowTime) {
1827
- const effectTime = flowTime % PROGRESS_REFERENCE_DURATION;
1828
- const progress = getProgress();
1829
- renderer.draw(
1830
- effectTime,
1831
- Number.isFinite(progress) ? progress : preset.initialProgress,
1832
- atlas.ready ? atlas.image : null
1833
- );
1834
- },
1835
- setColors(colors) {
1836
- renderer.setColors(colors);
1837
- },
1838
- dispose() {
1839
- if (resizeObserver) resizeObserver.disconnect();
1840
- if (onWindowResize) window.removeEventListener('resize', onWindowResize);
1841
- renderer.dispose();
1842
- overlay.remove();
1843
- root.classList.remove('has-webgl-progress');
1844
- }
1845
- };
1846
- }
1847
-
1848
- const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
1849
- const SUPPORTS_CTX_FILTER = typeof CanvasRenderingContext2D !== 'undefined' && 'filter' in CanvasRenderingContext2D.prototype;
1850
-
1851
- function stringSeed(value) {
1852
- let hash = 2166136261;
1853
- for (const character of value) {
1854
- hash ^= character.charCodeAt(0);
1855
- hash = Math.imul(hash, 16777619);
1856
- }
1857
- return (hash >>> 0) / 4294967295;
1858
- }
1859
-
1860
- function interpolate(template, values) {
1861
- return template.replace(/\{(\w+)\}/g, (_, key) => (values[key] !== undefined ? values[key] : ''));
1862
- }
1863
-
1864
- function prefersReducedMotion$1() {
1865
- return typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches;
1866
- }
1867
-
1868
- const FLOW_PROFILES = {
1869
- 'model-training': {
1870
- cycles: [0.98, 3.05, 5.45],
1871
- amplitudes: [11.2, 11.0, 5.2],
1872
- speeds: [0.31, -0.53, 0.78],
1873
- bulgeAmplitude: 8.2,
1874
- timeScale: 1.0,
1875
- glowWidth: 5.8,
1876
- haloWidth: 20,
1877
- whiteAlpha: 0.72,
1878
- whiteWidth: 1.15,
1879
- cloudWidth: 0.17,
1880
- autoRange: [25, 66]
1881
- },
1882
- 'agent-migration': {
1883
- cycles: [0.62, 1.55, 2.95],
1884
- amplitudes: [16.0, 7.8, 2.3],
1885
- speeds: [0.25, -0.4, 0.60],
1886
- bulgeAmplitude: 8.4,
1887
- timeScale: 0.82,
1888
- glowWidth: 6.3,
1889
- haloWidth: 21,
1890
- whiteAlpha: 0.18,
1891
- whiteWidth: 0.45,
1892
- cloudWidth: 0.18,
1893
- autoRange: [24, 62]
1894
- },
1895
- 'visual-training': {
1896
- cycles: [0.88, 2.45, 4.35],
1897
- amplitudes: [12.8, 10.2, 4.3],
1898
- speeds: [0.28, -0.47, 0.69],
1899
- bulgeAmplitude: 7.3,
1900
- timeScale: 0.91,
1901
- glowWidth: 6.0,
1902
- haloWidth: 21,
1903
- whiteAlpha: 0.30,
1904
- whiteWidth: 0.55,
1905
- cloudWidth: 0.175,
1906
- autoRange: [20, 75]
1907
- },
1908
- // ponytail: first-pass tide = asymmetric surge on the 2D fallback path.
1909
- // Tune surge/amplitudes after visual QA against the WebGL overlay.
1910
- 'tide': {
1911
- cycles: [0.72, 1.9, 4.2],
1912
- amplitudes: [19.0, 6.5, 1.5],
1913
- speeds: [0.42, -0.5, 0.66],
1914
- bulgeAmplitude: 9.5,
1915
- timeScale: 0.85,
1916
- glowWidth: 6.2,
1917
- haloWidth: 21,
1918
- whiteAlpha: 0.5,
1919
- whiteWidth: 1.0,
1920
- cloudWidth: 0.18,
1921
- autoRange: [18, 70],
1922
- surge: 0.55
1923
- }
1924
- };
1925
-
1926
- class ProgressCapsuleController {
1995
+ gradient.addColorStop(1, `${color}00`);
1996
+ context.fillStyle = gradient;
1997
+ context.fillRect(-radiusX, -radiusX, radiusX * 2, radiusX * 2);
1998
+ context.restore();
1999
+ }
2000
+
2001
+ function createAtlas(id) {
2002
+ const palette = PALETTES[id] || PALETTES['visual-training'];
2003
+ const canvas = document.createElement('canvas');
2004
+ canvas.width = FRAME_WIDTH * PROGRESS_REFERENCE_FRAME_COUNT;
2005
+ canvas.height = FRAME_HEIGHT;
2006
+ const context = canvas.getContext('2d');
2007
+
2008
+ for (let frame = 0; frame < PROGRESS_REFERENCE_FRAME_COUNT; frame += 1) {
2009
+ const phase = (frame / PROGRESS_REFERENCE_FRAME_COUNT) * Math.PI * 2;
2010
+ const left = frame * FRAME_WIDTH;
2011
+ context.save();
2012
+ context.translate(left, 0);
2013
+ context.fillStyle = palette[0];
2014
+ context.fillRect(0, 0, FRAME_WIDTH, FRAME_HEIGHT);
2015
+ context.globalCompositeOperation = 'screen';
2016
+
2017
+ drawCloud(context, 35 + Math.sin(phase * 0.83) * 5, 10 + Math.cos(phase * 0.61) * 5, 27, 18, palette[1], id === 'agent-migration' ? 0.32 : 0.42);
2018
+ drawCloud(context, 45 + Math.cos(phase * 0.72) * 4, 23 + Math.sin(phase * 0.54) * 4, 22, 15, palette[2], 0.44);
2019
+ drawCloud(context, 53 + Math.sin(phase * 1.07) * 2, 16 + Math.cos(phase * 0.89) * 6, 12, 13, palette[3], id === 'model-training' ? 0.30 : 0.20);
2020
+
2021
+ context.globalCompositeOperation = 'source-over';
2022
+ const trough = context.createRadialGradient(41, 16, 1, 41, 16, 17);
2023
+ trough.addColorStop(0, 'rgba(5,6,11,0.64)');
2024
+ trough.addColorStop(0.58, 'rgba(6,7,12,0.26)');
2025
+ trough.addColorStop(1, 'rgba(6,7,12,0)');
2026
+ context.fillStyle = trough;
2027
+ context.fillRect(20, 0, 44, FRAME_HEIGHT);
2028
+ context.restore();
2029
+ }
2030
+
2031
+ // A canvas is a valid TexImageSource. Keeping it directly avoids a PNG
2032
+ // encode -> base64 allocation -> Image decode round trip at startup.
2033
+ return { image: canvas, ready: true };
2034
+ }
2035
+
2036
+ function getProgressReferenceAtlas(id) {
2037
+ if (!CACHE[id]) CACHE[id] = createAtlas(id);
2038
+ return CACHE[id];
2039
+ }
2040
+
2041
+ /**
2042
+ * Attach a WebGL2 fluid overlay to a progress capsule root.
2043
+ *
2044
+ * @param {object} params
2045
+ * @param {HTMLElement} params.root progress capsule root element
2046
+ * @param {HTMLCanvasElement} params.canvas 2D fallback canvas (kept beneath)
2047
+ * @param {object} params.preset progress preset
2048
+ * @param {() => number} params.getProgress reads the current progress value
2049
+ * @returns {{ update(flowTime: number): void, setDprCap(cap: number): void, dispose(): void } | null}
2050
+ */
2051
+ function attachProgressFlowOverlay({
2052
+ root,
2053
+ canvas,
2054
+ preset,
2055
+ getProgress,
2056
+ dprCap = 2,
2057
+ powerPreference = 'high-performance',
2058
+ onContextLost,
2059
+ onContextRestored
2060
+ }) {
2061
+ const overlay = document.createElement('canvas');
2062
+ overlay.className = 'hj-progress-canvas hj-progress-overlay';
2063
+ overlay.setAttribute('aria-hidden', 'true');
2064
+ canvas.insertAdjacentElement('afterend', overlay);
2065
+
2066
+ const renderer = createProgressFlowRenderer(overlay, preset, {
2067
+ powerPreference,
2068
+ onContextLost,
2069
+ onContextRestored
2070
+ });
2071
+ if (!renderer) {
2072
+ overlay.remove();
2073
+ return null;
2074
+ }
2075
+
2076
+ const atlas = getProgressReferenceAtlas(preset.id);
2077
+ root.classList.add('has-webgl-progress');
2078
+
2079
+ const resize = () => {
2080
+ const bounds = root.getBoundingClientRect();
2081
+ const width = Math.max(1, bounds.width);
2082
+ const height = Math.max(1, bounds.height);
2083
+ const dpr = Math.min(window.devicePixelRatio || 1, dprCap);
2084
+ overlay.style.width = `${width}px`;
2085
+ overlay.style.height = `${height}px`;
2086
+ renderer.resize(width, height, dpr);
2087
+ };
2088
+
2089
+ let resizeObserver = null;
2090
+ let onWindowResize = null;
2091
+ if (typeof ResizeObserver !== 'undefined') {
2092
+ resizeObserver = new ResizeObserver(resize);
2093
+ resizeObserver.observe(root);
2094
+ } else {
2095
+ onWindowResize = resize;
2096
+ window.addEventListener('resize', onWindowResize);
2097
+ }
2098
+ resize();
2099
+
2100
+ return {
2101
+ update(flowTime) {
2102
+ const effectTime = flowTime % PROGRESS_REFERENCE_DURATION;
2103
+ const progress = getProgress();
2104
+ renderer.draw(
2105
+ effectTime,
2106
+ Number.isFinite(progress) ? progress : preset.initialProgress,
2107
+ atlas.ready ? atlas.image : null
2108
+ );
2109
+ },
2110
+ setColors(colors) {
2111
+ renderer.setColors(colors);
2112
+ },
2113
+ setDprCap(cap) {
2114
+ dprCap = cap;
2115
+ resize();
2116
+ },
2117
+ dispose() {
2118
+ if (resizeObserver) resizeObserver.disconnect();
2119
+ if (onWindowResize) window.removeEventListener('resize', onWindowResize);
2120
+ renderer.dispose();
2121
+ overlay.remove();
2122
+ root.classList.remove('has-webgl-progress');
2123
+ }
2124
+ };
2125
+ }
2126
+
2127
+ function normalizeRange(min, max, fallbackMin = 0, fallbackMax = 100) {
2128
+ let nextMin = toFiniteNumber(min);
2129
+ let nextMax = toFiniteNumber(max);
2130
+ if (nextMin === null) nextMin = fallbackMin;
2131
+ if (nextMax === null) nextMax = fallbackMax;
2132
+ if (nextMin > nextMax) {
2133
+ const swap = nextMin;
2134
+ nextMin = nextMax;
2135
+ nextMax = swap;
2136
+ }
2137
+ return [nextMin, nextMax];
2138
+ }
2139
+
2140
+ function clampProgressValue(value, min, max) {
2141
+ const number = toFiniteNumber(value);
2142
+ if (number === null) return null;
2143
+ return Math.min(Math.max(number, min), max);
2144
+ }
2145
+
2146
+ function normalizeProgressStep(step, fallback = 'any') {
2147
+ if (step === 'any' || step == null) return 'any';
2148
+ const increment = toFiniteNumber(step);
2149
+ return increment !== null && increment > 0 ? increment : fallback;
2150
+ }
2151
+
2152
+ function snapProgressValue(value, min, max, step = 'any') {
2153
+ const clamped = clampProgressValue(value, min, max);
2154
+ if (clamped === null) return null;
2155
+ if (step === 'any' || step == null) return clamped;
2156
+ const increment = normalizeProgressStep(step);
2157
+ if (increment === 'any') return clamped;
2158
+ const snapped = min + Math.round((clamped - min) / increment) * increment;
2159
+ return Math.min(Math.max(Number(snapped.toFixed(12)), min), max);
2160
+ }
2161
+
2162
+ function progressRatio(value, min, max) {
2163
+ const range = max - min;
2164
+ if (!Number.isFinite(range) || range <= 0) return 0;
2165
+ return Math.min(Math.max((value - min) / range, 0), 1);
2166
+ }
2167
+
2168
+ const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
2169
+ const SUPPORTS_CTX_FILTER = typeof CanvasRenderingContext2D !== 'undefined' && 'filter' in CanvasRenderingContext2D.prototype;
2170
+
2171
+ function stringSeed(value) {
2172
+ let hash = 2166136261;
2173
+ for (const character of value) {
2174
+ hash ^= character.charCodeAt(0);
2175
+ hash = Math.imul(hash, 16777619);
2176
+ }
2177
+ return (hash >>> 0) / 4294967295;
2178
+ }
2179
+
2180
+ function interpolate(template, values) {
2181
+ return template.replace(/\{(\w+)\}/g, (_, key) => (values[key] !== undefined ? values[key] : ''));
2182
+ }
2183
+
2184
+ const FLOW_PROFILES = {
2185
+ 'model-training': {
2186
+ cycles: [0.98, 3.05, 5.45],
2187
+ amplitudes: [11.2, 11.0, 5.2],
2188
+ speeds: [0.31, -0.53, 0.78],
2189
+ bulgeAmplitude: 8.2,
2190
+ timeScale: 1.0,
2191
+ glowWidth: 5.8,
2192
+ haloWidth: 20,
2193
+ whiteAlpha: 0.72,
2194
+ whiteWidth: 1.15,
2195
+ cloudWidth: 0.17,
2196
+ autoRange: [25, 66]
2197
+ },
2198
+ 'agent-migration': {
2199
+ cycles: [0.62, 1.55, 2.95],
2200
+ amplitudes: [16.0, 7.8, 2.3],
2201
+ speeds: [0.25, -0.4, 0.60],
2202
+ bulgeAmplitude: 8.4,
2203
+ timeScale: 0.82,
2204
+ glowWidth: 6.3,
2205
+ haloWidth: 21,
2206
+ whiteAlpha: 0.18,
2207
+ whiteWidth: 0.45,
2208
+ cloudWidth: 0.18,
2209
+ autoRange: [24, 62]
2210
+ },
2211
+ 'visual-training': {
2212
+ cycles: [0.88, 2.45, 4.35],
2213
+ amplitudes: [12.8, 10.2, 4.3],
2214
+ speeds: [0.28, -0.47, 0.69],
2215
+ bulgeAmplitude: 7.3,
2216
+ timeScale: 0.91,
2217
+ glowWidth: 6.0,
2218
+ haloWidth: 21,
2219
+ whiteAlpha: 0.30,
2220
+ whiteWidth: 0.55,
2221
+ cloudWidth: 0.175,
2222
+ autoRange: [20, 75]
2223
+ },
2224
+ // ponytail: first-pass tide = asymmetric surge on the 2D fallback path.
2225
+ // Tune surge/amplitudes after visual QA against the WebGL overlay.
2226
+ 'tide': {
2227
+ cycles: [0.72, 1.9, 4.2],
2228
+ amplitudes: [19.0, 6.5, 1.5],
2229
+ speeds: [0.42, -0.5, 0.66],
2230
+ bulgeAmplitude: 9.5,
2231
+ timeScale: 0.85,
2232
+ glowWidth: 6.2,
2233
+ haloWidth: 21,
2234
+ whiteAlpha: 0.5,
2235
+ whiteWidth: 1.0,
2236
+ cloudWidth: 0.18,
2237
+ autoRange: [18, 70],
2238
+ surge: 0.55
2239
+ }
2240
+ };
2241
+
2242
+ class ProgressCapsuleController {
1927
2243
  constructor({ root, canvas, valueElement, preset, emitter, options, copy, dirty }) {
1928
- this.root = root;
1929
- this.canvas = canvas;
1930
- this.valueElement = valueElement;
1931
- this.preset = preset;
1932
- this.emitter = emitter;
2244
+ this.root = root;
2245
+ this.canvas = canvas;
2246
+ this.valueElement = valueElement;
2247
+ this.preset = preset;
2248
+ this.emitter = emitter;
1933
2249
  this.options = options;
1934
2250
  this.copy = copy;
1935
2251
  this.dirty = dirty;
1936
2252
  this.valueSuffix = options.valueSuffix ?? copy.valueSuffix;
1937
- this.profile = preset.edgeStyle === 'tide'
1938
- ? FLOW_PROFILES.tide
1939
- : (FLOW_PROFILES[preset.id] || FLOW_PROFILES['visual-training']);
1940
- this.min = options.min ?? 0;
1941
- this.max = options.max ?? 100;
1942
- this.dprCap = dprCapFor(options.quality);
1943
- this.ctx = canvas.getContext('2d');
1944
- this.value = clamp(options.value ?? preset.initialProgress, this.min, this.max);
1945
- this.dragging = false;
1946
- this.webglActive = false;
1947
- this.flowTime = stringSeed(preset.id) * 31;
1948
- this.seed = stringSeed(`${preset.id}-reference`) * Math.PI * 2;
1949
- this.randomState = Math.floor(stringSeed(`${preset.id}-auto`) * 0x7fffffff) || 1;
1950
- this.dpr = 1;
1951
- this.width = 0;
1952
- this.height = 0;
1953
- this.handlers = {};
1954
-
1955
- this.resizeObserver = typeof ResizeObserver !== 'undefined'
1956
- ? new ResizeObserver(() => this.resizeCanvas())
1957
- : null;
1958
- if (this.resizeObserver) this.resizeObserver.observe(this.root);
1959
- else window.addEventListener('resize', this.resizeCanvas);
1960
-
1961
- this.suppressEvents = true;
1962
- this.setProgress(this.value, 'init');
1963
- this.suppressEvents = false;
1964
- this.bindEvents();
1965
- this.resizeCanvas();
1966
- }
1967
-
1968
- random() {
1969
- this.randomState = (Math.imul(this.randomState, 1664525) + 1013904223) >>> 0;
1970
- return this.randomState / 4294967296;
1971
- }
1972
-
1973
- setProgress(nextValue, source = 'auto') {
1974
- this.value = clamp(nextValue, this.min, this.max);
1975
- const rounded = Math.round(this.value);
1976
- this.root.style.setProperty('--progress', this.value.toFixed(2));
1977
- this.root.setAttribute('aria-valuenow', String(rounded));
1978
- this.root.setAttribute('aria-valuetext', `${rounded}${this.valueSuffix}`);
1979
- if (this.valueElement) this.valueElement.textContent = `${rounded}${this.valueSuffix}`;
2253
+ this.profile = preset.edgeStyle === 'tide'
2254
+ ? FLOW_PROFILES.tide
2255
+ : (FLOW_PROFILES[preset.id] || FLOW_PROFILES['visual-training']);
2256
+ [this.min, this.max] = normalizeRange(options.min, options.max);
2257
+ this.step = normalizeProgressStep(options.step);
2258
+ this.precision = Number.isInteger(Number(options.precision))
2259
+ ? clamp(Number(options.precision), 0, 20)
2260
+ : 0;
2261
+ this.formatValue = typeof options.formatValue === 'function' ? options.formatValue : null;
2262
+ this.direction = options.direction === 'rtl' ? 'rtl' : 'ltr';
2263
+ this.dprCap = effectiveDprCap(options.quality, options.renderScale);
2264
+ this.ctx = canvas.getContext('2d');
2265
+ this.value = snapProgressValue(options.value ?? preset.initialProgress, this.min, this.max, this.step);
2266
+ if (this.value === null) this.value = clamp(preset.initialProgress, this.min, this.max);
2267
+ this.dragging = false;
2268
+ this.webglActive = false;
2269
+ this.flowTime = stringSeed(preset.id) * 31;
2270
+ this.seed = stringSeed(`${preset.id}-reference`) * Math.PI * 2;
2271
+ this.randomState = Math.floor(stringSeed(`${preset.id}-auto`) * 0x7fffffff) || 1;
2272
+ this.dpr = 1;
2273
+ this.width = 0;
2274
+ this.height = 0;
2275
+ this.handlers = {};
2276
+
2277
+ this.onResize = () => {
2278
+ this.resizeCanvas();
2279
+ if (typeof this.options.onResize === 'function') this.options.onResize();
2280
+ };
2281
+ this.resizeObserver = typeof ResizeObserver !== 'undefined'
2282
+ ? new ResizeObserver(this.onResize)
2283
+ : null;
2284
+ if (this.resizeObserver) this.resizeObserver.observe(this.root);
2285
+ else window.addEventListener('resize', this.onResize);
2286
+
2287
+ this.suppressEvents = true;
2288
+ this.setProgress(this.value, 'init');
2289
+ this.suppressEvents = false;
2290
+ this.bindEvents();
2291
+ this.resizeCanvas();
2292
+ }
2293
+
2294
+ random() {
2295
+ this.randomState = (Math.imul(this.randomState, 1664525) + 1013904223) >>> 0;
2296
+ return this.randomState / 4294967296;
2297
+ }
2298
+
2299
+ displayText() {
2300
+ if (this.formatValue) {
2301
+ return String(this.formatValue(this.value, {
2302
+ min: this.min,
2303
+ max: this.max,
2304
+ suffix: this.valueSuffix
2305
+ }));
2306
+ }
2307
+ const number = this.precision > 0 ? this.value.toFixed(this.precision) : String(Math.round(this.value));
2308
+ return `${number}${this.valueSuffix}`;
2309
+ }
2310
+
2311
+ syncValueDom() {
2312
+ const text = this.displayText();
2313
+ this.root.style.setProperty('--progress', this.value.toFixed(2));
2314
+ this.root.style.setProperty('--progress-ratio', progressRatio(this.value, this.min, this.max).toFixed(4));
2315
+ this.root.setAttribute('aria-valuenow', String(this.value));
2316
+ this.root.setAttribute('aria-valuetext', text);
2317
+ if (this.valueElement) this.valueElement.textContent = text;
2318
+ }
2319
+
2320
+ setProgress(nextValue, source = 'auto') {
2321
+ const next = snapProgressValue(nextValue, this.min, this.max, this.step);
2322
+ if (next === null) return false;
2323
+ this.value = next;
2324
+ this.syncValueDom();
1980
2325
  if (!this.suppressEvents) {
1981
2326
  if (this.dirty) this.dirty.value = true;
1982
2327
  this.emitter.emit('change', { value: this.value, source });
1983
2328
  }
2329
+ return true;
1984
2330
  }
1985
2331
 
1986
2332
  setValueSuffix(suffix) {
2333
+ if (this.dirty) this.dirty.valueSuffix = true;
1987
2334
  this.valueSuffix = String(suffix == null ? '' : suffix);
1988
- const rounded = Math.round(this.value);
1989
- if (this.valueElement) this.valueElement.textContent = `${rounded}${this.valueSuffix}`;
1990
- this.root.setAttribute('aria-valuetext', `${rounded}${this.valueSuffix}`);
2335
+ this.syncValueDom();
2336
+ return this;
2337
+ }
2338
+
2339
+ setStep(step) {
2340
+ const nextStep = normalizeProgressStep(step, this.step);
2341
+ if (nextStep === this.step) return this;
2342
+ if (this.dirty) this.dirty.step = true;
2343
+ this.step = nextStep;
2344
+ const next = snapProgressValue(this.value, this.min, this.max, this.step);
2345
+ if (next !== null && next !== this.value) this.setProgress(next, 'prop');
2346
+ return this;
2347
+ }
2348
+
2349
+ setPrecision(precision) {
2350
+ const value = Number(precision);
2351
+ if (!Number.isInteger(value) || value < 0 || value > 20) return this;
2352
+ if (this.dirty) this.dirty.precision = true;
2353
+ this.precision = value;
2354
+ this.syncValueDom();
2355
+ return this;
2356
+ }
2357
+
2358
+ setFormatValue(formatter) {
2359
+ if (formatter != null && typeof formatter !== 'function') return this;
2360
+ if (this.dirty) this.dirty.formatValue = true;
2361
+ this.formatValue = formatter || null;
2362
+ this.syncValueDom();
2363
+ return this;
2364
+ }
2365
+
2366
+ setDirection(direction) {
2367
+ if (this.dirty) this.dirty.direction = true;
2368
+ this.direction = direction === 'rtl' ? 'rtl' : 'ltr';
2369
+ this.root.dataset.direction = this.direction;
1991
2370
  return this;
1992
2371
  }
1993
2372
 
1994
2373
  setShowValue(show) {
2374
+ if (this.dirty) this.dirty.showValue = true;
1995
2375
  if (this.valueElement) this.valueElement.hidden = show === false;
1996
2376
  return this;
1997
2377
  }
1998
-
1999
- resizeCanvas() {
2000
- const bounds = this.root.getBoundingClientRect();
2001
- this.dpr = Math.min(window.devicePixelRatio || 1, this.dprCap);
2002
- this.width = Math.max(1, bounds.width);
2003
- this.height = Math.max(1, bounds.height);
2004
- this.canvas.width = Math.round(this.width * this.dpr);
2005
- this.canvas.height = Math.round(this.height * this.dpr);
2006
- this.canvas.style.width = `${this.width}px`;
2007
- this.canvas.style.height = `${this.height}px`;
2008
- this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
2009
- }
2010
-
2011
- edgeEnvelope(yRatio) {
2012
- const edge = Math.sin(Math.PI * clamp(yRatio, 0, 1));
2013
- return Math.pow(Math.max(edge, 0), 0.48);
2014
- }
2015
-
2016
- localBulge(yRatio, time, index) {
2017
- const direction = index === 0 ? 1 : -1;
2018
- const center = 0.28 + index * 0.40 + Math.sin(time * (0.19 + index * 0.035) + this.seed * (1.1 + index)) * 0.13;
2019
- const width = 0.075 + index * 0.016 + Math.sin(time * 0.13 + this.seed * 2.1) * 0.012;
2020
- const distance = (yRatio - center) / Math.max(width, 0.035);
2021
- const gaussian = Math.exp(-0.5 * distance * distance);
2022
- return gaussian * Math.sin(time * (0.71 + index * 0.09) + this.seed * (2.7 + index)) * this.profile.bulgeAmplitude * direction;
2023
- }
2024
-
2025
- edgeOffset(y, time, phase = 0, amplitudeScale = 1) {
2026
- const yRatio = this.height > 0 ? y / this.height : 0;
2027
- const envelope = this.edgeEnvelope(yRatio);
2028
- const scaledTime = time * this.profile.timeScale;
2029
- const phaseTime = this.profile.surge
2030
- ? scaledTime + this.profile.surge * Math.sin(scaledTime * 2)
2031
- : scaledTime;
2032
- let offset = 0;
2033
-
2034
- for (let index = 0; index < this.profile.cycles.length; index += 1) {
2035
- const cycle = this.profile.cycles[index];
2036
- const amplitude = this.profile.amplitudes[index];
2037
- const speed = this.profile.speeds[index];
2038
- const amplitudeMotion = 0.74 + 0.26 * Math.sin(
2039
- phaseTime * (0.17 + index * 0.045) + this.seed * (index + 2.4)
2040
- );
2041
- offset += Math.sin(
2042
- yRatio * Math.PI * 2 * cycle + phaseTime * speed * Math.PI * 2 + this.seed * (index + 1) + phase
2043
- ) * amplitude * amplitudeMotion;
2044
- }
2045
-
2046
- offset += this.localBulge(yRatio, phaseTime + phase, 0);
2047
- offset += this.localBulge(yRatio, phaseTime - phase * 0.7, 1);
2048
- return offset * envelope * amplitudeScale;
2049
- }
2050
-
2051
- createEdgePath(ctx, baseX, time, phase = 0, amplitudeScale = 1) {
2052
- const step = Math.max(1.8, this.height / 92);
2053
- ctx.beginPath();
2054
- for (let y = 0; y <= this.height + step; y += step) {
2055
- const x = baseX + this.edgeOffset(y, time, phase, amplitudeScale);
2056
- if (y === 0) ctx.moveTo(x, y);
2057
- else ctx.lineTo(x, y);
2058
- }
2059
- }
2060
-
2061
- createFillPath(ctx, baseX, time, phase = 0, amplitudeScale = 1) {
2062
- const step = Math.max(1.8, this.height / 92);
2063
- ctx.beginPath();
2064
- ctx.moveTo(0, 0);
2065
- ctx.lineTo(baseX + this.edgeOffset(0, time, phase, amplitudeScale), 0);
2066
- for (let y = step; y <= this.height + step; y += step) {
2067
- ctx.lineTo(baseX + this.edgeOffset(y, time, phase, amplitudeScale), y);
2068
- }
2069
- ctx.lineTo(0, this.height);
2070
- ctx.closePath();
2071
- }
2072
-
2073
- drawEllipticalGlow(x, y, radiusX, radiusY, color, alpha) {
2074
- const ctx = this.ctx;
2075
- ctx.save();
2076
- ctx.translate(x, y);
2077
- ctx.scale(1, radiusY / radiusX);
2078
- const gradient = ctx.createRadialGradient(0, 0, 0, 0, 0, radiusX);
2079
- gradient.addColorStop(0, hexToRgba(color, alpha));
2080
- gradient.addColorStop(0.42, hexToRgba(color, alpha * 0.48));
2081
- gradient.addColorStop(1, hexToRgba(color, 0));
2082
- ctx.globalCompositeOperation = 'screen';
2083
- ctx.fillStyle = gradient;
2084
- ctx.fillRect(-radiusX, -radiusX, radiusX * 2, radiusX * 2);
2085
- ctx.restore();
2086
- }
2087
-
2088
- drawDarkEllipticalShadow(x, y, radiusX, radiusY, alpha) {
2089
- const ctx = this.ctx;
2090
- ctx.save();
2091
- ctx.translate(x, y);
2092
- ctx.scale(1, radiusY / radiusX);
2093
- const gradient = ctx.createRadialGradient(0, 0, 0, 0, 0, radiusX);
2094
- gradient.addColorStop(0, `rgba(6, 6, 11, ${alpha})`);
2095
- gradient.addColorStop(0.54, `rgba(8, 8, 14, ${alpha * 0.62})`);
2096
- gradient.addColorStop(1, 'rgba(8, 8, 14, 0)');
2097
- ctx.globalCompositeOperation = 'source-over';
2098
- ctx.fillStyle = gradient;
2099
- ctx.fillRect(-radiusX, -radiusX, radiusX * 2, radiusX * 2);
2100
- ctx.restore();
2101
- }
2102
-
2103
- drawColorClouds(shoreline, time, accentA, accentB, glow) {
2104
- this.ctx;
2105
- const width = this.width;
2106
- const height = this.height;
2107
- const scale = this.profile.cloudWidth;
2108
- const t = time * this.profile.timeScale;
2109
-
2110
- const upperY = height * (0.28 + Math.sin(t * 0.24 + this.seed) * 0.13);
2111
- const lowerY = height * (0.70 + Math.cos(t * 0.21 + this.seed * 1.7) * 0.12);
2112
- const middleY = height * (0.49 + Math.sin(t * 0.31 + this.seed * 2.3) * 0.15);
2113
-
2114
- const farX = shoreline - width * 0.095;
2115
- const farRx = Math.max(52, width * scale);
2116
- const farRy = height * 0.42;
2117
- this.drawEllipticalGlow(farX, upperY, farRx, farRy, accentA, 0.38);
2118
- this.drawDarkEllipticalShadow(
2119
- farX + farRx * 0.16,
2120
- upperY,
2121
- farRx * 0.58,
2122
- farRy * 0.66,
2123
- 0.74
2124
- );
2125
-
2126
- const lowerX = shoreline - width * 0.072;
2127
- const lowerRx = Math.max(44, width * scale * 0.82);
2128
- const lowerRy = height * 0.36;
2129
- this.drawEllipticalGlow(lowerX, lowerY, lowerRx, lowerRy, accentB, 0.31);
2130
- this.drawDarkEllipticalShadow(
2131
- lowerX + lowerRx * 0.14,
2132
- lowerY,
2133
- lowerRx * 0.54,
2134
- lowerRy * 0.62,
2135
- 0.64
2136
- );
2137
-
2138
- this.drawEllipticalGlow(
2139
- shoreline - width * 0.034,
2140
- middleY,
2141
- Math.max(30, width * scale * 0.48),
2142
- height * 0.27,
2143
- glow,
2144
- 0.20
2145
- );
2146
- }
2147
-
2148
- drawPathBand({ baseX, time, phase, amplitudeScale, color, alpha, blur, width, composite = 'screen' }) {
2149
- const ctx = this.ctx;
2150
- this.createEdgePath(ctx, baseX, time, phase, amplitudeScale);
2151
- ctx.save();
2152
- ctx.globalCompositeOperation = composite;
2153
- ctx.globalAlpha = alpha;
2154
- // Safari < 18 and some old WebViews ignore ctx.filter; setting it is a
2155
- // no-op there, so only assign when supported to keep intent explicit.
2156
- if (SUPPORTS_CTX_FILTER) ctx.filter = `blur(${blur}px)`;
2157
- ctx.strokeStyle = color;
2158
- ctx.lineWidth = width;
2159
- ctx.stroke();
2160
- ctx.restore();
2161
- }
2162
-
2163
- setRange(min, max) {
2164
- if (min > max) {
2165
- const swap = min;
2166
- min = max;
2167
- max = swap;
2378
+
2379
+ resizeCanvas() {
2380
+ const bounds = this.root.getBoundingClientRect();
2381
+ this.dpr = Math.min(window.devicePixelRatio || 1, this.dprCap);
2382
+ this.width = Math.max(1, bounds.width);
2383
+ this.height = Math.max(1, bounds.height);
2384
+ this.canvas.width = Math.round(this.width * this.dpr);
2385
+ this.canvas.height = Math.round(this.height * this.dpr);
2386
+ this.canvas.style.width = `${this.width}px`;
2387
+ this.canvas.style.height = `${this.height}px`;
2388
+ this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
2389
+ }
2390
+
2391
+ edgeEnvelope(yRatio) {
2392
+ const edge = Math.sin(Math.PI * clamp(yRatio, 0, 1));
2393
+ return Math.pow(Math.max(edge, 0), 0.48);
2394
+ }
2395
+
2396
+ localBulge(yRatio, time, index) {
2397
+ const direction = index === 0 ? 1 : -1;
2398
+ const center = 0.28 + index * 0.40 + Math.sin(time * (0.19 + index * 0.035) + this.seed * (1.1 + index)) * 0.13;
2399
+ const width = 0.075 + index * 0.016 + Math.sin(time * 0.13 + this.seed * 2.1) * 0.012;
2400
+ const distance = (yRatio - center) / Math.max(width, 0.035);
2401
+ const gaussian = Math.exp(-0.5 * distance * distance);
2402
+ return gaussian * Math.sin(time * (0.71 + index * 0.09) + this.seed * (2.7 + index)) * this.profile.bulgeAmplitude * direction;
2403
+ }
2404
+
2405
+ edgeOffset(y, time, phase = 0, amplitudeScale = 1) {
2406
+ const yRatio = this.height > 0 ? y / this.height : 0;
2407
+ const envelope = this.edgeEnvelope(yRatio);
2408
+ const scaledTime = time * this.profile.timeScale;
2409
+ const phaseTime = this.profile.surge
2410
+ ? scaledTime + this.profile.surge * Math.sin(scaledTime * 2)
2411
+ : scaledTime;
2412
+ let offset = 0;
2413
+
2414
+ for (let index = 0; index < this.profile.cycles.length; index += 1) {
2415
+ const cycle = this.profile.cycles[index];
2416
+ const amplitude = this.profile.amplitudes[index];
2417
+ const speed = this.profile.speeds[index];
2418
+ const amplitudeMotion = 0.74 + 0.26 * Math.sin(
2419
+ phaseTime * (0.17 + index * 0.045) + this.seed * (index + 2.4)
2420
+ );
2421
+ offset += Math.sin(
2422
+ yRatio * Math.PI * 2 * cycle + phaseTime * speed * Math.PI * 2 + this.seed * (index + 1) + phase
2423
+ ) * amplitude * amplitudeMotion;
2424
+ }
2425
+
2426
+ offset += this.localBulge(yRatio, phaseTime + phase, 0);
2427
+ offset += this.localBulge(yRatio, phaseTime - phase * 0.7, 1);
2428
+ return offset * envelope * amplitudeScale;
2429
+ }
2430
+
2431
+ createEdgePath(ctx, baseX, time, phase = 0, amplitudeScale = 1) {
2432
+ const step = Math.max(1.8, this.height / 92);
2433
+ ctx.beginPath();
2434
+ for (let y = 0; y <= this.height + step; y += step) {
2435
+ const x = baseX + this.edgeOffset(y, time, phase, amplitudeScale);
2436
+ if (y === 0) ctx.moveTo(x, y);
2437
+ else ctx.lineTo(x, y);
2438
+ }
2439
+ }
2440
+
2441
+ createFillPath(ctx, baseX, time, phase = 0, amplitudeScale = 1) {
2442
+ const step = Math.max(1.8, this.height / 92);
2443
+ ctx.beginPath();
2444
+ ctx.moveTo(0, 0);
2445
+ ctx.lineTo(baseX + this.edgeOffset(0, time, phase, amplitudeScale), 0);
2446
+ for (let y = step; y <= this.height + step; y += step) {
2447
+ ctx.lineTo(baseX + this.edgeOffset(y, time, phase, amplitudeScale), y);
2168
2448
  }
2169
- this.min = min;
2170
- this.max = max;
2171
- const clamped = clamp(this.value, this.min, this.max);
2172
- if (clamped !== this.value) this.setProgress(clamped, 'prop');
2173
- }
2174
-
2449
+ ctx.lineTo(0, this.height);
2450
+ ctx.closePath();
2451
+ }
2452
+
2453
+ drawEllipticalGlow(x, y, radiusX, radiusY, color, alpha) {
2454
+ const ctx = this.ctx;
2455
+ ctx.save();
2456
+ ctx.translate(x, y);
2457
+ ctx.scale(1, radiusY / radiusX);
2458
+ const gradient = ctx.createRadialGradient(0, 0, 0, 0, 0, radiusX);
2459
+ gradient.addColorStop(0, hexToRgba(color, alpha));
2460
+ gradient.addColorStop(0.42, hexToRgba(color, alpha * 0.48));
2461
+ gradient.addColorStop(1, hexToRgba(color, 0));
2462
+ ctx.globalCompositeOperation = 'screen';
2463
+ ctx.fillStyle = gradient;
2464
+ ctx.fillRect(-radiusX, -radiusX, radiusX * 2, radiusX * 2);
2465
+ ctx.restore();
2466
+ }
2467
+
2468
+ drawDarkEllipticalShadow(x, y, radiusX, radiusY, alpha) {
2469
+ const ctx = this.ctx;
2470
+ ctx.save();
2471
+ ctx.translate(x, y);
2472
+ ctx.scale(1, radiusY / radiusX);
2473
+ const gradient = ctx.createRadialGradient(0, 0, 0, 0, 0, radiusX);
2474
+ gradient.addColorStop(0, `rgba(6, 6, 11, ${alpha})`);
2475
+ gradient.addColorStop(0.54, `rgba(8, 8, 14, ${alpha * 0.62})`);
2476
+ gradient.addColorStop(1, 'rgba(8, 8, 14, 0)');
2477
+ ctx.globalCompositeOperation = 'source-over';
2478
+ ctx.fillStyle = gradient;
2479
+ ctx.fillRect(-radiusX, -radiusX, radiusX * 2, radiusX * 2);
2480
+ ctx.restore();
2481
+ }
2482
+
2483
+ drawColorClouds(shoreline, time, accentA, accentB, glow) {
2484
+ this.ctx;
2485
+ const width = this.width;
2486
+ const height = this.height;
2487
+ const scale = this.profile.cloudWidth;
2488
+ const t = time * this.profile.timeScale;
2489
+
2490
+ const upperY = height * (0.28 + Math.sin(t * 0.24 + this.seed) * 0.13);
2491
+ const lowerY = height * (0.70 + Math.cos(t * 0.21 + this.seed * 1.7) * 0.12);
2492
+ const middleY = height * (0.49 + Math.sin(t * 0.31 + this.seed * 2.3) * 0.15);
2493
+
2494
+ const farX = shoreline - width * 0.095;
2495
+ const farRx = Math.max(52, width * scale);
2496
+ const farRy = height * 0.42;
2497
+ this.drawEllipticalGlow(farX, upperY, farRx, farRy, accentA, 0.38);
2498
+ this.drawDarkEllipticalShadow(
2499
+ farX + farRx * 0.16,
2500
+ upperY,
2501
+ farRx * 0.58,
2502
+ farRy * 0.66,
2503
+ 0.74
2504
+ );
2505
+
2506
+ const lowerX = shoreline - width * 0.072;
2507
+ const lowerRx = Math.max(44, width * scale * 0.82);
2508
+ const lowerRy = height * 0.36;
2509
+ this.drawEllipticalGlow(lowerX, lowerY, lowerRx, lowerRy, accentB, 0.31);
2510
+ this.drawDarkEllipticalShadow(
2511
+ lowerX + lowerRx * 0.14,
2512
+ lowerY,
2513
+ lowerRx * 0.54,
2514
+ lowerRy * 0.62,
2515
+ 0.64
2516
+ );
2517
+
2518
+ this.drawEllipticalGlow(
2519
+ shoreline - width * 0.034,
2520
+ middleY,
2521
+ Math.max(30, width * scale * 0.48),
2522
+ height * 0.27,
2523
+ glow,
2524
+ 0.20
2525
+ );
2526
+ }
2527
+
2528
+ drawPathBand({ baseX, time, phase, amplitudeScale, color, alpha, blur, width, composite = 'screen' }) {
2529
+ const ctx = this.ctx;
2530
+ this.createEdgePath(ctx, baseX, time, phase, amplitudeScale);
2531
+ ctx.save();
2532
+ ctx.globalCompositeOperation = composite;
2533
+ ctx.globalAlpha = alpha;
2534
+ // Safari < 18 and some old WebViews ignore ctx.filter; setting it is a
2535
+ // no-op there, so only assign when supported to keep intent explicit.
2536
+ if (SUPPORTS_CTX_FILTER) ctx.filter = `blur(${blur}px)`;
2537
+ ctx.strokeStyle = color;
2538
+ ctx.lineWidth = width;
2539
+ ctx.stroke();
2540
+ ctx.restore();
2541
+ }
2542
+
2543
+ setRange(min, max) {
2544
+ const nextRange = normalizeRange(min, max, this.min, this.max);
2545
+ this.min = nextRange[0];
2546
+ this.max = nextRange[1];
2547
+ this.root.setAttribute('aria-valuemin', String(this.min));
2548
+ this.root.setAttribute('aria-valuemax', String(this.max));
2549
+ const next = snapProgressValue(this.value, this.min, this.max, this.step);
2550
+ if (next !== this.value) this.setProgress(next, 'prop');
2551
+ else this.syncValueDom();
2552
+ return this;
2553
+ }
2554
+
2175
2555
  drawReferenceFlow() {
2176
- const ctx = this.ctx;
2177
- const width = this.width;
2178
- const height = this.height;
2179
- if (!ctx || width <= 0 || height <= 0) return;
2180
-
2556
+ const ctx = this.ctx;
2557
+ const width = this.width;
2558
+ const height = this.height;
2559
+ if (!ctx || width <= 0 || height <= 0) return;
2560
+
2181
2561
  const range = Math.max(this.max - this.min, 1);
2182
2562
  const shoreline = width * Math.min(Math.max((this.value - this.min) / range, 0), 1);
2183
- const time = this.flowTime;
2184
- const [dark, accentA, accentB, glow] = this.preset.colors;
2185
-
2186
- ctx.clearRect(0, 0, width, height);
2187
- ctx.fillStyle = '#202126';
2188
- ctx.fillRect(0, 0, width, height);
2189
-
2190
- this.createFillPath(ctx, shoreline, time, 0, 1);
2191
- const bodyGradient = ctx.createLinearGradient(0, 0, Math.max(shoreline, 1), 0);
2192
- bodyGradient.addColorStop(0, dark);
2193
- bodyGradient.addColorStop(0.74, dark);
2194
- bodyGradient.addColorStop(0.89, hexToRgba(dark, 0.99));
2195
- bodyGradient.addColorStop(0.955, hexToRgba(accentA, 0.09));
2196
- bodyGradient.addColorStop(0.992, hexToRgba(accentB, 0.54));
2197
- bodyGradient.addColorStop(1, hexToRgba(glow, 0.78));
2198
- ctx.fillStyle = bodyGradient;
2199
- ctx.fill();
2200
-
2201
- this.drawColorClouds(shoreline, time, accentA, accentB, glow);
2202
-
2203
- this.drawPathBand({
2204
- baseX: shoreline - width * 0.105,
2205
- time,
2206
- phase: 1.42,
2207
- amplitudeScale: 1.18,
2208
- color: accentA,
2209
- alpha: 0.22,
2210
- blur: 21,
2211
- width: 54
2212
- });
2213
- this.drawPathBand({
2214
- baseX: shoreline - width * 0.073,
2215
- time,
2216
- phase: -0.92,
2217
- amplitudeScale: 1.06,
2218
- color: accentB,
2219
- alpha: 0.30,
2220
- blur: 15,
2221
- width: 42
2222
- });
2223
- this.drawPathBand({
2224
- baseX: shoreline - width * 0.047,
2225
- time,
2226
- phase: 0.42,
2227
- amplitudeScale: 0.94,
2228
- color: 'rgba(5, 5, 10, 0.92)',
2229
- alpha: 0.72,
2230
- blur: 12,
2231
- width: 34,
2232
- composite: 'source-over'
2233
- });
2234
- this.drawPathBand({
2235
- baseX: shoreline - width * 0.025,
2236
- time,
2237
- phase: -0.28,
2238
- amplitudeScale: 0.96,
2239
- color: accentB,
2240
- alpha: 0.66,
2241
- blur: 8,
2242
- width: 28
2243
- });
2244
-
2245
- ctx.save();
2246
- this.createFillPath(ctx, shoreline, time, 0, 1);
2247
- ctx.clip();
2248
- this.createEdgePath(ctx, shoreline, time, 0, 1);
2249
-
2250
- ctx.save();
2251
- ctx.globalCompositeOperation = 'screen';
2252
- ctx.strokeStyle = hexToRgba(accentA, 0.20);
2253
- ctx.lineWidth = this.profile.haloWidth;
2254
- ctx.shadowColor = accentA;
2255
- ctx.shadowBlur = this.profile.haloWidth * 0.72;
2256
- ctx.stroke();
2257
- ctx.restore();
2258
-
2259
- ctx.save();
2260
- ctx.globalCompositeOperation = 'screen';
2261
- ctx.strokeStyle = hexToRgba(accentB, 0.78);
2262
- ctx.lineWidth = this.profile.glowWidth + 4.2;
2263
- ctx.shadowColor = accentB;
2264
- ctx.shadowBlur = 8;
2265
- ctx.stroke();
2266
- ctx.restore();
2267
-
2268
- ctx.save();
2269
- ctx.globalCompositeOperation = 'screen';
2270
- ctx.strokeStyle = hexToRgba(glow, 0.88);
2271
- ctx.lineWidth = this.profile.glowWidth;
2272
- ctx.shadowColor = glow;
2273
- ctx.shadowBlur = 5;
2274
- ctx.stroke();
2275
- ctx.restore();
2276
-
2277
- ctx.restore();
2278
-
2279
- ctx.save();
2280
- ctx.globalCompositeOperation = 'screen';
2281
- ctx.strokeStyle = hexToRgba(glow, 0.84);
2282
- ctx.lineWidth = 2.15;
2283
- ctx.shadowColor = glow;
2284
- ctx.shadowBlur = 2.5;
2285
- this.createEdgePath(ctx, shoreline, time, 0, 1);
2286
- ctx.stroke();
2287
- ctx.restore();
2288
-
2289
- if (this.profile.whiteAlpha > 0.05) {
2290
- ctx.save();
2291
- ctx.globalCompositeOperation = 'screen';
2292
- ctx.strokeStyle = `rgba(255,255,245,${this.profile.whiteAlpha})`;
2293
- ctx.lineWidth = this.profile.whiteWidth;
2294
- this.createEdgePath(ctx, shoreline, time, 0, 1);
2295
- ctx.stroke();
2296
- ctx.restore();
2297
- }
2298
- }
2299
-
2563
+ const time = this.flowTime;
2564
+ const [dark, accentA, accentB, glow] = this.preset.colors;
2565
+
2566
+ ctx.clearRect(0, 0, width, height);
2567
+ ctx.fillStyle = '#202126';
2568
+ ctx.fillRect(0, 0, width, height);
2569
+
2570
+ this.createFillPath(ctx, shoreline, time, 0, 1);
2571
+ const bodyGradient = ctx.createLinearGradient(0, 0, Math.max(shoreline, 1), 0);
2572
+ bodyGradient.addColorStop(0, dark);
2573
+ bodyGradient.addColorStop(0.74, dark);
2574
+ bodyGradient.addColorStop(0.89, hexToRgba(dark, 0.99));
2575
+ bodyGradient.addColorStop(0.955, hexToRgba(accentA, 0.09));
2576
+ bodyGradient.addColorStop(0.992, hexToRgba(accentB, 0.54));
2577
+ bodyGradient.addColorStop(1, hexToRgba(glow, 0.78));
2578
+ ctx.fillStyle = bodyGradient;
2579
+ ctx.fill();
2580
+
2581
+ this.drawColorClouds(shoreline, time, accentA, accentB, glow);
2582
+
2583
+ this.drawPathBand({
2584
+ baseX: shoreline - width * 0.105,
2585
+ time,
2586
+ phase: 1.42,
2587
+ amplitudeScale: 1.18,
2588
+ color: accentA,
2589
+ alpha: 0.22,
2590
+ blur: 21,
2591
+ width: 54
2592
+ });
2593
+ this.drawPathBand({
2594
+ baseX: shoreline - width * 0.073,
2595
+ time,
2596
+ phase: -0.92,
2597
+ amplitudeScale: 1.06,
2598
+ color: accentB,
2599
+ alpha: 0.30,
2600
+ blur: 15,
2601
+ width: 42
2602
+ });
2603
+ this.drawPathBand({
2604
+ baseX: shoreline - width * 0.047,
2605
+ time,
2606
+ phase: 0.42,
2607
+ amplitudeScale: 0.94,
2608
+ color: 'rgba(5, 5, 10, 0.92)',
2609
+ alpha: 0.72,
2610
+ blur: 12,
2611
+ width: 34,
2612
+ composite: 'source-over'
2613
+ });
2614
+ this.drawPathBand({
2615
+ baseX: shoreline - width * 0.025,
2616
+ time,
2617
+ phase: -0.28,
2618
+ amplitudeScale: 0.96,
2619
+ color: accentB,
2620
+ alpha: 0.66,
2621
+ blur: 8,
2622
+ width: 28
2623
+ });
2624
+
2625
+ ctx.save();
2626
+ this.createFillPath(ctx, shoreline, time, 0, 1);
2627
+ ctx.clip();
2628
+ this.createEdgePath(ctx, shoreline, time, 0, 1);
2629
+
2630
+ ctx.save();
2631
+ ctx.globalCompositeOperation = 'screen';
2632
+ ctx.strokeStyle = hexToRgba(accentA, 0.20);
2633
+ ctx.lineWidth = this.profile.haloWidth;
2634
+ ctx.shadowColor = accentA;
2635
+ ctx.shadowBlur = this.profile.haloWidth * 0.72;
2636
+ ctx.stroke();
2637
+ ctx.restore();
2638
+
2639
+ ctx.save();
2640
+ ctx.globalCompositeOperation = 'screen';
2641
+ ctx.strokeStyle = hexToRgba(accentB, 0.78);
2642
+ ctx.lineWidth = this.profile.glowWidth + 4.2;
2643
+ ctx.shadowColor = accentB;
2644
+ ctx.shadowBlur = 8;
2645
+ ctx.stroke();
2646
+ ctx.restore();
2647
+
2648
+ ctx.save();
2649
+ ctx.globalCompositeOperation = 'screen';
2650
+ ctx.strokeStyle = hexToRgba(glow, 0.88);
2651
+ ctx.lineWidth = this.profile.glowWidth;
2652
+ ctx.shadowColor = glow;
2653
+ ctx.shadowBlur = 5;
2654
+ ctx.stroke();
2655
+ ctx.restore();
2656
+
2657
+ ctx.restore();
2658
+
2659
+ ctx.save();
2660
+ ctx.globalCompositeOperation = 'screen';
2661
+ ctx.strokeStyle = hexToRgba(glow, 0.84);
2662
+ ctx.lineWidth = 2.15;
2663
+ ctx.shadowColor = glow;
2664
+ ctx.shadowBlur = 2.5;
2665
+ this.createEdgePath(ctx, shoreline, time, 0, 1);
2666
+ ctx.stroke();
2667
+ ctx.restore();
2668
+
2669
+ if (this.profile.whiteAlpha > 0.05) {
2670
+ ctx.save();
2671
+ ctx.globalCompositeOperation = 'screen';
2672
+ ctx.strokeStyle = `rgba(255,255,245,${this.profile.whiteAlpha})`;
2673
+ ctx.lineWidth = this.profile.whiteWidth;
2674
+ this.createEdgePath(ctx, shoreline, time, 0, 1);
2675
+ ctx.stroke();
2676
+ ctx.restore();
2677
+ }
2678
+ }
2679
+
2300
2680
  updateFromPointer(event) {
2301
2681
  const bounds = this.root.getBoundingClientRect();
2302
- const ratio = bounds.width > 0 ? (event.clientX - bounds.left) / bounds.width : 0;
2682
+ let ratio = bounds.width > 0 ? (event.clientX - bounds.left) / bounds.width : 0;
2683
+ if (this.direction === 'rtl') ratio = 1 - ratio;
2303
2684
  this.setProgress(this.min + ratio * (this.max - this.min), 'drag');
2304
2685
  }
2305
-
2686
+
2687
+ onKeyDown(event) {
2688
+ const range = this.max - this.min;
2689
+ const configuredStep = Number(this.step);
2690
+ const step = Number.isFinite(configuredStep) && configuredStep > 0
2691
+ ? configuredStep
2692
+ : Math.max(range / 100, 1e-7);
2693
+ const direction = this.direction === 'rtl' ? -1 : 1;
2694
+ const aliases = { Left: 'ArrowLeft', Right: 'ArrowRight', Up: 'ArrowUp', Down: 'ArrowDown' };
2695
+ const keyCodes = {
2696
+ 35: 'End', 36: 'Home', 33: 'PageUp', 34: 'PageDown',
2697
+ 37: 'ArrowLeft', 38: 'ArrowUp', 39: 'ArrowRight', 40: 'ArrowDown'
2698
+ };
2699
+ const key = aliases[event.key] || event.key || keyCodes[event.keyCode];
2700
+ let next = null;
2701
+ if (key === 'ArrowRight') next = this.value + step * direction;
2702
+ else if (key === 'ArrowLeft') next = this.value - step * direction;
2703
+ else if (key === 'ArrowUp') next = this.value + step;
2704
+ else if (key === 'ArrowDown') next = this.value - step;
2705
+ else if (key === 'PageUp') next = this.value + step * 10;
2706
+ else if (key === 'PageDown') next = this.value - step * 10;
2707
+ else if (key === 'Home') next = this.min;
2708
+ else if (key === 'End') next = this.max;
2709
+ if (next === null) return;
2710
+ event.preventDefault();
2711
+ this.setProgress(next, 'keyboard');
2712
+ }
2713
+
2306
2714
  beginDrag(event) {
2307
2715
  if (event.button !== undefined && event.button !== 0) return;
2308
2716
  event.preventDefault();
@@ -2342,85 +2750,101 @@ class ProgressCapsuleController {
2342
2750
  this.pendingPointer = null;
2343
2751
  if (pending) this.updateFromPointer(pending);
2344
2752
  this.root.classList.remove('is-dragging');
2345
- try {
2346
- if (event?.pointerId !== undefined && this.root.hasPointerCapture?.(event.pointerId)) {
2347
- this.root.releasePointerCapture(event.pointerId);
2348
- }
2349
- } catch {}
2350
- this.emitter.emit('dragend', { value: this.value });
2351
- }
2352
-
2353
- bindEvents() {
2354
- if (this.options.draggable !== false) {
2355
- this.handlers.pointerdown = (event) => this.beginDrag(event);
2356
- this.handlers.pointermove = (event) => this.moveDrag(event);
2357
- this.handlers.pointerup = (event) => this.endDrag(event);
2358
- this.handlers.pointercancel = (event) => this.endDrag(event);
2359
- this.root.addEventListener('pointerdown', this.handlers.pointerdown);
2360
- this.root.addEventListener('pointermove', this.handlers.pointermove);
2361
- this.root.addEventListener('pointerup', this.handlers.pointerup);
2362
- this.root.addEventListener('pointercancel', this.handlers.pointercancel);
2363
- }
2364
-
2365
- }
2366
-
2367
- update(delta, paused) {
2368
- if (!paused) this.flowTime += delta;
2369
- }
2370
-
2371
- draw() {
2372
- // Performance fix: when the WebGL overlay is active the 2D layer is
2373
- // hidden behind it, so drawing it every frame would be wasted CPU.
2374
- if (this.webglActive) return;
2375
- this.drawReferenceFlow();
2376
- }
2377
-
2378
- randomize() {
2379
- this.flowTime = this.random() * 40;
2380
- }
2381
-
2382
- setColors(colors) {
2383
- if (!Array.isArray(colors) || colors.length !== 4) return;
2384
- this.preset = { ...this.preset, colors };
2385
- }
2386
-
2753
+ try {
2754
+ if (event?.pointerId !== undefined && this.root.hasPointerCapture?.(event.pointerId)) {
2755
+ this.root.releasePointerCapture(event.pointerId);
2756
+ }
2757
+ } catch {}
2758
+ this.emitter.emit('dragend', { value: this.value });
2759
+ }
2760
+
2761
+ bindEvents() {
2762
+ if (this.options.draggable !== false) {
2763
+ this.handlers.pointerdown = (event) => this.beginDrag(event);
2764
+ this.handlers.pointermove = (event) => this.moveDrag(event);
2765
+ this.handlers.pointerup = (event) => this.endDrag(event);
2766
+ this.handlers.pointercancel = (event) => this.endDrag(event);
2767
+ this.root.addEventListener('pointerdown', this.handlers.pointerdown);
2768
+ this.root.addEventListener('pointermove', this.handlers.pointermove);
2769
+ this.root.addEventListener('pointerup', this.handlers.pointerup);
2770
+ this.root.addEventListener('pointercancel', this.handlers.pointercancel);
2771
+ }
2772
+
2773
+ if (this.options.keyboard !== false) {
2774
+ this.handlers.keydown = (event) => this.onKeyDown(event);
2775
+ this.root.addEventListener('keydown', this.handlers.keydown);
2776
+ }
2777
+
2778
+ }
2779
+
2780
+ update(delta, paused) {
2781
+ if (!paused) this.flowTime += delta;
2782
+ }
2783
+
2784
+ draw() {
2785
+ // Performance fix: when the WebGL overlay is active the 2D layer is
2786
+ // hidden behind it, so drawing it every frame would be wasted CPU.
2787
+ if (this.webglActive) return;
2788
+ this.drawReferenceFlow();
2789
+ }
2790
+
2791
+ randomize() {
2792
+ this.flowTime = this.random() * 40;
2793
+ return this.flowTime;
2794
+ }
2795
+
2796
+ setColors(colors) {
2797
+ if (!Array.isArray(colors) || colors.length !== 4) return;
2798
+ this.preset.colors = [...colors];
2799
+ }
2800
+
2387
2801
  dispose() {
2388
2802
  if (this._dragRaf) cancelAnimationFrame(this._dragRaf);
2389
2803
  if (this.resizeObserver) this.resizeObserver.disconnect();
2390
- else window.removeEventListener('resize', this.resizeCanvas);
2391
- for (const name of Object.keys(this.handlers)) {
2392
- const handler = this.handlers[name];
2393
- this.root.removeEventListener(name, handler);
2394
- }
2395
- this.handlers = {};
2396
- }
2397
- }
2398
-
2399
- /**
2804
+ else window.removeEventListener('resize', this.onResize);
2805
+ for (const name of Object.keys(this.handlers)) {
2806
+ const handler = this.handlers[name];
2807
+ this.root.removeEventListener(name, handler);
2808
+ }
2809
+ this.handlers = {};
2810
+ }
2811
+ }
2812
+
2813
+ /**
2400
2814
  * Mount a fluid progress capsule into `container`.
2401
2815
  *
2402
- * Options: preset (literary name), width, height, value, min, max,
2403
- * draggable, colors, edgeStyle, textRatio (0-100, text region
2816
+ * Options: preset (literary name), width, height, value/modelValue, min, max,
2817
+ * step, draggable, keyboard, direction, precision/formatValue, colors,
2818
+ * edgeStyle, textRatio (0-100, text region
2404
2819
  * width in percent), text (HTML string or DOM nodes for the text slot),
2405
2820
  * colorContent (HTML string or DOM nodes for the color slot),
2406
- * showValue (show/hide the right-side percentage), quality,
2407
- * respectReducedMotion, cssVars.
2821
+ * showValue (show/hide the right-side percentage), quality, renderScale,
2822
+ * powerPreference, fps, paused/static, respectReducedMotion, cssVars.
2408
2823
  */
2409
2824
  function createProgressCapsule(container, options = {}) {
2410
- if (!container || typeof container.appendChild !== 'function') {
2411
- throw new Error('createProgressCapsule: container element is required');
2412
- }
2413
-
2825
+ if (!container || typeof container.appendChild !== 'function') {
2826
+ throw new Error('createProgressCapsule: container element is required');
2827
+ }
2828
+
2414
2829
  const preset = { ...getPreset('progress', options.preset ?? '星火') };
2415
2830
  const merged = normalizeOptions(DEFAULTS.progress, preset, options);
2416
- if (merged.min > merged.max) {
2417
- const swap = merged.min;
2418
- merged.min = merged.max;
2419
- merged.max = swap;
2420
- }
2421
- const normalizedColors = merged.colors.map(normalizeColor);
2422
- if (normalizedColors.every(Boolean)) preset.colors = normalizedColors;
2423
- preset.edgeStyle = merged.edgeStyle;
2831
+ [merged.min, merged.max] = normalizeRange(merged.min, merged.max);
2832
+ if (merged.modelValue !== undefined) merged.value = merged.modelValue;
2833
+ const normalizedColors = Array.isArray(merged.colors) ? merged.colors.map(normalizeColor) : [];
2834
+ if (normalizedColors.length === 4 && normalizedColors.every(Boolean)) preset.colors = normalizedColors;
2835
+ const initialEdgeStyle = merged.edgeStyle === 'tide' || merged.edgeStyle === 'flow'
2836
+ ? merged.edgeStyle
2837
+ : preset.edgeStyle;
2838
+ preset.edgeStyle = initialEdgeStyle;
2839
+ merged.edgeStyle = initialEdgeStyle;
2840
+ merged.colors = [...preset.colors];
2841
+ const optionColors = Array.isArray(options.colors) ? options.colors.map(normalizeColor) : [];
2842
+ let colorOverride = optionColors.length === 4 && optionColors.every(Boolean)
2843
+ ? [...optionColors]
2844
+ : null;
2845
+ let edgeStyleOverride = options.edgeStyle === 'flow' || options.edgeStyle === 'tide'
2846
+ ? merged.edgeStyle
2847
+ : null;
2424
2848
  const copy = COPY;
2425
2849
  let customLabel = typeof options.label === 'string' && options.label ? options.label : null;
2426
2850
  // Vue 的裸布尔属性(<ProgressCapsule disabled />)会传成空字符串,
@@ -2429,19 +2853,32 @@ function createProgressCapsule(container, options = {}) {
2429
2853
  const readonly = merged.readonly === true || merged.readonly === '' || merged.readonly === 'true';
2430
2854
  const locked = disabled || readonly;
2431
2855
  const effectiveDraggable = locked ? false : merged.draggable !== false;
2856
+ const effectiveKeyboard = locked ? false : merged.keyboard !== false;
2432
2857
  const dirty = {
2433
2858
  preset: false,
2434
2859
  colors: false,
2435
2860
  textRatio: false,
2436
2861
  cssVars: false,
2437
2862
  edgeStyle: false,
2438
- value: false
2863
+ value: false,
2864
+ step: false,
2865
+ precision: false,
2866
+ formatValue: false,
2867
+ direction: false,
2868
+ showValue: false,
2869
+ valueSuffix: false,
2870
+ quality: false,
2871
+ renderScale: false,
2872
+ paused: false,
2873
+ static: false,
2874
+ fps: false
2439
2875
  };
2440
2876
 
2441
2877
  const root = document.createElement('div');
2442
2878
  root.className = 'hj-capsule-root hj-progress-root';
2443
2879
  root.setAttribute('role', 'slider');
2444
2880
  root.setAttribute('data-draggable', String(effectiveDraggable));
2881
+ root.dataset.direction = merged.direction === 'rtl' ? 'rtl' : 'ltr';
2445
2882
  if (disabled) {
2446
2883
  root.setAttribute('aria-disabled', 'true');
2447
2884
  root.classList.add('is-disabled');
@@ -2450,10 +2887,11 @@ function createProgressCapsule(container, options = {}) {
2450
2887
  root.setAttribute('aria-readonly', 'true');
2451
2888
  root.classList.add('is-readonly');
2452
2889
  }
2453
- if (locked) root.setAttribute('tabindex', '-1');
2454
- root.setAttribute('aria-valuemin', String(merged.min ?? 0));
2455
- root.setAttribute('aria-valuemax', String(merged.max ?? 100));
2456
- root.setAttribute('aria-valuenow', String(preset.initialProgress));
2890
+ root.setAttribute('tabindex', locked ? '-1' : (effectiveKeyboard ? '0' : '-1'));
2891
+ root.setAttribute('aria-orientation', 'horizontal');
2892
+ root.setAttribute('aria-valuemin', String(merged.min ?? 0));
2893
+ root.setAttribute('aria-valuemax', String(merged.max ?? 100));
2894
+ root.setAttribute('aria-valuenow', String(preset.initialProgress));
2457
2895
  const updateAria = () => {
2458
2896
  root.setAttribute(
2459
2897
  'aria-label',
@@ -2461,10 +2899,10 @@ function createProgressCapsule(container, options = {}) {
2461
2899
  );
2462
2900
  };
2463
2901
  updateAria();
2464
- const canvas = document.createElement('canvas');
2465
- canvas.className = 'hj-progress-canvas';
2466
- canvas.setAttribute('aria-hidden', 'true');
2467
-
2902
+ const canvas = document.createElement('canvas');
2903
+ canvas.className = 'hj-progress-canvas';
2904
+ canvas.setAttribute('aria-hidden', 'true');
2905
+
2468
2906
  // Slot containers are always present but transparent by default; they only
2469
2907
  // provide geometry, never typography/padding/background (docs: 插槽 CSS 约定).
2470
2908
  const fillContent = (layer, content) => {
@@ -2493,17 +2931,31 @@ function createProgressCapsule(container, options = {}) {
2493
2931
  valueElement.className = 'hj-progress-value';
2494
2932
  valueElement.setAttribute('aria-hidden', 'true');
2495
2933
  valueElement.hidden = merged.showValue === false;
2496
-
2934
+
2497
2935
  root.appendChild(canvas);
2498
2936
  root.appendChild(textLayer);
2499
2937
  root.appendChild(visualLayer);
2500
2938
  root.appendChild(valueElement);
2501
2939
  container.appendChild(root);
2502
-
2940
+
2503
2941
  const emitter = createEmitter();
2504
- let paused = merged.respectReducedMotion && prefersReducedMotion$1();
2942
+ let manuallyPaused = merged.paused === true;
2943
+ let reducedPaused = false;
2944
+ let staticMode = merged.static === true;
2945
+ let contextLost = false;
2505
2946
  let disposed = false;
2506
-
2947
+ let renderOnce = () => {};
2948
+ const onContextLost = () => {
2949
+ contextLost = true;
2950
+ emitter.emit('contextlost', {});
2951
+ };
2952
+ const onContextRestored = () => {
2953
+ contextLost = false;
2954
+ emitter.emit('contextrestored', {});
2955
+ renderOnce();
2956
+ wakeScheduler();
2957
+ };
2958
+
2507
2959
  const applySize = () => {
2508
2960
  root.style.width = parseSize(merged.width);
2509
2961
  root.style.height = parseSize(merged.height);
@@ -2513,28 +2965,39 @@ function createProgressCapsule(container, options = {}) {
2513
2965
  root.style.setProperty('--hj-text-width', `${merged.textRatio}%`);
2514
2966
  }
2515
2967
  };
2516
- applySize();
2517
-
2968
+ applySize();
2969
+
2518
2970
  const controller = new ProgressCapsuleController({
2519
2971
  root,
2520
2972
  canvas,
2521
2973
  valueElement,
2522
2974
  preset,
2523
2975
  emitter,
2524
- options: { ...merged, draggable: effectiveDraggable },
2976
+ options: {
2977
+ ...merged,
2978
+ draggable: effectiveDraggable,
2979
+ keyboard: effectiveKeyboard,
2980
+ onResize: () => {
2981
+ if (manuallyPaused || reducedPaused || staticMode) renderOnce();
2982
+ }
2983
+ },
2525
2984
  copy,
2526
2985
  dirty
2527
2986
  });
2528
-
2529
- let overlay = null;
2530
- if (merged.renderer !== 'canvas2d') {
2531
- overlay = attachProgressFlowOverlay({
2532
- root,
2533
- canvas,
2534
- preset,
2535
- getProgress: () => controller.value
2536
- });
2537
- }
2987
+
2988
+ let overlay = null;
2989
+ if (merged.renderer !== 'canvas2d') {
2990
+ overlay = attachProgressFlowOverlay({
2991
+ root,
2992
+ canvas,
2993
+ preset,
2994
+ getProgress: () => progressRatio(controller.value, controller.min, controller.max) * 100,
2995
+ dprCap: effectiveDprCap(merged.quality, merged.renderScale),
2996
+ powerPreference: merged.powerPreference,
2997
+ onContextLost,
2998
+ onContextRestored
2999
+ });
3000
+ }
2538
3001
  if (overlay) controller.webglActive = true;
2539
3002
  else if (merged.renderer !== 'canvas2d') {
2540
3003
  nextTick(() => {
@@ -2542,66 +3005,99 @@ function createProgressCapsule(container, options = {}) {
2542
3005
  emitter.emit('error', { message: 'WebGL2 unavailable, using Canvas2D fallback' });
2543
3006
  });
2544
3007
  }
2545
-
2546
- const visibility = createVisibilityGuard(root);
2547
- let flowTime = 0;
2548
- const unsubscribe = subscribeScheduler(
2549
- (delta, now) => {
2550
- flowTime += delta;
2551
- controller.update(delta, false);
2552
- if (visibility.isVisible()) {
2553
- controller.draw();
2554
- if (overlay) overlay.update(flowTime);
2555
- }
2556
- },
2557
- () => paused
2558
- );
2559
-
3008
+
3009
+ const visibility = createVisibilityGuard(root, wakeScheduler);
3010
+ const motionPreference = createReducedMotionPreference(
3011
+ merged.respectReducedMotion,
3012
+ (matches) => {
3013
+ reducedPaused = matches;
3014
+ if (matches) renderOnce();
3015
+ else wakeScheduler();
3016
+ }
3017
+ );
3018
+ reducedPaused = motionPreference.matches();
3019
+ const isMotionPaused = () => manuallyPaused || reducedPaused || staticMode || contextLost;
3020
+ let flowTime = 0;
3021
+ const frameGate = createFrameGate(merged.fps);
3022
+ renderOnce = () => {
3023
+ controller.draw();
3024
+ if (overlay) overlay.update(flowTime);
3025
+ };
3026
+ renderOnce();
3027
+ const offPausedChange = emitter.on('change', () => {
3028
+ if (isMotionPaused()) renderOnce();
3029
+ });
3030
+ const unsubscribe = subscribeScheduler(
3031
+ (delta, now) => {
3032
+ flowTime += delta;
3033
+ controller.update(delta, false);
3034
+ if (frameGate.shouldDraw(delta)) {
3035
+ controller.draw();
3036
+ if (overlay) overlay.update(flowTime);
3037
+ }
3038
+ },
3039
+ () => isMotionPaused() || !visibility.isVisible()
3040
+ );
3041
+
2560
3042
  nextTick(() => {
2561
3043
  if (disposed) return;
2562
3044
  emitter.emit('ready', { preset: { ...preset } });
2563
3045
  });
2564
-
3046
+
2565
3047
  const syncDom = () => {
2566
3048
  updateAria();
2567
3049
  };
2568
-
2569
- return {
2570
- element: root,
2571
- canvas,
2572
- preset,
2573
- on: emitter.on,
2574
- off: emitter.off,
2575
- setValue(value, source = 'prop') {
2576
- controller.setProgress(value, source);
2577
- return this;
2578
- },
2579
- getValue() {
2580
- return controller.value;
2581
- },
2582
- setRange(min, max) {
2583
- controller.setRange(min, max);
2584
- return this;
2585
- },
3050
+
3051
+ return {
3052
+ element: root,
3053
+ canvas,
3054
+ preset,
3055
+ on: emitter.on,
3056
+ off: emitter.off,
3057
+ setValue(value, source = 'prop') {
3058
+ controller.setProgress(value, source);
3059
+ return this;
3060
+ },
3061
+ getValue() {
3062
+ return controller.value;
3063
+ },
3064
+ setRange(min, max) {
3065
+ controller.setRange(min, max);
3066
+ if (isMotionPaused()) renderOnce();
3067
+ return this;
3068
+ },
2586
3069
  setPreset(ref) {
2587
3070
  const next = getPreset('progress', ref);
2588
3071
  dirty.preset = true;
2589
3072
  Object.assign(preset, next);
3073
+ if (colorOverride) preset.colors = [...colorOverride];
3074
+ if (edgeStyleOverride) preset.edgeStyle = edgeStyleOverride;
2590
3075
  const nextColors = preset.colors.map(normalizeColor);
2591
3076
  if (nextColors.every(Boolean)) preset.colors = nextColors;
2592
3077
  controller.preset = preset;
2593
- controller.profile = next.edgeStyle === 'tide'
3078
+ controller.profile = preset.edgeStyle === 'tide'
2594
3079
  ? FLOW_PROFILES.tide
2595
- : (FLOW_PROFILES[next.id] || FLOW_PROFILES['visual-training']);
3080
+ : (FLOW_PROFILES[preset.id] || FLOW_PROFILES['visual-training']);
2596
3081
  controller.flowTime = stringSeed(next.id) * 31;
2597
3082
  controller.seed = stringSeed(`${next.id}-reference`) * Math.PI * 2;
2598
3083
  syncDom();
2599
3084
  if (overlay) {
2600
3085
  overlay.dispose();
2601
- overlay = attachProgressFlowOverlay({ root, canvas, preset, getProgress: () => controller.value });
3086
+ contextLost = false;
3087
+ overlay = attachProgressFlowOverlay({
3088
+ root,
3089
+ canvas,
3090
+ preset,
3091
+ getProgress: () => progressRatio(controller.value, controller.min, controller.max) * 100,
3092
+ dprCap: effectiveDprCap(merged.quality, merged.renderScale),
3093
+ powerPreference: merged.powerPreference,
3094
+ onContextLost,
3095
+ onContextRestored
3096
+ });
2602
3097
  }
2603
3098
  controller.webglActive = Boolean(overlay);
2604
3099
  controller.resizeCanvas();
3100
+ if (isMotionPaused()) renderOnce();
2605
3101
  emitter.emit('presetchange', { preset: { ...preset } });
2606
3102
  return this;
2607
3103
  },
@@ -2609,16 +3105,28 @@ function createProgressCapsule(container, options = {}) {
2609
3105
  const value = String(edgeStyle || '').toLowerCase();
2610
3106
  if (value !== 'flow' && value !== 'tide') return this;
2611
3107
  dirty.edgeStyle = true;
3108
+ edgeStyleOverride = value;
2612
3109
  preset.edgeStyle = value;
2613
3110
  controller.profile = value === 'tide'
2614
3111
  ? FLOW_PROFILES.tide
2615
3112
  : (FLOW_PROFILES[preset.id] || FLOW_PROFILES['visual-training']);
2616
3113
  if (overlay) {
2617
3114
  overlay.dispose();
2618
- overlay = attachProgressFlowOverlay({ root, canvas, preset, getProgress: () => controller.value });
3115
+ contextLost = false;
3116
+ overlay = attachProgressFlowOverlay({
3117
+ root,
3118
+ canvas,
3119
+ preset,
3120
+ getProgress: () => progressRatio(controller.value, controller.min, controller.max) * 100,
3121
+ dprCap: effectiveDprCap(merged.quality, merged.renderScale),
3122
+ powerPreference: merged.powerPreference,
3123
+ onContextLost,
3124
+ onContextRestored
3125
+ });
2619
3126
  }
2620
3127
  controller.webglActive = Boolean(overlay);
2621
3128
  controller.resizeCanvas();
3129
+ if (isMotionPaused()) renderOnce();
2622
3130
  return this;
2623
3131
  },
2624
3132
  setText(content) {
@@ -2653,6 +3161,22 @@ function createProgressCapsule(container, options = {}) {
2653
3161
  controller.setValueSuffix(suffix);
2654
3162
  return this;
2655
3163
  },
3164
+ setStep(step) {
3165
+ controller.setStep(step);
3166
+ return this;
3167
+ },
3168
+ setPrecision(precision) {
3169
+ controller.setPrecision(precision);
3170
+ return this;
3171
+ },
3172
+ setFormatValue(formatter) {
3173
+ controller.setFormatValue(formatter);
3174
+ return this;
3175
+ },
3176
+ setDirection(direction) {
3177
+ controller.setDirection(direction);
3178
+ return this;
3179
+ },
2656
3180
  setShowValue(show) {
2657
3181
  controller.setShowValue(show);
2658
3182
  return this;
@@ -2661,11 +3185,14 @@ function createProgressCapsule(container, options = {}) {
2661
3185
  const next = Array.isArray(colors) ? colors.map(normalizeColor) : [];
2662
3186
  if (next.length !== 4 || next.some((color) => !color)) return this;
2663
3187
  dirty.colors = true;
3188
+ colorOverride = [...next];
3189
+ preset.colors = [...next];
2664
3190
  controller.setColors(next);
2665
3191
  if (overlay) overlay.setColors(next);
2666
3192
  controller.resizeCanvas();
3193
+ if (isMotionPaused()) renderOnce();
2667
3194
  return this;
2668
- },
3195
+ },
2669
3196
  setSize(width, height) {
2670
3197
  if (width !== undefined) {
2671
3198
  parseSize(width);
@@ -2676,111 +3203,208 @@ function createProgressCapsule(container, options = {}) {
2676
3203
  merged.height = height;
2677
3204
  }
2678
3205
  applySize();
2679
- controller.resizeCanvas();
2680
- return this;
2681
- },
2682
- randomize() {
2683
- controller.randomize();
2684
- return this;
2685
- },
2686
- pause() {
2687
- paused = true;
2688
- return this;
2689
- },
2690
- resume() {
2691
- paused = false;
2692
- return this;
2693
- },
2694
- setQuality(quality) {
2695
- merged.quality = quality;
2696
- controller.dprCap = dprCapFor(quality);
2697
- controller.resizeCanvas();
2698
- return this;
2699
- },
3206
+ controller.resizeCanvas();
3207
+ if (isMotionPaused()) renderOnce();
3208
+ return this;
3209
+ },
3210
+ randomize() {
3211
+ flowTime = controller.randomize();
3212
+ if (isMotionPaused()) renderOnce();
3213
+ return this;
3214
+ },
3215
+ pause() {
3216
+ dirty.paused = true;
3217
+ manuallyPaused = true;
3218
+ renderOnce();
3219
+ return this;
3220
+ },
3221
+ resume() {
3222
+ dirty.paused = true;
3223
+ manuallyPaused = false;
3224
+ wakeScheduler();
3225
+ return this;
3226
+ },
3227
+ setPaused(value) {
3228
+ return value ? this.pause() : this.resume();
3229
+ },
3230
+ setStatic(value) {
3231
+ dirty.static = true;
3232
+ staticMode = value === true;
3233
+ merged.static = staticMode;
3234
+ if (staticMode) renderOnce();
3235
+ else wakeScheduler();
3236
+ return this;
3237
+ },
3238
+ setFps(fps) {
3239
+ dirty.fps = true;
3240
+ merged.fps = frameGate.setFps(fps);
3241
+ wakeScheduler();
3242
+ return this;
3243
+ },
3244
+ setQuality(quality) {
3245
+ dirty.quality = true;
3246
+ merged.quality = quality;
3247
+ controller.dprCap = effectiveDprCap(quality, merged.renderScale);
3248
+ controller.resizeCanvas();
3249
+ if (overlay) overlay.setDprCap(controller.dprCap);
3250
+ if (isMotionPaused()) renderOnce();
3251
+ return this;
3252
+ },
3253
+ setRenderScale(renderScale) {
3254
+ const value = Number(renderScale);
3255
+ if (!Number.isFinite(value)) return this;
3256
+ dirty.renderScale = true;
3257
+ merged.renderScale = Math.min(1, Math.max(0.25, value));
3258
+ controller.dprCap = effectiveDprCap(merged.quality, merged.renderScale);
3259
+ controller.resizeCanvas();
3260
+ if (overlay) overlay.setDprCap(controller.dprCap);
3261
+ if (isMotionPaused()) renderOnce();
3262
+ return this;
3263
+ },
2700
3264
  dispose() {
2701
3265
  disposed = true;
2702
3266
  unsubscribe();
2703
- visibility.dispose();
2704
- if (overlay) overlay.dispose();
3267
+ offPausedChange();
3268
+ visibility.dispose();
3269
+ motionPreference.dispose();
3270
+ if (overlay) overlay.dispose();
2705
3271
  controller.dispose();
2706
3272
  root.remove();
2707
3273
  },
2708
3274
  get textRatio() { return merged.textRatio; },
2709
3275
  get cssVars() { return merged.cssVars; },
3276
+ get min() { return controller.min; },
3277
+ get max() { return controller.max; },
3278
+ get step() { return controller.step; },
3279
+ get precision() { return controller.precision; },
3280
+ get formatValue() { return controller.formatValue; },
3281
+ get direction() { return controller.direction; },
3282
+ get showValue() { return !valueElement.hidden; },
3283
+ get valueSuffix() { return controller.valueSuffix; },
3284
+ get quality() { return merged.quality; },
3285
+ get renderScale() { return merged.renderScale; },
3286
+ get paused() { return manuallyPaused; },
3287
+ get static() { return staticMode; },
3288
+ get fps() { return frameGate.getFps(); },
2710
3289
  dirty
2711
3290
  };
2712
3291
  }
2713
3292
 
2714
- function prefersReducedMotion() {
2715
- return typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches;
2716
- }
2717
-
2718
- /**
2719
- * Mount the cosmic (nebula) material as a background layer inside `host`.
2720
- *
2721
- * The layer is absolutely positioned and defaults to `z-index: -1`, so it
2722
- * paints behind the host's in-flow content: text/buttons/images inside the
2723
- * host sit on top with zero extra CSS. Host gets `position: relative;
2724
- * isolation: isolate` automatically so the layer can never escape its box
2725
- * (or break stacking outside it).
2726
- *
3293
+ const HOST_STYLES = new WeakMap();
3294
+
3295
+ function acquireHostStyles(host) {
3296
+ const active = HOST_STYLES.get(host);
3297
+ if (active) {
3298
+ active.count += 1;
3299
+ return;
3300
+ }
3301
+ const state = {
3302
+ count: 1,
3303
+ position: host.style.position,
3304
+ isolation: host.style.isolation
3305
+ };
3306
+ const computed = getComputedStyle(host);
3307
+ if (computed.position === 'static' || computed.position === '') host.style.position = 'relative';
3308
+ host.style.isolation = 'isolate';
3309
+ HOST_STYLES.set(host, state);
3310
+ }
3311
+
3312
+ function releaseHostStyles(host) {
3313
+ const state = HOST_STYLES.get(host);
3314
+ if (!state) return;
3315
+ state.count -= 1;
3316
+ if (state.count > 0) return;
3317
+ host.style.position = state.position;
3318
+ host.style.isolation = state.isolation;
3319
+ HOST_STYLES.delete(host);
3320
+ }
3321
+
3322
+ /**
3323
+ * Mount the cosmic (nebula) material as a background layer inside `host`.
3324
+ *
3325
+ * The layer is absolutely positioned and defaults to `z-index: -1`, so it
3326
+ * paints behind the host's in-flow content: text/buttons/images inside the
3327
+ * host sit on top with zero extra CSS. Host gets `position: relative;
3328
+ * isolation: isolate` automatically so the layer can never escape its box
3329
+ * (or break stacking outside it).
3330
+ *
2727
3331
  * Options: preset (literary name), colors, seed, speed, quality,
2728
- * renderer, mouseColor, respectReducedMotion, opacity (0-1), fallbackColor
2729
- * (true=preset base color, a hex string, or false to disable).
2730
- */
2731
- function createColorBackground(host, options = {}) {
2732
- if (!host || typeof host.appendChild !== 'function') {
2733
- throw new Error('createColorBackground: host element is required');
2734
- }
2735
-
3332
+ * renderer, renderScale, powerPreference, fps, paused/static, mouseColor,
3333
+ * respectReducedMotion, opacity (0-1), fallbackColor (true=preset base
3334
+ * color, a hex string, or false to disable).
3335
+ */
3336
+ function createColorBackground(host, options = {}) {
3337
+ if (!host || typeof host.appendChild !== 'function') {
3338
+ throw new Error('createColorBackground: host element is required');
3339
+ }
3340
+
2736
3341
  const preset = { ...getPreset('capsule', options.preset ?? '初光') };
2737
3342
  const merged = normalizeOptions(
2738
- {
2739
- quality: DEFAULTS.capsule.quality,
2740
- renderer: DEFAULTS.capsule.renderer,
2741
- respectReducedMotion: DEFAULTS.capsule.respectReducedMotion,
2742
- mouseColor: true,
2743
- opacity: 1,
2744
- fallbackColor: true
2745
- },
2746
- preset,
3343
+ {
3344
+ quality: DEFAULTS.capsule.quality,
3345
+ renderer: DEFAULTS.capsule.renderer,
3346
+ respectReducedMotion: DEFAULTS.capsule.respectReducedMotion,
3347
+ mouseColor: true,
3348
+ renderScale: 1,
3349
+ powerPreference: 'high-performance',
3350
+ fps: DEFAULTS.capsule.fps,
3351
+ paused: false,
3352
+ static: false,
3353
+ opacity: 1,
3354
+ fallbackColor: true
3355
+ },
3356
+ preset,
2747
3357
  options
2748
3358
  );
2749
- const normalizedColors = merged.colors.map(normalizeColor);
2750
- if (normalizedColors.every(Boolean)) preset.colors = normalizedColors;
2751
- preset.seed = merged.seed;
2752
- preset.speed = merged.speed;
2753
-
2754
- const computed = getComputedStyle(host);
2755
- if (computed.position === 'static' || computed.position === '') {
2756
- host.style.position = 'relative';
2757
- }
2758
- host.style.isolation = 'isolate';
2759
-
2760
- const layer = document.createElement('div');
2761
- layer.className = 'dlc-color-layer';
2762
-
2763
- const syncLayerVars = () => {
2764
- layer.style.setProperty('--dlc-color-opacity', String(merged.opacity));
2765
- const fallback =
2766
- merged.fallbackColor === false || merged.fallbackColor == null
2767
- ? 'transparent'
2768
- : typeof merged.fallbackColor === 'string'
2769
- ? merged.fallbackColor
2770
- : preset.colors[0];
2771
- layer.style.setProperty('--dlc-color-bg', fallback);
2772
- };
2773
- syncLayerVars();
2774
-
2775
- const canvas = document.createElement('canvas');
2776
- canvas.className = 'dlc-color-canvas';
2777
- canvas.setAttribute('aria-hidden', 'true');
2778
- layer.appendChild(canvas);
2779
- host.appendChild(layer);
2780
-
3359
+ merged.opacity = Number.isFinite(Number(merged.opacity))
3360
+ ? Math.min(1, Math.max(0, Number(merged.opacity)))
3361
+ : 1;
3362
+ const normalizedColors = Array.isArray(merged.colors) ? merged.colors.map(normalizeColor) : [];
3363
+ if (normalizedColors.length === 4 && normalizedColors.every(Boolean)) preset.colors = normalizedColors;
3364
+ const initialSeed = toFiniteNumber(merged.seed);
3365
+ const initialSpeed = toFiniteNumber(merged.speed);
3366
+ if (initialSeed !== null) preset.seed = initialSeed;
3367
+ if (initialSpeed !== null) preset.speed = initialSpeed;
3368
+ merged.colors = [...preset.colors];
3369
+ merged.seed = preset.seed;
3370
+ merged.speed = preset.speed;
3371
+ const optionColors = Array.isArray(options.colors) ? options.colors.map(normalizeColor) : [];
3372
+ let colorOverride = optionColors.length === 4 && optionColors.every(Boolean)
3373
+ ? [...optionColors]
3374
+ : null;
3375
+ let seedOverride = options.seed !== undefined ? toFiniteNumber(options.seed) : null;
3376
+ let speedOverride = options.speed !== undefined ? toFiniteNumber(options.speed) : null;
3377
+
3378
+ acquireHostStyles(host);
3379
+
3380
+ const layer = document.createElement('div');
3381
+ layer.className = 'dlc-color-layer';
3382
+
3383
+ const syncLayerVars = () => {
3384
+ layer.style.setProperty('--dlc-color-opacity', String(merged.opacity));
3385
+ const fallback =
3386
+ merged.fallbackColor === false || merged.fallbackColor == null
3387
+ ? 'transparent'
3388
+ : typeof merged.fallbackColor === 'string'
3389
+ ? merged.fallbackColor
3390
+ : preset.colors[0];
3391
+ layer.style.setProperty('--dlc-color-bg', fallback);
3392
+ };
3393
+ syncLayerVars();
3394
+
3395
+ const canvas = document.createElement('canvas');
3396
+ canvas.className = 'dlc-color-canvas';
3397
+ canvas.setAttribute('aria-hidden', 'true');
3398
+ layer.appendChild(canvas);
3399
+ host.appendChild(layer);
3400
+
2781
3401
  const emitter = createEmitter();
2782
- let paused = merged.respectReducedMotion && prefersReducedMotion();
3402
+ let manuallyPaused = merged.paused === true;
3403
+ let reducedPaused = false;
3404
+ let staticMode = merged.static === true;
3405
+ let contextLost = false;
2783
3406
  let disposed = false;
3407
+ let renderOnce = () => {};
2784
3408
  const dirty = {
2785
3409
  preset: false,
2786
3410
  seed: false,
@@ -2788,70 +3412,113 @@ function createColorBackground(host, options = {}) {
2788
3412
  colors: false,
2789
3413
  opacity: false,
2790
3414
  fallbackColor: false,
2791
- mouseColor: false
3415
+ mouseColor: false,
3416
+ quality: false,
3417
+ renderScale: false,
3418
+ paused: false,
3419
+ static: false,
3420
+ fps: false
2792
3421
  };
2793
-
2794
- let renderer;
2795
- const useWebgl = merged.renderer !== 'canvas2d';
2796
- if (useWebgl) {
2797
- try {
2798
- renderer = new CosmicRenderer(canvas, merged, {
2799
- dprCap: dprCapFor(merged.quality),
2800
- mouseColor: merged.mouseColor !== false,
2801
- eventTarget: host
2802
- });
2803
- } catch (error) {
2804
- renderer = new FallbackRenderer(canvas, merged);
3422
+
3423
+ let renderer;
3424
+ const useWebgl = merged.renderer !== 'canvas2d';
3425
+ if (useWebgl) {
3426
+ try {
3427
+ renderer = new CosmicRenderer(canvas, merged, {
3428
+ dprCap: effectiveDprCap(merged.quality, merged.renderScale),
3429
+ mouseColor: merged.mouseColor !== false,
3430
+ eventTarget: host,
3431
+ powerPreference: merged.powerPreference,
3432
+ onContextLost: () => {
3433
+ contextLost = true;
3434
+ emitter.emit('contextlost', {});
3435
+ },
3436
+ onContextRestored: () => {
3437
+ contextLost = false;
3438
+ emitter.emit('contextrestored', {});
3439
+ renderOnce();
3440
+ wakeScheduler();
3441
+ }
3442
+ });
3443
+ } catch (error) {
3444
+ renderer = new FallbackRenderer(canvas, merged, {
3445
+ dprCap: effectiveDprCap(merged.quality, merged.renderScale)
3446
+ });
2805
3447
  nextTick(() => {
2806
3448
  if (disposed) return;
2807
3449
  emitter.emit('error', {
2808
3450
  message: String(error && error.message ? error.message : error)
2809
3451
  });
2810
3452
  });
2811
- }
2812
- } else {
2813
- renderer = new FallbackRenderer(canvas, merged);
2814
- }
2815
-
2816
- const resizeObserver =
2817
- typeof ResizeObserver !== 'undefined'
2818
- ? new ResizeObserver(() => renderer.resize())
2819
- : null;
2820
- if (resizeObserver) resizeObserver.observe(host);
2821
- else window.addEventListener('resize', renderer.resize);
2822
-
2823
- const visibility = createVisibilityGuard(layer);
2824
-
2825
- let animationTime = 0;
2826
- const unsubscribe = subscribeScheduler(
2827
- (delta) => {
2828
- animationTime += delta;
2829
- if (visibility.isVisible()) renderer.draw(animationTime);
2830
- },
2831
- () => paused
2832
- );
2833
-
3453
+ }
3454
+ } else {
3455
+ renderer = new FallbackRenderer(canvas, merged, {
3456
+ dprCap: effectiveDprCap(merged.quality, merged.renderScale)
3457
+ });
3458
+ }
3459
+ renderer.resize();
3460
+
3461
+ let animationTime = 0;
3462
+ const frameGate = createFrameGate(merged.fps);
3463
+ renderOnce = () => renderer.draw(animationTime);
3464
+
3465
+ const resize = () => {
3466
+ renderer.resize();
3467
+ if (manuallyPaused || reducedPaused || staticMode) renderOnce();
3468
+ };
3469
+ const resizeObserver =
3470
+ typeof ResizeObserver !== 'undefined'
3471
+ ? new ResizeObserver(resize)
3472
+ : null;
3473
+ if (resizeObserver) resizeObserver.observe(host);
3474
+ else window.addEventListener('resize', resize);
3475
+
3476
+ const visibility = createVisibilityGuard(layer, wakeScheduler);
3477
+ const motionPreference = createReducedMotionPreference(
3478
+ merged.respectReducedMotion,
3479
+ (matches) => {
3480
+ reducedPaused = matches;
3481
+ if (matches) renderOnce();
3482
+ else wakeScheduler();
3483
+ }
3484
+ );
3485
+ reducedPaused = motionPreference.matches();
3486
+ const isMotionPaused = () => manuallyPaused || reducedPaused || staticMode || contextLost;
3487
+
3488
+ renderOnce();
3489
+ const unsubscribe = subscribeScheduler(
3490
+ (delta) => {
3491
+ animationTime += delta;
3492
+ if (frameGate.shouldDraw(delta)) renderer.draw(animationTime);
3493
+ },
3494
+ () => isMotionPaused() || !visibility.isVisible()
3495
+ );
3496
+
2834
3497
  nextTick(() => {
2835
3498
  if (disposed) return;
2836
3499
  emitter.emit('ready', { preset: { ...preset } });
2837
3500
  });
2838
-
2839
- return {
2840
- element: host,
2841
- layer,
2842
- canvas,
2843
- preset,
2844
- on: emitter.on,
2845
- off: emitter.off,
3501
+
3502
+ return {
3503
+ element: host,
3504
+ layer,
3505
+ canvas,
3506
+ preset,
3507
+ on: emitter.on,
3508
+ off: emitter.off,
2846
3509
  setPreset(ref) {
2847
3510
  const next = getPreset('capsule', ref);
2848
3511
  dirty.preset = true;
2849
3512
  Object.assign(preset, next);
3513
+ if (colorOverride) preset.colors = [...colorOverride];
3514
+ if (seedOverride !== null) preset.seed = seedOverride;
3515
+ if (speedOverride !== null) preset.speed = speedOverride;
2850
3516
  const nextColors = preset.colors.map(normalizeColor);
2851
3517
  if (nextColors.every(Boolean)) preset.colors = nextColors;
2852
3518
  renderer.setPreset({ ...preset });
2853
3519
  renderer.resize();
2854
3520
  syncLayerVars();
3521
+ if (isMotionPaused()) renderOnce();
2855
3522
  emitter.emit('presetchange', { preset: { ...preset } });
2856
3523
  return this;
2857
3524
  },
@@ -2859,25 +3526,31 @@ function createColorBackground(host, options = {}) {
2859
3526
  const next = Array.isArray(colors) ? colors.map(normalizeColor) : [];
2860
3527
  if (next.length !== 4 || next.some((color) => !color)) return this;
2861
3528
  dirty.colors = true;
3529
+ colorOverride = [...next];
2862
3530
  preset.colors = next;
2863
3531
  renderer.setPreset({ ...preset, colors: next });
2864
3532
  syncLayerVars();
3533
+ if (isMotionPaused()) renderOnce();
2865
3534
  return this;
2866
3535
  },
2867
3536
  setSeed(seed) {
2868
- const value = Number(seed);
2869
- if (!Number.isFinite(value)) return this;
3537
+ const value = toFiniteNumber(seed);
3538
+ if (value === null) return this;
2870
3539
  dirty.seed = true;
3540
+ seedOverride = value;
2871
3541
  preset.seed = value;
2872
3542
  renderer.setPreset({ ...preset });
3543
+ if (isMotionPaused()) renderOnce();
2873
3544
  return this;
2874
3545
  },
2875
3546
  setSpeed(speed) {
2876
- const value = Number(speed);
2877
- if (!Number.isFinite(value)) return this;
3547
+ const value = toFiniteNumber(speed);
3548
+ if (value === null) return this;
2878
3549
  dirty.speed = true;
3550
+ speedOverride = value;
2879
3551
  preset.speed = value;
2880
3552
  renderer.setPreset({ ...preset });
3553
+ if (isMotionPaused()) renderOnce();
2881
3554
  return this;
2882
3555
  },
2883
3556
  setFallbackColor(value) {
@@ -2892,45 +3565,88 @@ function createColorBackground(host, options = {}) {
2892
3565
  if (typeof renderer.setMouseColor === 'function') renderer.setMouseColor(merged.mouseColor);
2893
3566
  return this;
2894
3567
  },
2895
- setQuality(quality) {
2896
- merged.quality = quality;
2897
- if (typeof renderer.setDprCap === 'function') renderer.setDprCap(dprCapFor(quality));
2898
- return this;
2899
- },
3568
+ setQuality(quality) {
3569
+ dirty.quality = true;
3570
+ merged.quality = quality;
3571
+ if (typeof renderer.setDprCap === 'function') {
3572
+ renderer.setDprCap(effectiveDprCap(quality, merged.renderScale));
3573
+ }
3574
+ if (isMotionPaused()) renderOnce();
3575
+ return this;
3576
+ },
3577
+ setRenderScale(renderScale) {
3578
+ const value = Number(renderScale);
3579
+ if (!Number.isFinite(value)) return this;
3580
+ dirty.renderScale = true;
3581
+ merged.renderScale = Math.min(1, Math.max(0.25, value));
3582
+ if (typeof renderer.setDprCap === 'function') {
3583
+ renderer.setDprCap(effectiveDprCap(merged.quality, merged.renderScale));
3584
+ }
3585
+ if (isMotionPaused()) renderOnce();
3586
+ return this;
3587
+ },
2900
3588
  setOpacity(value) {
2901
3589
  const opacity = Number(value);
2902
3590
  if (!Number.isFinite(opacity)) return this;
2903
3591
  dirty.opacity = true;
2904
3592
  merged.opacity = Math.min(1, Math.max(0, opacity));
2905
- layer.style.setProperty('--dlc-color-opacity', String(merged.opacity));
2906
- return this;
2907
- },
3593
+ layer.style.setProperty('--dlc-color-opacity', String(merged.opacity));
3594
+ return this;
3595
+ },
2908
3596
  randomize() {
2909
3597
  this.setSeed(Math.random() * 100);
2910
3598
  return this;
2911
3599
  },
2912
- pause() {
2913
- paused = true;
2914
- return this;
2915
- },
2916
- resume() {
2917
- paused = false;
2918
- return this;
2919
- },
3600
+ pause() {
3601
+ dirty.paused = true;
3602
+ manuallyPaused = true;
3603
+ renderOnce();
3604
+ return this;
3605
+ },
3606
+ resume() {
3607
+ dirty.paused = true;
3608
+ manuallyPaused = false;
3609
+ wakeScheduler();
3610
+ return this;
3611
+ },
3612
+ setPaused(value) {
3613
+ return value ? this.pause() : this.resume();
3614
+ },
3615
+ setStatic(value) {
3616
+ dirty.static = true;
3617
+ staticMode = value === true;
3618
+ merged.static = staticMode;
3619
+ if (staticMode) renderOnce();
3620
+ else wakeScheduler();
3621
+ return this;
3622
+ },
3623
+ setFps(fps) {
3624
+ dirty.fps = true;
3625
+ merged.fps = frameGate.setFps(fps);
3626
+ wakeScheduler();
3627
+ return this;
3628
+ },
2920
3629
  dispose() {
2921
3630
  disposed = true;
2922
3631
  unsubscribe();
2923
- visibility.dispose();
2924
- if (resizeObserver) resizeObserver.disconnect();
2925
- else window.removeEventListener('resize', renderer.resize);
3632
+ visibility.dispose();
3633
+ motionPreference.dispose();
3634
+ if (resizeObserver) resizeObserver.disconnect();
3635
+ else window.removeEventListener('resize', resize);
2926
3636
  renderer.dispose();
2927
3637
  layer.remove();
3638
+ releaseHostStyles(host);
2928
3639
  },
2929
3640
  get opacity() { return merged.opacity; },
2930
3641
  get fallbackColor() { return merged.fallbackColor; },
2931
3642
  get mouseColor() { return merged.mouseColor; },
3643
+ get quality() { return merged.quality; },
3644
+ get renderScale() { return merged.renderScale; },
3645
+ get paused() { return manuallyPaused; },
3646
+ get static() { return staticMode; },
3647
+ get fps() { return frameGate.getFps(); },
2932
3648
  dirty
2933
3649
  };
2934
3650
  }
2935
3651
 
2936
- export { ALL_PRESETS, COPY, CosmicRenderer, DEFAULTS, FallbackRenderer, PRESETS, PROGRESS_PRESETS, ProgressCapsuleController, ProgressFlowRenderer, QUALITY_TIERS, createCapsule, createColorBackground, createProgressCapsule, dprCapFor, getPreset, hexToRgb01$1 as hexToRgb01, hexToRgba, normalizeColor, parseSize, pauseAll, resumeAll, validateColors };
3652
+ export { ALL_PRESETS, COPY, CosmicRenderer, DEFAULTS, FallbackRenderer, PRESETS, PROGRESS_PRESETS, ProgressCapsuleController, ProgressFlowRenderer, QUALITY_TIERS, createCapsule, createColorBackground, createProgressCapsule, dprCapFor, effectiveDprCap, getPreset, hexToRgb01$1 as hexToRgb01, hexToRgba, normalizeColor, parseSize, pauseAll, resumeAll, validateColors };